forked from Expensify/react-native-onyx
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathutils.ts
More file actions
323 lines (275 loc) · 13.1 KB
/
utils.ts
File metadata and controls
323 lines (275 loc) · 13.1 KB
1
2
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
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
import type {OnyxInput, OnyxKey} from './types';
type EmptyObject = Record<string, never>;
type EmptyValue = EmptyObject | null | undefined;
/**
* A tuple where the first value is the path to the nested object that contains the
* internal `ONYX_INTERNALS__REPLACE_OBJECT_MARK` flag, and the second value is the data we want to replace
* in that path.
*
* This tuple will be used in SQLiteProvider to replace the nested object using `JSON_REPLACE`.
* */
type FastMergeReplaceNullPatch = [string[], unknown];
type FastMergeOptions = {
/** If true, null object values will be removed. */
shouldRemoveNestedNulls?: boolean;
/**
* If set to "mark", we will mark objects that are set to null instead of simply removing them,
* so that we can batch changes together, without losing information about the object removal.
* If set to "replace", we will completely replace the marked objects with the new value instead of merging them.
*/
objectRemovalMode?: 'mark' | 'replace' | 'none';
};
type FastMergeMetadata = {
/** The list of tuples that will be used in SQLiteProvider to replace the nested objects using `JSON_REPLACE`. */
replaceNullPatches: FastMergeReplaceNullPatch[];
};
type FastMergeResult<TValue> = {
/** The result of the merge. */
result: TValue;
/** The list of tuples that will be used in SQLiteProvider to replace the nested objects using `JSON_REPLACE`. */
replaceNullPatches: FastMergeReplaceNullPatch[];
};
const ONYX_INTERNALS__REPLACE_OBJECT_MARK = 'ONYX_INTERNALS__REPLACE_OBJECT_MARK';
/**
* Merges two objects and removes null values if "shouldRemoveNestedNulls" is set to true
*
* We generally want to remove null values from objects written to disk and cache, because it decreases the amount of data stored in memory and on disk.
*/
function fastMerge<TValue>(target: TValue, source: TValue, options?: FastMergeOptions, metadata?: FastMergeMetadata, basePath: string[] = []): FastMergeResult<TValue> {
if (!metadata) {
// eslint-disable-next-line no-param-reassign
metadata = {
replaceNullPatches: [],
};
}
// We have to ignore arrays, primitives and nullish values here,
// otherwise "mergeObject" will throw an error,
// because it expects an object as "source"
if (!isMergeableObject(source)) {
return {result: source, replaceNullPatches: metadata.replaceNullPatches};
}
const optionsWithDefaults: FastMergeOptions = {
shouldRemoveNestedNulls: options?.shouldRemoveNestedNulls ?? false,
objectRemovalMode: options?.objectRemovalMode ?? 'none',
};
const mergedValue = mergeObject(target, source as Record<string, unknown>, optionsWithDefaults, metadata, basePath) as TValue;
return {result: mergedValue, replaceNullPatches: metadata.replaceNullPatches};
}
/**
* Merges the source object into the target object.
* @param target - The target object.
* @param source - The source object.
* @param options - The options for the merge.
* @param metadata - The metadata for the merge.
* @param basePath - The base path for the merge.
* @returns - The merged object.
*/
function mergeObject<TObject extends Record<string, unknown>>(
target: TObject | unknown | null | undefined,
source: TObject,
options: FastMergeOptions,
metadata: FastMergeMetadata,
basePath: string[],
): TObject {
const destination: Record<string, unknown> = {};
const targetObject = isMergeableObject(target) ? target : undefined;
// Track whether the merge actually changed anything compared to target.
// If nothing changed, we return the original target reference for reference stability.
let hasChanged = !targetObject;
// First we want to copy over all keys from the target into the destination object,
// in case "target" is a mergable object.
// If "shouldRemoveNestedNulls" is true, we want to remove null values from the merged object
// and therefore we need to omit keys where either the source or target value is null.
if (targetObject) {
for (const key of Object.keys(targetObject)) {
const targetProperty = targetObject?.[key];
const sourceProperty = source?.[key];
// If "shouldRemoveNestedNulls" is true, we want to remove (nested) null values from the merged object.
// If either the source or target value is null, we want to omit the key from the merged object.
const shouldOmitNullishProperty = options.shouldRemoveNestedNulls && (targetProperty === null || sourceProperty === null);
if (targetProperty === undefined || shouldOmitNullishProperty) {
hasChanged = true;
continue;
}
destination[key] = targetProperty;
}
}
// After copying over all keys from the target object, we want to merge the source object into the destination object.
for (const key of Object.keys(source)) {
let targetProperty = targetObject?.[key];
const sourceProperty = source?.[key] as Record<string, unknown>;
// If "shouldRemoveNestedNulls" is true, we want to remove (nested) null values from the merged object.
// If the source value is null, we want to omit the key from the merged object.
const shouldOmitNullishProperty = options.shouldRemoveNestedNulls && sourceProperty === null;
if (sourceProperty === undefined || shouldOmitNullishProperty) {
continue;
}
// If the source value is not a mergable object, we need to set the key directly.
if (!isMergeableObject(sourceProperty)) {
if (destination[key] !== sourceProperty) {
hasChanged = true;
}
destination[key] = sourceProperty;
continue;
}
// If "shouldMarkRemovedObjects" is enabled and the previous merge change (targetProperty) is null,
// it means we want to fully replace this object when merging the batched changes with the Onyx value.
// To achieve this, we first mark these nested objects with an internal flag.
// When calling fastMerge again with "mark" removal mode, the marked objects will be removed.
if (options.objectRemovalMode === 'mark' && targetProperty === null) {
hasChanged = true;
targetProperty = {[ONYX_INTERNALS__REPLACE_OBJECT_MARK]: true};
metadata.replaceNullPatches.push([[...basePath, key], {...sourceProperty}]);
}
// Later, when merging the batched changes with the Onyx value, if a nested object of the batched changes
// has the internal flag set, we replace the entire destination object with the source one and remove
// the flag.
if (options.objectRemovalMode === 'replace' && sourceProperty[ONYX_INTERNALS__REPLACE_OBJECT_MARK]) {
hasChanged = true;
// We do a spread here in order to have a new object reference and allow us to delete the internal flag
// of the merged object only.
const sourcePropertyWithoutMark = {...sourceProperty};
delete sourcePropertyWithoutMark.ONYX_INTERNALS__REPLACE_OBJECT_MARK;
destination[key] = sourcePropertyWithoutMark;
continue;
}
const merged = fastMerge(targetProperty, sourceProperty, options, metadata, [...basePath, key]).result;
if (merged !== targetProperty) {
hasChanged = true;
}
destination[key] = merged;
}
return hasChanged ? (destination as TObject) : (targetObject as TObject);
}
/** Checks whether the given object is an object and not null/undefined. */
function isEmptyObject<T>(obj: T | EmptyValue): obj is EmptyValue {
return typeof obj === 'object' && Object.keys(obj || {}).length === 0;
}
/**
* Checks whether the given value can be merged. It has to be an object, but not an array, RegExp or Date.
* Mostly copied from https://medium.com/@lubaka.a/how-to-remove-lodash-performance-improvement-b306669ad0e1.
*/
function isMergeableObject<TObject extends Record<string, unknown>>(value: unknown): value is TObject {
const isNonNullObject = value != null ? typeof value === 'object' : false;
return isNonNullObject && !(value instanceof RegExp) && !(value instanceof Date) && !Array.isArray(value);
}
/** Deep removes the nested null values from the given value. Returns the original reference if no nulls were found. */
function removeNestedNullValues<TValue extends OnyxInput<OnyxKey> | null>(value: TValue): TValue {
if (value === null || value === undefined || typeof value !== 'object' || Array.isArray(value)) {
return value;
}
let hasChanged = false;
const result: Record<string, unknown> = {};
// eslint-disable-next-line no-restricted-syntax, guard-for-in
for (const key in value) {
const propertyValue = value[key];
if (propertyValue === null || propertyValue === undefined) {
hasChanged = true;
continue;
}
if (typeof propertyValue === 'object' && !Array.isArray(propertyValue)) {
const cleaned = removeNestedNullValues(propertyValue);
if (cleaned !== propertyValue) {
hasChanged = true;
}
result[key] = cleaned;
} else {
result[key] = propertyValue;
}
}
return hasChanged ? (result as TValue) : value;
}
/** Formats the action name by uppercasing and adding the key if provided. */
function formatActionName(method: string, key?: OnyxKey): string {
return key ? `${method.toUpperCase()}/${key}` : method.toUpperCase();
}
/** validate that the update and the existing value are compatible */
function checkCompatibilityWithExistingValue(
value: unknown,
existingValue: unknown,
): {isCompatible: boolean; existingValueType?: string; newValueType?: string; isEmptyArrayCoercion?: boolean} {
if (!existingValue || !value) {
return {
isCompatible: true,
};
}
// PHP's associative arrays cannot distinguish between an empty list and an
// empty object, so it encodes both as []. A key that should hold an
// object may arrive from the server as [] and be stored that way. If
// we then try to MERGE an object into that key, the array-vs-object type check
// would normally block it. Since an empty array carries no data worth
// preserving, we treat it as compatible with an object update and coerce it.
const isObjectValue = typeof value === 'object' && !Array.isArray(value);
if (Array.isArray(existingValue) && existingValue.length === 0 && isObjectValue) {
return {isCompatible: true, isEmptyArrayCoercion: true};
}
const existingValueType = Array.isArray(existingValue) ? 'array' : 'non-array';
const newValueType = Array.isArray(value) ? 'array' : 'non-array';
if (existingValueType !== newValueType) {
return {
isCompatible: false,
existingValueType,
newValueType,
};
}
return {
isCompatible: true,
};
}
/**
* Filters an object based on a condition and an inclusion flag.
*
* @param obj - The object to filter.
* @param condition - The condition to apply.
* @param include - If true, include entries that match the condition; otherwise, exclude them.
* @returns The filtered object.
*/
function filterObject<TValue>(obj: Record<string, TValue>, condition: string | string[] | ((entry: [string, TValue]) => boolean), include: boolean): Record<string, TValue> {
const result: Record<string, TValue> = {};
const entries = Object.entries(obj);
for (const [key, value] of entries) {
let shouldInclude: boolean;
if (Array.isArray(condition)) {
shouldInclude = condition.includes(key);
} else if (typeof condition === 'string') {
shouldInclude = key === condition;
} else {
shouldInclude = condition([key, value]);
}
if (include ? shouldInclude : !shouldInclude) {
result[key] = value;
}
}
return result;
}
/**
* Picks entries from an object based on a condition.
*
* @param obj - The object to pick entries from.
* @param condition - The condition to determine which entries to pick.
* @returns The object containing only the picked entries.
*/
function pick<TValue>(obj: Record<string, TValue>, condition: string | string[] | ((entry: [string, TValue]) => boolean)): Record<string, TValue> {
return filterObject(obj, condition, true);
}
/**
* Omits entries from an object based on a condition.
*
* @param obj - The object to omit entries from.
* @param condition - The condition to determine which entries to omit.
* @returns The object containing only the remaining entries after omission.
*/
function omit<TValue>(obj: Record<string, TValue>, condition: string | string[] | ((entry: [string, TValue]) => boolean)): Record<string, TValue> {
return filterObject(obj, condition, false);
}
export default {
fastMerge,
isEmptyObject,
formatActionName,
removeNestedNullValues,
checkCompatibilityWithExistingValue,
pick,
omit,
ONYX_INTERNALS__REPLACE_OBJECT_MARK,
};
export type {FastMergeResult, FastMergeReplaceNullPatch, FastMergeOptions};