Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ Extends the base `Options` interface:
interface AtomOptions extends Options {
reversible?: boolean; // Include oldValue for undo. Default: true
arrayIdentityKeys?: Record<string, string | FunctionKey>;
keysToSkip?: readonly string[];
keysToSkip?: readonly (string | RegExp)[];
}
```

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -649,7 +662,7 @@ interface Options {
arrayIdentityKeys?: Record<string, string | FunctionKey> | Map<string | RegExp, string | FunctionKey>;
/** @deprecated Use arrayIdentityKeys instead */
embeddedObjKeys?: Record<string, string | FunctionKey> | Map<string | RegExp, string | FunctionKey>;
keysToSkip?: readonly string[];
keysToSkip?: readonly (string | RegExp)[];
treatTypeChangeAsReplace?: boolean; // default: true
}
```
Expand Down
19 changes: 14 additions & 5 deletions src/jsonDiff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand All @@ -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({
Expand Down
116 changes: 116 additions & 0 deletions tests/jsonDiff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
Loading