ts-data-forge 14.9.0 → 14.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/others/index.d.mts +1 -0
- package/dist/others/index.d.mts.map +1 -1
- package/dist/others/index.mjs +1 -0
- package/dist/others/index.mjs.map +1 -1
- package/dist/others/replace-equal-deep.d.mts +95 -0
- package/dist/others/replace-equal-deep.d.mts.map +1 -0
- package/dist/others/replace-equal-deep.mjs +156 -0
- package/dist/others/replace-equal-deep.mjs.map +1 -0
- package/package.json +4 -4
- package/src/others/index.mts +1 -0
- package/src/others/replace-equal-deep.mts +225 -0
- package/src/others/replace-equal-deep.test.mts +342 -0
package/dist/others/index.d.mts
CHANGED
|
@@ -6,6 +6,7 @@ export * from './if-then.mjs';
|
|
|
6
6
|
export * from './map-nullable.mjs';
|
|
7
7
|
export * from './memoize-function.mjs';
|
|
8
8
|
export * from './panic-error.mjs';
|
|
9
|
+
export * from './replace-equal-deep.mjs';
|
|
9
10
|
export * from './tuple.mjs';
|
|
10
11
|
export * from './unknown-to-string.mjs';
|
|
11
12
|
//# sourceMappingURL=index.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../../src/others/index.mts"],"names":[],"mappings":"AAAA,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC;AACxC,cAAc,uBAAuB,CAAC;AACtC,cAAc,eAAe,CAAC;AAC9B,cAAc,oBAAoB,CAAC;AACnC,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC;AAClC,cAAc,aAAa,CAAC;AAC5B,cAAc,yBAAyB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../../src/others/index.mts"],"names":[],"mappings":"AAAA,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC;AACxC,cAAc,uBAAuB,CAAC;AACtC,cAAc,eAAe,CAAC;AAC9B,cAAc,oBAAoB,CAAC;AACnC,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC;AAClC,cAAc,0BAA0B,CAAC;AACzC,cAAc,aAAa,CAAC;AAC5B,cAAc,yBAAyB,CAAC"}
|
package/dist/others/index.mjs
CHANGED
|
@@ -6,6 +6,7 @@ export * from './if-then.mjs';
|
|
|
6
6
|
export * from './map-nullable.mjs';
|
|
7
7
|
export * from './memoize-function.mjs';
|
|
8
8
|
export * from './panic-error.mjs';
|
|
9
|
+
export * from './replace-equal-deep.mjs';
|
|
9
10
|
export * from './tuple.mjs';
|
|
10
11
|
export * from './unknown-to-string.mjs';
|
|
11
12
|
//# sourceMappingURL=index.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","sourceRoot":"","sources":["../../src/others/index.mts"],"names":[],"mappings":"AAAA,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC;AACxC,cAAc,uBAAuB,CAAC;AACtC,cAAc,eAAe,CAAC;AAC9B,cAAc,oBAAoB,CAAC;AACnC,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC;AAClC,cAAc,aAAa,CAAC;AAC5B,cAAc,yBAAyB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.mjs","sourceRoot":"","sources":["../../src/others/index.mts"],"names":[],"mappings":"AAAA,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC;AACxC,cAAc,uBAAuB,CAAC;AACtC,cAAc,eAAe,CAAC;AAC9B,cAAc,oBAAoB,CAAC;AACnC,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC;AAClC,cAAc,0BAA0B,CAAC;AACzC,cAAc,aAAa,CAAC;AAC5B,cAAc,yBAAyB,CAAC"}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns `next` with every part deeply equal to the same part of `current`
|
|
3
|
+
* replaced by that part of `current` (structural sharing). When the whole of
|
|
4
|
+
* `next` is deeply equal to `current`, `current` itself is returned, so an
|
|
5
|
+
* `Object.is` comparison of the result with `current` tells whether anything
|
|
6
|
+
* changed, and a reader that compares by reference sees a new object only
|
|
7
|
+
* where the value really changed.
|
|
8
|
+
*
|
|
9
|
+
* Only plain objects and arrays are walked into. Anything else — a `Map`, a
|
|
10
|
+
* `Set`, a `Date`, a class instance — is compared whole with
|
|
11
|
+
* {@link fastDeepEqual} and kept or replaced whole.
|
|
12
|
+
*
|
|
13
|
+
* Array items are matched by index. Give `key` to match them by key instead:
|
|
14
|
+
* an item for which it returns something other than `undefined` is matched
|
|
15
|
+
* with the item of `current` it returns the same key for, and an item it
|
|
16
|
+
* returns `undefined` for is still matched by index. The one `key` is used
|
|
17
|
+
* for the arrays at every depth.
|
|
18
|
+
*
|
|
19
|
+
* Neither argument is changed. The result shares objects with both, so it
|
|
20
|
+
* relies on neither being changed afterwards.
|
|
21
|
+
*
|
|
22
|
+
* Call it where the new value is written over the one held, so that nothing
|
|
23
|
+
* downstream has a new object it must compare again.
|
|
24
|
+
*
|
|
25
|
+
* @param current - The value held now
|
|
26
|
+
* @param next - The value to write over it
|
|
27
|
+
* @param options - {@link ReplaceEqualDeepOptions}
|
|
28
|
+
* @returns `current` when `next` is deeply equal to it; otherwise a value
|
|
29
|
+
* deeply equal to `next` that reuses the equal parts of `current`
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```ts
|
|
33
|
+
* type PullReport = DeepReadonly<{
|
|
34
|
+
* summary: { open: number };
|
|
35
|
+
* pulls: { number: number; title: string }[];
|
|
36
|
+
* }>;
|
|
37
|
+
*
|
|
38
|
+
* const held: PullReport = {
|
|
39
|
+
* summary: { open: 2 },
|
|
40
|
+
* pulls: [
|
|
41
|
+
* { number: 1, title: 'one' },
|
|
42
|
+
* { number: 2, title: 'two' },
|
|
43
|
+
* ],
|
|
44
|
+
* } as const;
|
|
45
|
+
*
|
|
46
|
+
* // A server answers with the whole report every time.
|
|
47
|
+
* const answer: PullReport = {
|
|
48
|
+
* summary: { open: 2 },
|
|
49
|
+
* pulls: [
|
|
50
|
+
* { number: 1, title: 'one' },
|
|
51
|
+
* { number: 2, title: 'two (edited)' },
|
|
52
|
+
* ],
|
|
53
|
+
* } as const;
|
|
54
|
+
*
|
|
55
|
+
* const written = replaceEqualDeep(held, answer);
|
|
56
|
+
*
|
|
57
|
+
* assert.deepStrictEqual(written, answer);
|
|
58
|
+
*
|
|
59
|
+
* // What did not change is the object already held,
|
|
60
|
+
* assert.strictEqual(written.summary, held.summary);
|
|
61
|
+
*
|
|
62
|
+
* assert.strictEqual(written.pulls[0], held.pulls[0]);
|
|
63
|
+
*
|
|
64
|
+
* // and what changed is new.
|
|
65
|
+
* assert.notStrictEqual(written.pulls[1], held.pulls[1]);
|
|
66
|
+
*
|
|
67
|
+
* // An answer that changes nothing gives back the value held.
|
|
68
|
+
* assert.strictEqual(replaceEqualDeep(held, structuredClone(held)), held);
|
|
69
|
+
*
|
|
70
|
+
* // Match array items by key rather than by index.
|
|
71
|
+
* const opened = replaceEqualDeep(
|
|
72
|
+
* held.pulls,
|
|
73
|
+
* [{ number: 0, title: 'zero' }, ...structuredClone(held.pulls)],
|
|
74
|
+
* {
|
|
75
|
+
* key: (item) =>
|
|
76
|
+
* isRecord(item) && hasKey(item, 'number') ? item.number : undefined,
|
|
77
|
+
* },
|
|
78
|
+
* );
|
|
79
|
+
*
|
|
80
|
+
* assert.strictEqual(opened[1], held.pulls[0]);
|
|
81
|
+
*
|
|
82
|
+
* assert.strictEqual(opened[2], held.pulls[1]);
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export declare const replaceEqualDeep: <T>(current: T, next: T, options?: ReplaceEqualDeepOptions) => T;
|
|
86
|
+
/** Options of {@link replaceEqualDeep}. */
|
|
87
|
+
export type ReplaceEqualDeepOptions = Readonly<{
|
|
88
|
+
/**
|
|
89
|
+
* The key an array item is matched by. Items it returns `undefined` for
|
|
90
|
+
* are matched by index. Keys are compared with `SameValueZero`, as `Map`
|
|
91
|
+
* compares them.
|
|
92
|
+
*/
|
|
93
|
+
key?: (item: unknown) => unknown;
|
|
94
|
+
}>;
|
|
95
|
+
//# sourceMappingURL=replace-equal-deep.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"replace-equal-deep.d.mts","sourceRoot":"","sources":["../../src/others/replace-equal-deep.mts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmFG;AACH,eAAO,MAAM,gBAAgB,GAAI,CAAC,WACvB,CAAC,QACJ,CAAC,YACG,uBAAuB,KAChC,CAIwC,CAAC;AAE5C,2CAA2C;AAC3C,MAAM,MAAM,uBAAuB,GAAG,QAAQ,CAAC;IAC7C;;;;OAIG;IACH,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,OAAO,CAAC;CAClC,CAAC,CAAC"}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { hasKey, isRecord } from '../guard/index.mjs';
|
|
2
|
+
import { fastDeepEqual } from './fast-deep-equal.mjs';
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
export const replaceEqualDeep = (current, next, options) =>
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
replace(current, next, options?.key);
|
|
92
|
+
const replace = (current, next, key) => Object.is(current, next)
|
|
93
|
+
? current
|
|
94
|
+
: isPlainArray(current) && isPlainArray(next)
|
|
95
|
+
? replaceArray(current, next, key)
|
|
96
|
+
: isPlainObject(current) && isPlainObject(next)
|
|
97
|
+
? replaceObject(current, next, key)
|
|
98
|
+
: fastDeepEqual(current, next)
|
|
99
|
+
? current
|
|
100
|
+
: next;
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
const replaceArray = (current, next, key) => {
|
|
105
|
+
const byKey = key === undefined ? undefined : indexByKey(current, key);
|
|
106
|
+
let mut_isCurrent = current.length === next.length;
|
|
107
|
+
let mut_isNext = true;
|
|
108
|
+
const mut_shared = [];
|
|
109
|
+
for (let mut_index = 0; mut_index < next.length; mut_index += 1) {
|
|
110
|
+
const index = mut_index;
|
|
111
|
+
const item = next[index];
|
|
112
|
+
const itemKey = key?.(item);
|
|
113
|
+
const before = itemKey === undefined || byKey === undefined
|
|
114
|
+
? index < current.length
|
|
115
|
+
? current[index]
|
|
116
|
+
: absent
|
|
117
|
+
: byKey.has(itemKey)
|
|
118
|
+
? byKey.get(itemKey)
|
|
119
|
+
: absent;
|
|
120
|
+
const result = before === absent ? item : replace(before, item, key);
|
|
121
|
+
mut_isCurrent &&= Object.is(result, current[index]);
|
|
122
|
+
mut_isNext &&= Object.is(result, item);
|
|
123
|
+
mut_shared.push(result);
|
|
124
|
+
}
|
|
125
|
+
return mut_isCurrent ? current : mut_isNext ? next : mut_shared;
|
|
126
|
+
};
|
|
127
|
+
const replaceObject = (current, next, key) => {
|
|
128
|
+
const nextKeys = Object.keys(next);
|
|
129
|
+
let mut_isCurrent = Object.keys(current).length === nextKeys.length;
|
|
130
|
+
let mut_isNext = true;
|
|
131
|
+
const mut_shared = {};
|
|
132
|
+
for (const k of nextKeys) {
|
|
133
|
+
const value = next[k];
|
|
134
|
+
const has = hasKey(current, k);
|
|
135
|
+
const result = has ? replace(current[k], value, key) : value;
|
|
136
|
+
mut_isCurrent &&= has && Object.is(result, current[k]);
|
|
137
|
+
mut_isNext &&= Object.is(result, value);
|
|
138
|
+
mut_shared[k] = result;
|
|
139
|
+
}
|
|
140
|
+
return mut_isCurrent ? current : mut_isNext ? next : mut_shared;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const absent = Symbol('absent');
|
|
144
|
+
const indexByKey = (items, key) => {
|
|
145
|
+
const mut_byKey = new Map();
|
|
146
|
+
for (const item of items) {
|
|
147
|
+
const itemKey = key(item);
|
|
148
|
+
if (itemKey !== undefined && !mut_byKey.has(itemKey)) {
|
|
149
|
+
mut_byKey.set(itemKey, item);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return mut_byKey;
|
|
153
|
+
};
|
|
154
|
+
const isPlainArray = (value) => Array.isArray(value) && Object.getPrototypeOf(value) === Array.prototype;
|
|
155
|
+
const isPlainObject = (value) => isRecord(value) && Object.getPrototypeOf(value) === Object.prototype;
|
|
156
|
+
//# sourceMappingURL=replace-equal-deep.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"replace-equal-deep.mjs","sourceRoot":"","sources":["../../src/others/replace-equal-deep.mts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ts-data-forge",
|
|
3
|
-
"version": "14.
|
|
3
|
+
"version": "14.10.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"keywords": [
|
|
6
6
|
"typescript",
|
|
@@ -46,9 +46,9 @@
|
|
|
46
46
|
"@mui/material": "9.4.0",
|
|
47
47
|
"@types/react": "19.3.0",
|
|
48
48
|
"@vitest/browser-playwright": "5.0.3",
|
|
49
|
-
"eslint-config-typed": "5.19.
|
|
50
|
-
"eslint-plugin-ts-fortress": "1.3.
|
|
51
|
-
"eslint-plugin-ts-type-forge": "0.8.
|
|
49
|
+
"eslint-config-typed": "5.19.1",
|
|
50
|
+
"eslint-plugin-ts-fortress": "1.3.7",
|
|
51
|
+
"eslint-plugin-ts-type-forge": "0.8.12",
|
|
52
52
|
"immer": "11.1.21",
|
|
53
53
|
"playwright": "1.64.0",
|
|
54
54
|
"prettier": "3.9.9",
|
package/src/others/index.mts
CHANGED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type { MutableRecord, UnknownRecord } from 'ts-type-forge';
|
|
2
|
+
import { hasKey, isRecord } from '../guard/index.mjs';
|
|
3
|
+
import { fastDeepEqual } from './fast-deep-equal.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns `next` with every part deeply equal to the same part of `current`
|
|
7
|
+
* replaced by that part of `current` (structural sharing). When the whole of
|
|
8
|
+
* `next` is deeply equal to `current`, `current` itself is returned, so an
|
|
9
|
+
* `Object.is` comparison of the result with `current` tells whether anything
|
|
10
|
+
* changed, and a reader that compares by reference sees a new object only
|
|
11
|
+
* where the value really changed.
|
|
12
|
+
*
|
|
13
|
+
* Only plain objects and arrays are walked into. Anything else — a `Map`, a
|
|
14
|
+
* `Set`, a `Date`, a class instance — is compared whole with
|
|
15
|
+
* {@link fastDeepEqual} and kept or replaced whole.
|
|
16
|
+
*
|
|
17
|
+
* Array items are matched by index. Give `key` to match them by key instead:
|
|
18
|
+
* an item for which it returns something other than `undefined` is matched
|
|
19
|
+
* with the item of `current` it returns the same key for, and an item it
|
|
20
|
+
* returns `undefined` for is still matched by index. The one `key` is used
|
|
21
|
+
* for the arrays at every depth.
|
|
22
|
+
*
|
|
23
|
+
* Neither argument is changed. The result shares objects with both, so it
|
|
24
|
+
* relies on neither being changed afterwards.
|
|
25
|
+
*
|
|
26
|
+
* Call it where the new value is written over the one held, so that nothing
|
|
27
|
+
* downstream has a new object it must compare again.
|
|
28
|
+
*
|
|
29
|
+
* @param current - The value held now
|
|
30
|
+
* @param next - The value to write over it
|
|
31
|
+
* @param options - {@link ReplaceEqualDeepOptions}
|
|
32
|
+
* @returns `current` when `next` is deeply equal to it; otherwise a value
|
|
33
|
+
* deeply equal to `next` that reuses the equal parts of `current`
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* type PullReport = DeepReadonly<{
|
|
38
|
+
* summary: { open: number };
|
|
39
|
+
* pulls: { number: number; title: string }[];
|
|
40
|
+
* }>;
|
|
41
|
+
*
|
|
42
|
+
* const held: PullReport = {
|
|
43
|
+
* summary: { open: 2 },
|
|
44
|
+
* pulls: [
|
|
45
|
+
* { number: 1, title: 'one' },
|
|
46
|
+
* { number: 2, title: 'two' },
|
|
47
|
+
* ],
|
|
48
|
+
* } as const;
|
|
49
|
+
*
|
|
50
|
+
* // A server answers with the whole report every time.
|
|
51
|
+
* const answer: PullReport = {
|
|
52
|
+
* summary: { open: 2 },
|
|
53
|
+
* pulls: [
|
|
54
|
+
* { number: 1, title: 'one' },
|
|
55
|
+
* { number: 2, title: 'two (edited)' },
|
|
56
|
+
* ],
|
|
57
|
+
* } as const;
|
|
58
|
+
*
|
|
59
|
+
* const written = replaceEqualDeep(held, answer);
|
|
60
|
+
*
|
|
61
|
+
* assert.deepStrictEqual(written, answer);
|
|
62
|
+
*
|
|
63
|
+
* // What did not change is the object already held,
|
|
64
|
+
* assert.strictEqual(written.summary, held.summary);
|
|
65
|
+
*
|
|
66
|
+
* assert.strictEqual(written.pulls[0], held.pulls[0]);
|
|
67
|
+
*
|
|
68
|
+
* // and what changed is new.
|
|
69
|
+
* assert.notStrictEqual(written.pulls[1], held.pulls[1]);
|
|
70
|
+
*
|
|
71
|
+
* // An answer that changes nothing gives back the value held.
|
|
72
|
+
* assert.strictEqual(replaceEqualDeep(held, structuredClone(held)), held);
|
|
73
|
+
*
|
|
74
|
+
* // Match array items by key rather than by index.
|
|
75
|
+
* const opened = replaceEqualDeep(
|
|
76
|
+
* held.pulls,
|
|
77
|
+
* [{ number: 0, title: 'zero' }, ...structuredClone(held.pulls)],
|
|
78
|
+
* {
|
|
79
|
+
* key: (item) =>
|
|
80
|
+
* isRecord(item) && hasKey(item, 'number') ? item.number : undefined,
|
|
81
|
+
* },
|
|
82
|
+
* );
|
|
83
|
+
*
|
|
84
|
+
* assert.strictEqual(opened[1], held.pulls[0]);
|
|
85
|
+
*
|
|
86
|
+
* assert.strictEqual(opened[2], held.pulls[1]);
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
export const replaceEqualDeep = <T,>(
|
|
90
|
+
current: T,
|
|
91
|
+
next: T,
|
|
92
|
+
options?: ReplaceEqualDeepOptions,
|
|
93
|
+
): T =>
|
|
94
|
+
// The walk sees `unknown`; what it returns is `current`, `next`, or a copy
|
|
95
|
+
// of `next`'s shape whose parts are parts of either, so it is a `T`.
|
|
96
|
+
// eslint-disable-next-line total-functions/no-unsafe-type-assertion
|
|
97
|
+
replace(current, next, options?.key) as T;
|
|
98
|
+
|
|
99
|
+
/** Options of {@link replaceEqualDeep}. */
|
|
100
|
+
export type ReplaceEqualDeepOptions = Readonly<{
|
|
101
|
+
/**
|
|
102
|
+
* The key an array item is matched by. Items it returns `undefined` for
|
|
103
|
+
* are matched by index. Keys are compared with `SameValueZero`, as `Map`
|
|
104
|
+
* compares them.
|
|
105
|
+
*/
|
|
106
|
+
key?: (item: unknown) => unknown;
|
|
107
|
+
}>;
|
|
108
|
+
|
|
109
|
+
type Key = (item: unknown) => unknown;
|
|
110
|
+
|
|
111
|
+
const replace = (
|
|
112
|
+
current: unknown,
|
|
113
|
+
next: unknown,
|
|
114
|
+
key: Key | undefined,
|
|
115
|
+
): unknown =>
|
|
116
|
+
Object.is(current, next)
|
|
117
|
+
? current
|
|
118
|
+
: isPlainArray(current) && isPlainArray(next)
|
|
119
|
+
? replaceArray(current, next, key)
|
|
120
|
+
: isPlainObject(current) && isPlainObject(next)
|
|
121
|
+
? replaceObject(current, next, key)
|
|
122
|
+
: fastDeepEqual(current, next)
|
|
123
|
+
? current
|
|
124
|
+
: next;
|
|
125
|
+
|
|
126
|
+
// Each walk returns `current` when every part of the result is the same part
|
|
127
|
+
// of `current`, `next` when every part is the same part of `next` (nothing
|
|
128
|
+
// was reused, so no copy is needed), and the copy otherwise.
|
|
129
|
+
|
|
130
|
+
const replaceArray = (
|
|
131
|
+
current: readonly unknown[],
|
|
132
|
+
next: readonly unknown[],
|
|
133
|
+
key: Key | undefined,
|
|
134
|
+
): readonly unknown[] => {
|
|
135
|
+
const byKey = key === undefined ? undefined : indexByKey(current, key);
|
|
136
|
+
|
|
137
|
+
let mut_isCurrent = current.length === next.length;
|
|
138
|
+
|
|
139
|
+
let mut_isNext = true;
|
|
140
|
+
|
|
141
|
+
const mut_shared: unknown[] = [];
|
|
142
|
+
|
|
143
|
+
for (let mut_index = 0; mut_index < next.length; mut_index += 1) {
|
|
144
|
+
const index = mut_index;
|
|
145
|
+
|
|
146
|
+
const item = next[index];
|
|
147
|
+
|
|
148
|
+
const itemKey = key?.(item);
|
|
149
|
+
|
|
150
|
+
const before =
|
|
151
|
+
itemKey === undefined || byKey === undefined
|
|
152
|
+
? index < current.length
|
|
153
|
+
? current[index]
|
|
154
|
+
: absent
|
|
155
|
+
: byKey.has(itemKey)
|
|
156
|
+
? byKey.get(itemKey)
|
|
157
|
+
: absent;
|
|
158
|
+
|
|
159
|
+
const result = before === absent ? item : replace(before, item, key);
|
|
160
|
+
|
|
161
|
+
mut_isCurrent &&= Object.is(result, current[index]);
|
|
162
|
+
|
|
163
|
+
mut_isNext &&= Object.is(result, item);
|
|
164
|
+
|
|
165
|
+
mut_shared.push(result);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return mut_isCurrent ? current : mut_isNext ? next : mut_shared;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const replaceObject = (
|
|
172
|
+
current: UnknownRecord,
|
|
173
|
+
next: UnknownRecord,
|
|
174
|
+
key: Key | undefined,
|
|
175
|
+
): UnknownRecord => {
|
|
176
|
+
const nextKeys = Object.keys(next);
|
|
177
|
+
|
|
178
|
+
let mut_isCurrent = Object.keys(current).length === nextKeys.length;
|
|
179
|
+
|
|
180
|
+
let mut_isNext = true;
|
|
181
|
+
|
|
182
|
+
const mut_shared: MutableRecord<string, unknown> = {};
|
|
183
|
+
|
|
184
|
+
for (const k of nextKeys) {
|
|
185
|
+
const value = next[k];
|
|
186
|
+
|
|
187
|
+
const has = hasKey(current, k);
|
|
188
|
+
|
|
189
|
+
const result = has ? replace(current[k], value, key) : value;
|
|
190
|
+
|
|
191
|
+
mut_isCurrent &&= has && Object.is(result, current[k]);
|
|
192
|
+
|
|
193
|
+
mut_isNext &&= Object.is(result, value);
|
|
194
|
+
|
|
195
|
+
mut_shared[k] = result;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return mut_isCurrent ? current : mut_isNext ? next : mut_shared;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// What an item of `next` is matched with when nothing of `current` is.
|
|
202
|
+
const absent: unique symbol = Symbol('absent');
|
|
203
|
+
|
|
204
|
+
const indexByKey = (
|
|
205
|
+
items: readonly unknown[],
|
|
206
|
+
key: Key,
|
|
207
|
+
): ReadonlyMap<unknown, unknown> => {
|
|
208
|
+
const mut_byKey = new Map<unknown, unknown>();
|
|
209
|
+
|
|
210
|
+
for (const item of items) {
|
|
211
|
+
const itemKey = key(item);
|
|
212
|
+
|
|
213
|
+
if (itemKey !== undefined && !mut_byKey.has(itemKey)) {
|
|
214
|
+
mut_byKey.set(itemKey, item);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return mut_byKey;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const isPlainArray = (value: unknown): value is readonly unknown[] =>
|
|
222
|
+
Array.isArray(value) && Object.getPrototypeOf(value) === Array.prototype;
|
|
223
|
+
|
|
224
|
+
const isPlainObject = (value: unknown): value is UnknownRecord =>
|
|
225
|
+
isRecord(value) && Object.getPrototypeOf(value) === Object.prototype;
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// A `Date` is one of the values compared whole, so the cases build some.
|
|
2
|
+
/* eslint-disable unicorn/prefer-temporal */
|
|
3
|
+
import type { DeepReadonly, ReadonlyRecord } from 'ts-type-forge';
|
|
4
|
+
import { expectType } from '../expect-type.mjs';
|
|
5
|
+
import { hasKey, isRecord } from '../guard/index.mjs';
|
|
6
|
+
import { replaceEqualDeep } from './replace-equal-deep.mjs';
|
|
7
|
+
|
|
8
|
+
describe(replaceEqualDeep, () => {
|
|
9
|
+
test('returns the current value when the next one is deeply equal', () => {
|
|
10
|
+
const current = {
|
|
11
|
+
id: 1,
|
|
12
|
+
tags: ['a', 'b'],
|
|
13
|
+
owner: { name: 'x', roles: [{ role: 'admin' }] },
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
const next = {
|
|
17
|
+
id: 1,
|
|
18
|
+
tags: ['a', 'b'],
|
|
19
|
+
owner: { name: 'x', roles: [{ role: 'admin' }] },
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
assert.strictEqual(replaceEqualDeep(current, next), current);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('keeps the branches that did not change and replaces the rest', () => {
|
|
26
|
+
const current: Snapshot = {
|
|
27
|
+
summary: { open: 3, merged: 1 },
|
|
28
|
+
queue: [{ number: 1 }, { number: 2 }],
|
|
29
|
+
readAt: 100,
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
const next: Snapshot = {
|
|
33
|
+
summary: { open: 3, merged: 1 },
|
|
34
|
+
queue: [{ number: 1 }, { number: 3 }],
|
|
35
|
+
readAt: 200,
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
const shared = replaceEqualDeep(current, next);
|
|
39
|
+
|
|
40
|
+
assert.deepStrictEqual(shared, next);
|
|
41
|
+
|
|
42
|
+
assert.notStrictEqual(shared, current);
|
|
43
|
+
|
|
44
|
+
assert.strictEqual(shared.summary, current.summary);
|
|
45
|
+
|
|
46
|
+
assert.notStrictEqual(shared.queue, current.queue);
|
|
47
|
+
|
|
48
|
+
assert.strictEqual(shared.queue[0], current.queue[0]);
|
|
49
|
+
|
|
50
|
+
assert.strictEqual(shared.queue[1], next.queue[1]);
|
|
51
|
+
|
|
52
|
+
assert.strictEqual(shared.readAt, 200);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('returns the next primitive when it differs', () => {
|
|
56
|
+
assert.strictEqual(replaceEqualDeep(1, 2), 2);
|
|
57
|
+
|
|
58
|
+
assert.strictEqual(replaceEqualDeep('a', 'a'), 'a');
|
|
59
|
+
|
|
60
|
+
assert.isTrue(Number.isNaN(replaceEqualDeep(Number.NaN, Number.NaN)));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('replaces a value whose kind changed whole', () => {
|
|
64
|
+
const next: unknown = { 0: 'a' } as const;
|
|
65
|
+
|
|
66
|
+
assert.strictEqual(replaceEqualDeep<unknown>(['a'], next), next);
|
|
67
|
+
|
|
68
|
+
const nextArray: unknown = ['a'] as const;
|
|
69
|
+
|
|
70
|
+
assert.strictEqual(
|
|
71
|
+
replaceEqualDeep<unknown>({ 0: 'a' }, nextArray),
|
|
72
|
+
nextArray,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const nextNull: unknown = null;
|
|
76
|
+
|
|
77
|
+
assert.strictEqual(replaceEqualDeep<unknown>({}, nextNull), null);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('builds a new object when a property is added or removed', () => {
|
|
81
|
+
const inner = { x: 1 } as const;
|
|
82
|
+
|
|
83
|
+
const added = replaceEqualDeep<ReadonlyRecord<string, unknown>>(
|
|
84
|
+
{ inner },
|
|
85
|
+
{ inner: { x: 1 }, extra: true },
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
assert.deepStrictEqual(added, { inner: { x: 1 }, extra: true });
|
|
89
|
+
|
|
90
|
+
assert.strictEqual(added['inner'], inner);
|
|
91
|
+
|
|
92
|
+
const current: ReadonlyRecord<string, unknown> = {
|
|
93
|
+
inner,
|
|
94
|
+
gone: 1,
|
|
95
|
+
} as const;
|
|
96
|
+
|
|
97
|
+
const removed = replaceEqualDeep(current, { inner: { x: 1 } });
|
|
98
|
+
|
|
99
|
+
assert.deepStrictEqual(removed, { inner: { x: 1 } });
|
|
100
|
+
|
|
101
|
+
assert.notStrictEqual(removed, current);
|
|
102
|
+
|
|
103
|
+
assert.isFalse(Object.hasOwn(removed, 'gone'));
|
|
104
|
+
|
|
105
|
+
assert.strictEqual(removed['inner'], inner);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('builds a new object when a property set to undefined goes missing', () => {
|
|
109
|
+
const current: Readonly<{ a?: number | undefined }> = {
|
|
110
|
+
a: undefined,
|
|
111
|
+
} as const;
|
|
112
|
+
|
|
113
|
+
const shared = replaceEqualDeep(current, {});
|
|
114
|
+
|
|
115
|
+
assert.notStrictEqual(shared, current);
|
|
116
|
+
|
|
117
|
+
assert.isFalse(Object.hasOwn(shared, 'a'));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('matches array items by index by default', () => {
|
|
121
|
+
const one: Numbered = { number: 1 } as const;
|
|
122
|
+
|
|
123
|
+
const two: Numbered = { number: 2 } as const;
|
|
124
|
+
|
|
125
|
+
const current: readonly Numbered[] = [one, two] as const;
|
|
126
|
+
|
|
127
|
+
const shorter = replaceEqualDeep(current, [{ number: 1 }]);
|
|
128
|
+
|
|
129
|
+
assert.deepStrictEqual(shorter, [{ number: 1 }]);
|
|
130
|
+
|
|
131
|
+
assert.strictEqual(shorter[0], one);
|
|
132
|
+
|
|
133
|
+
const longer = replaceEqualDeep(current, [
|
|
134
|
+
{ number: 1 },
|
|
135
|
+
{ number: 2 },
|
|
136
|
+
{ number: 3 },
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
assert.strictEqual(longer[0], one);
|
|
140
|
+
|
|
141
|
+
assert.strictEqual(longer[1], two);
|
|
142
|
+
|
|
143
|
+
// An item put in front moves every one after it, so nothing matches.
|
|
144
|
+
const shifted = replaceEqualDeep(current, [
|
|
145
|
+
{ number: 0 },
|
|
146
|
+
{ number: 1 },
|
|
147
|
+
{ number: 2 },
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
assert.notStrictEqual(shifted[1], one);
|
|
151
|
+
|
|
152
|
+
assert.notStrictEqual(shifted[2], two);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('matches array items by key when `key` returns one', () => {
|
|
156
|
+
const one: Pull = { number: 1, title: 'one' } as const;
|
|
157
|
+
|
|
158
|
+
const two: Pull = { number: 2, title: 'two' } as const;
|
|
159
|
+
|
|
160
|
+
const current: readonly Pull[] = [one, two] as const;
|
|
161
|
+
|
|
162
|
+
const key = (item: unknown): unknown =>
|
|
163
|
+
isRecord(item) && hasKey(item, 'number') ? item.number : undefined;
|
|
164
|
+
|
|
165
|
+
const shared = replaceEqualDeep(
|
|
166
|
+
current,
|
|
167
|
+
[
|
|
168
|
+
{ number: 0, title: 'zero' },
|
|
169
|
+
{ number: 1, title: 'one' },
|
|
170
|
+
{ number: 2, title: 'two (edited)' },
|
|
171
|
+
],
|
|
172
|
+
{ key },
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
assert.strictEqual(shared[1], one);
|
|
176
|
+
|
|
177
|
+
assert.notStrictEqual(shared[2], two);
|
|
178
|
+
|
|
179
|
+
assert.deepStrictEqual(shared[2], { number: 2, title: 'two (edited)' });
|
|
180
|
+
|
|
181
|
+
const reordered = replaceEqualDeep(
|
|
182
|
+
current,
|
|
183
|
+
[
|
|
184
|
+
{ number: 2, title: 'two' },
|
|
185
|
+
{ number: 1, title: 'one' },
|
|
186
|
+
],
|
|
187
|
+
{ key },
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
assert.notStrictEqual(reordered, current);
|
|
191
|
+
|
|
192
|
+
assert.strictEqual(reordered[0], two);
|
|
193
|
+
|
|
194
|
+
assert.strictEqual(reordered[1], one);
|
|
195
|
+
|
|
196
|
+
const same = replaceEqualDeep(
|
|
197
|
+
current,
|
|
198
|
+
[
|
|
199
|
+
{ number: 1, title: 'one' },
|
|
200
|
+
{ number: 2, title: 'two' },
|
|
201
|
+
],
|
|
202
|
+
{ key },
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
assert.strictEqual(same, current);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('matches by index the items `key` gives no key, at every depth', () => {
|
|
209
|
+
const key = (item: unknown): unknown =>
|
|
210
|
+
isRecord(item) && hasKey(item, 'id') ? item.id : undefined;
|
|
211
|
+
|
|
212
|
+
const labels: readonly string[] = ['bug', 'ui'] as const;
|
|
213
|
+
|
|
214
|
+
const current: readonly Labeled[] = [{ id: 'a', labels }] as const;
|
|
215
|
+
|
|
216
|
+
const shared = replaceEqualDeep(
|
|
217
|
+
current,
|
|
218
|
+
[
|
|
219
|
+
{ id: 'b', labels: ['x'] },
|
|
220
|
+
{ id: 'a', labels: ['bug', 'ui'] },
|
|
221
|
+
],
|
|
222
|
+
{ key },
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
assert.strictEqual(shared[1], current[0]);
|
|
226
|
+
|
|
227
|
+
assert.strictEqual(shared[1]?.labels, labels);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('compares a Map, a Set, a Date or a class instance whole', () => {
|
|
231
|
+
const map = new Map([['a', { x: 1 }]]);
|
|
232
|
+
|
|
233
|
+
assert.strictEqual(replaceEqualDeep(map, new Map([['a', { x: 1 }]])), map);
|
|
234
|
+
|
|
235
|
+
const changedMap = new Map([
|
|
236
|
+
['a', { x: 1 }],
|
|
237
|
+
['b', { x: 2 }],
|
|
238
|
+
]);
|
|
239
|
+
|
|
240
|
+
const sharedMap = replaceEqualDeep(map, changedMap);
|
|
241
|
+
|
|
242
|
+
assert.strictEqual(sharedMap, changedMap);
|
|
243
|
+
|
|
244
|
+
// Nothing inside is reused.
|
|
245
|
+
assert.notStrictEqual(sharedMap.get('a'), map.get('a'));
|
|
246
|
+
|
|
247
|
+
const set = new Set([1, 2]);
|
|
248
|
+
|
|
249
|
+
assert.strictEqual(replaceEqualDeep(set, new Set([1, 2])), set);
|
|
250
|
+
|
|
251
|
+
const date = new Date(0);
|
|
252
|
+
|
|
253
|
+
assert.strictEqual(replaceEqualDeep(date, new Date(0)), date);
|
|
254
|
+
|
|
255
|
+
const laterDate = new Date(1);
|
|
256
|
+
|
|
257
|
+
assert.strictEqual(replaceEqualDeep(date, laterDate), laterDate);
|
|
258
|
+
|
|
259
|
+
class Point {
|
|
260
|
+
readonly x: number;
|
|
261
|
+
|
|
262
|
+
readonly inner: Readonly<{ y: number }>;
|
|
263
|
+
|
|
264
|
+
constructor(x: number, y: number) {
|
|
265
|
+
this.x = x;
|
|
266
|
+
|
|
267
|
+
this.inner = { y };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const point = new Point(1, 2);
|
|
272
|
+
|
|
273
|
+
assert.strictEqual(replaceEqualDeep(point, new Point(1, 2)), point);
|
|
274
|
+
|
|
275
|
+
const moved = new Point(3, 2);
|
|
276
|
+
|
|
277
|
+
const sharedPoint = replaceEqualDeep(point, moved);
|
|
278
|
+
|
|
279
|
+
assert.strictEqual(sharedPoint, moved);
|
|
280
|
+
|
|
281
|
+
assert.notStrictEqual(sharedPoint.inner, point.inner);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('reuses a Map compared whole inside a plain object', () => {
|
|
285
|
+
const map = new Map([['a', 1]]);
|
|
286
|
+
|
|
287
|
+
const current = { map, count: 1 } as const;
|
|
288
|
+
|
|
289
|
+
const shared = replaceEqualDeep(current, {
|
|
290
|
+
map: new Map([['a', 1]]),
|
|
291
|
+
count: 2,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
assert.strictEqual(shared.map, map);
|
|
295
|
+
|
|
296
|
+
assert.strictEqual(shared.count, 2);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('changes neither argument', () => {
|
|
300
|
+
const current: Listed = { list: [{ a: 1 }, { a: 2 }] } as const;
|
|
301
|
+
|
|
302
|
+
const next: Listed = { list: [{ a: 1 }, { a: 3 }] } as const;
|
|
303
|
+
|
|
304
|
+
const currentCopy = structuredClone(current);
|
|
305
|
+
|
|
306
|
+
const nextCopy = structuredClone(next);
|
|
307
|
+
|
|
308
|
+
replaceEqualDeep(current, next);
|
|
309
|
+
|
|
310
|
+
assert.deepStrictEqual(current, currentCopy);
|
|
311
|
+
|
|
312
|
+
assert.deepStrictEqual(next, nextCopy);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('keeps the type of its arguments', () => {
|
|
316
|
+
type PullReport = Readonly<{
|
|
317
|
+
entries: readonly Readonly<{ number: number }>[];
|
|
318
|
+
}>;
|
|
319
|
+
|
|
320
|
+
const report: PullReport = { entries: [] } as const;
|
|
321
|
+
|
|
322
|
+
const shared = replaceEqualDeep(report, { entries: [{ number: 1 }] });
|
|
323
|
+
|
|
324
|
+
expectType<typeof shared, PullReport>('=');
|
|
325
|
+
|
|
326
|
+
assert.deepStrictEqual(shared, { entries: [{ number: 1 }] });
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
type Snapshot = DeepReadonly<{
|
|
331
|
+
summary: { open: number; merged: number };
|
|
332
|
+
queue: { number: number }[];
|
|
333
|
+
readAt: number;
|
|
334
|
+
}>;
|
|
335
|
+
|
|
336
|
+
type Numbered = Readonly<{ number: number }>;
|
|
337
|
+
|
|
338
|
+
type Pull = Readonly<{ number: number; title: string }>;
|
|
339
|
+
|
|
340
|
+
type Labeled = Readonly<{ id: string; labels: readonly string[] }>;
|
|
341
|
+
|
|
342
|
+
type Listed = DeepReadonly<{ list: { a: number }[] }>;
|