Skip to content

Commit dcf2d71

Browse files
committed
fix: address reviewed behavior and API edge cases
1 parent cc21f1b commit dcf2d71

5 files changed

Lines changed: 107 additions & 4 deletions

File tree

‎index.d.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ diff({a: 1}, {a: 2});
3131
//=> [{op: 'replace', path: '/a', value: 2}]
3232
```
3333
*/
34+
export function diff(
35+
oldObject: readonly unknown[],
36+
newObject: readonly unknown[],
37+
basePath?: string
38+
): Operation[];
39+
3440
export function diff(
3541
oldObject: Record<string, unknown>,
3642
newObject: Record<string, unknown>,
@@ -52,6 +58,11 @@ patch({a: 1}, [{op: 'replace', path: '/a', value: 2}]);
5258
//=> {a: 2}
5359
```
5460
*/
61+
export function patch(
62+
object: readonly unknown[],
63+
operations: Operation[]
64+
): unknown[];
65+
5566
export function patch(
5667
object: Record<string, unknown>,
5768
operations: Operation[]
@@ -64,6 +75,11 @@ Alias for `patch`.
6475
@param operations - The array of operations to apply.
6576
@returns A new patched object (input is not mutated).
6677
*/
78+
export function applyPatch(
79+
object: readonly unknown[],
80+
operations: Operation[]
81+
): unknown[];
82+
6783
export function applyPatch(
6884
object: Record<string, unknown>,
6985
operations: Operation[]

‎index.js‎

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,27 @@ export function diff(oldObject, newObject, basePath = "") {
8989
return diffObjects(oldObject, newObject, basePath);
9090
}
9191

92+
const arrayIndexPattern = /^(0|[1-9]\d*)$/;
93+
94+
function arrayIndex(array, segment, allowAdd = false) {
95+
if (allowAdd && segment === "-") {
96+
return array.length;
97+
}
98+
const index = Number(segment);
99+
const maximum = allowAdd ? array.length : array.length - 1;
100+
if (
101+
!(arrayIndexPattern.test(segment) && Number.isSafeInteger(index)) ||
102+
index > maximum
103+
) {
104+
throw new TypeError(`Invalid array index: ${segment}`);
105+
}
106+
return index;
107+
}
108+
92109
function navigatePath(object, segments) {
93110
let current = object;
94111
for (const segment of segments) {
95-
const key = Array.isArray(current) ? Number(segment) : segment;
112+
const key = Array.isArray(current) ? arrayIndex(current, segment) : segment;
96113
if (!Object.hasOwn(current, key)) {
97114
throw new TypeError(
98115
`Cannot navigate through non-own property: ${segment}`
@@ -106,17 +123,22 @@ function navigatePath(object, segments) {
106123
}
107124

108125
function parsePointer(path) {
126+
if (!path.startsWith("/")) {
127+
throw new TypeError("Expected a non-root JSON Pointer starting with '/'");
128+
}
109129
return path
130+
.slice(1)
110131
.split("/")
111-
.filter(Boolean)
112132
.map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"));
113133
}
114134

115135
function applyOperation(parent, key, operation) {
116136
switch (operation.op) {
117137
case "add":
118138
case "replace": {
119-
if (key === "__proto__") {
139+
if (Array.isArray(parent) && operation.op === "add") {
140+
parent.splice(key, 0, operation.value);
141+
} else if (key === "__proto__") {
120142
Object.defineProperty(parent, key, {
121143
configurable: true,
122144
enumerable: true,
@@ -154,7 +176,9 @@ export function patch(object, operations) {
154176
const lastSegment = segments.pop();
155177
const parent =
156178
segments.length === 0 ? result : navigatePath(result, segments);
157-
const key = Array.isArray(parent) ? Number(lastSegment) : lastSegment;
179+
const key = Array.isArray(parent)
180+
? arrayIndex(parent, lastSegment, operation.op === "add")
181+
: lastSegment;
158182

159183
applyOperation(parent, key, operation);
160184
}

‎index.test-d.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,10 @@ expectType<Record<string, unknown>>(
1414

1515
expectError(diff("not an object", {}));
1616
expectError(patch("not an object", []));
17+
18+
expectType<Operation[]>(diff([1, 2], [1]));
19+
expectType<unknown[]>(patch([1, 2], [{ op: "remove", path: "/1" }]));
20+
expectType<unknown[]>(applyPatch([1], [{ op: "add", path: "/-", value: 2 }]));
21+
const readonlyArray = [1, 2] as const;
22+
expectType<Operation[]>(diff(readonlyArray, [1]));
23+
expectType<unknown[]>(patch(readonlyArray, []));

‎readme.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ Recursively compares nested objects and arrays (by index).
5656

5757
Applies an array of operations to a deep clone of `object` and returns the new object. The input is never mutated.
5858

59+
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.
60+
61+
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.
62+
5963
### applyPatch(object, operations)
6064

6165
Alias for `patch`.

‎test.js‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,58 @@
11
import test from "ava";
22
import { applyPatch, diff, patch } from "./index.js";
33

4+
test("round-trips empty object keys without changing neighboring fields", (t) => {
5+
const original = { "": { a: 1 }, a: 9 };
6+
const updated = { "": { a: 2 }, a: 9 };
7+
t.deepEqual(patch(original, diff(original, updated)), updated);
8+
t.deepEqual(patch({ "": 1 }, [{ op: "replace", path: "/", value: 2 }]), {
9+
"": 2,
10+
});
11+
});
12+
13+
test("array add inserts at the requested index and supports append", (t) => {
14+
t.deepEqual(patch({ a: [1, 2] }, [{ op: "add", path: "/a/0", value: 0 }]), {
15+
a: [0, 1, 2],
16+
});
17+
t.deepEqual(patch([1], [{ op: "add", path: "/-", value: 2 }]), [1, 2]);
18+
});
19+
20+
test("rejects invalid array indices instead of removing another element", (t) => {
21+
for (const segment of [
22+
"not-an-index",
23+
"-1",
24+
"01",
25+
"1.5",
26+
"1e0",
27+
"",
28+
"2",
29+
"-",
30+
]) {
31+
const original = { a: [1, 2] };
32+
t.throws(() => patch(original, [{ op: "remove", path: `/a/${segment}` }]), {
33+
instanceOf: TypeError,
34+
});
35+
t.deepEqual(original, { a: [1, 2] });
36+
}
37+
t.throws(() => patch([1], [{ op: "add", path: "/2", value: 2 }]), {
38+
instanceOf: TypeError,
39+
});
40+
t.throws(
41+
() =>
42+
patch({ a: [{ x: 1 }] }, [{ op: "replace", path: "/a/00/x", value: 2 }]),
43+
{ instanceOf: TypeError }
44+
);
45+
});
46+
47+
test("distinguishes the empty-key pointer from unsupported root pointers", (t) => {
48+
t.throws(() => patch({}, [{ op: "add", path: "", value: 1 }]), {
49+
instanceOf: TypeError,
50+
});
51+
t.throws(() => patch({}, [{ op: "add", path: "a", value: 1 }]), {
52+
instanceOf: TypeError,
53+
});
54+
});
55+
456
// --- diff: additions ---
557

658
test("diff detects additions", (t) => {

0 commit comments

Comments
 (0)