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.
@@ -2,19 +2,21 @@ 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 { removeFieldExpressions } from '../operations/fieldExpressions.js';
5
6
  import { getMaxRows, hasBatchInsert } from '../../utils/knexUtils.js';
6
7
  import { isInternalProp } from '../../utils/internalPropUtils.js';
7
- import { chunk, difference, groupBy, isObject, nonStrictEquals } from '../../utils/objectUtils.js';
8
- import { map as promiseMap } from '../../utils/promiseUtils/index.js';
9
8
  import {
10
- clearKeysOfRemovedRows,
11
- fetchGeneratedKeys,
12
- getChangedKeysOfRelatedRows,
13
- resolveDependencies,
14
- resolveFilledDependencies,
15
- resolveReference,
16
- setKeyArrays,
17
- } from './graphKeys.js';
9
+ chunk,
10
+ difference,
11
+ get,
12
+ groupBy,
13
+ isObject,
14
+ jsonEquals,
15
+ nonStrictEquals,
16
+ set,
17
+ } from '../../utils/objectUtils.js';
18
+ import { map as promiseMap } from '../../utils/promiseUtils/index.js';
19
+ import { hasOwnerKey, isOtherKey } from './graphKeys.js';
18
20
 
19
21
  const MAX_INSERT_BATCH_SIZE = 100;
20
22
  const MAX_CONCURRENCY = 100;
@@ -94,7 +96,9 @@ export class GraphWriter {
94
96
  // Rows that reference their owners are found through the relation.
95
97
  // BelongsToOne rows aren't referenced anymore once their owners are
96
98
  // patched.
97
- const owners = [...new Set(groupNodes.map((node) => node.parent.obj))];
99
+ const owners = [...new Set(groupNodes.map((node) => node.parent))].map((parent) =>
100
+ getRelationOwner(this.schedule.keys, parent.node, parent.obj, relation),
101
+ );
98
102
  const query = (
99
103
  areReferenced
100
104
  ? relation.ownerModelClass.relatedQuery(relation.name).for(owners)
@@ -122,14 +126,15 @@ export class GraphWriter {
122
126
  await promiseMap(batches, (batch) => this._insertBatch(builder, batch), {
123
127
  concurrency: getConcurrency(builder, nodes),
124
128
  });
125
- await this._patch(
126
- builder,
127
- nodes.filter((node) => this.schedule.slotMovedNodes.has(node)),
128
- );
129
129
 
130
130
  for (const node of references) {
131
131
  resolveDependencies(this.schedule, node);
132
132
  }
133
+
134
+ await this._patch(
135
+ builder,
136
+ [...nodes, ...references].filter((node) => this.schedule.earlyNodes.has(node)),
137
+ );
133
138
  }
134
139
 
135
140
  async _insertBatch(builder, nodes) {
@@ -272,6 +277,8 @@ export class GraphWriter {
272
277
  } finally {
273
278
  for (const [node, props] of omittedProps) {
274
279
  restoreOmitFromDatabaseJson(node.obj, props);
280
+ // The keys of `setKeyPaths()` stay where a hook resolves the patch:
281
+ removeFieldExpressions(node.modelClass, node.obj);
275
282
  }
276
283
  }
277
284
  }
@@ -281,6 +288,12 @@ export class GraphWriter {
281
288
  _preparePatch(builder, node) {
282
289
  const { referencedNode } = node;
283
290
 
291
+ // A row that the graph relates by its key alone isn't looked up, its key is
292
+ // only written to its owner:
293
+ if (this.plan.isRelatedByKey(node)) {
294
+ return null;
295
+ }
296
+
284
297
  // The props that the graph fills into the model of a `#ref` model are only
285
298
  // final now:
286
299
  if (referencedNode) {
@@ -301,6 +314,8 @@ export class GraphWriter {
301
314
  ? node.relation.relatedProp.props
302
315
  : []),
303
316
  ];
317
+ // The keys in json paths that the write sets or clears:
318
+ let pathKeys = new Map();
304
319
  // Changing the id never makes sense with `upsertGraph()`. A many-to-many
305
320
  // `#ref` model whose model writes its row only writes its join row:
306
321
  const idProps = node.modelClass.getIdPropertyArray();
@@ -308,11 +323,15 @@ export class GraphWriter {
308
323
  !!referencedNode && node.relation.keySide === 'join' && this.plan.isCoveredByModel(node);
309
324
  const isWritable = (prop) =>
310
325
  !idProps.includes(prop) &&
326
+ !pathKeys.has(prop) &&
311
327
  !this.schedule.keys.isOnlyRead(node, prop) &&
312
328
  (!isJoinRowOnly || node.relation.joinTableExtras.some((extra) => extra.aliasProp === prop));
313
329
 
314
330
  const getPropsToUpdate = (changedProps) =>
315
- (shouldPatch || shouldUpdate ? changedProps : keyProps).filter(isWritable);
331
+ (shouldPatch || shouldUpdate
332
+ ? changedProps
333
+ : keyProps.flatMap((prop) => pathKeys.get(prop) || prop)
334
+ ).filter(isWritable);
316
335
 
317
336
  // Leave out the props that don't change, and return true if any do.
318
337
  const handleUpdate = ({ changedProps, unchangedProps } = this._findChanges(node)) => {
@@ -332,21 +351,43 @@ export class GraphWriter {
332
351
  // what to update. Otherwise, the hook may still change the model, and we
333
352
  // check again after it runs, but only if the model has props to update, as
334
353
  // relates are no updates (#2233, #2605).
335
- const changes = this._findChanges(node);
336
- const propsToUpdate = getPropsToUpdate(changes.changedProps);
337
354
  const hasBeforeUpdate = hasHook(node.obj, 'beforeUpdate');
338
355
 
339
- if (propsToUpdate.length === 0) {
356
+ let changes = this._findChanges(node);
357
+
358
+ if (getPropsToUpdate(changes.changedProps).length === 0) {
340
359
  return null;
341
- } else if (!hasBeforeUpdate) {
342
- handleUpdate(changes);
343
360
  }
344
361
 
345
362
  node.obj.$validate(null, {
346
363
  dataPath: node.dataPathKey,
347
- patch: shouldPatch || !shouldUpdate,
364
+ patch: !shouldUpdate,
348
365
  });
349
366
 
367
+ // Write the keys in json columns as path updates where their column holds
368
+ // nothing else, which keeps its other values (#415). After the validation,
369
+ // as `update()` validates them as properties of their own:
370
+ pathKeys = setKeyPaths(node, [
371
+ ...this.schedule.keys.getSetCells(node),
372
+ ...this.plan
373
+ .getRemovedOwnerRelations(node)
374
+ .flatMap(({ ownerProp }) =>
375
+ ownerProp.props.map((_, index) => ({ prop: ownerProp, index })),
376
+ ),
377
+ ]);
378
+
379
+ if (pathKeys.size > 0) {
380
+ changes = this._findChanges(node);
381
+ }
382
+
383
+ const propsToUpdate = getPropsToUpdate(changes.changedProps);
384
+
385
+ if (propsToUpdate.length === 0) {
386
+ return null;
387
+ } else if (!hasBeforeUpdate) {
388
+ handleUpdate(changes);
389
+ }
390
+
350
391
  // Rows that their owners relate by their keys can't be found through the
351
392
  // relation until the owners are patched, so they're patched directly.
352
393
  const { relation } = node;
@@ -357,23 +398,35 @@ export class GraphWriter {
357
398
 
358
399
  return {
359
400
  node,
360
- shouldPatch,
401
+ shouldUpdate,
361
402
  hasBeforeUpdate,
362
403
  handleUpdate,
363
404
  propsToUpdate,
405
+ fieldExpressionKeys: [...pathKeys.values()].flat(),
364
406
  isPatchedThroughRelation,
365
407
  };
366
408
  }
367
409
 
368
410
  _writePatch(
369
411
  builder,
370
- { node, shouldPatch, hasBeforeUpdate, handleUpdate, propsToUpdate, isPatchedThroughRelation },
412
+ {
413
+ node,
414
+ shouldUpdate,
415
+ hasBeforeUpdate,
416
+ handleUpdate,
417
+ propsToUpdate,
418
+ fieldExpressionKeys,
419
+ isPatchedThroughRelation,
420
+ },
371
421
  ) {
372
422
  const query = (
373
423
  isPatchedThroughRelation
374
- ? // Find the row through the keys of the owner's current row, as the
375
- // graph may leave them out of the owner:
376
- (node.parent.existing || node.parent).obj
424
+ ? getRelationOwner(
425
+ this.schedule.keys,
426
+ node.parent,
427
+ (node.parent.existing || node.parent).obj,
428
+ node.relation,
429
+ )
377
430
  .$relatedQuery(node.relation.name)
378
431
  .findById(node.obj.$id())
379
432
  : (node.current?.obj || node.obj).$query()
@@ -386,6 +439,8 @@ export class GraphWriter {
386
439
  // Join rows are patched with the extras among the props to update. The
387
440
  // extras of new join rows are written with them, see `_findChanges()`:
388
441
  joinTableExtras: node.relation?.keySide === 'join' && !node.current ? [] : propsToUpdate,
442
+ // The keys in json columns, also in updates:
443
+ fieldExpressionKeys,
389
444
  })
390
445
  .copyFrom(builder, isReturningAll);
391
446
 
@@ -399,7 +454,8 @@ export class GraphWriter {
399
454
  });
400
455
  }
401
456
 
402
- return (shouldPatch ? query.patch(node.obj) : query.update(node.obj))
457
+ // Writes of only the keys under `noUpdate` are patches too:
458
+ return (shouldUpdate ? query.update(node.obj) : query.patch(node.obj))
403
459
  .runAfter(async (result, query) => {
404
460
  if (isObject(result) && result.$isObjectionModel) {
405
461
  // Handle `returning('*')`.
@@ -484,6 +540,26 @@ async function throwForMissingRow(builder, node) {
484
540
  }
485
541
  }
486
542
 
543
+ // Return the owner to find the rows of `relation` through: the current row
544
+ // `obj` of the owner, as the graph may leave its keys out, with the final keys
545
+ // of `node` if they change, as the relation was read at them.
546
+ function getRelationOwner(keys, node, obj, relation) {
547
+ const { ownerProp } = relation;
548
+ const values = keys.getReadProps(node, ownerProp);
549
+
550
+ if (isOtherKey(values, ownerProp.getProps(obj))) {
551
+ const owner = obj.$clone({ shallow: true });
552
+ values.forEach((value, i) => {
553
+ if (value !== undefined) {
554
+ ownerProp.setProp(owner, i, value);
555
+ }
556
+ });
557
+ return owner;
558
+ } else {
559
+ return obj;
560
+ }
561
+ }
562
+
487
563
  function createPatchNotFoundError(builder, node) {
488
564
  return node.modelClass.createNotFoundError(builder.context(), {
489
565
  message: `model (id=${node.obj.$id()}) was not found when updating it. An earlier write of the graph or a concurrent query may have deleted it`,
@@ -506,3 +582,254 @@ function getConcurrency(builder, nodes) {
506
582
  function isReturningAll(operation) {
507
583
  return operation.name === 'returning' && operation.args.includes('*');
508
584
  }
585
+
586
+ // Copy the keys and `#ref{}` values that `node` waits for into its model.
587
+ function resolveDependencies(schedule, node) {
588
+ for (const dependency of schedule.dependencies.get(node) || []) {
589
+ if (!schedule.deferredDependencies.has(dependency)) {
590
+ if (dependency.relation) {
591
+ // Hooks see the props of the models that `#ref` models refer to:
592
+ resolveReference(dependency.related);
593
+ resolveReference(dependency.dependent);
594
+ } else if (dependency.reference) {
595
+ resolvePropertyReference(dependency.dependent, dependency.reference);
596
+ }
597
+ }
598
+ }
599
+
600
+ schedule.keys.resolvedNodes.add(node);
601
+ schedule.keys.fill(node);
602
+ }
603
+
604
+ // Copy the keys and `#ref{}` values that `node` waits for into its model,
605
+ // after those that the graph fills into the models that it reads them from,
606
+ // e.g. the new key of an owner whose BelongsToOne row is replaced. Rows that
607
+ // move first only wait for rows that exist, see `GraphSchedule#_waitsForWrites()`.
608
+ function resolveFilledDependencies(schedule, node, visitedNodes = new Set()) {
609
+ visitedNodes.add(node);
610
+
611
+ for (const dependency of schedule.dependencies.get(node) || []) {
612
+ const referencedNode = dependency.node.referencedNode || dependency.node;
613
+
614
+ if (
615
+ !visitedNodes.has(referencedNode) &&
616
+ (dependency.isKeyWait || schedule.readsFilledProps(dependency))
617
+ ) {
618
+ resolveFilledDependencies(schedule, referencedNode, visitedNodes);
619
+ }
620
+ }
621
+
622
+ resolveDependencies(schedule, node);
623
+ }
624
+
625
+ // Change the arrays of keys that `node` holds for the relations that the graph
626
+ // gives it: Remove the keys of the current related rows that it deletes or
627
+ // unrelates, and add the keys of the related models that it writes. Other
628
+ // keys stay, e.g. of rows that the relation's filter leaves out. Relations
629
+ // that the graph leaves out keep their arrays (#1202). Return the props that
630
+ // change.
631
+ function setKeyArrays(plan, node) {
632
+ const { actions, graphOptions } = plan;
633
+ const { existing } = node;
634
+ const props = [];
635
+
636
+ for (const relation of node.relations) {
637
+ const { name } = relation;
638
+
639
+ if (hasOwnerKeyArray(relation)) {
640
+ const { ownerProp, relatedProp } = relation;
641
+ const getKey = (related) => relatedProp.getProp(related.obj, 0);
642
+ const keys = relation.getKeys(existing ? existing.obj : node.obj);
643
+ const removed = (existing?.activeChildren || []).filter(
644
+ (child) =>
645
+ child.relation.name === name && ['delete', 'unrelate'].includes(actions.get(child)),
646
+ );
647
+ const added = node.activeChildren.filter(
648
+ (child) =>
649
+ child.relation.name === name &&
650
+ !child.toBeUnrelatedOrDeleted &&
651
+ actions.get(child) !== 'ignore',
652
+ );
653
+
654
+ for (const related of added) {
655
+ resolveReference(related);
656
+ }
657
+
658
+ const newKeys = relation.changeKeys(keys, {
659
+ add: added.map(getKey),
660
+ remove: removed.map(getKey),
661
+ });
662
+
663
+ if (!existing || graphOptions.shouldUpdate(node)) {
664
+ // Inserts and updates write the whole row:
665
+ ownerProp.setProp(node.obj, 0, newKeys);
666
+ props.push(ownerProp.props[0]);
667
+ } else if (!jsonEquals(keys, newKeys)) {
668
+ const patch = relation.createKeysPatch(existing.obj, newKeys);
669
+ Object.assign(node.obj, patch);
670
+ props.push(...Object.keys(patch));
671
+ }
672
+ }
673
+ }
674
+
675
+ return props;
676
+ }
677
+
678
+ // Return the keys of `node` that point to other BelongsToOne rows than before,
679
+ // which the graph inserts or relates, and which `GraphKeys` filled in for their
680
+ // places.
681
+ function getChangedKeysOfRelatedRows(plan, keys, node) {
682
+ const { actions } = plan;
683
+ const props = [];
684
+
685
+ for (const child of node.activeChildren) {
686
+ const { relation } = child;
687
+
688
+ // Ignored models don't replace the current one, and models marked with
689
+ // `#unrelate` or `#delete` are handled by `clearKeysOfRemovedRows()`:
690
+ if (hasOwnerKey(relation) && actions.get(child) !== 'ignore') {
691
+ relation.ownerProp.forEach((i) => {
692
+ const currentValue = node.existing && relation.ownerProp.getProp(node.existing.obj, i);
693
+ const value = relation.ownerProp.getProp(node.obj, i);
694
+
695
+ if (
696
+ value !== undefined &&
697
+ currentValue != value &&
698
+ keys.isSet(node, relation.ownerProp, i)
699
+ ) {
700
+ props.push(relation.ownerProp.props[i]);
701
+ }
702
+ });
703
+ }
704
+ }
705
+
706
+ return props;
707
+ }
708
+
709
+ // Clear the keys of `node` that point to BelongsToOne rows that are removed,
710
+ // and return the props that change.
711
+ function clearKeysOfRemovedRows(plan, node) {
712
+ const props = [];
713
+
714
+ for (const relation of plan.getRemovedOwnerRelations(node)) {
715
+ relation.ownerProp.forEach((i) => {
716
+ if (relation.ownerProp.getProp(node.existing.obj, i) != null) {
717
+ relation.ownerProp.setProp(node.obj, i, null);
718
+ props.push(relation.ownerProp.props[i]);
719
+ }
720
+ });
721
+ }
722
+
723
+ return props;
724
+ }
725
+
726
+ // Give a `#ref` model the props of the model it refers to that it doesn't have,
727
+ // and the `filledProps` again, as they may have changed since. Its own keys
728
+ // stay its own, and the join table extras of the place of the model belong to
729
+ // the join row there.
730
+ function resolveReference(node, filledProps = []) {
731
+ const { referencedNode, relation } = node;
732
+
733
+ if (referencedNode) {
734
+ const ownKeys = relation?.keySide === 'related' ? relation.relatedProp.props : [];
735
+ const modelExtras =
736
+ referencedNode.relation?.keySide === 'join'
737
+ ? referencedNode.relation.joinTableExtras.map((extra) => extra.aliasProp)
738
+ : [];
739
+
740
+ for (const prop of difference(Object.keys(referencedNode.obj), modelExtras)) {
741
+ if (
742
+ !Object.hasOwn(node.obj, prop) ||
743
+ (filledProps.includes(prop) && !ownKeys.includes(prop))
744
+ ) {
745
+ node.obj[prop] = referencedNode.obj[prop];
746
+ }
747
+ }
748
+ }
749
+ }
750
+
751
+ // Replace a `#ref{}` in a prop of `node` with the value it refers to:
752
+ function resolvePropertyReference(node, { path, match, refPath, isWholeValue, referencedNode }) {
753
+ const value = get(referencedNode.obj, refPath);
754
+ set(node.obj, path, isWholeValue ? value : get(node.obj, path).replace(match, value));
755
+ }
756
+
757
+ // Fetch the keys that the relations of the inserted nodes need, but that the
758
+ // database generated and the insert didn't return, like a uuid that isn't the
759
+ // id (Vincit#853).
760
+ async function fetchGeneratedKeys(builder, modelClass, nodes) {
761
+ const keyProps = (node) => [
762
+ ...(node.relation && node.relation.keySide !== 'related' ? [node.relation.relatedProp] : []),
763
+ ...node.activeChildren
764
+ .filter((child) => child.relation.keySide !== 'owner')
765
+ .map((child) => child.relation.ownerProp),
766
+ ];
767
+ const missingNodes = nodes.filter((node) =>
768
+ keyProps(node).some((prop) => prop.getProps(node.obj).some((value) => value === undefined)),
769
+ );
770
+
771
+ if (missingNodes.length > 0 && missingNodes.every((node) => node.obj.$hasId())) {
772
+ // SQL Server returns columns selected twice as arrays, so the ids are
773
+ // part of the set:
774
+ const columns = new Set([
775
+ ...modelClass.getIdColumnArray(),
776
+ ...missingNodes.flatMap((node) => keyProps(node)).flatMap((prop) => prop.cols),
777
+ ]);
778
+ const rows = await modelClass
779
+ .query()
780
+ .childQueryOf(builder, { fork: true, isInternalQuery: true })
781
+ .findByIds(missingNodes.map((node) => node.obj.$id()))
782
+ .select([...columns]);
783
+ const rowsByKey = new Map(rows.map((row) => [row.$idKey(), row]));
784
+
785
+ for (const node of missingNodes) {
786
+ const row = rowsByKey.get(node.obj.$idKey());
787
+
788
+ for (const prop of keyProps(node)) {
789
+ prop.forEach((i) => {
790
+ if (prop.getProp(node.obj, i) === undefined) {
791
+ prop.setProp(node.obj, i, prop.getProp(row, i));
792
+ }
793
+ });
794
+ }
795
+ }
796
+ }
797
+ }
798
+
799
+ // Add the changed keys of `cells` at the top level of json columns to `node` as
800
+ // field expressions where their column holds nothing but these keys, and return
801
+ // them by column. PostgreSQL and MySQL can't write nested paths whose parents
802
+ // are missing, so their columns are written whole.
803
+ function setKeyPaths(node, cells) {
804
+ const keysByColumn = new Map();
805
+ const pathCells = cells.filter(({ prop, index }) => prop.isJsonKey(index));
806
+
807
+ for (const [column, columnCells] of groupBy(pathCells, ({ prop, index }) => prop.props[index])) {
808
+ const keys = {};
809
+
810
+ for (const { prop, index } of columnCells) {
811
+ prop.setProp(keys, index, prop.getProp(node.obj, index));
812
+ }
813
+
814
+ if (jsonEquals(node.obj[column], keys[column])) {
815
+ const patch = {};
816
+
817
+ for (const { prop, index } of columnCells) {
818
+ const value = prop.getProp(node.obj, index);
819
+
820
+ if (!node.current || !nonStrictEquals(prop.getProp(node.current.obj, index), value)) {
821
+ prop.patch(patch, index, value);
822
+ }
823
+ }
824
+
825
+ Object.assign(node.obj, patch);
826
+ keysByColumn.set(column, Object.keys(patch));
827
+ }
828
+ }
829
+
830
+ return keysByColumn;
831
+ }
832
+
833
+ function hasOwnerKeyArray(relation) {
834
+ return relation.keySide === 'owner' && !relation.isOneToOne();
835
+ }