@zenstackhq/client-helpers 3.1.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/src/mutator.ts ADDED
@@ -0,0 +1,449 @@
1
+ import { clone, enumerate, invariant, zip } from '@zenstackhq/common-helpers';
2
+ import type { FieldDef, SchemaDef } from '@zenstackhq/schema';
3
+ import { log, type Logger } from './logging';
4
+ import { NestedWriteVisitor } from './nested-write-visitor';
5
+ import type { ORMWriteActionType } from './types';
6
+
7
+ /**
8
+ * Tries to apply a mutation to a query result.
9
+ *
10
+ * @param queryModel the model of the query
11
+ * @param queryOp the operation of the query
12
+ * @param queryData the result data of the query
13
+ * @param mutationModel the model of the mutation
14
+ * @param mutationOp the operation of the mutation
15
+ * @param mutationArgs the arguments of the mutation
16
+ * @param schema the schema
17
+ * @param logging logging configuration
18
+ * @returns the updated query data if the mutation is applicable, otherwise undefined
19
+ */
20
+ export async function applyMutation(
21
+ queryModel: string,
22
+ queryOp: string,
23
+ queryData: any,
24
+ mutationModel: string,
25
+ mutationOp: ORMWriteActionType,
26
+ mutationArgs: any,
27
+ schema: SchemaDef,
28
+ logging: Logger | undefined,
29
+ ) {
30
+ if (!queryData || (typeof queryData !== 'object' && !Array.isArray(queryData))) {
31
+ return undefined;
32
+ }
33
+
34
+ if (!queryOp.startsWith('find')) {
35
+ // only findXXX results are applicable
36
+ return undefined;
37
+ }
38
+
39
+ return await doApplyMutation(queryModel, queryData, mutationModel, mutationOp, mutationArgs, schema, logging);
40
+ }
41
+
42
+ async function doApplyMutation(
43
+ queryModel: string,
44
+ queryData: any,
45
+ mutationModel: string,
46
+ mutationOp: ORMWriteActionType,
47
+ mutationArgs: any,
48
+ schema: SchemaDef,
49
+ logging: Logger | undefined,
50
+ ) {
51
+ let resultData = queryData;
52
+ let updated = false;
53
+
54
+ const visitor = new NestedWriteVisitor(schema, {
55
+ create: (model, args) => {
56
+ if (
57
+ model === queryModel &&
58
+ Array.isArray(resultData) // "create" mutation is only relevant for arrays
59
+ ) {
60
+ const r = createMutate(queryModel, resultData, args, schema, logging);
61
+ if (r) {
62
+ resultData = r;
63
+ updated = true;
64
+ }
65
+ }
66
+ },
67
+
68
+ createMany: (model, args) => {
69
+ if (
70
+ model === queryModel &&
71
+ args?.data &&
72
+ Array.isArray(resultData) // "createMany" mutation is only relevant for arrays
73
+ ) {
74
+ for (const oneArg of enumerate(args.data)) {
75
+ const r = createMutate(queryModel, resultData, oneArg, schema, logging);
76
+ if (r) {
77
+ resultData = r;
78
+ updated = true;
79
+ }
80
+ }
81
+ }
82
+ },
83
+
84
+ update: (model, args) => {
85
+ if (
86
+ model === queryModel &&
87
+ !Array.isArray(resultData) // array elements will be handled with recursion
88
+ ) {
89
+ const r = updateMutate(queryModel, resultData, model, args, schema, logging);
90
+ if (r) {
91
+ resultData = r;
92
+ updated = true;
93
+ }
94
+ }
95
+ },
96
+
97
+ upsert: (model, args) => {
98
+ if (model === queryModel && args?.where && args?.create && args?.update) {
99
+ const r = upsertMutate(queryModel, resultData, model, args, schema, logging);
100
+ if (r) {
101
+ resultData = r;
102
+ updated = true;
103
+ }
104
+ }
105
+ },
106
+
107
+ delete: (model, args) => {
108
+ if (model === queryModel) {
109
+ const r = deleteMutate(queryModel, resultData, model, args, schema, logging);
110
+ if (r) {
111
+ resultData = r;
112
+ updated = true;
113
+ }
114
+ }
115
+ },
116
+ });
117
+
118
+ await visitor.visit(mutationModel, mutationOp, mutationArgs);
119
+
120
+ const modelFields = schema.models[queryModel]?.fields;
121
+ invariant(modelFields, `Model ${queryModel} not found in schema`);
122
+
123
+ if (Array.isArray(resultData)) {
124
+ // try to apply mutation to each item in the array, replicate the entire
125
+ // array if any item is updated
126
+
127
+ let arrayCloned = false;
128
+ for (let i = 0; i < resultData.length; i++) {
129
+ const item = resultData[i];
130
+ if (
131
+ !item ||
132
+ typeof item !== 'object' ||
133
+ item.$optimistic // skip items already optimistically updated
134
+ ) {
135
+ continue;
136
+ }
137
+
138
+ const r = await doApplyMutation(queryModel, item, mutationModel, mutationOp, mutationArgs, schema, logging);
139
+
140
+ if (r && typeof r === 'object') {
141
+ if (!arrayCloned) {
142
+ resultData = [...resultData];
143
+ arrayCloned = true;
144
+ }
145
+ resultData[i] = r;
146
+ updated = true;
147
+ }
148
+ }
149
+ } else if (resultData !== null && typeof resultData === 'object') {
150
+ // Clone resultData to prevent mutations affecting the loop
151
+ const currentData = { ...resultData };
152
+
153
+ // iterate over each field and apply mutation to nested data models
154
+ for (const [key, value] of Object.entries(currentData)) {
155
+ const fieldDef = modelFields[key];
156
+ if (!fieldDef?.relation) {
157
+ continue;
158
+ }
159
+
160
+ const r = await doApplyMutation(
161
+ fieldDef.type,
162
+ value,
163
+ mutationModel,
164
+ mutationOp,
165
+ mutationArgs,
166
+ schema,
167
+ logging,
168
+ );
169
+
170
+ if (r && typeof r === 'object') {
171
+ resultData = { ...resultData, [key]: r };
172
+ updated = true;
173
+ }
174
+ }
175
+ }
176
+
177
+ return updated ? resultData : undefined;
178
+ }
179
+
180
+ function createMutate(
181
+ queryModel: string,
182
+ currentData: any,
183
+ newData: any,
184
+ schema: SchemaDef,
185
+ logging: Logger | undefined,
186
+ ) {
187
+ if (!newData) {
188
+ return undefined;
189
+ }
190
+
191
+ const modelFields = schema.models[queryModel]?.fields;
192
+ if (!modelFields) {
193
+ return undefined;
194
+ }
195
+
196
+ const insert: any = {};
197
+ const newDataFields = Object.keys(newData);
198
+
199
+ Object.entries(modelFields).forEach(([name, field]) => {
200
+ if (field.relation && newData[name]) {
201
+ // deal with "connect"
202
+ assignForeignKeyFields(field, insert, newData[name]);
203
+ return;
204
+ }
205
+
206
+ if (newDataFields.includes(name)) {
207
+ insert[name] = clone(newData[name]);
208
+ } else {
209
+ const defaultAttr = field.attributes?.find((attr) => attr.name === '@default');
210
+ if (field.type === 'DateTime') {
211
+ // default value for DateTime field
212
+ if (defaultAttr || field.attributes?.some((attr) => attr.name === '@updatedAt')) {
213
+ insert[name] = new Date();
214
+ return;
215
+ }
216
+ }
217
+
218
+ const defaultArg = defaultAttr?.args?.[0]?.value;
219
+ if (defaultArg?.kind === 'literal') {
220
+ // other default value
221
+ insert[name] = defaultArg.value;
222
+ }
223
+ }
224
+ });
225
+
226
+ // add temp id value
227
+ const idFields = getIdFields(schema, queryModel);
228
+ idFields.forEach((f) => {
229
+ if (insert[f.name] === undefined) {
230
+ if (f.type === 'Int' || f.type === 'BigInt') {
231
+ const currMax = Array.isArray(currentData)
232
+ ? Math.max(
233
+ ...[...currentData].map((item) => {
234
+ const idv = parseInt(item[f.name]);
235
+ return isNaN(idv) ? 0 : idv;
236
+ }),
237
+ )
238
+ : 0;
239
+ insert[f.name] = currMax + 1;
240
+ } else {
241
+ insert[f.name] = crypto.randomUUID();
242
+ }
243
+ }
244
+ });
245
+
246
+ insert.$optimistic = true;
247
+
248
+ if (logging) {
249
+ log(logging, `Applying optimistic create for ${queryModel}: ${JSON.stringify(insert)}`);
250
+ }
251
+
252
+ return [insert, ...(Array.isArray(currentData) ? currentData : [])];
253
+ }
254
+
255
+ function updateMutate(
256
+ queryModel: string,
257
+ currentData: any,
258
+ mutateModel: string,
259
+ mutateArgs: any,
260
+ schema: SchemaDef,
261
+ logging: Logger | undefined,
262
+ ) {
263
+ if (!currentData || typeof currentData !== 'object') {
264
+ return undefined;
265
+ }
266
+
267
+ if (!mutateArgs?.where || typeof mutateArgs.where !== 'object') {
268
+ return undefined;
269
+ }
270
+
271
+ if (!mutateArgs?.data || typeof mutateArgs.data !== 'object') {
272
+ return undefined;
273
+ }
274
+
275
+ if (!idFieldsMatch(mutateModel, currentData, mutateArgs.where, schema)) {
276
+ return undefined;
277
+ }
278
+
279
+ const modelFields = schema.models[queryModel]?.fields;
280
+ if (!modelFields) {
281
+ return undefined;
282
+ }
283
+
284
+ let updated = false;
285
+ let resultData = currentData;
286
+
287
+ for (const [key, value] of Object.entries<any>(mutateArgs.data)) {
288
+ const fieldInfo = modelFields[key];
289
+ if (!fieldInfo) {
290
+ continue;
291
+ }
292
+
293
+ if (fieldInfo.relation && !value?.connect) {
294
+ // relation field but without "connect"
295
+ continue;
296
+ }
297
+
298
+ if (!updated) {
299
+ // clone
300
+ resultData = { ...currentData };
301
+ }
302
+
303
+ if (fieldInfo.relation) {
304
+ // deal with "connect"
305
+ assignForeignKeyFields(fieldInfo, resultData, value);
306
+ } else {
307
+ resultData[key] = clone(value);
308
+ }
309
+ resultData.$optimistic = true;
310
+ updated = true;
311
+
312
+ if (logging) {
313
+ log(logging, `Applying optimistic update for ${queryModel}: ${JSON.stringify(resultData)}`);
314
+ }
315
+ }
316
+
317
+ return updated ? resultData : undefined;
318
+ }
319
+
320
+ function upsertMutate(
321
+ queryModel: string,
322
+ currentData: any,
323
+ model: string,
324
+ args: { where: object; create: any; update: any },
325
+ schema: SchemaDef,
326
+ logging: Logger | undefined,
327
+ ) {
328
+ let updated = false;
329
+ let resultData = currentData;
330
+
331
+ if (Array.isArray(resultData)) {
332
+ // check if we should create or update
333
+ const foundIndex = resultData.findIndex((x) => idFieldsMatch(model, x, args.where, schema));
334
+ if (foundIndex >= 0) {
335
+ const updateResult = updateMutate(
336
+ queryModel,
337
+ resultData[foundIndex],
338
+ model,
339
+ { where: args.where, data: args.update },
340
+ schema,
341
+ logging,
342
+ );
343
+ if (updateResult) {
344
+ // replace the found item with updated item
345
+ resultData = [...resultData.slice(0, foundIndex), updateResult, ...resultData.slice(foundIndex + 1)];
346
+ updated = true;
347
+ }
348
+ } else {
349
+ const createResult = createMutate(queryModel, resultData, args.create, schema, logging);
350
+ if (createResult) {
351
+ resultData = createResult;
352
+ updated = true;
353
+ }
354
+ }
355
+ } else {
356
+ // try update only
357
+ const updateResult = updateMutate(
358
+ queryModel,
359
+ resultData,
360
+ model,
361
+ { where: args.where, data: args.update },
362
+ schema,
363
+ logging,
364
+ );
365
+ if (updateResult) {
366
+ resultData = updateResult;
367
+ updated = true;
368
+ }
369
+ }
370
+
371
+ return updated ? resultData : undefined;
372
+ }
373
+
374
+ function deleteMutate(
375
+ queryModel: string,
376
+ currentData: any,
377
+ mutateModel: string,
378
+ mutateArgs: any,
379
+ schema: SchemaDef,
380
+ logging: Logger | undefined,
381
+ ) {
382
+ // TODO: handle mutation of nested reads?
383
+
384
+ if (!currentData || !mutateArgs) {
385
+ return undefined;
386
+ }
387
+
388
+ if (queryModel !== mutateModel) {
389
+ return undefined;
390
+ }
391
+
392
+ let updated = false;
393
+ let result = currentData;
394
+
395
+ if (Array.isArray(currentData)) {
396
+ for (const item of currentData) {
397
+ if (idFieldsMatch(mutateModel, item, mutateArgs, schema)) {
398
+ result = (result as unknown[]).filter((x) => x !== item);
399
+ updated = true;
400
+ if (logging) {
401
+ log(logging, `Applying optimistic delete for ${queryModel}: ${JSON.stringify(item)}`);
402
+ }
403
+ }
404
+ }
405
+ } else {
406
+ if (idFieldsMatch(mutateModel, currentData, mutateArgs, schema)) {
407
+ result = null;
408
+ updated = true;
409
+ if (logging) {
410
+ log(logging, `Applying optimistic delete for ${queryModel}: ${JSON.stringify(currentData)}`);
411
+ }
412
+ }
413
+ }
414
+
415
+ return updated ? result : undefined;
416
+ }
417
+
418
+ function idFieldsMatch(model: string, x: any, y: any, schema: SchemaDef) {
419
+ if (!x || !y || typeof x !== 'object' || typeof y !== 'object') {
420
+ return false;
421
+ }
422
+ const idFields = getIdFields(schema, model);
423
+ if (idFields.length === 0) {
424
+ return false;
425
+ }
426
+ return idFields.every((f) => x[f.name] === y[f.name]);
427
+ }
428
+
429
+ function assignForeignKeyFields(field: FieldDef, resultData: any, mutationData: any) {
430
+ // convert "connect" like `{ connect: { id: '...' } }` to foreign key fields
431
+ // assignment: `{ userId: '...' }`
432
+ if (!mutationData?.connect) {
433
+ return;
434
+ }
435
+
436
+ if (!field.relation?.fields || !field.relation.references) {
437
+ return;
438
+ }
439
+
440
+ for (const [idField, fkField] of zip(field.relation.references, field.relation.fields)) {
441
+ if (idField in mutationData.connect) {
442
+ resultData[fkField] = mutationData.connect[idField];
443
+ }
444
+ }
445
+ }
446
+
447
+ function getIdFields(schema: SchemaDef, model: string) {
448
+ return (schema.models[model]?.idFields ?? []).map((f) => schema.models[model]!.fields[f]!);
449
+ }
@@ -0,0 +1,68 @@
1
+ import type { FieldDef, SchemaDef } from '@zenstackhq/schema';
2
+
3
+ /**
4
+ * Callback functions for nested read visitor.
5
+ */
6
+ export type NestedReadVisitorCallback = {
7
+ /**
8
+ * Callback for each field visited.
9
+ * @returns If returns false, traversal will not continue into this field.
10
+ */
11
+ field?: (
12
+ model: string,
13
+ field: FieldDef | undefined,
14
+ kind: 'include' | 'select' | undefined,
15
+ args: unknown,
16
+ ) => void | boolean;
17
+ };
18
+
19
+ /**
20
+ * Visitor for nested read payload.
21
+ */
22
+ export class NestedReadVisitor {
23
+ constructor(
24
+ private readonly schema: SchemaDef,
25
+ private readonly callback: NestedReadVisitorCallback,
26
+ ) {}
27
+
28
+ private doVisit(model: string, field: FieldDef | undefined, kind: 'include' | 'select' | undefined, args: unknown) {
29
+ if (this.callback.field) {
30
+ const r = this.callback.field(model, field, kind, args);
31
+ if (r === false) {
32
+ return;
33
+ }
34
+ }
35
+
36
+ if (!args || typeof args !== 'object') {
37
+ return;
38
+ }
39
+
40
+ let selectInclude: any;
41
+ let nextKind: 'select' | 'include' | undefined;
42
+ if ((args as any).select) {
43
+ selectInclude = (args as any).select;
44
+ nextKind = 'select';
45
+ } else if ((args as any).include) {
46
+ selectInclude = (args as any).include;
47
+ nextKind = 'include';
48
+ }
49
+
50
+ if (selectInclude && typeof selectInclude === 'object') {
51
+ for (const [k, v] of Object.entries(selectInclude)) {
52
+ if (k === '_count' && typeof v === 'object' && v) {
53
+ // recurse into { _count: { ... } }
54
+ this.doVisit(model, field, kind, v);
55
+ } else {
56
+ const field = this.schema.models[model]?.fields[k];
57
+ if (field) {
58
+ this.doVisit(field.type, field, nextKind, v);
59
+ }
60
+ }
61
+ }
62
+ }
63
+ }
64
+
65
+ visit(model: string, args: unknown) {
66
+ this.doVisit(model, undefined, undefined, args);
67
+ }
68
+ }