Skip to content

Commit 34a4900

Browse files
authored
Add move and copy operations (#400)
* Add move and copy operations - AtomOp: add 'move' | 'copy', IAtomOperation: add from? field - validateAtom: move/copy validation with self-move prevention - applyAtom: resolveValueAtPath helper, applyMoveOp, applyCopyOp - invertAtom: move swaps from/path, copy→remove with oldValue - fromAtom: throws for move/copy (v4 changeset incompatible) - 47 new tests covering validation, apply, inversion, sequential semantics, and edge cases - Bumps to 5.0.0-alpha.7 * Address review: use parseAtomPath in resolveValueAtPath, fix root move - resolveValueAtPath now uses parseAtomPath typed segments instead of regex on atomic path — correctly handles bracket properties - Root ($) excluded from self-move subtree check — moving from root to a descendant is valid - Remove unused splitJSONPath import * Address review: literalKey in resolve, null filter match, coverage - resolveValueAtPath respects literalKey for bracket-notation filters - Remove != null guard to allow null filter value matching - Add value-filter test for codecov coverage - Update docstring * Add type guards in resolveValueAtPath, handle undefined in copy * Add type guard coverage tests, fix codecov patch threshold
1 parent cc7917d commit 34a4900

3 files changed

Lines changed: 763 additions & 6 deletions

File tree

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.6",
3+
"version": "5.0.0-alpha.7",
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: 122 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,20 @@ import {
1212
import type { FunctionKey } from './helpers.js';
1313
import {
1414
formatFilterLiteral,
15+
parseAtomPath,
1516
atomicPathToAtomPath,
1617
atomPathToAtomicPath,
1718
extractKeyFromAtomicPath,
1819
} from './atomPath.js';
1920

2021
// ─── Types ──────────────────────────────────────────────────────────────────
2122

22-
export type AtomOp = 'add' | 'remove' | 'replace';
23+
export type AtomOp = 'add' | 'remove' | 'replace' | 'move' | 'copy';
2324

2425
export interface IAtomOperation {
2526
op: AtomOp;
2627
path: string;
28+
from?: string;
2729
value?: any;
2830
oldValue?: any;
2931
[key: string]: any;
@@ -66,7 +68,7 @@ export function validateAtom(atom: unknown): { valid: boolean; errors: string[]
6668
errors.push(`operations[${i}]: must be an object`);
6769
continue;
6870
}
69-
if (!['add', 'remove', 'replace'].includes(op.op)) {
71+
if (!['add', 'remove', 'replace', 'move', 'copy'].includes(op.op)) {
7072
errors.push(`operations[${i}]: invalid op '${op.op}'`);
7173
}
7274
if (typeof op.path !== 'string') {
@@ -86,6 +88,33 @@ export function validateAtom(atom: unknown): { valid: boolean; errors: string[]
8688
if (op.op === 'replace' && !('value' in op)) {
8789
errors.push(`operations[${i}]: replace operation must have value`);
8890
}
91+
if (op.op === 'move') {
92+
if (typeof op.from !== 'string') {
93+
errors.push(`operations[${i}]: move operation must have from (string)`);
94+
}
95+
if ('value' in op) {
96+
errors.push(`operations[${i}]: move operation must not have value`);
97+
}
98+
if ('oldValue' in op) {
99+
errors.push(`operations[${i}]: move operation must not have oldValue`);
100+
}
101+
if (typeof op.from === 'string' && typeof op.path === 'string') {
102+
if (op.from === op.path) {
103+
errors.push(`operations[${i}]: move operation from must not equal path (self-move)`);
104+
}
105+
if (op.from !== '$' && (op.path.startsWith(op.from + '.') || op.path.startsWith(op.from + '['))) {
106+
errors.push(`operations[${i}]: move operation path must not be a subtree of from`);
107+
}
108+
}
109+
}
110+
if (op.op === 'copy') {
111+
if (typeof op.from !== 'string') {
112+
errors.push(`operations[${i}]: copy operation must have from (string)`);
113+
}
114+
if ('oldValue' in op) {
115+
errors.push(`operations[${i}]: copy operation must not have oldValue`);
116+
}
117+
}
89118
}
90119
}
91120

@@ -463,6 +492,10 @@ export function fromAtom(atom: IJsonAtom): IAtomicChange[] {
463492
}
464493

465494
return atom.operations.map((op) => {
495+
if (op.op === 'move' || op.op === 'copy') {
496+
throw new Error(`${op.op} operations cannot be converted to v4 atomic changes`);
497+
}
498+
466499
const atomicPath = atomPathToAtomicPath(op.path);
467500
const key = extractKeyFromAtomicPath(atomicPath);
468501

@@ -519,14 +552,17 @@ export function invertAtom(atom: IJsonAtom): IJsonAtom {
519552
if (op.op === 'remove' && !('oldValue' in op)) {
520553
throw new Error(`operations[${i}]: remove operation missing oldValue — atom is not reversible`);
521554
}
555+
if (op.op === 'copy' && !('value' in op)) {
556+
throw new Error(`operations[${i}]: copy operation missing value — atom is not reversible`);
557+
}
522558
}
523559

524560
// Reverse the operations array and invert each operation
525561
const invertedOps: IAtomOperation[] = [...atom.operations].reverse().map((op) => {
526562
// Preserve extension properties (any key not in standard set)
527563
const extensions: Record<string, any> = {};
528564
for (const key of Object.keys(op)) {
529-
if (!['op', 'path', 'value', 'oldValue'].includes(key)) {
565+
if (!['op', 'path', 'from', 'value', 'oldValue'].includes(key)) {
530566
extensions[key] = op[key];
531567
}
532568
}
@@ -538,6 +574,10 @@ export function invertAtom(atom: IJsonAtom): IJsonAtom {
538574
return { op: 'add' as AtomOp, path: op.path, value: op.oldValue, ...extensions };
539575
case 'replace':
540576
return { op: 'replace' as AtomOp, path: op.path, value: op.oldValue, oldValue: op.value, ...extensions };
577+
case 'move':
578+
return { op: 'move' as AtomOp, from: op.path, path: op.from!, ...extensions };
579+
case 'copy':
580+
return { op: 'remove' as AtomOp, path: op.path, oldValue: op.value, ...extensions };
541581
/* istanbul ignore next -- exhaustive switch */
542582
default:
543583
throw new Error(`Unknown operation: ${op.op}`);
@@ -557,6 +597,55 @@ export function invertAtom(atom: IJsonAtom): IJsonAtom {
557597

558598
// ─── applyAtom ─────────────────────────────────────────────────────────────
559599

600+
const NESTED_PATH_RE = /^[a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*$/;
601+
602+
/**
603+
* Resolve a value at a JSON Atom path within an object.
604+
* Uses parseAtomPath for correct handling of all path forms.
605+
*/
606+
function resolveValueAtPath(obj: any, atomPath: string): any {
607+
const segments = parseAtomPath(atomPath);
608+
let current = obj;
609+
for (const seg of segments) {
610+
switch (seg.type) {
611+
case 'root':
612+
break;
613+
case 'property':
614+
if (current == null || typeof current !== 'object') {
615+
throw new Error(`Cannot access property '${seg.name}' on ${current === null ? 'null' : typeof current} at path: ${atomPath}`);
616+
}
617+
current = current[seg.name];
618+
break;
619+
case 'index':
620+
if (!Array.isArray(current)) {
621+
throw new Error(`Cannot access index ${seg.index} on non-array at path: ${atomPath}`);
622+
}
623+
current = current[seg.index];
624+
break;
625+
case 'keyFilter': {
626+
if (!Array.isArray(current)) {
627+
throw new Error(`Cannot apply key filter on non-array at path: ${atomPath}`);
628+
}
629+
const prop = seg.property;
630+
const isPath = !seg.literalKey && prop.includes('.') && NESTED_PATH_RE.test(prop);
631+
current = current.find((el: any) => {
632+
const resolved = isPath ? prop.split('.').reduce((c: any, s: string) => c?.[s], el) : el[prop];
633+
return JSON.stringify(resolved) === JSON.stringify(seg.value);
634+
});
635+
break;
636+
}
637+
case 'valueFilter': {
638+
if (!Array.isArray(current)) {
639+
throw new Error(`Cannot apply value filter on non-array at path: ${atomPath}`);
640+
}
641+
current = current.find((el: any) => JSON.stringify(el) === JSON.stringify(seg.value));
642+
break;
643+
}
644+
}
645+
}
646+
return current;
647+
}
648+
560649
/**
561650
* Apply a JSON Atom document to an object.
562651
* Processes operations sequentially. Handles root operations directly.
@@ -571,7 +660,36 @@ export function applyAtom(obj: any, atom: IJsonAtom): any {
571660
let result: any = obj;
572661

573662
for (const op of atom.operations) {
574-
if (op.path === '$') {
663+
if (op.op === 'move') {
664+
// Read value at from
665+
const value = resolveValueAtPath(result, op.from!);
666+
// Remove from source
667+
const removeOp: IAtomOperation = { op: 'remove', path: op.from!, oldValue: value };
668+
if (removeOp.path === '$') {
669+
result = applyRootOp(result, removeOp);
670+
} else {
671+
const removeChange = atomOpToAtomicChange(removeOp);
672+
applyChangeset(result, unatomizeChangeset([removeChange]));
673+
}
674+
// Add to target
675+
const addOp: IAtomOperation = { op: 'add', path: op.path, value };
676+
if (addOp.path === '$') {
677+
result = applyRootOp(result, addOp);
678+
} else {
679+
const addChange = atomOpToAtomicChange(addOp);
680+
applyChangeset(result, unatomizeChangeset([addChange]));
681+
}
682+
} else if (op.op === 'copy') {
683+
const source = resolveValueAtPath(result, op.from!);
684+
const value = source === undefined ? undefined : JSON.parse(JSON.stringify(source));
685+
const addOp: IAtomOperation = { op: 'add', path: op.path, value };
686+
if (addOp.path === '$') {
687+
result = applyRootOp(result, addOp);
688+
} else {
689+
const addChange = atomOpToAtomicChange(addOp);
690+
applyChangeset(result, unatomizeChangeset([addChange]));
691+
}
692+
} else if (op.path === '$') {
575693
result = applyRootOp(result, op);
576694
} else {
577695
const atomicChange = atomOpToAtomicChange(op);

0 commit comments

Comments
 (0)