objection 4.0.0-rc.2 → 4.0.0-rc.4
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/lib/queryBuilder/InternalOptions.js +4 -0
- package/lib/queryBuilder/graph/GraphLoader.js +201 -126
- package/lib/queryBuilder/graph/GraphNode.js +18 -3
- package/lib/queryBuilder/graph/GraphOptions.js +24 -49
- package/lib/queryBuilder/graph/GraphPlan.js +105 -108
- package/lib/queryBuilder/graph/GraphSchedule.js +102 -55
- package/lib/queryBuilder/graph/GraphUpsert.js +48 -548
- package/lib/queryBuilder/graph/GraphWriter.js +355 -28
- package/lib/queryBuilder/graph/graphChecks.js +534 -0
- package/lib/queryBuilder/graph/graphKeys.js +189 -418
- package/lib/queryBuilder/jsonDialects/JsonDialect.js +6 -0
- package/lib/queryBuilder/operations/UpdateOperation.js +5 -2
- package/lib/queryBuilder/operations/fieldExpressions.js +10 -7
- package/lib/relations/RelationProperty.js +6 -0
- package/package.json +3 -2
- package/typings/objection/index.d.ts +516 -163
|
@@ -0,0 +1,534 @@
|
|
|
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
|
+
// The data paths are only built for the errors:
|
|
247
|
+
const getPath = (node, key) => () => [node.dataPathKey, key].filter(Boolean).join('.');
|
|
248
|
+
const isSlot = (relation) => relation.keySide === 'related' && relation.isOneToOne();
|
|
249
|
+
|
|
250
|
+
// The props that the models give, except for the ids and the keys that the
|
|
251
|
+
// relations of the same model set, which follow these relations:
|
|
252
|
+
for (const node of nodes) {
|
|
253
|
+
const { modelClass, obj, relation } = node;
|
|
254
|
+
const skippedProps = [
|
|
255
|
+
...modelClass.getRelationNames(),
|
|
256
|
+
...modelClass.getIdPropertyArray(),
|
|
257
|
+
...(relation?.keySide === 'related' ? relation.relatedProp.props : []),
|
|
258
|
+
// The extras of join rows belong to the join rows:
|
|
259
|
+
...(relation?.keySide === 'join' ? relation.joinTableExtras.map((e) => e.aliasProp) : []),
|
|
260
|
+
...node.relations.filter(hasOwnerKey).flatMap((relation) => relation.ownerProp.props),
|
|
261
|
+
];
|
|
262
|
+
|
|
263
|
+
const rowId = getRowId(node);
|
|
264
|
+
rowTables.set(rowId, modelClass.getTableName());
|
|
265
|
+
rowRelations.set(rowId, (rowRelations.get(rowId) || new Set()).add(relation));
|
|
266
|
+
|
|
267
|
+
if (!node.toBeUnrelatedOrDeleted) {
|
|
268
|
+
for (const prop of Object.keys(obj)) {
|
|
269
|
+
if (obj[prop] !== undefined && !isInternalProp(prop) && !skippedProps.includes(prop)) {
|
|
270
|
+
setNodeValue(node, prop, obj[prop], getPath(node, prop));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// A pair of models has one join row, which gets the extras of all places
|
|
276
|
+
// of the pair:
|
|
277
|
+
if (relation?.keySide === 'join' && !node.toBeUnrelatedOrDeleted) {
|
|
278
|
+
for (const { aliasProp } of relation.joinTableExtras) {
|
|
279
|
+
if (obj[aliasProp] !== undefined) {
|
|
280
|
+
const path = getPath(node, aliasProp);
|
|
281
|
+
setValue(
|
|
282
|
+
getJoinRowId(node),
|
|
283
|
+
`join row of ${relation.name}`,
|
|
284
|
+
aliasProp,
|
|
285
|
+
obj[aliasProp],
|
|
286
|
+
path,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// The value of the key prop `i` of `prop` that `node` gives across a
|
|
294
|
+
// relation, from wherever the graph gives the row, or one that stands for it:
|
|
295
|
+
const getKey = (node, prop, i) =>
|
|
296
|
+
prop.getProp(node.obj, i) ??
|
|
297
|
+
rows.get(getRowId(node))?.get(prop.props[i])?.value ?? {
|
|
298
|
+
row: getRowId(node),
|
|
299
|
+
prop: prop.props[i],
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// The keys that the relations give the rows, the rows that the graph puts
|
|
303
|
+
// in the slots of HasOneRelations, and whether the rows are deleted:
|
|
304
|
+
const slots = [];
|
|
305
|
+
for (const node of nodes) {
|
|
306
|
+
const { relation, parent } = node;
|
|
307
|
+
const path = getPath(node);
|
|
308
|
+
const isUnrelated = node.toBeUnrelated;
|
|
309
|
+
const isDeleted = node.toBeDeleted && !isUnrelated;
|
|
310
|
+
|
|
311
|
+
if (relation?.keySide === 'related') {
|
|
312
|
+
// A row that lists itself on the same column restates its own key:
|
|
313
|
+
const isSelfLink =
|
|
314
|
+
getRowId(node) === getRowId(parent) &&
|
|
315
|
+
hasSameColumns(relation.relatedProp, relation.ownerProp);
|
|
316
|
+
|
|
317
|
+
if (!isDeleted && !isSelfLink) {
|
|
318
|
+
relation.relatedProp.forEach((i) => {
|
|
319
|
+
const key = isUnrelated ? null : getKey(parent, relation.ownerProp, i);
|
|
320
|
+
setNodeValue(node, relation.relatedProp.props[i], key, path);
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (isSlot(relation)) {
|
|
325
|
+
const row = node.toBeUnrelatedOrDeleted ? null : getRowId(node);
|
|
326
|
+
slots.push({ owner: parent, relation, row, path });
|
|
327
|
+
}
|
|
328
|
+
} else if (relation && hasOwnerKey(relation)) {
|
|
329
|
+
relation.ownerProp.forEach((i) => {
|
|
330
|
+
const key = node.toBeUnrelatedOrDeleted ? null : getKey(node, relation.relatedProp, i);
|
|
331
|
+
setNodeValue(parent, relation.ownerProp.props[i], key, path);
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
setNodeValue(node, '#delete', isDeleted, path);
|
|
336
|
+
|
|
337
|
+
// All places of a pair of models keep or remove its join row:
|
|
338
|
+
if (relation?.keySide === 'join') {
|
|
339
|
+
const name = `join row of ${relation.name}`;
|
|
340
|
+
setValue(getJoinRowId(node), name, '#unrelate', node.toBeUnrelatedOrDeleted, path);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// `null` clears the key or the slot:
|
|
344
|
+
for (const nullRelation of node.relations.filter(({ name }) => node.obj[name] === null)) {
|
|
345
|
+
if (hasOwnerKey(nullRelation)) {
|
|
346
|
+
nullRelation.ownerProp.forEach((i) => {
|
|
347
|
+
setNodeValue(
|
|
348
|
+
node,
|
|
349
|
+
nullRelation.ownerProp.props[i],
|
|
350
|
+
null,
|
|
351
|
+
getPath(node, nullRelation.name),
|
|
352
|
+
);
|
|
353
|
+
});
|
|
354
|
+
} else if (isSlot(nullRelation)) {
|
|
355
|
+
const path = getPath(node, nullRelation.name);
|
|
356
|
+
slots.push({ owner: node, relation: nullRelation, row: null, path });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// The rows of a table by the values that the graph gives the props of a key,
|
|
362
|
+
// with the values that stand for keys as they are and the others as strings,
|
|
363
|
+
// so that e.g. `1` and `'1'` are the same key:
|
|
364
|
+
const toIndexKey = (values) =>
|
|
365
|
+
JSON.stringify(values.map((value) => (isObject(value) ? value : String(value))));
|
|
366
|
+
const indexes = new Map();
|
|
367
|
+
const getRowsByKey = (table, props) => {
|
|
368
|
+
const indexName = `${table}:${props.join()}`;
|
|
369
|
+
let index = indexes.get(indexName);
|
|
370
|
+
|
|
371
|
+
if (!index) {
|
|
372
|
+
index = new Map();
|
|
373
|
+
indexes.set(indexName, index);
|
|
374
|
+
|
|
375
|
+
for (const [rowId, values] of rows) {
|
|
376
|
+
const keyValues = props.map((prop) => values.get(prop)?.value);
|
|
377
|
+
|
|
378
|
+
if (rowTables.get(rowId) === table && keyValues.every((value) => value != null)) {
|
|
379
|
+
const indexKey = toIndexKey(keyValues);
|
|
380
|
+
const rowIds = index.get(indexKey);
|
|
381
|
+
|
|
382
|
+
if (rowIds) {
|
|
383
|
+
rowIds.push(rowId);
|
|
384
|
+
} else {
|
|
385
|
+
index.set(indexKey, [rowId]);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return index;
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
// Any other row that the graph gives the key of the owner of a slot is a
|
|
395
|
+
// second row in it, unless the relation filters its rows and the graph
|
|
396
|
+
// doesn't reach the row through it:
|
|
397
|
+
for (const { owner, relation, row, path } of slots) {
|
|
398
|
+
const { relatedModelClass, relatedProp, ownerProp } = relation;
|
|
399
|
+
const ownerKey = ownerProp.props.map((_, i) => getKey(owner, ownerProp, i));
|
|
400
|
+
const rowsByKey = getRowsByKey(relatedModelClass.getTableName(), relatedProp.props);
|
|
401
|
+
const otherRowId = (rowsByKey.get(toIndexKey(ownerKey)) || []).find(
|
|
402
|
+
(rowId) => rowId !== row && (!relation.modify || rowRelations.get(rowId).has(relation)),
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
if (otherRowId) {
|
|
406
|
+
const other = rows.get(otherRowId).get(relatedProp.props[0]);
|
|
407
|
+
throw builder.modelClass().createValidationError({
|
|
408
|
+
type: ValidationErrorType.InvalidGraph,
|
|
409
|
+
message: `the graph puts two ${relatedModelClass.name} rows in ${relation.name} of one ${owner.modelClass.name}, at ${formatPath(path())} and at ${formatPath(other.path())}`,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// References need the ids of the models they point to, which the database
|
|
416
|
+
// only gives the models that the graph writes. The ids of new models that the
|
|
417
|
+
// graph doesn't write, e.g. as `noInsert` skips them, are never known.
|
|
418
|
+
// Everything else reads the models as the graph gives them. The references of
|
|
419
|
+
// models that aren't written are never resolved.
|
|
420
|
+
export function checkReferences(builder, plan) {
|
|
421
|
+
// Models that `noInsert` or `noRelate` ignore aren't written, but the ones
|
|
422
|
+
// marked with `#delete` or `#unrelate` keep their values:
|
|
423
|
+
const writtenNodes = new Set(
|
|
424
|
+
plan.activeNodes.filter(
|
|
425
|
+
(node) => plan.actions.get(node) !== 'ignore' || node.toBeUnrelatedOrDeleted,
|
|
426
|
+
),
|
|
427
|
+
);
|
|
428
|
+
const isReadable = (node, refPath) =>
|
|
429
|
+
writtenNodes.has(node) ||
|
|
430
|
+
node.obj.$hasId() ||
|
|
431
|
+
(!!refPath && !node.modelClass.getIdPropertyArray().includes(refPath[0]));
|
|
432
|
+
|
|
433
|
+
for (const node of plan.activeNodes.filter((node) => writtenNodes.has(node))) {
|
|
434
|
+
const { referencedNode, propertyReferences } = node;
|
|
435
|
+
|
|
436
|
+
if (
|
|
437
|
+
(referencedNode && !isReadable(referencedNode)) ||
|
|
438
|
+
propertyReferences.some(({ referencedNode, refPath }) => !isReadable(referencedNode, refPath))
|
|
439
|
+
) {
|
|
440
|
+
throw builder.modelClass().createValidationError({
|
|
441
|
+
type: ValidationErrorType.InvalidGraph,
|
|
442
|
+
message: `the reference at ${formatPath(node.dataPathKey)} points to a model that the graph doesn't write before it`,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// A model that the graph relates by the key of its BelongsToOneRelation alone
|
|
449
|
+
// isn't looked up: only its key is written to its owner. So it can't give other
|
|
450
|
+
// props, unless they aren't written, relations or values that others refer to.
|
|
451
|
+
export function checkKeyRelates(builder, plan) {
|
|
452
|
+
const { graphOptions } = plan;
|
|
453
|
+
|
|
454
|
+
for (const node of plan.activeNodes.filter((node) => plan.isRelatedByKey(node))) {
|
|
455
|
+
const { modelClass, relation, obj } = node;
|
|
456
|
+
const relationNames = modelClass.getRelationNames();
|
|
457
|
+
const idProps = modelClass.getIdPropertyArray();
|
|
458
|
+
const isWritten = graphOptions.shouldPatch(node) || graphOptions.shouldUpdate(node);
|
|
459
|
+
const name = Object.keys(obj).find((key) => {
|
|
460
|
+
const value = obj[key];
|
|
461
|
+
return (
|
|
462
|
+
value !== undefined &&
|
|
463
|
+
!isInternalProp(key) &&
|
|
464
|
+
!relation.relatedProp.props.includes(key) &&
|
|
465
|
+
!(value === null && idProps.includes(key)) &&
|
|
466
|
+
(relationNames.includes(key) || isWritten)
|
|
467
|
+
);
|
|
468
|
+
});
|
|
469
|
+
const isReferenced =
|
|
470
|
+
node.propertyReferencingNodes.length > 0 ||
|
|
471
|
+
plan.nodes.some((other) => other.referencedNode === node);
|
|
472
|
+
|
|
473
|
+
if (name || isReferenced) {
|
|
474
|
+
throw builder.modelClass().createValidationError({
|
|
475
|
+
type: ValidationErrorType.InvalidGraph,
|
|
476
|
+
message: `the ${modelClass.name} at ${formatPath(node.dataPathKey)} is related by its key alone, so ${
|
|
477
|
+
name ? `its ${name} can't be written` : "it can't be referenced"
|
|
478
|
+
}`,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// A `#ref{}` waits for the model it reads if the value only exists once that
|
|
485
|
+
// model is written. These waits follow from the graph alone, so their cycles
|
|
486
|
+
// are found before the first write.
|
|
487
|
+
export function checkReferenceCycles(nodes) {
|
|
488
|
+
const getWaitedNodes = (node) =>
|
|
489
|
+
node.propertyReferences
|
|
490
|
+
.filter((reference) => reference.referencedNode !== node && readsWrittenValue(reference))
|
|
491
|
+
.map(({ referencedNode }) => referencedNode);
|
|
492
|
+
const visitingNodes = new Set();
|
|
493
|
+
const visitedNodes = new Set();
|
|
494
|
+
|
|
495
|
+
const visit = (node) => {
|
|
496
|
+
if (visitingNodes.has(node)) {
|
|
497
|
+
throw new Error('the object graph contains cyclic references');
|
|
498
|
+
} else if (!visitedNodes.has(node)) {
|
|
499
|
+
visitingNodes.add(node);
|
|
500
|
+
getWaitedNodes(node).forEach(visit);
|
|
501
|
+
visitingNodes.delete(node);
|
|
502
|
+
visitedNodes.add(node);
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
nodes.forEach(visit);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Inserting or relating a model of a many-to-many relation inserts a join row,
|
|
510
|
+
// which would be a new related row if the join table is also the related table
|
|
511
|
+
// (#133).
|
|
512
|
+
export function checkJoinTables(builder, plan) {
|
|
513
|
+
for (const node of plan.activeNodes) {
|
|
514
|
+
const action = plan.actions.get(node);
|
|
515
|
+
|
|
516
|
+
if (
|
|
517
|
+
node.relation?.keySide === 'join' &&
|
|
518
|
+
!node.current &&
|
|
519
|
+
(action === 'insert' || action === 'relate')
|
|
520
|
+
) {
|
|
521
|
+
node.relation.assertCanModifyJoinTable(builder, action);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Return true if a `#ref{}` reads a value that only exists once the model it
|
|
527
|
+
// refers to is written: the id of a new model, or another `#ref{}`.
|
|
528
|
+
function readsWrittenValue({ referencedNode, refPath: [prop] }) {
|
|
529
|
+
return (
|
|
530
|
+
(!referencedNode.obj.$hasId() &&
|
|
531
|
+
referencedNode.modelClass.getIdPropertyArray().includes(prop)) ||
|
|
532
|
+
referencedNode.propertyReferences.some(({ path }) => path[0] === prop)
|
|
533
|
+
);
|
|
534
|
+
}
|