-
-
Notifications
You must be signed in to change notification settings - Fork 98
Expand file tree
/
Copy pathCollection.ts
More file actions
376 lines (339 loc) · 11.3 KB
/
Collection.ts
File metadata and controls
376 lines (339 loc) · 11.3 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
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
import ArraySchema from './Array.js';
import { consistentSerialize } from './consistentSerialize.js';
import Values from './Values.js';
import {
INormalizeDelegate,
PolymorphicInterface,
IQueryDelegate,
Mergeable,
} from '../interface.js';
import type { Values as ValuesType, Array as ArrayType } from '../schema.js';
import type { DefaultArgs } from '../schemaTypes.js';
const pushMerge = (existing: any, incoming: any) => {
return [...existing, ...incoming];
};
const unshiftMerge = (existing: any, incoming: any) => {
return [...incoming, ...existing];
};
const valuesMerge = (existing: any, incoming: any) => {
return { ...existing, ...incoming };
};
const removeMerge = (existing: Array<any>, incoming: any) => {
return existing.filter((item: any) => !incoming.includes(item));
};
const createArray = (value: any) => [...value];
const createValue = (value: any) => ({ ...value });
/**
* Entities but for Arrays instead of classes
* @see https://dataclient.io/rest/api/Collection
*/
export default class CollectionSchema<
S extends PolymorphicInterface = any,
Args extends any[] = DefaultArgs,
Parent = any,
> implements Mergeable
{
declare protected nestKey: (parent: any, key: string) => Record<string, any>;
declare protected argsKey?: (...args: any) => Record<string, any>;
declare readonly schema: S;
declare readonly key: string;
declare push: S extends ArrayType<any> ? CollectionSchema<S, Args, Parent>
: undefined;
declare unshift: S extends ArrayType<any> ? CollectionSchema<S, Args, Parent>
: undefined;
declare assign: S extends ValuesType<any> ? CollectionSchema<S, Args, Parent>
: undefined;
declare remove: S extends ArrayType<any> ? CollectionSchema<S, Args, Parent>
: undefined;
addWith<P extends any[] = Args>(
merge: (existing: any, incoming: any) => any,
createCollectionFilter?: (
...args: P
) => (collectionKey: Record<string, string>) => boolean,
): CollectionSchema<S, P> {
return CreateAdder(this, merge, createCollectionFilter);
}
// this adds to any list *in store* that has same members as the urlParams
// so fetch(create, { userId: 'bob', completed: true }, data)
// would possibly add to {}, {userId: 'bob'}, {completed: true}, {userId: 'bob', completed: true } - but only those already in the store
// it ignores keys that start with sort as those are presumed to not filter results
protected createCollectionFilter(...args: Args) {
return (collectionKey: Record<string, string>) =>
Object.entries(collectionKey).every(
([key, value]) =>
this.nonFilterArgumentKeys(key) ||
// strings are canonical form. See pk() above for value transformation
`${args[0][key]}` === value ||
`${args[1]?.[key]}` === value,
);
}
protected nonFilterArgumentKeys(key: string) {
return key.startsWith('order');
}
constructor(schema: S, options?: CollectionOptions<Args, Parent>) {
this.schema =
Array.isArray(schema) ? (new ArraySchema(schema[0]) as any) : schema;
if (!options) {
this.argsKey = params => ({ ...params });
} else {
if ('nestKey' in options) {
(this as any).nestKey = options.nestKey;
} else if ('argsKey' in options) {
this.argsKey = options.argsKey;
} else {
this.argsKey = params => ({ ...params });
}
}
this.key = keyFromSchema(this.schema);
if ((options as any)?.nonFilterArgumentKeys) {
const { nonFilterArgumentKeys } = options as {
nonFilterArgumentKeys: ((key: string) => boolean) | string[] | RegExp;
};
if (typeof nonFilterArgumentKeys === 'function') {
this.nonFilterArgumentKeys = nonFilterArgumentKeys;
} else if (nonFilterArgumentKeys instanceof RegExp) {
this.nonFilterArgumentKeys = key => nonFilterArgumentKeys.test(key);
} else {
this.nonFilterArgumentKeys = key => nonFilterArgumentKeys.includes(key);
}
} else if ((options as any)?.createCollectionFilter)
// TODO(breaking): rename to filterCollections
this.createCollectionFilter = (
options as any as {
createCollectionFilter: (
...args: Args
) => (collectionKey: Record<string, string>) => boolean;
}
).createCollectionFilter.bind(this) as any;
// >>>>>>>>>>>>>>CREATION<<<<<<<<<<<<<<
if (this.schema instanceof ArraySchema) {
this.createIfValid = createArray;
this.push = CreateAdder(this, pushMerge);
this.unshift = CreateAdder(this, unshiftMerge);
this.remove = CreateAdder(this, removeMerge);
} else if (schema instanceof Values) {
this.createIfValid = createValue;
this.assign = CreateAdder(this, valuesMerge);
}
}
get cacheWith(): object {
return this.schema.schema;
}
toString() {
return this.key;
}
toJSON() {
return {
key: this.key,
schema: this.schema.schema.toJSON(),
};
}
pk(value: any, parent: any, key: string, args: readonly any[]) {
const obj =
this.argsKey ? this.argsKey(...args) : this.nestKey(parent, key);
for (const key in obj) {
if (['number', 'boolean'].includes(typeof obj[key]))
obj[key] = `${obj[key]}`;
}
return consistentSerialize(obj);
}
// >>>>>>>>>>>>>>NORMALIZE<<<<<<<<<<<<<<
normalize(
input: any,
parent: Parent,
key: string,
args: any[],
visit: (...args: any) => any,
delegate: INormalizeDelegate,
): string {
const normalizedValue = this.schema.normalize(
input,
parent,
key,
args,
visit,
delegate,
);
const id = this.pk(normalizedValue, parent, key, args);
delegate.mergeEntity(this, id, normalizedValue);
return id;
}
// always replace
merge(existing: any, incoming: any) {
return incoming;
}
shouldReorder(
existingMeta: { date: number; fetchedAt: number },
incomingMeta: { date: number; fetchedAt: number },
existing: any,
incoming: any,
) {
return incomingMeta.fetchedAt < existingMeta.fetchedAt;
}
mergeWithStore(
existingMeta: {
date: number;
fetchedAt: number;
},
incomingMeta: { date: number; fetchedAt: number },
existing: any,
incoming: any,
) {
return this.shouldReorder(existingMeta, incomingMeta, existing, incoming) ?
this.merge(incoming, existing)
: this.merge(existing, incoming);
}
mergeMetaWithStore(
existingMeta: {
fetchedAt: number;
date: number;
expiresAt: number;
},
incomingMeta: { fetchedAt: number; date: number; expiresAt: number },
existing: any,
incoming: any,
) {
return this.shouldReorder(existingMeta, incomingMeta, existing, incoming) ?
existingMeta
: incomingMeta;
}
// >>>>>>>>>>>>>>DENORMALIZE<<<<<<<<<<<<<<
queryKey(args: Args, unvisit: unknown, delegate: IQueryDelegate): any {
if (this.argsKey) {
const pk = this.pk(undefined, undefined, '', args);
// ensure this actually has entity or we shouldn't try to use it in our query
if (delegate.getEntity(this.key, pk)) return pk;
}
}
declare createIfValid: (value: any) => any | undefined;
denormalize(
input: any,
args: readonly any[],
unvisit: (schema: any, input: any) => any,
): ReturnType<S['denormalize']> {
return this.schema.denormalize(input, args, unvisit) as any;
}
}
export type CollectionOptions<
Args extends any[] = DefaultArgs,
Parent = any,
> = (
| {
/** Defines lookups for Collections nested in other schemas.
*
* @see https://dataclient.io/rest/api/Collection#nestKey
*/
nestKey?: (parent: Parent, key: string) => Record<string, any>;
}
| {
/** Defines lookups top-level Collections using ...args.
*
* @see https://dataclient.io/rest/api/Collection#argsKey
*/
argsKey?: (...args: Args) => Record<string, any>;
}
) &
(
| {
/** Sets a default createCollectionFilter for addWith(), push, unshift, and assign.
*
* @see https://dataclient.io/rest/api/Collection#createcollectionfilter
*/
createCollectionFilter?: (
...args: Args
) => (collectionKey: Record<string, string>) => boolean;
}
| {
/** Test to determine which arg keys should **not** be used for filtering results.
*
* @see https://dataclient.io/rest/api/Collection#nonfilterargumentkeys
*/
nonFilterArgumentKeys?: ((key: string) => boolean) | string[] | RegExp;
}
);
function CreateAdder<C extends CollectionSchema<any, any>, P extends any[]>(
collection: C,
merge: (existing: any, incoming: any) => any[],
createCollectionFilter?: (
...args: P
) => (collectionKey: Record<string, string>) => boolean,
) {
const properties: PropertyDescriptorMap = {
merge: { value: merge },
normalize: { value: normalizeCreate },
queryKey: { value: queryKeyCreate },
};
if (collection.schema instanceof ArraySchema) {
properties.createIfValid = { value: createIfValid };
properties.denormalize = { value: denormalize };
}
if (createCollectionFilter) {
properties.createCollectionFilter = { value: createCollectionFilter };
}
return Object.create(collection, properties);
}
function queryKeyCreate() {}
function normalizeCreate(
this: CollectionSchema<any, any>,
input: any,
parent: any,
key: string,
args: readonly any[],
visit: ((...args: any) => any) & { creating?: boolean },
delegate: INormalizeDelegate,
): any {
if (process.env.NODE_ENV !== 'production') {
// means 'this is a creation endpoint' - so real PKs are not required
// this is used by Entity.normalize() to determine whether to allow empty pks
// visit instances are created on each normalize call so this will safely be reset
visit.creating = true;
}
const normalizedValue = this.schema.normalize(
!(this.schema instanceof ArraySchema) || Array.isArray(input) ?
input
: [input],
parent,
key,
args,
visit,
delegate,
);
// parent is args when not nested
const filterCollections = (this.createCollectionFilter as any)(...args);
// add to any collections that match this
const entities = delegate.getEntities(this.key);
if (entities)
for (const collectionKey of entities.keys()) {
if (!filterCollections(JSON.parse(collectionKey))) continue;
delegate.mergeEntity(this, collectionKey, normalizedValue);
}
return normalizedValue as any;
}
function createIfValid(value: object): any | undefined {
return Array.isArray(value) ? [...value] : { ...value };
}
// only for arrays
function denormalize(
this: CollectionSchema<any, any>,
input: any,
args: readonly any[],
unvisit: (schema: any, input: any) => any,
): any {
return Array.isArray(input) ?
(this.schema.denormalize(input, args, unvisit) as any)
: (this.schema.denormalize([input], args, unvisit)[0] as any);
}
/**
* We call schema.denormalize and schema.normalize directly
* instead of visit/unvisit as we are not operating on new data
* so the additional checks in those methods are redundant
*/
function keyFromSchema(schema: PolymorphicInterface) {
if (schema instanceof ArraySchema) {
// this assumes the definition of Array/Values is Entity
return `[${schema.schemaKey()}]`;
} else if (schema instanceof Values) {
return `{${schema.schemaKey()}}`;
}
return `(${schema.schemaKey()})`;
}