Skip to content

Commit 03ba9e0

Browse files
feat: add diffWith fn to support custom object types
1 parent fdb2ceb commit 03ba9e0

File tree

8 files changed

+441
-38
lines changed

8 files changed

+441
-38
lines changed

.changeset/mean-plums-flash.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@opentf/obj-diff": minor
3+
---
4+
5+
Added diffWith function to compare custom object types.

README.md

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
- Patching
2424

25-
- Supports comparing more object types
25+
- Supports comparing custom object types
2626

2727
- TypeScript Support
2828

@@ -215,6 +215,66 @@ const out = patch(a, diff(a, b));
215215
assert.deepStrictEqual(out, b); // ok
216216
```
217217

218+
## Comparing Custom Types
219+
220+
By default, the `diff` function cannot compare every object types other than the list above.
221+
222+
You can extend the default `diff` function using the `diffWith` function.
223+
224+
Now you can compare any object types of your own.
225+
226+
### Usage - diffWith()
227+
```js
228+
diff(
229+
obj1: object,
230+
obj2: object,
231+
fn: (a: object, b: object) => boolean | undefined
232+
): Array<DiffResult>
233+
```
234+
235+
### Examples
236+
237+
Let us compare the `MongoDB` bson `ObjectId` objects.
238+
239+
```js
240+
import { ObjectId } from "bson";
241+
import { diffWith } from "../src";
242+
243+
const record1 = {
244+
_id: new ObjectId(),
245+
title: "Article 1",
246+
desc: "The article description.",
247+
};
248+
249+
const record2 = {
250+
_id: new ObjectId(),
251+
title: "Article 1",
252+
desc: "The new article description.",
253+
};
254+
255+
const result = diffWith(record1, record2, (a, b) => {
256+
if (a instanceof ObjectId && b instanceof ObjectId) {
257+
return a.toString() !== b.toString();
258+
}
259+
});
260+
261+
console.log(result);
262+
/*
263+
[
264+
{
265+
t: 2,
266+
p: [ "_id" ],
267+
v: new ObjectId('663088b877dd3c9aaec482d4'),
268+
},
269+
{
270+
t: 2,
271+
p: [ "desc" ],
272+
v: "The new article description.",
273+
}
274+
]
275+
*/
276+
```
277+
218278
## Benchmark
219279

220280
```diff
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { ObjectId } from "bson";
2+
import { diffWith } from "../src";
3+
4+
describe("diffWith", () => {
5+
test("compare custom class object", () => {
6+
class Person {
7+
constructor(name) {
8+
this._id = Math.random().toString().slice(2);
9+
this.name = name;
10+
}
11+
12+
toString() {
13+
return `Person<${this._id}>`;
14+
}
15+
}
16+
17+
const p1 = new Person("x");
18+
const p2 = new Person("x");
19+
const obj1 = {
20+
person: p1,
21+
};
22+
const obj2 = {
23+
person: p2,
24+
};
25+
26+
const result = diffWith(obj1, obj2, (a, b) => {
27+
if (a instanceof Person && b instanceof Person) {
28+
return a._id !== b._id;
29+
}
30+
});
31+
32+
expect(result[0].t).toBe(2);
33+
expect(result[0].p).toEqual(["person"]);
34+
});
35+
36+
test("bson ObjectId", () => {
37+
const _id = new ObjectId();
38+
const record1 = {
39+
_id: _id,
40+
title: "Article 1",
41+
desc: "The article description.",
42+
};
43+
44+
const record2 = {
45+
_id: _id,
46+
title: "Article 1",
47+
desc: "The new article description.",
48+
};
49+
50+
const record3 = {
51+
_id: new ObjectId(),
52+
title: "Article 1",
53+
desc: "The article 3 description.",
54+
};
55+
56+
let result = diffWith(record1, record2, (a, b) => {
57+
if (a instanceof ObjectId && b instanceof ObjectId) {
58+
return a.toString() !== b.toString();
59+
}
60+
});
61+
62+
expect(result).toEqual([
63+
{
64+
p: ["desc"],
65+
t: 2,
66+
v: "The new article description.",
67+
},
68+
]);
69+
70+
result = diffWith(record1, record3, (a, b) => {
71+
if (a instanceof ObjectId && b instanceof ObjectId) {
72+
return a.toString() !== b.toString();
73+
}
74+
});
75+
expect(result[0].t).toBe(2);
76+
expect(result[0].p).toEqual(["_id"]);
77+
});
78+
});

packages/obj-diff/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,15 @@
5555
"@eslint/js": "^9.1.1",
5656
"@swc/jest": "^0.2.36",
5757
"@types/jest": "^29.5.12",
58+
"bson": "^6.6.0",
5859
"eslint": "^9.1.1",
59-
"globals": "^15.0.0",
60+
"globals": "^15.1.0",
6061
"jest": "^29.7.0",
6162
"tsup": "^8.0.2",
6263
"typescript": "^5.4.5",
63-
"typescript-eslint": "^7.7.1"
64+
"typescript-eslint": "^7.8.0"
6465
},
6566
"dependencies": {
66-
"@opentf/std": "^0.11.0"
67+
"@opentf/std": "^0.12.0"
6768
}
6869
}

packages/obj-diff/src/diffWith.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { isObj } from "@opentf/std";
2+
import { ADDED, CHANGED, DELETED } from "./constants";
3+
import { DiffResult } from "./types";
4+
5+
function objDiff(
6+
a: object,
7+
b: object,
8+
path: Array<string | number>,
9+
objRefSet1: WeakSet<WeakKey>,
10+
objRefSet2: WeakSet<WeakKey>,
11+
fn: (a: object, b: object) => boolean | undefined
12+
): DiffResult[] {
13+
const result: DiffResult[] = [];
14+
15+
if (typeof a === "object" && a !== null && b !== null) {
16+
// For circular refs
17+
if (objRefSet1.has(a) && objRefSet2.has(b)) {
18+
return [];
19+
}
20+
21+
objRefSet1.add(a as WeakKey);
22+
objRefSet2.add(b as WeakKey);
23+
24+
if (Array.isArray(a) && Array.isArray(b)) {
25+
for (let i = 0; i < a.length; i++) {
26+
if (Object.hasOwn(b, i)) {
27+
result.push(
28+
...objDiff(
29+
a[i],
30+
(b as Array<unknown>)[i] as object,
31+
[...path, i],
32+
objRefSet1,
33+
objRefSet2,
34+
fn
35+
)
36+
);
37+
} else {
38+
result.push({ t: DELETED, p: [...path, i] });
39+
}
40+
}
41+
42+
for (let i = 0; i < (b as []).length; i++) {
43+
if (!Object.hasOwn(a, i)) {
44+
result.push({
45+
t: ADDED,
46+
p: [...path, i],
47+
v: (b as Array<unknown>)[i],
48+
});
49+
}
50+
}
51+
52+
return result;
53+
}
54+
55+
if (isObj(a) && isObj(b)) {
56+
for (const k of Object.keys(a)) {
57+
if (Object.hasOwn(b, k)) {
58+
result.push(
59+
...objDiff(
60+
(a as Record<string, unknown>)[k] as object,
61+
(b as Record<string, unknown>)[k] as object,
62+
[...path, k],
63+
objRefSet1,
64+
objRefSet2,
65+
fn
66+
)
67+
);
68+
} else {
69+
result.push({ t: DELETED, p: [...path, k] });
70+
}
71+
}
72+
73+
for (const k of Object.keys(b)) {
74+
if (!Object.hasOwn(a, k)) {
75+
result.push({
76+
t: ADDED,
77+
p: [...path, k],
78+
v: (b as Record<string, unknown>)[k],
79+
});
80+
}
81+
}
82+
83+
return result;
84+
}
85+
86+
if (a instanceof Date && b instanceof Date) {
87+
if (!Object.is(a.getTime(), (b as Date).getTime())) {
88+
return [{ t: CHANGED, p: path, v: b }];
89+
}
90+
}
91+
92+
if (a instanceof Map && b instanceof Map) {
93+
if (a.size !== (b as Map<unknown, unknown>).size) {
94+
return [{ t: CHANGED, p: path, v: b }];
95+
}
96+
97+
for (const k of a.keys()) {
98+
if (!Object.is(a.get(k), (b as Map<unknown, unknown>).get(k))) {
99+
return [{ t: CHANGED, p: path, v: b }];
100+
}
101+
}
102+
}
103+
104+
if (a instanceof Set && b instanceof Set) {
105+
if (a.size !== (b as Set<unknown>).size) {
106+
return [{ t: CHANGED, p: path, v: b }];
107+
}
108+
109+
for (const v of a) {
110+
if (!(b as Set<unknown>).has(v)) {
111+
return [{ t: CHANGED, p: path, v: b }];
112+
}
113+
}
114+
}
115+
116+
if (
117+
Object.prototype.toString.call(a) !== Object.prototype.toString.call(b)
118+
) {
119+
return [{ t: CHANGED, p: path, v: b }];
120+
}
121+
122+
if (fn(a, b)) {
123+
return [{ t: CHANGED, p: path, v: b }];
124+
}
125+
} else {
126+
if (!Object.is(a, b)) {
127+
return [{ t: CHANGED, p: path, v: b }];
128+
}
129+
}
130+
131+
return result;
132+
}
133+
134+
/**
135+
* Performs a deep difference between two objects with custom comparator function.
136+
*
137+
* @example
138+
* diff({a: 1}, {a: 5}) //=> [{t: 2, p: ['a'], v: 5}]
139+
*/
140+
export default function diff(
141+
obj1: object,
142+
obj2: object,
143+
fn: (a: object, b: object) => boolean | undefined
144+
): Array<DiffResult> {
145+
const objRefSet1 = new WeakSet();
146+
const objRefSet2 = new WeakSet();
147+
148+
return objDiff(obj1, obj2, [], objRefSet1, objRefSet2, fn);
149+
}

packages/obj-diff/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import diff from "./diff";
2+
import diffWith from "./diffWith";
23
import patch from "./patch";
34
import type { DiffResult } from "./types";
45

5-
export { diff, patch, DiffResult };
6+
export { diff, diffWith, patch, DiffResult };

packages/obj-diff/src/patch.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import { clone, set, unset } from "@opentf/std";
22
import { DiffResult } from "./types";
33

4+
/**
5+
* You can apply the diff result onto the original object to get the modified object.
6+
*
7+
* @example
8+
* const a = {a: 1, b: 2};
9+
* const b = {a: 2, c: 3};
10+
* const out = patch(a, diff(a, b));
11+
* assert.deepStrictEqual(out, b); // ok
12+
*/
413
export default function patch(obj: object, patches: Array<DiffResult>) {
514
const c = clone(obj);
615

0 commit comments

Comments
 (0)