1
0
Fork 0

Add DiffStore

* src/store/DiffStore.js: Add class.
* test/store/DiffStoreTest.js: Add test case.

DEV-2296
master
Mike Gerwitz 2017-01-27 15:42:16 -05:00
parent 3289e42003
commit 24180e704a
2 changed files with 528 additions and 0 deletions

View File

@ -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
)
);
},
} );

View File

@ -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',
} );
} );
} );
} );