diff --git a/packages/loki/spec/generic/changesApi.spec.js b/packages/loki/spec/generic/changesApi.spec.js index 75f3ba00..bc798f0f 100644 --- a/packages/loki/spec/generic/changesApi.spec.js +++ b/packages/loki/spec/generic/changesApi.spec.js @@ -55,4 +55,42 @@ describe("changesApi", () => { expect(users.getChanges().length).toEqual(0); }); + + it("works with delta mode", function () { + const db = new loki(), + options = { + asyncListeners: false, + disableChangesApi: false, + disableDeltaChangesApi: false + }, + items = db.addCollection("items", options); + + // Add some documents to the collection + items.insert({name: "mjolnir", owner: "thor", maker: {name: "dwarves", count: 1}}); + items.insert({name: "gungnir", owner: "odin", maker: {name: "elves", count: 1}}); + items.insert({name: "tyrfing", owner: "Svafrlami", maker: {name: "dwarves", count: 1}}); + items.insert({name: "draupnir", owner: "odin", maker: {name: "elves", count: 1}}); + + // Find and update an existing document + const tyrfing = items.findOne({"name": "tyrfing"}); + tyrfing.owner = "arngrim"; + items.update(tyrfing); + tyrfing.maker.count = 4; + items.update(tyrfing); + + let changes = db.serializeChanges(["items"]); + changes = JSON.parse(changes); + + expect(changes.length).toEqual(6); + + const firstUpdate = changes[4]; + expect(firstUpdate.operation).toEqual("U"); + expect(firstUpdate.obj.owner).toEqual("arngrim"); + expect(firstUpdate.obj.name).toBeUndefined(); + + const secondUpdate = changes[5]; + expect(secondUpdate.operation).toEqual("U"); + expect(secondUpdate.obj.owner).toBeUndefined(); + expect(secondUpdate.obj.maker).toEqual({count: 4}); + }); }); diff --git a/packages/loki/src/collection.js b/packages/loki/src/collection.js index ebb0c1c1..9ab7cb87 100644 --- a/packages/loki/src/collection.js +++ b/packages/loki/src/collection.js @@ -85,6 +85,7 @@ export class Collection extends LokiEventEmitter { * @param {boolean} [options.adaptiveBinaryIndices=true] - collection indices will be actively rebuilt rather than lazily * @param {boolean} [options.asyncListeners=false] - whether listeners are invoked asynchronously * @param {boolean} [options.disableChangesApi=true] - set to false to enable Changes API + * @param {boolean} [options.disableDeltaChangesApi=true] - set to false to enable Delta Changes API (requires Changes API, forces cloning) * @param {boolean} [options.autoupdate=false] - use Object.observe to update objects automatically * @param {boolean} [options.clone=false] - specify whether inserts and queries clone to/from user * @param {boolean} [options.serializableIndices =true[]] - converts date values on binary indexed property values are serializable @@ -171,6 +172,12 @@ export class Collection extends LokiEventEmitter { // disable track changes this.disableChangesApi = options.disableChangesApi !== undefined ? options.disableChangesApi : true; + // disable delta update object style on changes + this.disableDeltaChangesApi = options.disableDeltaChangesApi !== undefined ? options.disableDeltaChangesApi : true; + if (this.disableChangesApi) { + this.disableDeltaChangesApi = true; + } + // option to observe objects and update them automatically, ignored if Object.observe is not supported this.autoupdate = options.autoupdate !== undefined ? options.autoupdate : false; @@ -258,14 +265,54 @@ export class Collection extends LokiEventEmitter { * This method creates a clone of the current status of an object and associates operation and collection name, * so the parent db can aggregate and generate a changes object for the entire db */ - function createChange(name, op, obj) { + function createChange(name, op, obj, old) { self.changes.push({ name, operation: op, - obj: JSON.parse(JSON.stringify(obj)) + obj: op === "U" && !self.disableDeltaChangesApi ? getChangeDelta(obj, old) : JSON.parse(JSON.stringify(obj)) }); } + //Compare changed object (which is a forced clone) with existing object and return the delta + function getChangeDelta(obj, old) { + if (old) { + return getObjectDelta(old, obj); + } + else { + return JSON.parse(JSON.stringify(obj)); + } + } + + this.getChangeDelta = getChangeDelta; + + function getObjectDelta(oldObject, newObject) { + const propertyNames = newObject !== null && typeof newObject === "object" ? Object.keys(newObject) : null; + if (propertyNames && propertyNames.length && ["string", "boolean", "number"].indexOf(typeof(newObject)) < 0) { + const delta = {}; + for (let i = 0; i < propertyNames.length; i++) { + const propertyName = propertyNames[i]; + if (newObject.hasOwnProperty(propertyName)) { + if (!oldObject.hasOwnProperty(propertyName) || self.uniqueNames.indexOf(propertyName) >= 0 || propertyName === "$loki" || propertyName === "meta") { + delta[propertyName] = newObject[propertyName]; + } + else { + const propertyDelta = getObjectDelta(oldObject[propertyName], newObject[propertyName]); + if (typeof propertyDelta !== "undefined" && propertyDelta !== {}) { + delta[propertyName] = propertyDelta; + } + } + } + } + return Object.keys(delta).length === 0 ? undefined : delta; + } + else { + return oldObject === newObject ? undefined : newObject; + } + } + + this.getObjectDelta = getObjectDelta; + + // clear all the changes function flushChanges() { self.changes = []; @@ -323,8 +370,8 @@ export class Collection extends LokiEventEmitter { createChange(self.name, "I", obj); } - function createUpdateChange(obj) { - createChange(self.name, "U", obj); + function createUpdateChange(obj, old) { + createChange(self.name, "U", obj, old); } function insertMetaWithChange(obj) { @@ -332,9 +379,9 @@ export class Collection extends LokiEventEmitter { createInsertChange(obj); } - function updateMetaWithChange(obj) { + function updateMetaWithChange(obj, old) { updateMeta(obj); - createUpdateChange(obj); + createUpdateChange(obj, old); } @@ -352,6 +399,9 @@ export class Collection extends LokiEventEmitter { this.setChangesApi = (enabled) => { this.disableChangesApi = !enabled; + if (!enabled) { + self.disableDeltaChangesApi = false; + } setHandlers(); }; /** @@ -361,8 +411,8 @@ export class Collection extends LokiEventEmitter { insertHandler(obj); }); - this.on("update", (obj) => { - updateHandler(obj); + this.on("update", (obj, old) => { + updateHandler(obj, old); }); this.on("delete", (obj) => { @@ -418,7 +468,10 @@ export class Collection extends LokiEventEmitter { } static fromJSONObject(obj, options, forceRebuild) { - let coll = new Collection(obj.name, {disableChangesApi: obj.disableChangesApi}); + let coll = new Collection(obj.name, { + disableChangesApi: obj.disableChangesApi, + disableDeltaChangesApi: obj.disableDeltaChangesApi + }); coll.adaptiveBinaryIndices = obj.adaptiveBinaryIndices !== undefined ? (obj.adaptiveBinaryIndices === true) : false; coll.transactional = obj.transactional; @@ -1016,7 +1069,7 @@ export class Collection extends LokiEventEmitter { position = arr[1]; // position in data array // if configured to clone, do so now... otherwise just use same obj reference - newInternal = this.cloneObjects ? clone(doc, this.cloneMethod) : doc; + newInternal = this.cloneObjects || !this.disableDeltaChangesApi ? clone(doc, this.cloneMethod) : doc; this.emit("pre-update", doc); @@ -1059,7 +1112,7 @@ export class Collection extends LokiEventEmitter { this.commit(); this.dirty = true; // for autosave scenarios - this.emit("update", doc, this.cloneObjects ? clone(oldInternal, this.cloneMethod) : null); + this.emit("update", doc, this.cloneObjects || !this.disableDeltaChangesApi ? clone(oldInternal, this.cloneMethod) : null); return doc; } catch (err) { this.rollback(); diff --git a/packages/loki/src/event_emitter.js b/packages/loki/src/event_emitter.js index 43c5969c..c9fa1b60 100644 --- a/packages/loki/src/event_emitter.js +++ b/packages/loki/src/event_emitter.js @@ -13,24 +13,24 @@ export class LokiEventEmitter { constructor() { /** - * @prop {hashmap} events - a hashmap, with each property being an array of callbacks - */ + * @prop {hashmap} events - a hashmap, with each property being an array of callbacks + */ this.events = {}; /** - * @prop {boolean} asyncListeners - boolean determines whether or not the callbacks associated with each event - * should happen in an async fashion or not - * Default is false, which means events are synchronous - */ + * @prop {boolean} asyncListeners - boolean determines whether or not the callbacks associated with each event + * should happen in an async fashion or not + * Default is false, which means events are synchronous + */ this.asyncListeners = false; } /** - * on(eventName, listener) - adds a listener to the queue of callbacks associated to an event - * @param {string|string[]} eventName - the name(s) of the event(s) to listen to - * @param {function} listener - callback function of listener to attach - * @returns {int} the index of the callback in the array of listeners for a particular event - */ + * on(eventName, listener) - adds a listener to the queue of callbacks associated to an event + * @param {string|string[]} eventName - the name(s) of the event(s) to listen to + * @param {function} listener - callback function of listener to attach + * @returns {int} the index of the callback in the array of listeners for a particular event + */ on(eventName, listener) { let event; @@ -50,21 +50,21 @@ export class LokiEventEmitter { } /** - * emit(eventName, data) - emits a particular event - * with the option of passing optional parameters which are going to be processed by the callback - * provided signatures match (i.e. if passing emit(event, arg0, arg1) the listener should take two parameters) - * @param {string} eventName - the name of the event - * @param {object} data - optional object passed with the event - */ - emit(eventName, data) { + * emit(eventName, data) - emits a particular event + * with the option of passing optional parameters which are going to be processed by the callback + * provided signatures match (i.e. if passing emit(event, arg0, arg1) the listener should take two parameters) + * @param {string} eventName - the name of the event + * @param {object} data - optional object passed with the event + */ + emit(eventName, ...data) { if (eventName && this.events[eventName]) { this.events[eventName].forEach((listener) => { if (this.asyncListeners) { setTimeout(() => { - listener(data); + listener(...data); }, 1); } else { - listener(data); + listener(...data); } }); @@ -72,21 +72,21 @@ export class LokiEventEmitter { } /** - * Alias of LokiEventEmitter.prototype.on - * addListener(eventName, listener) - adds a listener to the queue of callbacks associated to an event - * @param {string|string[]} eventName - the name(s) of the event(s) to listen to - * @param {function} listener - callback function of listener to attach - * @returns {int} the index of the callback in the array of listeners for a particular event - */ + * Alias of LokiEventEmitter.prototype.on + * addListener(eventName, listener) - adds a listener to the queue of callbacks associated to an event + * @param {string|string[]} eventName - the name(s) of the event(s) to listen to + * @param {function} listener - callback function of listener to attach + * @returns {int} the index of the callback in the array of listeners for a particular event + */ addListener(eventName, listener) { return this.on(eventName, listener); } /** - * removeListener() - removes the listener at position 'index' from the event 'eventName' - * @param {string|string[]} eventName - the name(s) of the event(s) which the listener is attached to - * @param {function} listener - the listener callback function to remove from emitter - */ + * removeListener() - removes the listener at position 'index' from the event 'eventName' + * @param {string|string[]} eventName - the name(s) of the event(s) which the listener is attached to + * @param {function} listener - the listener callback function to remove from emitter + */ removeListener(eventName, listener) { if (Array.isArray(eventName)) { eventName.forEach((currentEventName) => { diff --git a/packages/loki/src/resultset.js b/packages/loki/src/resultset.js index 4156714c..9da4e867 100644 --- a/packages/loki/src/resultset.js +++ b/packages/loki/src/resultset.js @@ -976,6 +976,12 @@ export class Resultset { forceCloneMethod = "shallow"; } + // if collection has delta changes active, then force clones and use 'parse-stringify' for effective change tracking of nested objects + if (!this.collection.disableDeltaChangesApi) { + forceClones = true; + forceCloneMethod = "parse-stringify"; + } + // if this has no filters applied, just return collection.data if (!this.filterInitialized) { if (this.filteredrows.length === 0) {