Skip to content

Commit 693af56

Browse files
authored
Fix diffAtom nested identity path notation (#392) (#401)
* Fix diffAtom nested identity path using bracket instead of dot notation (#392) walkChanges was not passing embeddedKeyIsPath to buildCanonicalFilterPath, so function-based identity keys returning nested paths like positionNumber.value produced @['positionNumber.value'] instead of @.positionNumber.value. * Address review: fix findElementByKey for nested paths, fix null fallback - findElementByKey now resolves nested paths via resolveNestedKey helper - Use undefined-only check instead of ?? to preserve null identity values * Fix null identity matching, string-based nested keys, add comprehensive tests - indexOfItemInArray/applyArrayChange: use !== undefined instead of != null so null identity values can match (null is a valid filter value) - compareArray/convertArrayToObj: support string-based nested identity keys (e.g. embeddedObjKeys: { items: 'positionNumber.value' }) by checking whether data has literal property or needs nested resolution - Add 8 new tests: full round-trip (diffAtom→applyAtom→revertAtom), string key, 3-level nesting, numeric/boolean/null identity values, toAtom bridge * Sync package-lock.json version to 5.0.0-alpha.8 * Guard in operator against primitives, fix comment accuracy
1 parent 34a4900 commit 693af56

5 files changed

Lines changed: 319 additions & 17 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "json-diff-ts",
3-
"version": "5.0.0-alpha.7",
3+
"version": "5.0.0-alpha.8",
44
"description": "Modern TypeScript JSON diff library - Zero dependencies, high performance, ESM + CommonJS support. Calculate and apply differences between JSON objects with advanced features like key-based array diffing, JSONPath support, and atomic changesets.",
55
"main": "./dist/index.cjs",
66
"module": "./dist/index.js",

src/jsonAtom.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,8 @@ function walkChanges(
220220
childChange.key,
221221
childOld,
222222
childNew,
223-
childChange
223+
childChange,
224+
change.embeddedKeyIsPath
224225
);
225226

226227
if (childChange.changes) {
@@ -283,7 +284,8 @@ function buildCanonicalFilterPath(
283284
changeKey: string,
284285
oldArr: any[],
285286
newArr: any[],
286-
change: IChange
287+
change: IChange,
288+
embeddedKeyIsPath?: boolean
287289
): string {
288290
if (embeddedKey === '$index') {
289291
return `${basePath}[${changeKey}]`;
@@ -309,9 +311,11 @@ function buildCanonicalFilterPath(
309311
}
310312

311313
// Named string key
312-
const element = findElementByKey(oldArr, newArr, embeddedKey, changeKey, change.type);
313-
const typedVal = element ? element[embeddedKey] : changeKey;
314-
const memberAccess = SIMPLE_PROPERTY_RE.test(embeddedKey) ? `.${embeddedKey}` : `['${embeddedKey.replace(/'/g, "''")}']`;
314+
const isNestedPath = embeddedKeyIsPath && NESTED_PATH_RE.test(embeddedKey);
315+
const element = findElementByKey(oldArr, newArr, embeddedKey, changeKey, change.type, isNestedPath);
316+
const resolved = element !== undefined ? resolveNestedKey(element, embeddedKey, !!isNestedPath) : undefined;
317+
const typedVal = resolved !== undefined ? resolved : changeKey;
318+
const memberAccess = SIMPLE_PROPERTY_RE.test(embeddedKey) || isNestedPath ? `.${embeddedKey}` : `['${embeddedKey.replace(/'/g, "''")}']`;
315319
return `${basePath}[?(@${memberAccess}==${formatFilterLiteral(typedVal)})]`;
316320
}
317321

@@ -360,20 +364,29 @@ function findElement(arr: any[], embeddedKey: string | FunctionKey, changeKey: s
360364
return arr.find((item) => item && String(item[embeddedKey]) === changeKey);
361365
}
362366

367+
function resolveNestedKey(item: any, key: string, isPath: boolean): any {
368+
if (isPath) {
369+
return key.split('.').reduce((c: any, s: string) => c?.[s], item);
370+
}
371+
return item?.[key];
372+
}
373+
363374
function findElementByKey(
364375
oldArr: any[],
365376
newArr: any[],
366377
embeddedKey: string,
367378
changeKey: string,
368-
opType: Operation
379+
opType: Operation,
380+
isPath?: boolean
369381
): any {
382+
const match = (item: any) => item && String(resolveNestedKey(item, embeddedKey, !!isPath)) === changeKey;
370383
// For REMOVE ops, element is in old array. For ADD, in new. For UPDATE, prefer old.
371384
if (opType === Operation.REMOVE || opType === Operation.UPDATE) {
372-
const el = oldArr?.find((item) => item && String(item[embeddedKey]) === changeKey);
385+
const el = oldArr?.find(match);
373386
if (el) return el;
374387
}
375388
if (opType === Operation.ADD || opType === Operation.UPDATE) {
376-
const el = newArr?.find((item) => item && String(item[embeddedKey]) === changeKey);
389+
const el = newArr?.find(match);
377390
if (el) return el;
378391
}
379392
return undefined;

src/jsonDiff.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -576,12 +576,18 @@ const compareArray = (oldObj: any, newObj: any, path: any, keyPath: any, options
576576
const isFunctionKey = typeof uniqKey === 'function' && uniqKey.length === 2;
577577
if (diffs.length) {
578578
const resolvedKey = isFunctionKey ? uniqKey(newObj[0] ?? oldObj[0], true) : uniqKey;
579+
// Nested path when resolvedKey contains '.' and matches NESTED_PATH_RE.
580+
// Function keys qualify directly; string keys qualify only if the sample
581+
// element does not expose resolvedKey as a literal property.
582+
const sampleEl = newObj[0] ?? oldObj[0];
583+
const isNestedPath = typeof resolvedKey === 'string' && resolvedKey.includes('.') && NESTED_PATH_RE.test(resolvedKey)
584+
&& (isFunctionKey || !(sampleEl != null && typeof sampleEl === 'object' && resolvedKey in sampleEl));
579585
return [
580586
{
581587
type: Operation.UPDATE,
582588
key: getKey(path),
583589
embeddedKey: resolvedKey,
584-
...(isFunctionKey && typeof resolvedKey === 'string' && NESTED_PATH_RE.test(resolvedKey) && resolvedKey.includes('.') ? { embeddedKeyIsPath: true } : {}),
590+
...(isNestedPath ? { embeddedKeyIsPath: true } : {}),
585591
changes: diffs
586592
}
587593
];
@@ -626,8 +632,12 @@ const convertArrayToObj = (arr: any[], uniqKey: any) => {
626632
obj[i] = value;
627633
}
628634
} else {
629-
// Convert string keys to functions for compatibility with es-toolkit keyBy
630-
const keyFunction = typeof uniqKey === 'string' ? (item: any) => item[uniqKey] : uniqKey;
635+
const maybeNestedPath = typeof uniqKey === 'string' && uniqKey.includes('.') && NESTED_PATH_RE.test(uniqKey);
636+
const keyFunction = typeof uniqKey === 'string'
637+
? (maybeNestedPath
638+
? (item: any) => (item != null && typeof item === 'object' && uniqKey in item) ? item[uniqKey] : resolveProperty(item, uniqKey, true)
639+
: (item: any) => item[uniqKey])
640+
: uniqKey;
631641
obj = keyBy(arr, keyFunction);
632642
}
633643
return obj;
@@ -680,7 +690,7 @@ const indexOfItemInArray = (arr: any[], key: any, value: any, isPath?: boolean)
680690
const item = arr[i];
681691
if (item == null) continue;
682692
const resolved = resolveProperty(item, key, isPath);
683-
if (resolved != null && String(resolved) === String(value)) {
693+
if (resolved !== undefined && String(resolved) === String(value)) {
684694
return i;
685695
}
686696
}
@@ -755,7 +765,7 @@ const applyArrayChange = (arr: any[], change: any) => {
755765
} else {
756766
element = arr.find((el) => {
757767
const resolved = resolveProperty(el, change.embeddedKey, change.embeddedKeyIsPath);
758-
return resolved != null && String(resolved) === String(subchange.key);
768+
return resolved !== undefined && String(resolved) === String(subchange.key);
759769
});
760770
}
761771
if (element) {
@@ -845,7 +855,7 @@ const revertArrayChange = (arr: any[], change: any) => {
845855
} else {
846856
element = arr.find((el) => {
847857
const resolved = resolveProperty(el, change.embeddedKey, change.embeddedKeyIsPath);
848-
return resolved != null && String(resolved) === String(subchange.key);
858+
return resolved !== undefined && String(resolved) === String(subchange.key);
849859
});
850860
}
851861
if (element) {

0 commit comments

Comments
 (0)