Skip to content
Merged
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
16 changes: 16 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
newObject: Record<string, unknown>,
Expand All @@ -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<string, unknown>,
operations: Operation[]
Expand All @@ -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<string, unknown>,
operations: Operation[]
Expand Down
32 changes: 28 additions & 4 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand All @@ -106,17 +123,22 @@ 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", "~"));
}

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,
Expand Down Expand Up @@ -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);
}
Expand Down
7 changes: 7 additions & 0 deletions index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,10 @@ expectType<Record<string, unknown>>(

expectError(diff("not an object", {}));
expectError(patch("not an object", []));

expectType<Operation[]>(diff([1, 2], [1]));
expectType<unknown[]>(patch([1, 2], [{ op: "remove", path: "/1" }]));
expectType<unknown[]>(applyPatch([1], [{ op: "add", path: "/-", value: 2 }]));
const readonlyArray = [1, 2] as const;
expectType<Operation[]>(diff(readonlyArray, [1]));
expectType<unknown[]>(patch(readonlyArray, []));
4 changes: 4 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
52 changes: 52 additions & 0 deletions test.js
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down
Loading