/* <CoffeeScript> {utils, Time} = require('tztime') functions = require('./functions').functions # !TODO: Do we need this here? {arrayOfMaps_To_CSVStyleArray, csvStyleArray_To_ArrayOfMaps} = require('./dataTransform') # !TODO: Do we need this here? INFINITY = '9999-01-01T00:00:00.000Z' class Store </CoffeeScript> */ /** * @class Lumenize.Store * * __An efficient, in-memory, datastore for snapshot data.__ * * Note, this store takes advantage of JavaScript's prototype inheritance to store snapshots in memory. Since the next snapshot might * only have one field different from the prior one, this saves a ton of space. There is some concern that this will * slow down certain operations because the JavaScript engine has to search all fields in the current level before bumping up * to the next. However, there is some evidence that modern JavaScript implementations handle this very efficiently. * * However, this choice means that each row in the snapshots array doesn't have all of the fields. * * Store keeps track of all of the fields it has seen so you can flatten a row(s) if necessary. * * Example: * * {Store} = require('../') * * snapshotCSVStyleArray = [ * ['RecordID', 'DefectID', 'Created_Date', 'Severity', 'Modified_Date', 'Status'], * [ 1, 1, '2014-06-16', 5, '2014-06-16', 'New'], * [ 100, 1, '2014-06-16', 5, '2014-07-17', 'In Progress'], * [ 1000, 1, '2014-06-16', 5, '2014-08-18', 'Done'], * ] * * defects = require('../').csvStyleArray_To_ArrayOfMaps(snapshotCSVStyleArray) * * config = * uniqueIDField: 'DefectID' * validFromField: 'Modified_Date' * idField: 'RecordID' * defaultValues: * Severity: 4 * * store = new Store(config, defects) * * console.log(require('../').table.toString(store.snapshots, store.fields)) * # | Modified_Date | _ValidTo | _PreviousValues | DefectID | RecordID | Created_Date | Severity | Status | * # | ------------------------ | ------------------------ | --------------- | -------- | -------- | ------------ | -------- | ----------- | * # | 2014-06-16T00:00:00.000Z | 2014-07-17T00:00:00.000Z | [object Object] | 1 | 1 | 2014-06-16 | 5 | New | * # | 2014-07-17T00:00:00.000Z | 2014-08-18T00:00:00.000Z | [object Object] | 1 | 100 | 2014-06-16 | 5 | In Progress | * # | 2014-08-18T00:00:00.000Z | 9999-01-01T00:00:00.000Z | [object Object] | 1 | 1000 | 2014-06-16 | 5 | Done | * * That's pretty boring. We pretty much got out what we put in. There are a few things to notice though. First, * Notice how the _ValidTo field is automatically set. Also, notice that it added the _PreviousValues field. This is * a record of the immediately proceeding values for the fields that changed. In this way, the records not only * represent the current snapshot; they also represent the state transition that occured to get into this snapshot * state. That's what stateBoundaryCrossedFilter and other methods key off of. * * Also, under the covers, the prototype of each snapshot is the prior snapshot and only the fields that changed * are actually stored in the next snapshot. So: * * console.log(store.snapshots[1] is store.snapshots[2].__proto__) * # true * * The Store also keeps the equivalent of a database index on uniqueIDField and keeps a pointer to the last snapshot * for each particular uniqueIDField. This provides a convenient way to do per entity analysis. * * console.log(store.byUniqueID['1'].snapshots[0].RecordID) * # 1 * * console.log(store.byUniqueID['1'].lastSnapshot.RecordID) * # 1000 * * @constructor * * @param {Object} config See Config options for details. * @param {Object[]} [snapshots] Optional parameter allowing the population of the Store at instantiation. * * @cfg {String} [uniqueIDField = "_EntityID"] Specifies the field that identifies unique entities. * @cfg {String} [validFromField = "_ValidFrom"] * @cfg {String} [validToField = "_ValidTo"] * @cfg {String} [idField = "_id"] * @cfg {String} [tz = "GMT"] * @cfg {Object} [defaultValues = {}] In some datastores, null numeric fields may be assumed to be zero and null * boolean fields may be assumed to be false. Lumenize makes no such assumption and will crash if a field value * is missing. the defaultValues becomes the root of prototype inheritance hierarchy. * */ /* <CoffeeScript> </CoffeeScript> */ /** * @property snapshots * @member Lumenize.Store * An Array of Objects * * The snapshots in compressed (via JavaScript inheritance) format */ /* <CoffeeScript> </CoffeeScript> */ /** * @property fields * @member Lumenize.Store * An Array of Strings * * The list of all fields that this Store has ever seen. Use to expand each row. */ /* <CoffeeScript> </CoffeeScript> */ /** * @property byUniqueID * @member Lumenize.Store * This is the database equivalent of an index by uniqueIDField. * * An Object in the form: * * { * '1234': { * snapshots: [...], * lastSnapshot: <points to last snapshot for this uniqueID> * }, * '7890': { * ... * }, * ... * } */ /* <CoffeeScript> @config = utils.clone(@userConfig) unless @config.uniqueIDField? @config.uniqueIDField = '_EntityID' unless @config.validFromField? @config.validFromField = '_ValidFrom' unless @config.validToField? @config.validToField = '_ValidTo' unless @config.tz? @config.tz = 'GMT' unless @config.defaultValues? @config.defaultValues = {} @config.defaultValues[@config.validFromField] = new Time(1, Time.MILLISECOND).toString() unless @config.idField? @config.idField = '_id' @snapshots = [] @fields = [@config.validFromField, @config.validToField, '_PreviousValues', @config.uniqueIDField] @lastValidFrom = new Time(1, Time.MILLISECOND).toString() @byUniqueID = {} @addSnapshots(snapshots) addSnapshots: (snapshots) -> </CoffeeScript> */ /** * @method addSnapshots * @member Lumenize.Store * Adds the snapshots to the Store * @param {Object[]} snapshots * @chainable * @return {Store} Returns this * */ /* <CoffeeScript> snapshots = utils._.sortBy(snapshots, @config.validFromField) for s in snapshots uniqueID = s[@config.uniqueIDField] utils.assert(uniqueID?, "Missing #{@config.uniqueIDField} field in submitted snapshot: \n" + JSON.stringify(s, null, 2)) dataForUniqueID = @byUniqueID[uniqueID] unless dataForUniqueID? # First time we've seen this uniqueID dataForUniqueID = snapshots: [] lastSnapshot: @config.defaultValues @byUniqueID[uniqueID] = dataForUniqueID if s[@config.validFromField] < dataForUniqueID.lastSnapshot[@config.validFromField] throw new Error("Got a new snapshot for a time earlier than the prior last snapshot for #{@config.uniqueIDField} #{uniqueID}.") # Eventually, we may have to handle this case. I should be able to enable _nextSnapshot and stitch a snapshot in between two existing ones else if s[@config.validFromField] is dataForUniqueID.lastSnapshot[@config.validFromField] for key, value of s dataForUniqueID.lastSnapshot[key] = value else validFrom = s[@config.validFromField] validFrom = new Time(validFrom, null, @config.tz).getISOStringInTZ(@config.tz) utils.assert(validFrom >= dataForUniqueID.lastSnapshot[@config.validFromField], "validFromField (#{validFrom}) must be >= lastValidFrom (#{dataForUniqueID.lastSnapshot[@config.validFromField]}) for this entity" ) # !TODO: Deal with out of order snapshots utils.assert(validFrom >= @lastValidFrom, "validFromField (#{validFrom}) must be >= lastValidFrom (#{@lastValidFrom}) for the Store") validTo = s[@config.validTo] if validTo? validTo = new Time(validTo, null, @config.tz).getISOStringInTZ(@config.tz) else validTo = INFINITY priorSnapshot = dataForUniqueID.lastSnapshot # Build new Snapshot for adding newSnapshot = {} newSnapshot._PreviousValues = {} for key, value of s unless key in [@config.validFromField, @config.validToField, '_PreviousValues', @config.uniqueIDField] unless key in @fields @fields.push(key) unless value == priorSnapshot[key] newSnapshot[key] = value unless key in [@config.idField] if priorSnapshot[key]? newSnapshot._PreviousValues[key] = priorSnapshot[key] else newSnapshot._PreviousValues[key] = null newSnapshot[@config.uniqueIDField] = uniqueID newSnapshot[@config.validFromField] = validFrom newSnapshot[@config.validToField] = validTo if s._PreviousValues? newSnapshot._PreviousValues = s._PreviousValues newSnapshot.__proto__ = priorSnapshot # Update priorSnapshot if priorSnapshot[@config.validToField] is INFINITY priorSnapshot[@config.validToField] = validFrom # priorSnapshot._NextSnapshot = newSnapshot # Adding link to next snapshot in case we want to do smart insertion later # Update metadata dataForUniqueID.lastSnapshot = newSnapshot @lastValidFrom = validFrom # Add the newSnapshot to the arrays @byUniqueID[uniqueID].snapshots.push(newSnapshot) @snapshots.push(newSnapshot) return this filtered: (filter) -> </CoffeeScript> */ /** * @method filtered * @member Lumenize.Store * Returns the subset of the snapshots that match the filter * @param {Function} filter * @return {Object[]} An array of snapshots. Note, they will not be flattened so they have references to their prototypes */ /* <CoffeeScript> result = [] for s in @snapshots if filter(s) result.push(s) return result stateBoundaryCrossedFiltered: (field, values, valueToTheRightOfBoundary, forward = true, assumeNullIsLowest = true) -> </CoffeeScript> */ /** * @method stateBoundaryCrossedFiltered * @member Lumenize.Store * Returns the subset of the snapshots where the field transitions from the left of valueToTheRightOfBoundary to * the right (inclusive) * @param {String} field * @param {String[]} values * @param {String} valueToTheRightOfBoundary * @param {Boolean} [forward = true] When true (the default), this will return the transitions from left to right * However, if you set this to false, it will return the transitions right to left. * @param {Boolean} [assumeNullIsLowest = true] Set to false if you don't want to consider transitions out of null * @return {Object[]} An array or snapshots. Note, they will not be flattened so they have references to their prototypes */ /* <CoffeeScript> index = values.indexOf(valueToTheRightOfBoundary) utils.assert(index >= 0, "stateToTheRightOfBoundary must be in stateList") left = values.slice(0, index) if assumeNullIsLowest left.unshift(null) right = values.slice(index) if forward filter = (s) -> s._PreviousValues.hasOwnProperty(field) and s._PreviousValues[field] in left and s[field] in right else filter = (s) -> s._PreviousValues.hasOwnProperty(field) and s._PreviousValues[field] in right and s[field] in left return @filtered(filter) stateBoundaryCrossedFilteredBothWays: (field, values, valueToTheRightOfBoundary, assumeNullIsLowest = true) -> </CoffeeScript> */ /** * @method stateBoundaryCrossedFilteredBothWays * @member Lumenize.Store * Shortcut to stateBoundaryCrossedFiltered for when you need both directions * @param {String} field * @param {String[]} values * @param {String} valueToTheRightOfBoundary * @param {Boolean} [assumeNullIsLowest = true] Set to false if you don't want to consider transitions out of null * @return {Object} An object with two root keys: 1) forward, 2) backward. The values are the arrays that are returned * from stateBoundaryCrossedFiltered */ /* <CoffeeScript> forward = @stateBoundaryCrossedFiltered(field, values, valueToTheRightOfBoundary, true, assumeNullIsLowest) backward = @stateBoundaryCrossedFiltered(field, values, valueToTheRightOfBoundary, false, assumeNullIsLowest) return {forward, backward} exports.Store = Store </CoffeeScript> */