diff --git a/changelog.md b/changelog.md index a5ff1e5..28d8bf0 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,9 @@ +# 2021-05-14 - v2.4.4 + + - added `mergeChanges` and `unwrap` method to sakota + - replace `deepEqual` with `lodash.isequal` + - added `hasSakota` a public static method + # 2021-03-16 - v2.4.3 - improved change tracking. now if the value of a property is updated with the same value it will not track as a change. diff --git a/package-lock.json b/package-lock.json index 8be2536..71eb9e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@creately/sakota", - "version": "2.4.3", + "version": "2.4.4", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -182,6 +182,30 @@ "integrity": "sha512-vw3VyFPa9mlba6NZPBZC3q2Zrnkgy5xuCVI43/tTLX6umdYrYvcFtQUKi2zH3PjFZQ9XCxNM/NMrM9uk8TPOzg==", "dev": true }, + "@types/lodash": { + "version": "4.14.169", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.169.tgz", + "integrity": "sha512-DvmZHoHTFJ8zhVYwCLWbQ7uAbYQEk52Ev2/ZiQ7Y7gQGeV9pjBqjnQpECMHfKS1rCYAhMI7LHVxwyZLZinJgdw==", + "dev": true + }, + "@types/lodash.isequal": { + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/@types/lodash.isequal/-/lodash.isequal-4.5.5.tgz", + "integrity": "sha512-4IKbinG7MGP131wRfceK6W4E/Qt3qssEFLF30LnJbjYiSfHGGRU/Io8YxXrZX109ir+iDETC8hw8QsDijukUVg==", + "dev": true, + "requires": { + "@types/lodash": "*" + } + }, + "@types/lodash.set": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@types/lodash.set/-/lodash.set-4.3.6.tgz", + "integrity": "sha512-ZeGDDlnRYTvS31Laij0RsSaguIUSBTYIlJFKL3vm3T2OAZAQj2YpSvVWJc0WiG4jqg9fGX6PAPGvDqBcHfSgFg==", + "dev": true, + "requires": { + "@types/lodash": "*" + } + }, "@webassemblyjs/ast": { "version": "1.8.5", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.8.5.tgz", @@ -3576,6 +3600,16 @@ "integrity": "sha512-UejweD1pDoXu+AD825lWwp4ZGtSwgnpZxb3JDViD7StjQz+Nb/6l093lx4OQ0foGWNRoc19mWy7BzL+UAK2iVg==", "dev": true }, + "lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=" + }, + "lodash.set": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz", + "integrity": "sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM=" + }, "log-symbols": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", diff --git a/package.json b/package.json index 511d36d..263f2ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@creately/sakota", - "version": "2.4.3", + "version": "2.4.4", "description": "Proxies js objects and records all changes made on an object without modifying the object.", "main": "dist/index.js", "typings": "dist/index.d.ts", @@ -15,6 +15,8 @@ "license": "MIT", "devDependencies": { "@types/jasmine": "^3.3.9", + "@types/lodash.isequal": "^4.5.5", + "@types/lodash.set": "^4.3.6", "istanbul-instrumenter-loader": "^3.0.1", "jasmine-core": "^3.3.0", "karma": "^3.1.4", @@ -27,5 +29,9 @@ "ts-loader": "^5.3.3", "typescript": "^3.3.3333", "webpack": "^4.29.6" + }, + "dependencies": { + "lodash.isequal": "^4.5.0", + "lodash.set": "^4.3.2" } } diff --git a/src/__tests__/index.spec.ts b/src/__tests__/index.spec.ts index 08d55cc..e6a1449 100644 --- a/src/__tests__/index.spec.ts +++ b/src/__tests__/index.spec.ts @@ -623,4 +623,217 @@ describe('Sakota', () => { expect(console.warn).toHaveBeenCalledTimes(1); }); }); + + // Test for mergeChanges + // ----------------------- + + describe('mergeChanges', () => { + it('should merge given changes into the sakota model', () => { + const source = { + a: 123, + a1: 23, + b: { + x: 234, + y: 345, + }, + b1: { + x: 234, + y: 345, + }, + c: [{ a: 123 }], + d: [1, 2, 3], + e: [{ a: 123 }], + }; + + const target = { + a: 234, + a2: 23, + b: { + x: 234, + z: 234, + }, + b1: { + x: 234, + }, + c: [{ b: 123 }, { a: 234 }], + d: [1, 3], + e: [{ a: 234 }], + }; + + const wrapped: any = Sakota.create(source); + wrapped.a = 234; + delete wrapped.a1; + wrapped.a2 = 23; + wrapped.b.x = 234; + delete wrapped.b.y; + wrapped.c = [{ b: 123 }, { a: 234 }]; + wrapped.b.z = 234; + wrapped.d = [1, 3]; + wrapped.e[0].a = 234; + delete wrapped.b1.y; + + const wrapped1: any = Sakota.create(source); + wrapped1.__sakota__.mergeChanges(wrapped.__sakota__.getChanges()); + expect(wrapped1).toEqual(target as any); + }); + + it('should merge given changes into the existing sakota changes', () => { + const source = { + a: 123, + a1: 23, + b: { + x: 234, + y: 345, + }, + c: [{ a: 123 }], + d: [1, 2, 3], + e: [{ a: 123 }, { b: 123 }], + }; + + const target = { + a: 234, + a2: 23, + b: { + x: 234, + z: 234, + }, + c: [{ b: 123 }, { a: 234 }], + d: [1, 3], + e: [{ a: 234 }, { b: 345 }], + }; + + const wrapped: any = Sakota.create(source); + wrapped.a = 234; + delete wrapped.a1; + wrapped.a2 = 23; + wrapped.b.x = 234; + delete wrapped.b.y; + wrapped.c = [{ b: 123 }, { a: 234 }]; + + const wrapped1: any = Sakota.create(source); + wrapped1.b.z = 234; + wrapped1.d = [1, 3]; + wrapped1.e[0].a = 234; + wrapped1.e[1].b = 345; + + wrapped.__sakota__.mergeChanges(wrapped1.__sakota__.getChanges()); + expect(wrapped).toEqual(target as any); + }); + }); + + // Test for unwrap + // ----------------------- + describe('unwrap', () => { + it('should create a copy of the object removing sakota', () => { + const obj: any = { + a: 123, + b: { + c: 234, + }, + }; + const wrapped = Sakota.create(freeze(obj)); + wrapped.a = 345; + wrapped.a1 = 234; + wrapped.b.c = 2345; + + const expected = { + a: 345, + a1: 234, + b: { + c: 2345, + }, + }; + + const unwrapped = wrapped.__sakota__.unwrap(); + expect(unwrapped).toEqual(expected); + expect(unwrapped === obj).toBeFalsy(); + expect(Sakota.hasSakota(unwrapped)).toBeFalsy(); + }); + + it('should apply the changes to the target object if unwrapped in place', () => { + const obj: any = { + a: 123, + b: { + c: 234, + }, + }; + const wrapped = Sakota.create(obj); + wrapped.a = 345; + wrapped.a1 = 234; + wrapped.b.c = 2345; + + const expected = { + a: 345, + a1: 234, + b: { + c: 2345, + }, + }; + + const unwrapped = wrapped.__sakota__.unwrap(true); + expect(unwrapped === obj).toBeTruthy(); + expect(obj).toEqual(expected); + expect(Sakota.hasSakota(unwrapped)).toBeFalsy(); + }); + + it('should remove Sakota wrapper around array props', () => { + const obj: any = { + a: [{ b: 234 }], + }; + const wrapped = Sakota.create(freeze(obj)); + wrapped.a[0].b = 345; + const expected = { + a: [{ b: 345 }], + }; + + const unwrapped = wrapped.__sakota__.unwrap(); + expect(unwrapped).toEqual(expected); + expect(unwrapped === obj).toBeFalsy(); + expect(Sakota.hasSakota(unwrapped)).toBeFalsy(); + }); + + it('should return the same object as target', () => { + const obj: any = { + a: [{ b: 234 }], + }; + const wrapped = Sakota.create(obj); + delete obj.a[0].b; + + const unwrapped = wrapped.__sakota__.unwrap(true); + expect(unwrapped === obj).toBeTruthy(); + expect(unwrapped).toEqual({ a: [{}] }); + expect(Sakota.hasSakota(unwrapped)).toBeFalsy(); + }); + }); + + describe('mergeChanges + unwrap', () => { + it('should handle multiple changes properly', () => { + const source = { + a: 123, + }; + const target = { + a: 234, + b: { + c: 345, + }, + c: { + d: 456, + }, + }; + const sakotaWrapped: any = Sakota.create(source); + sakotaWrapped.a = 234; + sakotaWrapped.b = { + c: 234, + d: 345, + }; + const sakotaWrapped1 = Sakota.create(sakotaWrapped.__sakota__.unwrap()); + sakotaWrapped1.b.c = 345; + delete sakotaWrapped1.b.d; + sakotaWrapped1.c = { d: 234 }; + sakotaWrapped1.c.d = 456; + sakotaWrapped.__sakota__.mergeChanges(sakotaWrapped1.__sakota__.getChanges()); + expect(sakotaWrapped.__sakota__.unwrap()).toEqual(sakotaWrapped); + expect(sakotaWrapped).toEqual(target); + }); + }); }); diff --git a/src/deep-equal.ts b/src/deep-equal.ts deleted file mode 100644 index b972194..0000000 --- a/src/deep-equal.ts +++ /dev/null @@ -1,26 +0,0 @@ -// ref: https://stackoverflow.com/a/25456134 - -const deepEqual = (x: any, y: any) => { - if (x === y) { - return true; - } else if (typeof x == 'object' && x != null && (typeof y == 'object' && y != null)) { - if (Object.keys(x).length != Object.keys(y).length) { - return false; - } - - for (var prop in x) { - if (y.hasOwnProperty(prop)) { - if (!deepEqual(x[prop], y[prop])) { - return false; - } - } else { - return false; - } - } - - return true; - } - return false; -}; - -export default deepEqual; diff --git a/src/index.ts b/src/index.ts index fa67012..30ccb58 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ -import deepEqual from './deep-equal'; +import isEqual from 'lodash.isequal'; +import _set from 'lodash.set'; /** * The key used to get the handler. @@ -225,7 +226,7 @@ export class Sakota implements ProxyHandler { */ public set(obj: any, key: KeyType, val: any): boolean { if (!Sakota.config.prodmode) { - if (this._hasSakota(val)) { + if (Sakota.hasSakota(val)) { console.warn('Sakota: value is also wrapped by Sakota!', { obj: obj, key, val }); } } @@ -239,7 +240,7 @@ export class Sakota implements ProxyHandler { if (!this.diff) { this.diff = { $set: {}, $unset: {} }; } - if (key in obj && deepEqual(obj[key], val)) { + if (key in obj && isEqual(obj[key], val)) { if (this.diff.$unset[key] || this.diff.$set[key]) { delete this.diff.$unset[key]; delete this.diff.$set[key]; @@ -314,6 +315,115 @@ export class Sakota implements ProxyHandler { return pattern ? this.filterChanges(changes, pattern) : changes; } + /** + * This is an internal method to merge the changes from a different sakota object. + * If there are 2 skota objects, and one is modified and the same modification + * needs to be applied to the other object this method can be used. + * @param changes changes in Sakota format. + */ + public mergeChanges(changes: Partial) { + if (Object.keys(changes).length === 0) { + return; + } + const diff = this.diff || { $set: {}, $unset: {} }; + const kidChanges: { [prefix: string]: Changes } = {}; + if (changes.$set) { + const $set = changes.$set; + Object.keys(changes.$set).forEach(key => { + const dotIndex = key.indexOf('.'); + if (dotIndex === -1) { + delete diff.$unset[key]; + delete this.kids[key]; + diff.$set[key] = $set[key]; + } else { + const kkey = key.substring(0, dotIndex); + if (kidChanges.hasOwnProperty(kkey)) { + kidChanges[kkey].$set[key.substring(dotIndex + 1)] = $set[key]; + } else { + kidChanges[kkey] = { + $set: { [key.substring(dotIndex + 1)]: $set[key] }, + $unset: {}, + }; + } + } + }); + } + if (changes.$unset) { + Object.keys(changes.$unset).forEach(key => { + const dotIndex = key.indexOf('.'); + if (dotIndex === -1) { + delete diff.$set[key]; + delete this.kids[key]; + diff.$unset[key] = true; + } else { + const kkey = key.substring(0, dotIndex); + if (kidChanges.hasOwnProperty(kkey)) { + kidChanges[kkey].$unset[key.substring(dotIndex + 1)] = true; + } else { + kidChanges[kkey] = { + $set: {}, + $unset: { [key.substring(dotIndex + 1)]: true }, + }; + } + } + }); + } + + Object.keys(kidChanges).forEach(k => { + if (diff.$set.hasOwnProperty(k)) { + /* istanbul ignore if */ + if (typeof diff.$set[k] !== 'object') { + throw new Error('Invalid modifier'); // this scenario is not expected. + } + this.applyModifier(diff.$set[k], kidChanges[k]); + } else if (this.target.hasOwnProperty(k)) { + this.getKid(k, (this.target as any)[k]).__sakota__.mergeChanges(kidChanges[k]); + } else { + console.warn('unexpected modifier', { path: k, modifier: changes }); + const skeys = Object.keys(kidChanges[k].$set); + const ukeys = Object.keys(kidChanges[k].$set); + if (skeys.length === 0 || ukeys.length > 0 || skeys.some(k => k.includes('.'))) { + throw new Error('Invalid modifier'); // this scenario is not expected. + } else { + diff.$set[k] = kidChanges[k].$set; + } + } + }); + + this.diff = diff; + this.changed = true; + this.changes = {}; + } + + /** + * applying Sakota diff to an object inplace. + * this is similar to @creately/mungo::modify method. + * @param obj + * @param modifier + */ + private applyModifier(obj: any, modifier: Changes) { + Object.keys(modifier.$set).forEach(k => { + _set(obj, k.split('.'), modifier.$set[k]); + }); + Object.keys(modifier.$unset).forEach(k => { + if (k.includes('.')) { + const path = k.split('.'); + k = path.pop() as string; + delete this._get(obj, path)[k]; + } else { + delete obj[k]; + } + }); + } + + private _get(obj: any, path: string[]): any { + if (path.length === 0) { + return obj; + } + const [prop, ...remainingPath] = path; + return this._get(obj[prop], remainingPath); + } + /** * Resets changes recorded in the proxy. Can be filtered by key name. */ @@ -331,6 +441,28 @@ export class Sakota implements ProxyHandler { this.onChange(); } + /** + * this method removes Sakota wrapper for the target object + * @param inplace if true modifies the target object otherwise returns a copy of the target + * @returns Sakota wrapper removed object + */ + public unwrap(inplace: boolean = false) { + const $set = this.diff ? Object.assign({}, this.diff.$set) : {}; + const $unset = this.diff ? Object.keys(this.diff.$unset) : []; + Object.keys(this.kids).forEach(k => { + $set[k] = this.kids[k].__sakota__.unwrap(inplace); + }); + let val: any; + if (Array.isArray(this.target)) { + val = inplace ? this.target : this.target.slice(); + Object.keys($set).forEach(k => (val[k] = $set[k])); + } else { + val = inplace ? Object.assign(this.target, $set) : Object.assign({}, this.target, $set); + } + $unset.forEach(k => delete val[k]); + return val; + } + // Private Methods // --------------- @@ -513,7 +645,7 @@ export class Sakota implements ProxyHandler { /** * Checks whether the value or it's children is proxied with Sakota. */ - private _hasSakota(value: unknown): boolean { + public static hasSakota(value: unknown): boolean { if (typeof value !== 'object') { return false; } @@ -525,7 +657,7 @@ export class Sakota implements ProxyHandler { } if (Array.isArray(value)) { for (const child of value) { - if (this._hasSakota(child)) { + if (Sakota.hasSakota(child)) { return true; } } @@ -533,7 +665,7 @@ export class Sakota implements ProxyHandler { } for (const key in value) { const child = (value as any)[key]; - if (this._hasSakota(child)) { + if (Sakota.hasSakota(child)) { return true; } } diff --git a/tsconfig.json b/tsconfig.json index 72e88ef..6a95c92 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "alwaysStrict": true, "declaration": true, + "esModuleInterop": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, "lib": [ "dom", "es2015" ],