objection 4.0.0-rc.2 → 4.0.0-rc.3

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.
@@ -0,0 +1,497 @@
1
+ import { formatPath } from './GraphNode.js';
2
+ import { hasOwnerKey, hasSameColumns, isOtherKey } from './graphKeys.js';
3
+ import { ValidationErrorType } from '../../model/ValidationError.js';
4
+ import { RelationExpression } from '../RelationExpression.js';
5
+ import { isObject, nonStrictEquals } from '../../utils/objectUtils.js';
6
+ import { isInternalProp } from '../../utils/internalPropUtils.js';
7
+
8
+ // Check a graph before `upsertGraph()` and `insertGraph()` write it, and the
9
+ // plan of its writes.
10
+
11
+ // The keys at which the relations of rows were read must not change once
12
+ // these relations are planned.
13
+ export function checkReadKeys(keys, readKeys) {
14
+ for (const [node, relationKeys] of readKeys) {
15
+ for (const [relation, values] of relationKeys) {
16
+ if (isOtherKey(keys.getReadProps(node, relation.ownerProp), values)) {
17
+ throw node.modelClass.createValidationError({
18
+ type: ValidationErrorType.InvalidGraph,
19
+ message: `the keys of the ${node.modelClass.name} at ${formatPath(node.dataPathKey)} depend on the rows that the graph moves`,
20
+ });
21
+ }
22
+ }
23
+ }
24
+ }
25
+
26
+ // Check the models with ids that are neither at their place nor related, once
27
+ // the current graph is loaded.
28
+ export function checkMissingModels(builder, plan) {
29
+ for (const node of plan.nodes) {
30
+ if (plan.intentFor(node) === 'missing') {
31
+ const id = node.obj.$id();
32
+
33
+ throw node.modelClass.createNotFoundError(builder.context(), {
34
+ message: node.isMissing
35
+ ? `model (id=${id}) does not exist. If you want to insert it with an id, use the insertMissing option`
36
+ : node.isRoot
37
+ ? `root model (id=${id}) does not exist. If you want to insert it with an id, use the insertMissing option`
38
+ : `model (id=${id}) is not a child of model (id=${node.parent.obj.$id()}). If you want to relate it, use the relate option. If you want to insert it with an id, use the insertMissing option`,
39
+ dataPath: node.dataPath,
40
+ });
41
+ }
42
+ }
43
+ }
44
+
45
+ // The graph writes keys of rows that can't change if they're part of the ids
46
+ // of the rows: the foreign key of a row that a HasMany or HasOne relate moves
47
+ // to another owner, and the foreign key of an existing row that its own
48
+ // BelongsToOne relate or insert points to another row, or that `null`,
49
+ // `#unrelate` or `#delete` clears. Keys that aren't known before the writes
50
+ // are compared only where they surely change, as new models get new keys.
51
+ export function checkChangedIds(builder, plan) {
52
+ const { actions } = plan;
53
+ const newKey = {};
54
+ const getKey = (node, prop, i) =>
55
+ actions.get(node) === 'insert' && !node.obj.$hasId()
56
+ ? newKey
57
+ : (prop.getProp(node.obj, i) ?? (node.existing && prop.getProp(node.existing.obj, i)));
58
+
59
+ const checkKey = (row, relation, rowProp, getNewKey) => {
60
+ const idProps = row.modelClass.getIdPropertyArray();
61
+ const isChanged =
62
+ actions.get(row) !== 'insert' &&
63
+ rowProp.props.some((prop, i) => {
64
+ const key = row.obj[prop];
65
+ const changedKey = getNewKey(i);
66
+ return (
67
+ idProps.includes(prop) &&
68
+ key !== undefined &&
69
+ changedKey !== undefined &&
70
+ (changedKey === newKey || !nonStrictEquals(key, changedKey))
71
+ );
72
+ });
73
+
74
+ if (isChanged) {
75
+ throw builder.modelClass().createValidationError({
76
+ type: ValidationErrorType.InvalidGraph,
77
+ message: `the graph changes the key of the ${row.modelClass.name} at ${formatPath(row.dataPathKey)} through ${relation.name}, which is part of its id`,
78
+ });
79
+ }
80
+ };
81
+
82
+ for (const node of plan.activeNodes) {
83
+ const { relation, parent } = node;
84
+ const action = actions.get(node);
85
+
86
+ if (relation?.keySide === 'related' && action === 'relate') {
87
+ checkKey(node.referencedNode || node, relation, relation.relatedProp, (i) =>
88
+ getKey(parent, relation.ownerProp, i),
89
+ );
90
+ } else if (relation && hasOwnerKey(relation)) {
91
+ if (node.toBeUnrelatedOrDeleted) {
92
+ if (node.current) {
93
+ checkKey(parent, relation, relation.ownerProp, () => null);
94
+ }
95
+ } else if (action === 'relate' || action === 'insert') {
96
+ checkKey(parent, relation, relation.ownerProp, (i) =>
97
+ getKey(node.referencedNode || node, relation.relatedProp, i),
98
+ );
99
+ }
100
+ }
101
+
102
+ // `null` clears the key:
103
+ for (const nullRelation of node.relations.filter(({ name }) => node.obj[name] === null)) {
104
+ if (hasOwnerKey(nullRelation)) {
105
+ checkKey(node, nullRelation, nullRelation.ownerProp, () => null);
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ // Check the whole graph before anything is written.
112
+ export function checkGraph(builder, plan) {
113
+ const { nodes, graphOptions } = plan;
114
+ const modelClass = builder.modelClass();
115
+ const createInvalidGraphError = (message) =>
116
+ modelClass.createValidationError({ type: ValidationErrorType.InvalidGraph, message });
117
+
118
+ const allowedExpression = builder.allowedGraphExpression();
119
+ const rootObjs = nodes.filter((node) => node.isRoot).map((node) => node.obj);
120
+
121
+ if (
122
+ allowedExpression &&
123
+ !allowedExpression.isSubExpression(RelationExpression.fromModelGraph(rootObjs))
124
+ ) {
125
+ throw modelClass.createValidationError({
126
+ type: ValidationErrorType.UnallowedRelation,
127
+ message: 'trying to upsert an unallowed relation',
128
+ });
129
+ }
130
+
131
+ if (
132
+ !graphOptions.allowRefs() &&
133
+ nodes.some((node) => node.isReference || node.propertyReferences.length > 0)
134
+ ) {
135
+ throw createInvalidGraphError(
136
+ '#ref references are not allowed in a graph by default. see the allowRefs insert/upsert graph option',
137
+ );
138
+ }
139
+
140
+ // `#unrelate` and `#delete` markers come from the graph data, which may be
141
+ // untrusted input. Like `#ref`, they have to be allowed explicitly (#2410).
142
+ // Root models can't be marked even with `allowMarkers`, so report that first.
143
+ // An insert has nothing to unrelate or delete, so `insertGraph` rejects them.
144
+ const markedNode = nodes.find((node) => node.toBeUnrelatedOrDeleted);
145
+
146
+ if (markedNode) {
147
+ const { graphUnrelateProp, graphDeleteProp } = markedNode.modelClass;
148
+ const markers = `${graphUnrelateProp} and ${graphDeleteProp}`;
149
+
150
+ if (nodes.some((node) => node.isRoot && node.toBeUnrelatedOrDeleted)) {
151
+ throw createInvalidGraphError(
152
+ `${markers} can only be used for related models, not for root models`,
153
+ );
154
+ } else if (!graphOptions.allowMarkers()) {
155
+ throw createInvalidGraphError(
156
+ graphOptions.isInsertOnly()
157
+ ? `${markers} can only be used in upsertGraph, not in insertGraph`
158
+ : `${markers} markers are not allowed in a graph by default. see the allowMarkers upsertGraph option`,
159
+ );
160
+ }
161
+ }
162
+
163
+ if (graphOptions.isInsertOnly()) {
164
+ const relatedNodes = nodes.filter((node) => plan.actionFor(node) === 'relate');
165
+
166
+ if (relatedNodes.some((node) => node.relation.keySide === 'related')) {
167
+ throw new Error(
168
+ 'You cannot relate HasManyRelation or HasOneRelation using insertGraph, because those require update operations. Consider using upsertGraph instead.',
169
+ );
170
+ }
171
+
172
+ // `upsertGraph()` upserts the branches below related models, but
173
+ // `insertGraph()` leaves them out, which would drop the new models there
174
+ // (#217). Models with ids below related models are taken as fetched models
175
+ // with loaded relations, and are ignored.
176
+ for (const node of relatedNodes) {
177
+ const newNode = node.descendants.find(
178
+ (descendant) => !descendant.hasId && plan.actionFor(descendant) === 'insert',
179
+ );
180
+
181
+ if (newNode) {
182
+ throw createInvalidGraphError(
183
+ `insertGraph cannot insert the new model at ${formatPath(newNode.dataPathKey)} below a related model. Insert it with a separate query or use upsertGraph instead`,
184
+ );
185
+ }
186
+ }
187
+ }
188
+ }
189
+
190
+ // One row can't get two values for one column, and the slot of a
191
+ // HasOneRelation can't get two rows. A `#ref` or `#dbRef` model is a place of
192
+ // the row that it refers to. The keys that relations give a row are the values
193
+ // of the key props across them, or stand for these where the graph doesn't
194
+ // give them, e.g. for new models.
195
+ export function checkPlacements(builder, nodes) {
196
+ const newRowIds = new Map();
197
+ const getRowId = (node) => {
198
+ const target = node.referencedNode || node;
199
+
200
+ if (target.obj.$hasId()) {
201
+ return target.rowKey;
202
+ } else {
203
+ if (!newRowIds.has(target)) {
204
+ newRowIds.set(target, `new:${newRowIds.size}`);
205
+ }
206
+
207
+ return newRowIds.get(target);
208
+ }
209
+ };
210
+
211
+ // The values that the graph gives each row, by prop, and the model classes
212
+ // and relations through which the graph reaches the rows:
213
+ const rows = new Map();
214
+ const rowTables = new Map();
215
+ const rowRelations = new Map();
216
+ const setValue = (rowId, name, prop, value, path) => {
217
+ const values = rows.get(rowId) || new Map();
218
+ const other = values.get(prop);
219
+ rows.set(rowId, values);
220
+
221
+ if (!other) {
222
+ values.set(prop, { value, path });
223
+ } else if (!nonStrictEquals(other.value, value)) {
224
+ throw builder.modelClass().createValidationError({
225
+ type: ValidationErrorType.InvalidGraph,
226
+ message: `the graph gives ${prop} of one ${name} two values, at ${formatPath(other.path)} and at ${formatPath(path)}`,
227
+ });
228
+ }
229
+ };
230
+ const setNodeValue = (node, prop, value, path) =>
231
+ setValue(getRowId(node), node.modelClass.name, prop, value, path);
232
+
233
+ // The join row of the pair of models that `node` and its parent are, the
234
+ // same through both relations of the join table:
235
+ const getJoinRowId = (node) =>
236
+ [
237
+ node.relation.joinTable,
238
+ ...[
239
+ [node.relation.joinTableOwnerProp.cols, getRowId(node.parent)],
240
+ [node.relation.joinTableRelatedProp.cols, getRowId(node)],
241
+ ]
242
+ .sort(([cols1], [cols2]) => cols1.join().localeCompare(cols2.join()))
243
+ .map(([cols, rowId]) => `${cols}=${rowId}`),
244
+ ].join(':');
245
+
246
+ const getPath = (node, ...keys) => [node.dataPathKey, ...keys].filter(Boolean).join('.');
247
+ const isSlot = (relation) => relation.keySide === 'related' && relation.isOneToOne();
248
+
249
+ // The props that the models give, except for the ids and the keys that the
250
+ // relations of the same model set, which follow these relations:
251
+ for (const node of nodes) {
252
+ const { modelClass, obj, relation } = node;
253
+ const skippedProps = [
254
+ ...modelClass.getRelationNames(),
255
+ ...modelClass.getIdPropertyArray(),
256
+ ...(relation?.keySide === 'related' ? relation.relatedProp.props : []),
257
+ // The extras of join rows belong to the join rows:
258
+ ...(relation?.keySide === 'join' ? relation.joinTableExtras.map((e) => e.aliasProp) : []),
259
+ ...node.relations.filter(hasOwnerKey).flatMap((relation) => relation.ownerProp.props),
260
+ ];
261
+
262
+ const rowId = getRowId(node);
263
+ rowTables.set(rowId, modelClass.getTableName());
264
+ rowRelations.set(rowId, (rowRelations.get(rowId) || new Set()).add(relation));
265
+
266
+ if (!node.toBeUnrelatedOrDeleted) {
267
+ for (const prop of Object.keys(obj)) {
268
+ if (obj[prop] !== undefined && !isInternalProp(prop) && !skippedProps.includes(prop)) {
269
+ setNodeValue(node, prop, obj[prop], getPath(node, prop));
270
+ }
271
+ }
272
+ }
273
+
274
+ // A pair of models has one join row, which gets the extras of all places
275
+ // of the pair:
276
+ if (relation?.keySide === 'join' && !node.toBeUnrelatedOrDeleted) {
277
+ for (const { aliasProp } of relation.joinTableExtras) {
278
+ if (obj[aliasProp] !== undefined) {
279
+ const path = getPath(node, aliasProp);
280
+ setValue(
281
+ getJoinRowId(node),
282
+ `join row of ${relation.name}`,
283
+ aliasProp,
284
+ obj[aliasProp],
285
+ path,
286
+ );
287
+ }
288
+ }
289
+ }
290
+ }
291
+
292
+ // The value of the key prop `i` of `prop` that `node` gives across a
293
+ // relation, from wherever the graph gives the row, or one that stands for it:
294
+ const getKey = (node, prop, i) =>
295
+ prop.getProp(node.obj, i) ??
296
+ rows.get(getRowId(node))?.get(prop.props[i])?.value ?? {
297
+ row: getRowId(node),
298
+ prop: prop.props[i],
299
+ };
300
+
301
+ // The keys that the relations give the rows, the rows that the graph puts
302
+ // in the slots of HasOneRelations, and whether the rows are deleted:
303
+ const slots = [];
304
+ for (const node of nodes) {
305
+ const { relation, parent } = node;
306
+ const path = getPath(node);
307
+ const isUnrelated = node.toBeUnrelated;
308
+ const isDeleted = node.toBeDeleted && !isUnrelated;
309
+
310
+ if (relation?.keySide === 'related') {
311
+ // A row that lists itself on the same column restates its own key:
312
+ const isSelfLink =
313
+ getRowId(node) === getRowId(parent) &&
314
+ hasSameColumns(relation.relatedProp, relation.ownerProp);
315
+
316
+ if (!isDeleted && !isSelfLink) {
317
+ relation.relatedProp.forEach((i) => {
318
+ const key = isUnrelated ? null : getKey(parent, relation.ownerProp, i);
319
+ setNodeValue(node, relation.relatedProp.props[i], key, path);
320
+ });
321
+ }
322
+
323
+ if (isSlot(relation)) {
324
+ const row = node.toBeUnrelatedOrDeleted ? null : getRowId(node);
325
+ slots.push({ owner: parent, relation, row, path });
326
+ }
327
+ } else if (relation && hasOwnerKey(relation)) {
328
+ relation.ownerProp.forEach((i) => {
329
+ const key = node.toBeUnrelatedOrDeleted ? null : getKey(node, relation.relatedProp, i);
330
+ setNodeValue(parent, relation.ownerProp.props[i], key, path);
331
+ });
332
+ }
333
+
334
+ setNodeValue(node, '#delete', isDeleted, path);
335
+
336
+ // All places of a pair of models keep or remove its join row:
337
+ if (relation?.keySide === 'join') {
338
+ const name = `join row of ${relation.name}`;
339
+ setValue(getJoinRowId(node), name, '#unrelate', node.toBeUnrelatedOrDeleted, path);
340
+ }
341
+
342
+ // `null` clears the key or the slot:
343
+ for (const nullRelation of node.relations.filter(({ name }) => node.obj[name] === null)) {
344
+ if (hasOwnerKey(nullRelation)) {
345
+ nullRelation.ownerProp.forEach((i) => {
346
+ setNodeValue(
347
+ node,
348
+ nullRelation.ownerProp.props[i],
349
+ null,
350
+ getPath(node, nullRelation.name),
351
+ );
352
+ });
353
+ } else if (isSlot(nullRelation)) {
354
+ const path = getPath(node, nullRelation.name);
355
+ slots.push({ owner: node, relation: nullRelation, row: null, path });
356
+ }
357
+ }
358
+ }
359
+
360
+ // The rows of a table by the values that the graph gives the props of a key,
361
+ // with the values that stand for keys as they are and the others as strings,
362
+ // so that e.g. `1` and `'1'` are the same key:
363
+ const toIndexKey = (values) =>
364
+ JSON.stringify(values.map((value) => (isObject(value) ? value : String(value))));
365
+ const indexes = new Map();
366
+ const getRowsByKey = (table, props) => {
367
+ const indexName = `${table}:${props.join()}`;
368
+ let index = indexes.get(indexName);
369
+
370
+ if (!index) {
371
+ index = new Map();
372
+ indexes.set(indexName, index);
373
+
374
+ for (const [rowId, values] of rows) {
375
+ const keyValues = props.map((prop) => values.get(prop)?.value);
376
+
377
+ if (rowTables.get(rowId) === table && keyValues.every((value) => value != null)) {
378
+ const indexKey = toIndexKey(keyValues);
379
+ const rowIds = index.get(indexKey);
380
+
381
+ if (rowIds) {
382
+ rowIds.push(rowId);
383
+ } else {
384
+ index.set(indexKey, [rowId]);
385
+ }
386
+ }
387
+ }
388
+ }
389
+
390
+ return index;
391
+ };
392
+
393
+ // Any other row that the graph gives the key of the owner of a slot is a
394
+ // second row in it, unless the relation filters its rows and the graph
395
+ // doesn't reach the row through it:
396
+ for (const { owner, relation, row, path } of slots) {
397
+ const { relatedModelClass, relatedProp, ownerProp } = relation;
398
+ const ownerKey = ownerProp.props.map((_, i) => getKey(owner, ownerProp, i));
399
+ const rowsByKey = getRowsByKey(relatedModelClass.getTableName(), relatedProp.props);
400
+ const otherRowId = (rowsByKey.get(toIndexKey(ownerKey)) || []).find(
401
+ (rowId) => rowId !== row && (!relation.modify || rowRelations.get(rowId).has(relation)),
402
+ );
403
+
404
+ if (otherRowId) {
405
+ const other = rows.get(otherRowId).get(relatedProp.props[0]);
406
+ throw builder.modelClass().createValidationError({
407
+ type: ValidationErrorType.InvalidGraph,
408
+ message: `the graph puts two ${relatedModelClass.name} rows in ${relation.name} of one ${owner.modelClass.name}, at ${formatPath(path)} and at ${formatPath(other.path)}`,
409
+ });
410
+ }
411
+ }
412
+ }
413
+
414
+ // References need the ids of the models they point to, which the database
415
+ // only gives the models that the graph writes. The ids of new models that the
416
+ // graph doesn't write, e.g. as `noInsert` skips them, are never known.
417
+ // Everything else reads the models as the graph gives them. The references of
418
+ // models that aren't written are never resolved.
419
+ export function checkReferences(builder, plan) {
420
+ // Models that `noInsert` or `noRelate` ignore aren't written, but the ones
421
+ // marked with `#delete` or `#unrelate` keep their values:
422
+ const writtenNodes = new Set(
423
+ plan.activeNodes.filter(
424
+ (node) => plan.actions.get(node) !== 'ignore' || node.toBeUnrelatedOrDeleted,
425
+ ),
426
+ );
427
+ const isReadable = (node, refPath) =>
428
+ writtenNodes.has(node) ||
429
+ node.obj.$hasId() ||
430
+ (!!refPath && !node.modelClass.getIdPropertyArray().includes(refPath[0]));
431
+
432
+ for (const node of plan.activeNodes.filter((node) => writtenNodes.has(node))) {
433
+ const { referencedNode, propertyReferences } = node;
434
+
435
+ if (
436
+ (referencedNode && !isReadable(referencedNode)) ||
437
+ propertyReferences.some(({ referencedNode, refPath }) => !isReadable(referencedNode, refPath))
438
+ ) {
439
+ throw builder.modelClass().createValidationError({
440
+ type: ValidationErrorType.InvalidGraph,
441
+ message: `the reference at ${formatPath(node.dataPathKey)} points to a model that the graph doesn't write before it`,
442
+ });
443
+ }
444
+ }
445
+ }
446
+
447
+ // A `#ref{}` waits for the model it reads if the value only exists once that
448
+ // model is written. These waits follow from the graph alone, so their cycles
449
+ // are found before the first write.
450
+ export function checkReferenceCycles(nodes) {
451
+ const getWaitedNodes = (node) =>
452
+ node.propertyReferences
453
+ .filter((reference) => reference.referencedNode !== node && readsWrittenValue(reference))
454
+ .map(({ referencedNode }) => referencedNode);
455
+ const visitingNodes = new Set();
456
+ const visitedNodes = new Set();
457
+
458
+ const visit = (node) => {
459
+ if (visitingNodes.has(node)) {
460
+ throw new Error('the object graph contains cyclic references');
461
+ } else if (!visitedNodes.has(node)) {
462
+ visitingNodes.add(node);
463
+ getWaitedNodes(node).forEach(visit);
464
+ visitingNodes.delete(node);
465
+ visitedNodes.add(node);
466
+ }
467
+ };
468
+
469
+ nodes.forEach(visit);
470
+ }
471
+
472
+ // Inserting or relating a model of a many-to-many relation inserts a join row,
473
+ // which would be a new related row if the join table is also the related table
474
+ // (#133).
475
+ export function checkJoinTables(builder, plan) {
476
+ for (const node of plan.activeNodes) {
477
+ const action = plan.actions.get(node);
478
+
479
+ if (
480
+ node.relation?.keySide === 'join' &&
481
+ !node.current &&
482
+ (action === 'insert' || action === 'relate')
483
+ ) {
484
+ node.relation.assertCanModifyJoinTable(builder, action);
485
+ }
486
+ }
487
+ }
488
+
489
+ // Return true if a `#ref{}` reads a value that only exists once the model it
490
+ // refers to is written: the id of a new model, or another `#ref{}`.
491
+ function readsWrittenValue({ referencedNode, refPath: [prop] }) {
492
+ return (
493
+ (!referencedNode.obj.$hasId() &&
494
+ referencedNode.modelClass.getIdPropertyArray().includes(prop)) ||
495
+ referencedNode.propertyReferences.some(({ path }) => path[0] === prop)
496
+ );
497
+ }