diff --git a/index.d.ts b/index.d.ts index 187caf2..9cbcd22 100644 --- a/index.d.ts +++ b/index.d.ts @@ -31,6 +31,12 @@ diff({a: 1}, {a: 2}); //=> [{op: 'replace', path: '/a', value: 2}] ``` */ +export function diff( + oldObject: readonly unknown[], + newObject: readonly unknown[], + basePath?: string +): Operation[]; + export function diff( oldObject: Record, newObject: Record, @@ -52,6 +58,11 @@ patch({a: 1}, [{op: 'replace', path: '/a', value: 2}]); //=> {a: 2} ``` */ +export function patch( + object: readonly unknown[], + operations: Operation[] +): unknown[]; + export function patch( object: Record, operations: Operation[] @@ -64,6 +75,11 @@ Alias for `patch`. @param operations - The array of operations to apply. @returns A new patched object (input is not mutated). */ +export function applyPatch( + object: readonly unknown[], + operations: Operation[] +): unknown[]; + export function applyPatch( object: Record, operations: Operation[] diff --git a/index.js b/index.js index b4b932f..efd0f61 100644 --- a/index.js +++ b/index.js @@ -89,10 +89,27 @@ export function diff(oldObject, newObject, basePath = "") { return diffObjects(oldObject, newObject, basePath); } +const arrayIndexPattern = /^(0|[1-9]\d*)$/; + +function arrayIndex(array, segment, allowAdd = false) { + if (allowAdd && segment === "-") { + return array.length; + } + const index = Number(segment); + const maximum = allowAdd ? array.length : array.length - 1; + if ( + !(arrayIndexPattern.test(segment) && Number.isSafeInteger(index)) || + index > maximum + ) { + throw new TypeError(`Invalid array index: ${segment}`); + } + return index; +} + function navigatePath(object, segments) { let current = object; for (const segment of segments) { - const key = Array.isArray(current) ? Number(segment) : segment; + const key = Array.isArray(current) ? arrayIndex(current, segment) : segment; if (!Object.hasOwn(current, key)) { throw new TypeError( `Cannot navigate through non-own property: ${segment}` @@ -106,9 +123,12 @@ function navigatePath(object, segments) { } function parsePointer(path) { + if (!path.startsWith("/")) { + throw new TypeError("Expected a non-root JSON Pointer starting with '/'"); + } return path + .slice(1) .split("/") - .filter(Boolean) .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~")); } @@ -116,7 +136,9 @@ function applyOperation(parent, key, operation) { switch (operation.op) { case "add": case "replace": { - if (key === "__proto__") { + if (Array.isArray(parent) && operation.op === "add") { + parent.splice(key, 0, operation.value); + } else if (key === "__proto__") { Object.defineProperty(parent, key, { configurable: true, enumerable: true, @@ -154,7 +176,9 @@ export function patch(object, operations) { const lastSegment = segments.pop(); const parent = segments.length === 0 ? result : navigatePath(result, segments); - const key = Array.isArray(parent) ? Number(lastSegment) : lastSegment; + const key = Array.isArray(parent) + ? arrayIndex(parent, lastSegment, operation.op === "add") + : lastSegment; applyOperation(parent, key, operation); } diff --git a/index.test-d.ts b/index.test-d.ts index e39fe85..16cbb75 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -14,3 +14,10 @@ expectType>( expectError(diff("not an object", {})); expectError(patch("not an object", [])); + +expectType(diff([1, 2], [1])); +expectType(patch([1, 2], [{ op: "remove", path: "/1" }])); +expectType(applyPatch([1], [{ op: "add", path: "/-", value: 2 }])); +const readonlyArray = [1, 2] as const; +expectType(diff(readonlyArray, [1])); +expectType(patch(readonlyArray, [])); diff --git a/readme.md b/readme.md index 2c5f909..97ed368 100644 --- a/readme.md +++ b/readme.md @@ -56,6 +56,10 @@ Recursively compares nested objects and arrays (by index). Applies an array of operations to a deep clone of `object` and returns the new object. The input is never mutated. +Object keys may be empty strings: `/` identifies an empty key, and `//a` identifies `a` inside that key. Root operations with the empty pointer are not supported. + +An array `add` inserts at the specified index; `/-` appends. Array indices must be canonical non-negative integers within the operation's bounds. Invalid indices throw instead of changing another element. Both `diff` and `patch` support top-level arrays, including their TypeScript declarations. + ### applyPatch(object, operations) Alias for `patch`. diff --git a/test.js b/test.js index 50d5383..6cfc2b9 100644 --- a/test.js +++ b/test.js @@ -1,6 +1,58 @@ import test from "ava"; import { applyPatch, diff, patch } from "./index.js"; +test("round-trips empty object keys without changing neighboring fields", (t) => { + const original = { "": { a: 1 }, a: 9 }; + const updated = { "": { a: 2 }, a: 9 }; + t.deepEqual(patch(original, diff(original, updated)), updated); + t.deepEqual(patch({ "": 1 }, [{ op: "replace", path: "/", value: 2 }]), { + "": 2, + }); +}); + +test("array add inserts at the requested index and supports append", (t) => { + t.deepEqual(patch({ a: [1, 2] }, [{ op: "add", path: "/a/0", value: 0 }]), { + a: [0, 1, 2], + }); + t.deepEqual(patch([1], [{ op: "add", path: "/-", value: 2 }]), [1, 2]); +}); + +test("rejects invalid array indices instead of removing another element", (t) => { + for (const segment of [ + "not-an-index", + "-1", + "01", + "1.5", + "1e0", + "", + "2", + "-", + ]) { + const original = { a: [1, 2] }; + t.throws(() => patch(original, [{ op: "remove", path: `/a/${segment}` }]), { + instanceOf: TypeError, + }); + t.deepEqual(original, { a: [1, 2] }); + } + t.throws(() => patch([1], [{ op: "add", path: "/2", value: 2 }]), { + instanceOf: TypeError, + }); + t.throws( + () => + patch({ a: [{ x: 1 }] }, [{ op: "replace", path: "/a/00/x", value: 2 }]), + { instanceOf: TypeError } + ); +}); + +test("distinguishes the empty-key pointer from unsupported root pointers", (t) => { + t.throws(() => patch({}, [{ op: "add", path: "", value: 1 }]), { + instanceOf: TypeError, + }); + t.throws(() => patch({}, [{ op: "add", path: "a", value: 1 }]), { + instanceOf: TypeError, + }); +}); + // --- diff: additions --- test("diff detects additions", (t) => {