objection 4.0.0-rc.3 → 4.0.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.
Files changed (25) hide show
  1. package/lib/dbErrors/parsers/sqlite/DBError/ConstraintViolationError/CheckViolationError/parser.js +2 -9
  2. package/lib/dbErrors/parsers/sqlite/DBError/ConstraintViolationError/ForeignKeyViolationError/parser.js +2 -9
  3. package/lib/dbErrors/parsers/sqlite/DBError/ConstraintViolationError/NotNullViolationError/parser.js +4 -2
  4. package/lib/dbErrors/parsers/sqlite/DBError/ConstraintViolationError/UniqueViolationError/parser.js +4 -2
  5. package/lib/dbErrors/parsers/sqlite/DBError/ConstraintViolationError/constraintMessage.js +16 -0
  6. package/lib/dbErrors/parsers/sqlite/DBError/ConstraintViolationError/parser.js +2 -1
  7. package/lib/dbErrors/parsers/sqlite/DBError/parser.js +2 -1
  8. package/lib/model/modelJsonAttributes.js +15 -6
  9. package/lib/objection.js +1 -0
  10. package/lib/queryBuilder/InternalOptions.js +4 -0
  11. package/lib/queryBuilder/StaticHookArguments.js +4 -2
  12. package/lib/queryBuilder/graph/GraphNode.js +8 -0
  13. package/lib/queryBuilder/graph/GraphPlan.js +13 -0
  14. package/lib/queryBuilder/graph/GraphSchedule.js +92 -40
  15. package/lib/queryBuilder/graph/GraphUpsert.js +2 -0
  16. package/lib/queryBuilder/graph/GraphWriter.js +159 -18
  17. package/lib/queryBuilder/graph/graphChecks.js +40 -3
  18. package/lib/queryBuilder/graph/graphKeys.js +146 -61
  19. package/lib/queryBuilder/jsonDialects/JsonDialect.js +6 -0
  20. package/lib/queryBuilder/operations/AsArrayOperation.js +10 -0
  21. package/lib/queryBuilder/operations/UpdateOperation.js +20 -7
  22. package/lib/queryBuilder/operations/fieldExpressions.js +15 -4
  23. package/lib/relations/RelationProperty.js +11 -0
  24. package/package.json +11 -4
  25. package/typings/objection/index.d.ts +191 -184
@@ -2,6 +2,7 @@ import { getModel } from '../../model/getModel.js';
2
2
  import { restoreOmitFromDatabaseJson } from '../../model/modelUtils.js';
3
3
  import { valuesKey } from '../../model/modelValues.js';
4
4
  import { UpdateOperation } from '../operations/UpdateOperation.js';
5
+ import { getJsonKeyPatch } from '../operations/fieldExpressions.js';
5
6
  import { getMaxRows, hasBatchInsert } from '../../utils/knexUtils.js';
6
7
  import { isInternalProp } from '../../utils/internalPropUtils.js';
7
8
  import {
@@ -9,6 +10,7 @@ import {
9
10
  difference,
10
11
  get,
11
12
  groupBy,
13
+ isEmpty,
12
14
  isObject,
13
15
  jsonEquals,
14
16
  nonStrictEquals,
@@ -25,6 +27,10 @@ export class GraphWriter {
25
27
  constructor(schedule) {
26
28
  this.schedule = schedule;
27
29
  this.plan = schedule.plan;
30
+ // The rows that are written, as the nodes of their models, and whether the
31
+ // key columns of relations may be null:
32
+ this.writtenRows = new Set();
33
+ this.nullableProps = new Map();
28
34
  }
29
35
 
30
36
  async write(builder) {
@@ -125,14 +131,15 @@ export class GraphWriter {
125
131
  await promiseMap(batches, (batch) => this._insertBatch(builder, batch), {
126
132
  concurrency: getConcurrency(builder, nodes),
127
133
  });
128
- await this._patch(
129
- builder,
130
- nodes.filter((node) => this.schedule.slotMovedNodes.has(node)),
131
- );
132
134
 
133
135
  for (const node of references) {
134
136
  resolveDependencies(this.schedule, node);
135
137
  }
138
+
139
+ await this._patch(
140
+ builder,
141
+ [...nodes, ...references].filter((node) => this.schedule.earlyNodes.has(node)),
142
+ );
136
143
  }
137
144
 
138
145
  async _insertBatch(builder, nodes) {
@@ -152,6 +159,7 @@ export class GraphWriter {
152
159
  }
153
160
 
154
161
  const insertNodes = nodes.filter((node) => actions.get(node) === 'insert');
162
+ await this._clearSlots(builder, insertNodes);
155
163
 
156
164
  if (insertNodes.length > 0) {
157
165
  const [{ modelClass }] = insertNodes;
@@ -177,6 +185,10 @@ export class GraphWriter {
177
185
  await fetchGeneratedKeys(builder, modelClass, insertNodes);
178
186
  this.schedule.keys.setInserted(insertNodes);
179
187
  }
188
+
189
+ for (const node of insertNodes) {
190
+ this.writtenRows.add(node);
191
+ }
180
192
  }
181
193
 
182
194
  _writeDeferredKeys(builder, dependencies) {
@@ -251,7 +263,60 @@ export class GraphWriter {
251
263
  });
252
264
  }
253
265
 
266
+ // Rows that swap slots in a cycle can't take them while the other rows still
267
+ // hold their keys, if they're unique. Under `unrelate`, the rows that leave
268
+ // the slots clear their keys first, as 3.x does, if they may be null, and
269
+ // take their new keys with their own writes, once the other rows took their
270
+ // slots (#489). Keys that can't be null are swapped directly, and so are keys
271
+ // in json columns, which hold other values:
272
+ async _clearSlots(builder, nodes) {
273
+ for (const { dependent, node, isSlotMove } of this.schedule.deferredDependencies) {
274
+ // The row that moves out of the slot, with its key:
275
+ const { modelClass } = node;
276
+ const relatedProp = isSlotMove && node.relation.relatedProp;
277
+ if (
278
+ relatedProp &&
279
+ nodes.includes(dependent) &&
280
+ !this.writtenRows.has(node.referencedNode || node) &&
281
+ relatedProp.props.every((_, i) => relatedProp.isColumn(i)) &&
282
+ this.plan.graphOptions.has(node, 'unrelate') &&
283
+ (await this._isNullable(builder, relatedProp))
284
+ ) {
285
+ // Without validation or hooks, as the row's own write follows:
286
+ const { schema } = builder.internalOptions();
287
+ const query = builder.unsafeKnex()(modelClass.getTableName());
288
+ const idColumns = modelClass.getIdColumnArray();
289
+ const ids = modelClass.getIdRelationProperty().getProps(node.obj);
290
+ await (schema ? query.withSchema(schema) : query)
291
+ .where(Object.fromEntries(idColumns.map((column, i) => [column, ids[i]])))
292
+ .update(Object.fromEntries(relatedProp.cols.map((column) => [column, null])));
293
+ }
294
+ }
295
+ }
296
+
297
+ // Return true if the database allows null in the columns of `prop`:
298
+ _isNullable(builder, prop) {
299
+ let isNullable = this.nullableProps.get(prop);
300
+
301
+ if (!isNullable) {
302
+ isNullable = prop.modelClass
303
+ .query()
304
+ .childQueryOf(builder)
305
+ .columnInfo()
306
+ .then((columnInfo) => prop.cols.every((column) => columnInfo[column]?.nullable));
307
+ this.nullableProps.set(prop, isNullable);
308
+ }
309
+
310
+ return isNullable;
311
+ }
312
+
254
313
  async _patch(builder, nodes) {
314
+ await this._clearSlots(builder, nodes);
315
+
316
+ for (const node of nodes) {
317
+ this.writtenRows.add(node.referencedNode || node);
318
+ }
319
+
255
320
  // The patches leave out the props that don't change, but the models that
256
321
  // the graph returns get them back once the step is written (#371):
257
322
  const omittedProps = new Map(nodes.map((node) => [node, node.obj.$omitFromDatabaseJson()]));
@@ -284,6 +349,12 @@ export class GraphWriter {
284
349
  _preparePatch(builder, node) {
285
350
  const { referencedNode } = node;
286
351
 
352
+ // A row that the graph relates by its key alone isn't looked up, its key is
353
+ // only written to its owner:
354
+ if (this.plan.isRelatedByKey(node)) {
355
+ return null;
356
+ }
357
+
287
358
  // The props that the graph fills into the model of a `#ref` model are only
288
359
  // final now:
289
360
  if (referencedNode) {
@@ -304,15 +375,24 @@ export class GraphWriter {
304
375
  ? node.relation.relatedProp.props
305
376
  : []),
306
377
  ];
378
+ // The path updates of the keys in json columns that the write sets or
379
+ // clears, by column:
380
+ let jsonKeyPatches = new Map();
307
381
  // Changing the id never makes sense with `upsertGraph()`. A many-to-many
308
382
  // `#ref` model whose model writes its row only writes its join row:
309
383
  const idProps = node.modelClass.getIdPropertyArray();
310
384
  const isJoinRowOnly =
311
385
  !!referencedNode && node.relation.keySide === 'join' && this.plan.isCoveredByModel(node);
312
- const isWritable = (prop) =>
313
- !idProps.includes(prop) &&
314
- !this.schedule.keys.isOnlyRead(node, prop) &&
315
- (!isJoinRowOnly || node.relation.joinTableExtras.some((extra) => extra.aliasProp === prop));
386
+ const isWritable = (prop) => {
387
+ // A json column that its key paths write is unchanged if they are:
388
+ const jsonKeyPatch = getJsonKeyPatch(node.obj, prop, jsonKeyPatches);
389
+ return (
390
+ !idProps.includes(prop) &&
391
+ !(jsonKeyPatch && isEmpty(jsonKeyPatch)) &&
392
+ !this.schedule.keys.isOnlyRead(node, prop) &&
393
+ (!isJoinRowOnly || node.relation.joinTableExtras.some((extra) => extra.aliasProp === prop))
394
+ );
395
+ };
316
396
 
317
397
  const getPropsToUpdate = (changedProps) =>
318
398
  (shouldPatch || shouldUpdate ? changedProps : keyProps).filter(isWritable);
@@ -335,21 +415,38 @@ export class GraphWriter {
335
415
  // what to update. Otherwise, the hook may still change the model, and we
336
416
  // check again after it runs, but only if the model has props to update, as
337
417
  // relates are no updates (#2233, #2605).
338
- const changes = this._findChanges(node);
339
- const propsToUpdate = getPropsToUpdate(changes.changedProps);
340
418
  const hasBeforeUpdate = hasHook(node.obj, 'beforeUpdate');
341
419
 
342
- if (propsToUpdate.length === 0) {
420
+ const changes = this._findChanges(node);
421
+
422
+ if (getPropsToUpdate(changes.changedProps).length === 0) {
343
423
  return null;
344
- } else if (!hasBeforeUpdate) {
345
- handleUpdate(changes);
346
424
  }
347
425
 
348
426
  node.obj.$validate(null, {
349
427
  dataPath: node.dataPathKey,
350
- patch: shouldPatch || !shouldUpdate,
428
+ patch: !shouldUpdate,
351
429
  });
352
430
 
431
+ // Write the keys in json columns as path updates where their column holds
432
+ // nothing else, which keeps its other values (#415):
433
+ jsonKeyPatches = getJsonKeyPatches(node, [
434
+ ...this.schedule.keys.getSetCells(node),
435
+ ...this.plan
436
+ .getRemovedOwnerRelations(node)
437
+ .flatMap(({ ownerProp }) =>
438
+ ownerProp.props.map((_, index) => ({ prop: ownerProp, index })),
439
+ ),
440
+ ]);
441
+
442
+ const propsToUpdate = getPropsToUpdate(changes.changedProps);
443
+
444
+ if (propsToUpdate.length === 0) {
445
+ return null;
446
+ } else if (!hasBeforeUpdate) {
447
+ handleUpdate(changes);
448
+ }
449
+
353
450
  // Rows that their owners relate by their keys can't be found through the
354
451
  // relation until the owners are patched, so they're patched directly.
355
452
  const { relation } = node;
@@ -360,17 +457,26 @@ export class GraphWriter {
360
457
 
361
458
  return {
362
459
  node,
363
- shouldPatch,
460
+ shouldUpdate,
364
461
  hasBeforeUpdate,
365
462
  handleUpdate,
366
463
  propsToUpdate,
464
+ jsonKeyPatches,
367
465
  isPatchedThroughRelation,
368
466
  };
369
467
  }
370
468
 
371
469
  _writePatch(
372
470
  builder,
373
- { node, shouldPatch, hasBeforeUpdate, handleUpdate, propsToUpdate, isPatchedThroughRelation },
471
+ {
472
+ node,
473
+ shouldUpdate,
474
+ hasBeforeUpdate,
475
+ handleUpdate,
476
+ propsToUpdate,
477
+ jsonKeyPatches,
478
+ isPatchedThroughRelation,
479
+ },
374
480
  ) {
375
481
  const query = (
376
482
  isPatchedThroughRelation
@@ -392,6 +498,7 @@ export class GraphWriter {
392
498
  // Join rows are patched with the extras among the props to update. The
393
499
  // extras of new join rows are written with them, see `_findChanges()`:
394
500
  joinTableExtras: node.relation?.keySide === 'join' && !node.current ? [] : propsToUpdate,
501
+ jsonKeyPatches,
395
502
  })
396
503
  .copyFrom(builder, isReturningAll);
397
504
 
@@ -405,7 +512,8 @@ export class GraphWriter {
405
512
  });
406
513
  }
407
514
 
408
- return (shouldPatch ? query.patch(node.obj) : query.update(node.obj))
515
+ // Writes of only the keys under `noUpdate` are patches too:
516
+ return (shouldUpdate ? query.update(node.obj) : query.patch(node.obj))
409
517
  .runAfter(async (result, query) => {
410
518
  if (isObject(result) && result.$isObjectionModel) {
411
519
  // Handle `returning('*')`.
@@ -554,7 +662,7 @@ function resolveDependencies(schedule, node) {
554
662
  // Copy the keys and `#ref{}` values that `node` waits for into its model,
555
663
  // after those that the graph fills into the models that it reads them from,
556
664
  // e.g. the new key of an owner whose BelongsToOne row is replaced. Rows that
557
- // move first only wait for rows that exist, see `GraphSchedule#_waitsForInserts()`.
665
+ // move first only wait for rows that exist, see `GraphSchedule#_waitsForWrites()`.
558
666
  function resolveFilledDependencies(schedule, node, visitedNodes = new Set()) {
559
667
  visitedNodes.add(node);
560
668
 
@@ -746,6 +854,39 @@ async function fetchGeneratedKeys(builder, modelClass, nodes) {
746
854
  }
747
855
  }
748
856
 
857
+ // Return the path updates of the changed keys of `cells` at the top level of
858
+ // json columns, by the columns that hold nothing but these keys. PostgreSQL and
859
+ // MySQL can't write nested paths whose parents are missing, so their columns
860
+ // are written whole.
861
+ function getJsonKeyPatches(node, cells) {
862
+ const patchesByColumn = new Map();
863
+ const pathCells = cells.filter(({ prop, index }) => prop.isJsonKey(index));
864
+
865
+ for (const [column, columnCells] of groupBy(pathCells, ({ prop, index }) => prop.props[index])) {
866
+ const keys = {};
867
+
868
+ for (const { prop, index } of columnCells) {
869
+ prop.setProp(keys, index, prop.getProp(node.obj, index));
870
+ }
871
+
872
+ if (jsonEquals(node.obj[column], keys[column])) {
873
+ const patch = {};
874
+
875
+ for (const { prop, index } of columnCells) {
876
+ const value = prop.getProp(node.obj, index);
877
+
878
+ if (!node.current || !nonStrictEquals(prop.getProp(node.current.obj, index), value)) {
879
+ prop.patch(patch, index, value);
880
+ }
881
+ }
882
+
883
+ patchesByColumn.set(column, { keys: keys[column], patch });
884
+ }
885
+ }
886
+
887
+ return patchesByColumn;
888
+ }
889
+
749
890
  function hasOwnerKeyArray(relation) {
750
891
  return relation.keySide === 'owner' && !relation.isOneToOne();
751
892
  }
@@ -223,7 +223,7 @@ export function checkPlacements(builder, nodes) {
223
223
  } else if (!nonStrictEquals(other.value, value)) {
224
224
  throw builder.modelClass().createValidationError({
225
225
  type: ValidationErrorType.InvalidGraph,
226
- message: `the graph gives ${prop} of one ${name} two values, at ${formatPath(other.path)} and at ${formatPath(path)}`,
226
+ message: `the graph gives ${prop} of one ${name} two values, at ${formatPath(other.path())} and at ${formatPath(path())}`,
227
227
  });
228
228
  }
229
229
  };
@@ -243,7 +243,8 @@ export function checkPlacements(builder, nodes) {
243
243
  .map(([cols, rowId]) => `${cols}=${rowId}`),
244
244
  ].join(':');
245
245
 
246
- const getPath = (node, ...keys) => [node.dataPathKey, ...keys].filter(Boolean).join('.');
246
+ // The data paths are only built for the errors:
247
+ const getPath = (node, key) => () => [node.dataPathKey, key].filter(Boolean).join('.');
247
248
  const isSlot = (relation) => relation.keySide === 'related' && relation.isOneToOne();
248
249
 
249
250
  // The props that the models give, except for the ids and the keys that the
@@ -405,7 +406,7 @@ export function checkPlacements(builder, nodes) {
405
406
  const other = rows.get(otherRowId).get(relatedProp.props[0]);
406
407
  throw builder.modelClass().createValidationError({
407
408
  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
+ 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
  });
410
411
  }
411
412
  }
@@ -444,6 +445,42 @@ export function checkReferences(builder, plan) {
444
445
  }
445
446
  }
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
+
447
484
  // A `#ref{}` waits for the model it reads if the value only exists once that
448
485
  // model is written. These waits follow from the graph alone, so their cycles
449
486
  // are found before the first write.