Add DiffStore
* src/store/DiffStore.js: Add class. * test/store/DiffStoreTest.js: Add test case. DEV-2296master
parent
3289e42003
commit
24180e704a
|
@ -0,0 +1,293 @@
|
||||||
|
/**
|
||||||
|
* Store that lazily computes diffs since last change
|
||||||
|
*
|
||||||
|
* Copyright (C) 2017 LoVullo Associates, Inc.
|
||||||
|
*
|
||||||
|
* This file is part of the Liza Data Collection Framework
|
||||||
|
*
|
||||||
|
* Liza is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const Class = require( 'easejs' ).Class;
|
||||||
|
const Store = require( './Store' );
|
||||||
|
const StoreMissError = require( './StoreMissError' );
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily compute diffs since last change
|
||||||
|
*
|
||||||
|
* This store recursively calculates the diff of scalars and
|
||||||
|
* objects. Unlike many other stores, you don't always get out what you put
|
||||||
|
* in.
|
||||||
|
*
|
||||||
|
* There are three operations:
|
||||||
|
* - `#add` stages a change to a key;
|
||||||
|
* - `#get` calculates the diff of a key against its committed value; and
|
||||||
|
* - `#clear` commits staged values, clearing all diffs.
|
||||||
|
*
|
||||||
|
* Values are recursively compared until a scalar is found. If the scalar
|
||||||
|
* matches the committed value, it is recognized as unchanged and
|
||||||
|
* represented as `undefined`. Otherwise, the staged value takes its
|
||||||
|
* place.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Promise resolving to [ undefined, "quux" ]
|
||||||
|
* DiffStore()
|
||||||
|
* .add( 'foo', [ "bar", "baz" ] )
|
||||||
|
* .then( store => store.clear() )
|
||||||
|
* .add( 'foo', [ "bar", "quux" ] )
|
||||||
|
* .then( store => store.get( 'foo' ) )
|
||||||
|
*
|
||||||
|
* // Promise resolving to { foo: undefined, baz: [ undefined, 'c' ] }
|
||||||
|
* DiffStore()
|
||||||
|
* .add( 'foo', { foo: 'bar', baz: [ 'a', 'b', ] } )
|
||||||
|
* .then( store => store.clear() )
|
||||||
|
* .add( 'foo', { baz: [ 'a', 'c' ] } )
|
||||||
|
* .then( store => store.get( 'foo' ) )
|
||||||
|
*
|
||||||
|
* The union of all keys of all objects are included in the diff:
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Promise resolving to { foo: undefined, baz: 'quux' }
|
||||||
|
* DiffStore()
|
||||||
|
* .add( 'foo', { foo: 'bar' } )
|
||||||
|
* .then( store => store.clear() )
|
||||||
|
* .add( 'foo', { baz: 'quux' } )
|
||||||
|
* .then( store => store.get( 'foo' ) )
|
||||||
|
*
|
||||||
|
* Values are diff'd since the last `#clear`, so adding a value multiple
|
||||||
|
* times will compare only the last one:
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Promise resolving to undefined
|
||||||
|
* DiffStore()
|
||||||
|
* .add( 'foo', 'foo' )
|
||||||
|
* .then( store => store.clear() )
|
||||||
|
* .add( 'foo', 'bar' )
|
||||||
|
* .add( 'foo', 'baz' )
|
||||||
|
* .add( 'foo', 'foo' )
|
||||||
|
* .then( store => store.get( 'foo' ) )
|
||||||
|
*
|
||||||
|
* // Promise resolving to undefined
|
||||||
|
* DiffStore()
|
||||||
|
* .add( 'foo', 'bar' )
|
||||||
|
* .then( store => store.clear() )
|
||||||
|
* .then( store => store.get( 'foo' ) )
|
||||||
|
*
|
||||||
|
* One caveat: since the diff represents the absence of changes as
|
||||||
|
* `undefined`, there is no way to distinguish between an actual undefined
|
||||||
|
* value and a non-change. If this is important to you, you can subtype
|
||||||
|
* this class and override `#diff`.
|
||||||
|
*
|
||||||
|
* For more examples, see the `DiffStoreTest` test case.
|
||||||
|
*/
|
||||||
|
module.exports = Class( 'DiffStore' )
|
||||||
|
.implement( Store )
|
||||||
|
.extend(
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* New data staged for committing
|
||||||
|
* @type {Object}
|
||||||
|
*/
|
||||||
|
'private _staged': {},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Previous values
|
||||||
|
* @type {Object}
|
||||||
|
*/
|
||||||
|
'private _commit': {},
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy item with value `value` to internal store matching against `key`
|
||||||
|
*
|
||||||
|
* Note that the key stored may be different than `key`. This
|
||||||
|
* information is important only if the internal stores are not
|
||||||
|
* encapsulated.
|
||||||
|
*
|
||||||
|
* @param {string} key store key to match against
|
||||||
|
* @param {*} value value for key
|
||||||
|
*
|
||||||
|
* @return {Promise.<Store>} promise to add item to store, resolving to
|
||||||
|
* self (for chaining)
|
||||||
|
*/
|
||||||
|
'virtual public add'( key, value )
|
||||||
|
{
|
||||||
|
this._staged[ key ] = value;
|
||||||
|
|
||||||
|
return Promise.resolve( this.__inst );
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve diff of `key`
|
||||||
|
*
|
||||||
|
* This performs a lazy diff of the data `D` behind `key`. For each
|
||||||
|
* scalar value in `D`, recursively, the value will be `undefined` if
|
||||||
|
* there is no change and will be the staged value if changed. A change
|
||||||
|
* occurs when the data `D` differs from the value of `key` before the
|
||||||
|
* last `#clear`. A value is staged when it has been added since the
|
||||||
|
* last `#clear`.
|
||||||
|
*
|
||||||
|
* @param {string} key store key
|
||||||
|
*
|
||||||
|
* @return {Promise} promise for the key value
|
||||||
|
*/
|
||||||
|
'virtual public get'( key )
|
||||||
|
{
|
||||||
|
if ( ( this._staged[ key ] || this._commit[ key ] ) === undefined )
|
||||||
|
{
|
||||||
|
return Promise.reject(
|
||||||
|
StoreMissError( `Key '${key}' does not exist` )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(
|
||||||
|
this.diff( this._staged[ key ], this._commit[ key ] )
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commit staged data and clear diffs
|
||||||
|
*
|
||||||
|
* All staged data will be committed. Until some committed key `k` has
|
||||||
|
* its data modified via `#add`, `k` will not be considered to have
|
||||||
|
* changed.
|
||||||
|
*
|
||||||
|
* @return {Promise.<Store>} promise to add item to store, resolving to
|
||||||
|
* self (for chaining)
|
||||||
|
*/
|
||||||
|
'virtual public clear'()
|
||||||
|
{
|
||||||
|
Object.keys( this._staged ).forEach(
|
||||||
|
key => this._commit[ key ] = this._staged[ key ]
|
||||||
|
);
|
||||||
|
|
||||||
|
this._staged = {};
|
||||||
|
|
||||||
|
return Promise.resolve( this.__inst );
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively diff two objects or scalars `data` and `orig`
|
||||||
|
*
|
||||||
|
* A datum in `data` is considered to be changed when it is not equal to
|
||||||
|
* the corresponding datum in `orig`. If the datum is an object, it is
|
||||||
|
* processed recursively until a scalar is reached for comparison.
|
||||||
|
*
|
||||||
|
* The algorithm processes the union of the keys of both `data` and
|
||||||
|
* `orig`.
|
||||||
|
*
|
||||||
|
* One caveat: since the diff represents the absence of changes as
|
||||||
|
* `undefined`, there is no way to distinguish between an actual
|
||||||
|
* undefined value and a non-change. If this is important to you, you
|
||||||
|
* can override this method.
|
||||||
|
*
|
||||||
|
* An example of the output of the algorithm is given in the class-level
|
||||||
|
* documentation.
|
||||||
|
*
|
||||||
|
* @param {*} data new data
|
||||||
|
* @param {*} orig original data to diff against
|
||||||
|
*
|
||||||
|
* @return {*} diff
|
||||||
|
*/
|
||||||
|
'virtual protected diff'( data, orig )
|
||||||
|
{
|
||||||
|
if ( orig === undefined )
|
||||||
|
{
|
||||||
|
// no previous, then data must be new, and so _is_ the diff
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
else if ( typeof data !== 'object' )
|
||||||
|
{
|
||||||
|
// only compare scalars (we'll recurse on objects)
|
||||||
|
return ( data === orig )
|
||||||
|
? undefined
|
||||||
|
: data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = this._getKeyUnion( data, orig );
|
||||||
|
let diff = ( Array.isArray( data ) ) ? [] : {};
|
||||||
|
|
||||||
|
for ( let key of keys )
|
||||||
|
{
|
||||||
|
diff[ key ] = this.diff( data[ key ], orig[ key ] );
|
||||||
|
}
|
||||||
|
|
||||||
|
return diff;
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the union of the keys of `first` and `second`
|
||||||
|
*
|
||||||
|
* `first` and `second` must both be of type `object`.
|
||||||
|
*
|
||||||
|
* @param {*} first some object
|
||||||
|
* @param {*} second some object
|
||||||
|
*
|
||||||
|
* @return {Set} Object.keys(first) ∪ Object.keys(second)
|
||||||
|
*/
|
||||||
|
'private _getKeyUnion'( first, second )
|
||||||
|
{
|
||||||
|
const keys = new Set( Object.keys( first ) );
|
||||||
|
|
||||||
|
Object.keys( second )
|
||||||
|
.forEach( key => keys.add( key ) );
|
||||||
|
|
||||||
|
return keys;
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold (reduce) all staged values
|
||||||
|
*
|
||||||
|
* A value is staged when it has been set but `#clear` has not yet
|
||||||
|
* been called---these are the only values that might be
|
||||||
|
* different. Since the purpose of this Store is to produce diffs,
|
||||||
|
* there is no way to iterate over all values previously encountered.
|
||||||
|
*
|
||||||
|
* The order of folding is undefined.
|
||||||
|
*
|
||||||
|
* The ternary function `callback` is of the same form as
|
||||||
|
* {@link Array#reduce}: the first argument is the value of the
|
||||||
|
* accumulator (initialized to the value of `initial`; the second
|
||||||
|
* is the stored item; and the third is the key of that item.
|
||||||
|
*
|
||||||
|
* @param {function(*,*,string=)} callback folding function
|
||||||
|
* @param {*} initial initial value for accumulator
|
||||||
|
*
|
||||||
|
* @return {Promise} promise of a folded value (final accumulator value)
|
||||||
|
*/
|
||||||
|
'public reduce'( callback, initial )
|
||||||
|
{
|
||||||
|
return Promise.resolve(
|
||||||
|
Object.keys( this._staged).reduce(
|
||||||
|
( accum, key ) => {
|
||||||
|
const value = this.diff(
|
||||||
|
this._staged[ key ],
|
||||||
|
this._commit[ key ]
|
||||||
|
);
|
||||||
|
|
||||||
|
return callback( accum, value, key );
|
||||||
|
},
|
||||||
|
initial
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
} );
|
|
@ -0,0 +1,235 @@
|
||||||
|
/**
|
||||||
|
* Test case for DiffStore
|
||||||
|
*
|
||||||
|
* Copyright (C) 2017 LoVullo Associates, Inc.
|
||||||
|
*
|
||||||
|
* This file is part of the Liza Data Collection Framework
|
||||||
|
*
|
||||||
|
* Liza is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const store = require( '../../' ).store;
|
||||||
|
const chai = require( 'chai' );
|
||||||
|
const expect = chai.expect;
|
||||||
|
const Class = require( 'easejs' ).Class;
|
||||||
|
const Sut = store.DiffStore;
|
||||||
|
const StoreMissError = store.StoreMissError;
|
||||||
|
|
||||||
|
chai.use( require( 'chai-as-promised' ) );
|
||||||
|
|
||||||
|
|
||||||
|
describe( 'store.DiffStore', () =>
|
||||||
|
{
|
||||||
|
it( 'considers first add call to be diffable', () =>
|
||||||
|
{
|
||||||
|
return expect(
|
||||||
|
Sut()
|
||||||
|
.add( 'foo', 'bar' )
|
||||||
|
.then( sut => sut.get( 'foo' ) )
|
||||||
|
).to.eventually.equal( 'bar' );
|
||||||
|
} );
|
||||||
|
|
||||||
|
|
||||||
|
it( 'does not clear diff on add of new key', () =>
|
||||||
|
{
|
||||||
|
return expect(
|
||||||
|
Sut()
|
||||||
|
.add( 'foo', 'bar' )
|
||||||
|
.then( sut => sut.add( 'baz', 'quux' ) )
|
||||||
|
.then( sut => Promise.all( [
|
||||||
|
sut.get( 'foo' ),
|
||||||
|
sut.get( 'baz' ),
|
||||||
|
] ) )
|
||||||
|
).to.eventually.deep.equal( [ 'bar', 'quux'] );
|
||||||
|
} );
|
||||||
|
|
||||||
|
|
||||||
|
it( 'updates diff when key modified before clear', () =>
|
||||||
|
{
|
||||||
|
return expect(
|
||||||
|
Sut()
|
||||||
|
.add( 'foo', 'bar' )
|
||||||
|
.then( sut => sut.add( 'foo', 'baz' ) )
|
||||||
|
.then( sut => sut.get( 'foo' ) )
|
||||||
|
).to.eventually.equal( 'baz' );
|
||||||
|
} );
|
||||||
|
|
||||||
|
|
||||||
|
it( 'considers key unchanged in diff immediately after clear', () =>
|
||||||
|
{
|
||||||
|
debugger;
|
||||||
|
return expect(
|
||||||
|
Sut()
|
||||||
|
.add( 'foo', 'bar' )
|
||||||
|
.then( sut => sut.clear() )
|
||||||
|
.then( sut => sut.get( 'foo' ) )
|
||||||
|
).to.eventually.equal( undefined );
|
||||||
|
} );
|
||||||
|
|
||||||
|
|
||||||
|
// distinction between unknown key and no change (compare to above test)
|
||||||
|
it( 'distinguishes between unchanged and unknown keys', () =>
|
||||||
|
{
|
||||||
|
debugger;
|
||||||
|
return expect(
|
||||||
|
Sut()
|
||||||
|
.add( 'foo', 'bar' )
|
||||||
|
.then( sut => sut.clear() )
|
||||||
|
.then( sut => sut.get( 'unknown' ) )
|
||||||
|
).to.eventually.be.rejectedWith( StoreMissError );
|
||||||
|
} );
|
||||||
|
|
||||||
|
|
||||||
|
[
|
||||||
|
// scalar
|
||||||
|
{
|
||||||
|
orig: 'bar',
|
||||||
|
next: 'baz',
|
||||||
|
expected: 'baz',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
orig: [ 'bar', 'baz' ],
|
||||||
|
next: 'baz',
|
||||||
|
expected: 'baz',
|
||||||
|
},
|
||||||
|
|
||||||
|
// returns new value if entire array changed
|
||||||
|
{
|
||||||
|
orig: [ 'bar', 'baz' ],
|
||||||
|
next: [ 'quux', 'quuux' ],
|
||||||
|
expected: [ 'quux', 'quuux' ],
|
||||||
|
},
|
||||||
|
|
||||||
|
// sets unchanged indexes to undefined
|
||||||
|
{
|
||||||
|
orig: [ 'bar', 'baz', 'quux' ],
|
||||||
|
next: [ 'bar', 'quux' ],
|
||||||
|
expected: [ undefined, 'quux', undefined ],
|
||||||
|
},
|
||||||
|
|
||||||
|
// next size > original
|
||||||
|
{
|
||||||
|
orig: [ 'bar', 'baz' ],
|
||||||
|
next: [ 'quux', 'baz', 'quuux' ],
|
||||||
|
expected: [ 'quux', undefined, 'quuux' ],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 5 ^
|
||||||
|
|
||||||
|
// same
|
||||||
|
{
|
||||||
|
orig: [ 'bar', 'baz' ],
|
||||||
|
next: [ 'bar', 'baz' ],
|
||||||
|
expected: [ undefined, undefined ],
|
||||||
|
},
|
||||||
|
|
||||||
|
// no longer an array
|
||||||
|
{
|
||||||
|
orig: [ 'bar', [ 'baz', 'quux' ] ],
|
||||||
|
next: [ 'bar', 'quux' ],
|
||||||
|
expected: [ undefined, 'quux'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// nested change
|
||||||
|
{
|
||||||
|
orig: [ 'bar', [ 'baz', 'quux' ] ],
|
||||||
|
next: [ 'bar', [ 'foo', 'quux' ] ],
|
||||||
|
expected: [ undefined, [ 'foo', undefined ] ],
|
||||||
|
},
|
||||||
|
|
||||||
|
// note that it always recurses to set undefined, even if all of
|
||||||
|
// them are undefined
|
||||||
|
{
|
||||||
|
orig: [ [ 'bar' ], [ [ 'baz', 'quux' ] ] ],
|
||||||
|
next: [ [ 'bar' ], [ [ 'baz', 'foo' ] ] ],
|
||||||
|
expected: [ [ undefined ], [ [ undefined, 'foo' ] ] ],
|
||||||
|
},
|
||||||
|
|
||||||
|
// there's not a distinction in the algorithm between numeric
|
||||||
|
// indexes and object keys
|
||||||
|
{
|
||||||
|
orig: { foo: 'bar' },
|
||||||
|
next: { foo: 'baz' },
|
||||||
|
expected: { foo: 'baz' },
|
||||||
|
},
|
||||||
|
|
||||||
|
// 10 ^
|
||||||
|
|
||||||
|
{
|
||||||
|
orig: { foo: 'bar' },
|
||||||
|
next: { foo: 'bar' },
|
||||||
|
expected: { foo: undefined },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
orig: { foo: 'bar', baz: 'quux' },
|
||||||
|
next: { foo: 'foo', baz: 'quux' },
|
||||||
|
expected: { foo: 'foo', baz: undefined },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
orig: { foo: 'bar', baz: 'quux' },
|
||||||
|
next: { baz: 'change' },
|
||||||
|
expected: { foo: undefined, baz: 'change' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
orig: { foo: 'bar', baz: [ 'a', 'b', ] },
|
||||||
|
next: { baz: [ 'a', 'c' ] },
|
||||||
|
expected: { foo: undefined, baz: [ undefined, 'c' ] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
orig: { foo: { bar: [ 'baz' ] } },
|
||||||
|
next: { foo: { bar: [ 'baz', 'quux' ] } },
|
||||||
|
expected: { foo: { bar: [ undefined, 'quux' ] } },
|
||||||
|
},
|
||||||
|
].forEach( ( { orig, next, expected }, i ) =>
|
||||||
|
{
|
||||||
|
it( `properly diffs (${i})`, () =>
|
||||||
|
{
|
||||||
|
return expect(
|
||||||
|
Sut()
|
||||||
|
.add( 'foo', orig )
|
||||||
|
.then( sut => sut.clear() )
|
||||||
|
.then( sut => sut.add( 'foo', next ) )
|
||||||
|
.then( sut => sut.get( 'foo' ) )
|
||||||
|
).to.eventually.deep.equal( expected );
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
|
||||||
|
|
||||||
|
describe( '#reduce', () =>
|
||||||
|
{
|
||||||
|
it( 'iterates though each diff', () =>
|
||||||
|
{
|
||||||
|
return expect(
|
||||||
|
Sut()
|
||||||
|
.add( 'foo', [ 'a', 'foo' ] )
|
||||||
|
.then( sut => sut.add( 'bar', 'b' ) )
|
||||||
|
.then( sut => sut.add( 'baz', 'c' ) )
|
||||||
|
.then( sut => sut.clear() )
|
||||||
|
.then( sut => sut.add( 'foo', [ 'a2', 'foo' ] ) )
|
||||||
|
.then( sut => sut.add( 'baz', 'c2' ) )
|
||||||
|
.then( sut => sut.reduce( ( accum, value, key ) =>
|
||||||
|
{
|
||||||
|
accum[ key ] = value;
|
||||||
|
return accum;
|
||||||
|
}, {} ) )
|
||||||
|
).to.eventually.deep.equal( {
|
||||||
|
foo: [ 'a2', undefined ],
|
||||||
|
baz: 'c2',
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
} );
|
Loading…
Reference in New Issue