From 3ab5adf25fb05bd81bdbda8ad21699856555a7f9 Mon Sep 17 00:00:00 2001 From: Ruben Grossmann Date: Thu, 23 Apr 2026 11:36:09 +0200 Subject: [PATCH] Update `keysToSkip` to use `RegExp` for path matching --- README.md | 17 +++++- src/jsonDiff.ts | 19 +++++-- tests/jsonDiff.test.ts | 116 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index b05e346..2b1a864 100644 --- a/README.md +++ b/README.md @@ -256,7 +256,7 @@ Extends the base `Options` interface: interface AtomOptions extends Options { reversible?: boolean; // Include oldValue for undo. Default: true arrayIdentityKeys?: Record; - keysToSkip?: readonly string[]; + keysToSkip?: readonly (string | RegExp)[]; } ``` @@ -614,7 +614,20 @@ diff(old, new, { arrayIdentityKeys: { tags: '$value' } }); #### Path Skipping ```typescript +// Skip an exact path and all its children diff(old, new, { keysToSkip: ['characters.metadata'] }); + +// Skip all children of a path using a regex but still detect ADD/REMOVE of the node itself +diff(old, new, { keysToSkip: [/^characters\.metadata\./] }); + +// Skip any path ending in a given key name, regardless of nesting depth +diff(old, new, { keysToSkip: [/\.secret$/] }); + +// Skip all properties except one +diff(old, new, { keysToSkip: [/^address\.(?!city$)/] }); + +// Mix strings and regexes in the same array +diff(old, new, { keysToSkip: ['characters.metadata', /\.secret$/] }); ``` #### Type Change Handling @@ -649,7 +662,7 @@ interface Options { arrayIdentityKeys?: Record | Map; /** @deprecated Use arrayIdentityKeys instead */ embeddedObjKeys?: Record | Map; - keysToSkip?: readonly string[]; + keysToSkip?: readonly (string | RegExp)[]; treatTypeChangeAsReplace?: boolean; // default: true } ``` diff --git a/src/jsonDiff.ts b/src/jsonDiff.ts index 9cca6a3..ba23f35 100644 --- a/src/jsonDiff.ts +++ b/src/jsonDiff.ts @@ -34,7 +34,7 @@ interface Options { arrayIdentityKeys?: EmbeddedObjKeysType | EmbeddedObjKeysMapType; /** @deprecated Use `arrayIdentityKeys` instead. */ embeddedObjKeys?: EmbeddedObjKeysType | EmbeddedObjKeysMapType; - keysToSkip?: readonly string[]; + keysToSkip?: readonly (string | RegExp)[]; treatTypeChangeAsReplace?: boolean; } @@ -371,9 +371,13 @@ const getKey = (path: string) => { const compare = (oldObj: any, newObj: any, path: any, keyPath: any, options: Options) => { let changes: any[] = []; - // Check if the current path should be skipped + // Check if the current path should be skipped const currentPath = keyPath.join('.'); - if (options.keysToSkip?.some(skipPath => { + if (options.keysToSkip?.some(skipSpec => { + if (skipSpec instanceof RegExp) { + return skipSpec.test(currentPath); + } + const skipPath = skipSpec; // Exact match if (currentPath === skipPath) { return true; @@ -504,6 +508,11 @@ const compare = (oldObj: any, newObj: any, path: any, keyPath: any, options: Opt return changes; }; +const isPathSkipped = (currentPath: string, skipSpec: string | RegExp): boolean => + skipSpec instanceof RegExp + ? skipSpec.test(currentPath) + : currentPath === skipSpec || currentPath.startsWith(skipSpec + '.'); + const compareObject = (oldObj: any, newObj: any, path: any, keyPath: any, skipPath = false, options: Options = {}) => { let k; let newKeyPath; @@ -535,7 +544,7 @@ const compareObject = (oldObj: any, newObj: any, path: any, keyPath: any, skipPa newKeyPath = skipPath ? keyPath : keyPath.concat([k]); // Check if the path should be skipped const currentPath = newKeyPath.join('.'); - if (options.keysToSkip?.some(skipPath => currentPath === skipPath || currentPath.startsWith(skipPath + '.'))) { + if (options.keysToSkip?.some(skipSpec => isPathSkipped(currentPath, skipSpec))) { continue; // Skip adding this key } changes.push({ @@ -551,7 +560,7 @@ const compareObject = (oldObj: any, newObj: any, path: any, keyPath: any, skipPa newKeyPath = skipPath ? keyPath : keyPath.concat([k]); // Check if the path should be skipped const currentPath = newKeyPath.join('.'); - if (options.keysToSkip?.some(skipPath => currentPath === skipPath || currentPath.startsWith(skipPath + '.'))) { + if (options.keysToSkip?.some(skipSpec => isPathSkipped(currentPath, skipSpec))) { continue; // Skip removing this key } changes.push({ diff --git a/tests/jsonDiff.test.ts b/tests/jsonDiff.test.ts index f3ccfe5..ecfdcfe 100644 --- a/tests/jsonDiff.test.ts +++ b/tests/jsonDiff.test.ts @@ -133,6 +133,122 @@ describe('jsonDiff#diff', () => { ]); }); + describe('regex keysToSkip', () => { + const base = { + user: { name: 'Alice', secret: 'abc123' }, + config: { secret: 'xyz', timeout: 30 } + }; + + it('skips paths matched by a regex pattern', () => { + const updated = { + user: { name: 'Alice', secret: 'changed' }, + config: { secret: 'changed', timeout: 30 } + }; + expect(diff(base, updated, { keysToSkip: [/\.secret$/] })).toEqual([]); + }); + + it('skips add of a path matched by regex', () => { + const withoutSecrets = { + user: { name: 'Alice' }, + config: { timeout: 30 } + }; + expect(diff(withoutSecrets, base, { keysToSkip: [/\.secret$/] })).toEqual([]); + }); + + it('skips removal of a path matched by regex', () => { + const withoutSecrets = { + user: { name: 'Alice' }, + config: { timeout: 30 } + }; + expect(diff(base, withoutSecrets, { keysToSkip: [/\.secret$/] })).toEqual([]); + }); + + it('still detects changes to non-matching paths', () => { + const updated = { + user: { name: 'Bob', secret: 'changed' }, + config: { secret: 'changed', timeout: 60 } + }; + expect(diff(base, updated, { keysToSkip: [/\.secret$/] })).toEqual([ + { type: 'UPDATE', key: 'user', changes: [{ type: 'UPDATE', key: 'name', value: 'Bob', oldValue: 'Alice' }] }, + { type: 'UPDATE', key: 'config', changes: [{ type: 'UPDATE', key: 'timeout', value: 60, oldValue: 30 }] } + ]); + }); + + it('can mix string and regex entries in keysToSkip', () => { + const updated = { + user: { name: 'Bob', secret: 'changed' }, + config: { secret: 'changed', timeout: 60 } + }; + expect(diff(base, updated, { keysToSkip: [/\.secret$/, 'config.timeout'] })).toEqual([ + { type: 'UPDATE', key: 'user', changes: [{ type: 'UPDATE', key: 'name', value: 'Bob', oldValue: 'Alice' }] } + ]); + }); + + describe('children of a path', () => { + const base = { + property: { + name: 'Alice', + address: { + formattedAddress: '123 Main St', + utcOffset: 0, + } + } + }; + + it('ignores property changes inside the matched path', () => { + const withChangedAddress = { + property: { + name: 'Alice', + address: { + formattedAddress: 'New Address', + utcOffset: 5, + } + } + }; + expect(diff(base, withChangedAddress, { keysToSkip: [/^property\.address\./] })).toEqual([]); + }); + + it('detects removal of the matched path itself', () => { + const withoutAddress = { property: { name: 'Alice' } }; + expect(diff(base, withoutAddress, { keysToSkip: [/^property\.address\./] })).toEqual([ + { + type: 'UPDATE', + key: 'property', + changes: [{ type: 'REMOVE', key: 'address', value: base.property.address }] + } + ]); + }); + + it('detects addition of the matched path itself', () => { + const withoutAddress = { property: { name: 'Alice' } }; + expect(diff(withoutAddress, base, { keysToSkip: [/^property\.address\./] })).toEqual([ + { + type: 'UPDATE', + key: 'property', + changes: [{ type: 'ADD', key: 'address', value: base.property.address }] + } + ]); + }); + }); + + it('skips all properties except one', () => { + const base = { + address: { street: '123 Main St', city: 'Springfield', zip: '12345' } + }; + const updated = { + address: { street: 'New Street', city: 'Shelbyville', zip: '99999' } + }; + + expect(diff(base, updated, { keysToSkip: [/^address\.(?!city$)/] })).toEqual([ + { + type: 'UPDATE', + key: 'address', + changes: [{ type: 'UPDATE', key: 'city', value: 'Shelbyville', oldValue: 'Springfield' }] + } + ]); + }); + }); + it.each(fixtures.assortedDiffs)( 'correctly diffs $oldVal with $newVal', ({ oldVal, newVal, expectedReplacement, expectedUpdate }) => {