-
Notifications
You must be signed in to change notification settings - Fork 9
Expand file tree
/
Copy pathparse.ts
More file actions
558 lines (490 loc) · 19.6 KB
/
Copy pathparse.ts
File metadata and controls
558 lines (490 loc) · 19.6 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
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
import type { TraversalCallbackContext } from 'object-traversal';
import { traverse } from 'object-traversal';
import { isArray, isObject, mapEntries, pick, shake } from 'radash';
import { v4 as uuidv4 } from 'uuid';
import { oFilter, getCurrentFields, getCurrentSchema, getParentNode } from '../../../helpers';
import type {
BQLMutationBlock,
BormOperation,
EnrichedBQLMutationBlock,
EnrichedBormRelation,
EnrichedBormSchema,
EnrichedLinkField,
} from '../../../types';
import { computeField } from '../../../engine/compute';
import { deepRemoveMetaData } from '../../../../tests/helpers/matchers';
import { EdgeSchema, EdgeType } from '../../../types/symbols';
export const parseBQLMutation = (
blocks: EnrichedBQLMutationBlock | EnrichedBQLMutationBlock[],
schema: EnrichedBormSchema,
) => {
const listNodes = (blocks: EnrichedBQLMutationBlock | EnrichedBQLMutationBlock[]) => {
// todo: make immutable
const nodes: BQLMutationBlock[] = [];
const edges: BQLMutationBlock[] = [];
/*
function getIdsByPath(path: string) {
const ids = nodes.filter((node) => node[Symbol.for('path') as any] === path).map((node) => node.id);
return ids.length === 1 ? ids[0] : ids;
} */
const getIdValue = (node: EnrichedBQLMutationBlock) => {
if (node.$id) {
return node.$id;
}
const currentSchema = getCurrentSchema(schema, node);
const { idFields } = currentSchema;
if (!idFields) {
throw new Error(`no idFields: ${JSON.stringify(node)}`);
}
// todo: composite ids
const [idField] = idFields;
if (!idField) {
throw new Error(`no idField: ${JSON.stringify(node)}`);
}
/// This is adding idfields for intermediary relations. In the future maybe it would be better to add the intermediary relations in the enrich step?
const idDataField = currentSchema.dataFields?.find((x) => x.path === idField);
const defaultIdField = computeField({
currentThing: node,
fieldSchema: idDataField, //id is always a datafield.
mandatoryDependencies: true, //can't send to db without every dependency being there
});
const idValue = node[idField] || node.$id || defaultIdField;
if (!idValue) {
throw new Error(`no idValue: ${JSON.stringify(node)}`);
}
return idValue;
};
const toNodes = (node: EnrichedBQLMutationBlock) => {
if (node.$op === 'create') {
const idValue = getIdValue(node);
if (nodes.find((x) => x.$id === idValue && x.$op === 'create')) {
throw new Error(`Duplicate id ${idValue} for node ${JSON.stringify(node)}`);
}
if (edges.find((x) => x.$bzId === node.$bzId)) {
throw new Error(`Duplicate $bzId ${node.$bzId} for node ${JSON.stringify(node)}`);
}
nodes.push({ ...node, $id: idValue });
return;
}
if (node.$tempId && node.$op === 'match') {
/// we don't add to the node list, those that are being matched as they don't need to be matched in db and if they have a $tempId then it means... they are being created in the same query!
return;
}
nodes.push(node);
};
const toEdges = (edge: EnrichedBQLMutationBlock) => {
if (edge.$op === 'create') {
const idValue = getIdValue(edge);
if (nodes.find((x) => x.$id === idValue)) {
// throw new Error(`Duplicate id ${idValue} for edge ${JSON.stringify(edge)}`);
}
if (edges.find((x) => x.$bzId === edge.$bzId)) {
throw new Error(`Duplicate %bzId ${edge.$bzIdd} for edge ${JSON.stringify(edge)}`);
}
edges.push({ ...edge, $id: idValue });
return;
}
edges.push(edge);
};
const listOp = ({ value: val, parent, meta }: TraversalCallbackContext) => {
if (!isObject(val)) {
return;
}
const value = val as EnrichedBQLMutationBlock;
/// no idea why this is needed lol, but sometimes is indeed undefined 🤷♀️
if (value.$thing) {
if (!value.$op) {
throw new Error(`Operation should be defined at this step ${JSON.stringify(value)}`);
}
if (!value.$bzId) {
throw new Error('[internal error] BzId not found');
}
/// this is used to group the right delete/unlink operations with the involved things
const currentThingSchema = getCurrentSchema(schema, value);
const {
dataFields: dataFieldPaths,
roleFields: roleFieldPaths,
linkFields: linkFieldPaths,
usedFields,
} = getCurrentFields(currentThingSchema, value);
const getChildOp = () => {
if (value.$op === 'create' || value.$op === 'delete') {
return value.$op;
}
// if its un update because linkfields or rolefields updated, but no attributes, then it a match
if (value.$op === 'update') {
const usedDataFields = usedFields.filter((x: string) => dataFieldPaths?.includes(x));
const usedRoleFields = usedFields.filter((x: string) => roleFieldPaths?.includes(x));
const usedLinkFields = usedFields.filter((x: string) => linkFieldPaths?.includes(x));
if (usedDataFields.length > 0) {
return 'update';
}
if (usedRoleFields.length > 0 || usedLinkFields.length > 0) {
return 'match';
}
throw new Error(`No fields on an $op:"update" for node ${JSON.stringify(value)}`);
}
return 'match';
};
const dataObj = {
...(value.$id && { $id: value.$id }),
...(value.$tempId && { $tempId: value.$tempId }),
...(value.$filter && { $filter: value.$filter }),
...{ $thing: value.$thing },
...(value.$thingType && { $thingType: value.$thingType }),
...shake(pick(value, dataFieldPaths || [''])),
$op: getChildOp() as BormOperation,
$bzId: value.$bzId,
};
/// split nodes with multiple ids // why? //no longer doing that
toNodes(dataObj);
// CASE 1: HAVE A PARENT THROUGH LINKFIELDS
const edgeSchema = value[EdgeSchema] as EnrichedLinkField;
if (edgeSchema?.fieldType === 'linkField') {
if (value.$op === 'link' || value.$op === 'unlink') {
if (value.$id || value.$filter) {
if (value.$tempId) {
throw new Error("can't specify a existing and a new element at once. Use an id/filter or a tempId");
}
nodes.push({ ...value, $op: 'match' });
}
// we add a "linkable" version of it so we can query it in the insertion
}
// this linkObj comes from nesting, which means it has no properties and no ID
// relations explicitely created are not impacted by this, and they get the $id from it's actual current value
const ownRelation = edgeSchema.relation === value.$thing;
const linkTempId = ownRelation ? value.$bzId : `LT_${uuidv4()}`;
const parentNode = getParentNode(blocks, parent, meta);
const parentId = parentNode.$bzId;
if (!parentId) {
throw new Error('No parent id found');
}
const getLinkObjOp = () => {
if (value.$op === 'delete') {
if (ownRelation) {
return 'match';
}
return 'delete';
}
if (value.$op === 'unlink') {
if (ownRelation) {
return 'unlink';
} // delete already present in the nodes array
return 'delete';
}
if (value.$op === 'link' || value.$op === 'create') {
if (ownRelation) {
return 'link';
} // create already present in the nodes array
return 'create';
}
// todo: probably check replaces
if (value.$op === 'replace') {
// Currently pre-queries do not cross reference data nested below a create operation
throw new Error('Unsupported: Nested replaces not implemented yet');
}
return 'match';
};
//validate that field is an actual role from the relation
const relationSchema = getCurrentSchema(schema, {
$thing: edgeSchema.relation,
$thingType: 'relation',
}) as EnrichedBormRelation;
const roles = Object.keys(relationSchema.roles);
if (!roles.includes(edgeSchema.plays)) {
throw new Error(
`[Wrong format] Field ${edgeSchema.plays} is not a role of relation ${edgeSchema.relation}`,
);
}
const edgeType1 = {
$bzId: linkTempId,
$thing: edgeSchema.relation,
$thingType: 'relation' as const,
...(value.$tempId ? { $tempId: value.$tempId } : {}),
$op: getLinkObjOp(),
// roles
...(!ownRelation ? { [edgeSchema.path]: value.$bzId } : {}),
[edgeSchema.plays]: parentId,
//Metadata
[EdgeSchema]: edgeSchema,
[EdgeType]: 'linkField',
};
// const testVal = {};
// todo: stuff 😂
if (ownRelation) {
//@ts-expect-error - TODO
toEdges(edgeType1);
}
/// when it has a parent through a linkField, we need to add an additional node (its dependency), as well as a match
/// no need for links, as links will have all the related things in the "link" object. While unlinks required dependencies as match and deletions as unlink (or dependencies would be also added)
/// this is only for relations that are not $self, as other relations will be deleted and don't need a match
if ((value.$op === 'unlink' || getLinkObjOp() === 'unlink') && ownRelation) {
toEdges({
$thing: edgeSchema.relation,
$thingType: 'relation' as const,
$bzId: linkTempId,
$op: 'match',
[edgeSchema.plays]: parentId,
[EdgeSchema]: edgeSchema,
[EdgeType]: 'linkField',
});
}
}
// CASE 2: IS RELATION AND HAS THINGS IN THEIR ROLES
if (value.$thingType === 'relation') {
const rolesObjFiltered = oFilter(value, (k: string, _v) => roleFieldPaths.includes(k));
/// we don't manage cardinality MANY for now, its managed differently if we are on a create/delete op or nested link/unlink op
// todo: this is super weird, remove
//@ts-expect-error - TODO
const rolesObjOnlyIds = mapEntries(rolesObjFiltered, (k: string, v) => {
if (isArray(v)) {
return [k, v];
}
if (isObject(v)) {
// @ts-expect-error - TODO description
return [k, v.$bzId];
}
return [k, v];
});
const objWithMetaDataOnly = oFilter(val, (k, _v) => {
// @ts-expect-error - TODO description
return k.startsWith('$') || k.startsWith('Symbol');
});
if (Object.keys(rolesObjFiltered).filter((x) => !x.startsWith('$')).length > 0) {
// 2.1 EDGE TYPE 2
if (value.$op === 'create' || value.$op === 'delete') {
/// if the relation is being created, then all objects in the roles are actually add
const getEdgeOp = (): BormOperation => {
if (value.$op === 'create') {
return 'link';
}
if (value.$op === 'delete') {
return 'match';
} /// if i'm not wrong, no need to unlink becasue is the director relation and will disappear 🤔
throw new Error('Unsupported parent of edge op');
};
const currentRoles = (getCurrentSchema(schema, value) as EnrichedBormRelation).roles;
/// group ids when cardinality MANY
const rolesObjOnlyIdsGrouped = mapEntries(rolesObjOnlyIds, (k: string, v) => {
const currentRoleCardinality = currentRoles[k]?.cardinality;
if (!currentRoleCardinality) {
throw new Error(`Role ${k} not found in schema`);
}
if (Array.isArray(v)) {
if (currentRoleCardinality === 'ONE') {
if (v.length > 1) {
throw new Error(`[Error] Role ${k} is not a MANY relation`);
} else {
return [k, v[0].$bzId || v[0]];
}
}
/// Replace the array of objects with an array of ids
return [k, v.map((vNested: any) => vNested.$bzId || vNested)];
}
//@ts-expect-error - TODO
return [k, v.$bzId || v];
});
// todo: validations
/// 1) each ONE role has only ONE element // 2) no delete ops // 3) no arrayOps, because it's empty (or maybe yes and just consider it an add?) ...
const edgeType2 = {
...objWithMetaDataOnly,
$thing: value.$thing,
$thingType: 'relation' as const,
$op: getEdgeOp(),
...rolesObjOnlyIdsGrouped, // override role fields by ids or tempIDs
$bzId: value.$bzId,
[EdgeType]: 'roleField' as const,
};
toEdges(edgeType2);
return;
}
// #endregion
// 2.2 EDGE TYPE 3
if (value.$op === 'match' || (value.$op === 'update' && Object.keys(rolesObjFiltered).length > 0)) {
let totalUnlinks = 0;
Object.entries(rolesObjFiltered).forEach(([role, operations]) => {
const operationsArray = isArray(operations) ? operations : [operations];
const getOp = (childOp: BormOperation): BormOperation => {
if (childOp === 'create' || childOp === 'replace') {
// if the children is being created, the edge is a link
return 'link';
}
return childOp;
};
operationsArray.forEach((operation) => {
if (!operation) {
return;
}
const op = getOp(operation.$op);
/// validations
if (op === 'replace') {
throw new Error('Not supported yet: replace on roleFields');
}
if (op === 'unlink' && totalUnlinks > 0) {
totalUnlinks += 1; // ugly temp solution while multiple roles can't be replaced
throw new Error(
'Not supported yet: Cannot unlink more than one role at a time, please split into two mutations',
);
}
/// Edges can only be link or unlink. When its match for deletion or creation we need to know which one of those, so its either unlink or link!
const edgeType3 = {
...objWithMetaDataOnly,
$thing: value.$thing,
$thingType: 'relation' as const,
$op: op === 'delete' ? 'unlink' : op,
[role]: operation.$bzId,
$bzId: value.$bzId,
[EdgeType]: 'roleField' as const,
};
toEdges(edgeType3);
/// when unlinking stuff, it must be merged with other potential roles.
/// so we need to add it as both as match and 'unlink' so it gets merged with other unlinks
// todo maybe a way to transform unlinks already in its own matches later? maybe split match-unlink and match-link
if (op === 'unlink') {
// toEdges({ ...edgeType3, $op: 'match' }); ///apparently no longer needed
}
});
});
}
// throw new Error('Unsupported direct relation operation');
}
}
}
};
traverse(blocks, listOp);
return [nodes, edges];
};
const [parsedThings, parsedEdges] = listNodes(blocks);
console.log('parsedThings', parsedThings);
console.log('parsedEdges', parsedEdges);
//console.log('parsedThings', parsedThings);
/// some cases where we extract things, they must be ignored.
/// One of this cases is the situation where we have a thing that is linked somwhere and created, or updated.
/// If it is only linked, we indeed need it with a "match" op, but if it is already there is no need to init it
const mergedThings = parsedThings.reduce((acc, thing) => {
// Skip if the current item doesn't have a $tempId
if (!thing.$bzId) {
return [...acc, thing];
}
// Check if this $tempId already exists in the accumulator
const existingIndex = acc.findIndex((t) => t.$bzId === thing.$bzId);
if (existingIndex === -1) {
// If it doesn't exist, add it to the accumulator
return [...acc, thing];
}
// If it exists, let's check the $op
if (acc[existingIndex].$op === 'create' && thing.$op === 'match') {
// If existing is 'create' and current is 'match', ignore current
return acc;
}
if (acc[existingIndex].$op === 'match' && (thing.$op === 'create' || thing.$op === 'match')) {
// If existing is 'match' and current is 'create' or 'match', replace existing with current
return [...acc.slice(0, existingIndex), thing, ...acc.slice(existingIndex + 1)];
}
//if both are update, we simply merge them
if (acc[existingIndex].$op === 'update' && thing.$op === 'update') {
return [...acc.slice(0, existingIndex), { ...acc[existingIndex], ...thing }, ...acc.slice(existingIndex + 1)];
}
if (acc[existingIndex].$op === 'delete' && thing.$op === 'match') {
//merge them
return [
...acc.slice(0, existingIndex),
{ ...acc[existingIndex], ...thing, $op: 'delete' },
...acc.slice(existingIndex + 1),
];
}
// For all other cases, throw an error
throw new Error(
`[Wrong format] Wrong operation combination for $tempId/$id "${thing.$tempId || thing.$id}". Existing: ${acc[existingIndex].$op}. Current: ${thing.$op}`,
);
}, [] as BQLMutationBlock[]);
/// merge attributes of relations that share the same $id
/// WHY => because sometimes we get the relation because of having a parent, and other times because it is specified in the relation's properties
const mergedEdges = parsedEdges.reduce((acc, curr) => {
const existingEdge = acc.find(
(r) =>
((r.$id && r.$id === curr.$id) || (r.$bzId && r.$bzId === curr.$bzId)) &&
r.$thing === curr.$thing &&
r.$op === curr.$op,
);
if (existingEdge) {
const newRelation = { ...existingEdge };
Object.keys(curr).forEach((key) => {
if (typeof key === 'symbol' || key.startsWith('$')) {
return;
}
const existingVal = existingEdge[key];
const currVal = curr[key];
//both values are arrays
if (Array.isArray(existingVal) && Array.isArray(currVal)) {
newRelation[key] = Array.from(new Set([...existingVal, ...currVal]));
}
///the curent one is not but hte new one it is
else if (!Array.isArray(existingVal) && Array.isArray(currVal)) {
if (existingVal !== undefined) {
// Avoid merging with undefined values.
newRelation[key] = Array.from(new Set([existingVal, ...currVal]));
} else {
newRelation[key] = currVal;
}
}
///the current one is but the new one it is not
else if (Array.isArray(existingVal) && !Array.isArray(currVal)) {
if (currVal !== undefined) {
// Avoid merging with undefined values.
newRelation[key] = Array.from(new Set([...existingVal, currVal]));
}
}
//both exist and are not arrays
else if (existingVal !== null && currVal !== null && existingVal !== undefined && currVal !== undefined) {
newRelation[key] = Array.from(new Set([existingVal, currVal]));
} else if (existingVal === undefined || existingVal === null) {
newRelation[key] = currVal;
}
});
const newAcc = acc.filter(
(r) =>
!(
((r.$id && r.$id === curr.$id) || (r.$bzId && r.$bzId === curr.$bzId)) &&
r.$thing === curr.$thing &&
r.$op === curr.$op
),
);
return [...newAcc, newRelation];
}
return [...acc, curr];
}, [] as BQLMutationBlock[]);
/// VALIDATIONS
// VALIDATION: Check that every thing in the list that is an edge, has at least one player
mergedThings.forEach((thing) => {
if (thing.$thingType === 'relation' || 'relation' in thing) {
//if it is a relation, we need at lease one edge defined for it
if (
mergedEdges.filter((edge) => edge.$bzId === thing.$bzId || (edge.$tempId && edge.$tempId === thing.$tempId))
.length === 0
) {
if (thing.$op === 'delete' || thing.$op === 'match' || thing.$op === 'update') {
return;
}
throw new Error(
`[Wrong format] Can't create a relation without any player. Node: ${JSON.stringify(deepRemoveMetaData(thing))}`,
);
}
}
});
///Validate that each tempId has at least one creation op:
const allThings = [...mergedThings, ...mergedEdges];
const tempIds = new Set(allThings.filter((x) => x.$tempId).map((x) => x.$tempId));
const orphanTempIds = Array.from(tempIds).filter(
(tempId) => !allThings.some((x) => x.$tempId === tempId && x.$op === 'create'),
);
if (orphanTempIds.length > 0) {
throw new Error(
`Can't link a $tempId that has not been created in the current mutation: ${orphanTempIds.join(', ')}`,
);
}
return {
mergedThings,
mergedEdges,
};
};