objection 4.0.0-rc.4 → 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.
@@ -1,19 +1,12 @@
1
1
  import { CheckViolationError } from '../../../../../errors/CheckViolationError.js';
2
-
3
- const REGEX = /SQLITE_CONSTRAINT: CHECK constraint failed/;
2
+ import { getConstraintMessage } from '../constraintMessage.js';
4
3
 
5
4
  export const checkViolationErrorParser = {
6
5
  error: CheckViolationError,
7
6
 
8
7
  parse: (err) => {
9
- const match = REGEX.exec(err.message);
10
-
11
- if (!match) {
12
- return null;
13
- }
14
-
15
8
  // No way to extract anything reliably.
16
- return {};
9
+ return getConstraintMessage(err, 'CHECK') ? {} : null;
17
10
  },
18
11
 
19
12
  subclassParsers: [],
@@ -1,19 +1,12 @@
1
1
  import { ForeignKeyViolationError } from '../../../../../errors/ForeignKeyViolationError.js';
2
-
3
- const REGEX = /SQLITE_CONSTRAINT: FOREIGN KEY constraint failed/;
2
+ import { getConstraintMessage } from '../constraintMessage.js';
4
3
 
5
4
  export const foreignKeyViolationErrorParser = {
6
5
  error: ForeignKeyViolationError,
7
6
 
8
7
  parse: (err) => {
9
- const match = REGEX.exec(err.message);
10
-
11
- if (!match) {
12
- return null;
13
- }
14
-
15
8
  // No way to extract anything reliably.
16
- return {};
9
+ return getConstraintMessage(err, 'FOREIGN KEY') ? {} : null;
17
10
  },
18
11
 
19
12
  subclassParsers: [],
@@ -1,12 +1,14 @@
1
1
  import { NotNullViolationError } from '../../../../../errors/NotNullViolationError.js';
2
+ import { getConstraintMessage } from '../constraintMessage.js';
2
3
 
3
- const NOT_COLUMN_REGEX = /SQLITE_CONSTRAINT: NOT NULL constraint failed: (.+)$/;
4
+ const NOT_COLUMN_REGEX = /^NOT NULL constraint failed: (.+)$/s;
4
5
 
5
6
  export const notNullViolationErrorParser = {
6
7
  error: NotNullViolationError,
7
8
 
8
9
  parse: (err) => {
9
- const colMatch = NOT_COLUMN_REGEX.exec(err.message);
10
+ const message = getConstraintMessage(err, 'NOT NULL');
11
+ const colMatch = message && NOT_COLUMN_REGEX.exec(message);
10
12
 
11
13
  if (!colMatch) {
12
14
  return null;
@@ -1,12 +1,14 @@
1
1
  import { UniqueViolationError } from '../../../../../errors/UniqueViolationError.js';
2
+ import { getConstraintMessage } from '../constraintMessage.js';
2
3
 
3
- const UNIQUE_COLUMNS_REGEX = /SQLITE_CONSTRAINT: UNIQUE constraint failed: (.+)$/;
4
+ const UNIQUE_COLUMNS_REGEX = /^UNIQUE constraint failed: (.+)$/s;
4
5
 
5
6
  export const uniqueViolationErrorParser = {
6
7
  error: UniqueViolationError,
7
8
 
8
9
  parse: (err) => {
9
- const colsMatch = UNIQUE_COLUMNS_REGEX.exec(err.message);
10
+ const message = getConstraintMessage(err, 'UNIQUE');
11
+ const colsMatch = message && UNIQUE_COLUMNS_REGEX.exec(message);
10
12
 
11
13
  if (!colsMatch) {
12
14
  return null;
@@ -0,0 +1,16 @@
1
+ // Return the text of the SQLite error `err` from `<kind> constraint failed` on,
2
+ // e.g. 'UNIQUE constraint failed: t.a', if it's a constraint error of that kind,
3
+ // or else `null`. `better-sqlite3` names the kind in its extended code, e.g.
4
+ // `SQLITE_CONSTRAINT_UNIQUE`, `sqlite3` only after `SQLITE_CONSTRAINT: `. knex
5
+ // puts the SQL with its values in front, so the last match is SQLite's own.
6
+ export function getConstraintMessage(err, kind) {
7
+ const text = `${kind} constraint failed`;
8
+ // A duplicate primary key is a unique violation, with its own code:
9
+ const codes = kind === 'UNIQUE' ? ['UNIQUE', 'PRIMARYKEY'] : [kind.replace(' ', '')];
10
+ const isKind =
11
+ codes.some((code) => err.code === `SQLITE_CONSTRAINT_${code}`) ||
12
+ (err.code === 'SQLITE_CONSTRAINT' &&
13
+ /.*SQLITE_CONSTRAINT: (.*)$/s.exec(err.message)?.[1].startsWith(text));
14
+
15
+ return isKind ? err.message.slice(err.message.lastIndexOf(text)) : null;
16
+ }
@@ -8,7 +8,8 @@ export const constraintViolationErrorParser = {
8
8
  error: ConstraintViolationError,
9
9
 
10
10
  parse: (err) => {
11
- if (err.code === 'SQLITE_CONSTRAINT') {
11
+ // `better-sqlite3` uses the extended codes, e.g. `SQLITE_CONSTRAINT_UNIQUE`:
12
+ if (/^SQLITE_CONSTRAINT(_|$)/.test(err.code)) {
12
13
  return {};
13
14
  } else {
14
15
  return null;
@@ -5,10 +5,11 @@ export const dbErrorParser = {
5
5
  error: DBError,
6
6
 
7
7
  parse: (err) => {
8
+ // `sqlite3` gives its errors an `errno`, `better-sqlite3` names them:
8
9
  if (
9
10
  typeof err.code === 'string' &&
10
11
  err.code.startsWith('SQLITE_') &&
11
- typeof err.errno === 'number'
12
+ (typeof err.errno === 'number' || err.name === 'SqliteError')
12
13
  ) {
13
14
  return {
14
15
  nativeError: err,
@@ -1,12 +1,12 @@
1
1
  import { asArray, isObject, flatten, isString } from '../utils/objectUtils.js';
2
2
  import { isQueryProp } from './modelQueryProps.js';
3
- import { isJsonParsedByDriver } from '../utils/knexUtils.js';
3
+ import { isJsonParsedByDriver, isMsSql, isSqlite } from '../utils/knexUtils.js';
4
4
 
5
5
  export function parseJsonAttributes(json, modelClass, knex) {
6
6
  const jsonAttr = modelClass.getJsonAttributes();
7
7
  const format = getJsonColumnFormat(modelClass, knex);
8
8
 
9
- if (jsonAttr.length && format !== 'parsed') {
9
+ if (jsonAttr.length && format !== 'json') {
10
10
  // JSON attributes may be returned as strings depending on the database and
11
11
  // the database client. Convert them to objects here.
12
12
  for (let i = 0, l = jsonAttr.length; i < l; ++i) {
@@ -85,14 +85,23 @@ function getPropertyTypes(prop) {
85
85
  return types;
86
86
  }
87
87
 
88
- // Resolve 'auto' to 'parsed' for drivers that return JSON columns parsed, or
89
- // else to `null`: JSON attributes may be text then, and are parsed leniently.
88
+ // Resolve 'auto' by the driver: the PostgreSQL drivers return JSON columns
89
+ // parsed, SQLite and SQL Server return JSON text, and MySQL's drivers and others
90
+ // may return JSON or plain text, e.g. from MariaDB, which stores JSON as text,
91
+ // and which knex can't tell apart from MySQL. Without a driver, rows passed by
92
+ // hand may hold either.
90
93
  function getJsonColumnFormat(modelClass, knex) {
91
94
  const { jsonColumnFormat } = modelClass;
92
95
 
93
96
  if (jsonColumnFormat === 'auto') {
94
- return knex && isJsonParsedByDriver(knex) ? 'parsed' : null;
95
- } else if (jsonColumnFormat === 'text' || jsonColumnFormat === 'parsed') {
97
+ return !knex
98
+ ? 'mixed'
99
+ : isJsonParsedByDriver(knex)
100
+ ? 'json'
101
+ : isSqlite(knex) || isMsSql(knex)
102
+ ? 'text'
103
+ : 'mixed';
104
+ } else if (['json', 'text', 'mixed'].includes(jsonColumnFormat)) {
96
105
  return jsonColumnFormat;
97
106
  } else {
98
107
  throw new Error(
package/lib/objection.js CHANGED
@@ -12,6 +12,7 @@ export {
12
12
  ConstraintViolationError,
13
13
  CheckViolationError,
14
14
  DataError,
15
+ wrapError,
15
16
  } from './dbErrors/index.js';
16
17
  export { QueryBuilderBase } from './queryBuilder/QueryBuilderBase.js';
17
18
  export { QueryBuilderOperation } from './queryBuilder/operations/QueryBuilderOperation.js';
@@ -6,9 +6,9 @@ export class InternalOptions {
6
6
  this.skipRelationFilter = false;
7
7
  // The join table extras that related updates write, all of them if null:
8
8
  this.joinTableExtras = null;
9
- // The field expressions that `upsertGraph()` writes keys in json columns of
10
- // updated rows with:
11
- this.fieldExpressionKeys = [];
9
+ // The path updates that `upsertGraph()` writes the keys in json columns of
10
+ // updated rows with, by column:
11
+ this.jsonKeyPatches = null;
12
12
  this.debug = false;
13
13
  this.schema = undefined;
14
14
  }
@@ -21,7 +21,7 @@ export class InternalOptions {
21
21
  copy.isInternalQuery = this.isInternalQuery;
22
22
  copy.skipRelationFilter = this.skipRelationFilter;
23
23
  copy.joinTableExtras = this.joinTableExtras;
24
- copy.fieldExpressionKeys = this.fieldExpressionKeys;
24
+ copy.jsonKeyPatches = this.jsonKeyPatches;
25
25
  copy.debug = this.debug;
26
26
  copy.schema = this.schema;
27
27
 
@@ -1,4 +1,5 @@
1
1
  import { asArray, isObject } from '../utils/objectUtils.js';
2
+ import { AsArrayOperation } from './operations/AsArrayOperation.js';
2
3
 
3
4
  const BUILDER_SYMBOL = Symbol();
4
5
 
@@ -26,12 +27,13 @@ export class StaticHookArguments {
26
27
  // ones installed by `throwIfNotFound()` and `traverse()`) are meant for
27
28
  // its result, not for the result of this find query. The same goes for
28
29
  // the value it was resolved with, e.g. the 0 of an update that only
29
- // changes the join rows of a many-to-many relation.
30
+ // changes the join rows of a many-to-many relation. The query resolves to
31
+ // an array also for single-result writes, unless the hook adds `first()`:
30
32
  return this[BUILDER_SYMBOL].toFindQuery()
31
33
  .clear('runAfter')
32
34
  .clearResolve()
33
35
  .clearWithGraphFetched()
34
- .runAfter(asArray);
36
+ .addOperation(new AsArrayOperation('asArray'), []);
35
37
  };
36
38
  }
37
39
 
@@ -15,8 +15,9 @@ import { formatPath } from './GraphNode.js';
15
15
  // keys deferred to break cycles and the join rows. Rows that move out of
16
16
  // the slots of HasOneRelations that new rows go into are related in the
17
17
  // same order, as the foreign key may be unique, and so are the patches and
18
- // relates that change the keys that these rows read, as a foreign key may
19
- // reference them (#428).
18
+ // relates that change the keys that these rows read or the values that
19
+ // their `#ref{}` copies, as a foreign key may reference them (#428, #491).
20
+ // Copies of values that are no keys wait unless that closes a cycle.
20
21
  // 4. Patch the changed rows, the other relates, and the keys of owners whose
21
22
  // BelongsToOne rows were inserted, related or removed.
22
23
  // 5. Unrelate the rows that their own to-many relation on their own key leaves
@@ -31,7 +32,8 @@ export class GraphSchedule {
31
32
  this.keys = keys;
32
33
  // The nodes that each node waits for, through its keys, its references and
33
34
  // the rows that move out of the slots that it goes into. `isKeyChange`
34
- // marks the patches and relates that change the keys it reads:
35
+ // marks the patches and relates that change the keys it reads or the values
36
+ // that its `#ref{}` copies, and `isCopy` the latter:
35
37
  this.dependencies = createDependencies(plan, this.keys);
36
38
  // Dependencies that aren't waited for, to break cycles. Deferred keys are
37
39
  // written after the inserts:
@@ -144,6 +146,7 @@ export class GraphSchedule {
144
146
  !handledNodes.has(dependency.node) &&
145
147
  (dependency.isSlotMove ||
146
148
  dependency.isKeyWait ||
149
+ dependency.isKeyChange ||
147
150
  !hasRow(dependency.node) ||
148
151
  this.readsFilledProps(dependency)),
149
152
  );
@@ -164,12 +167,14 @@ export class GraphSchedule {
164
167
 
165
168
  // Break a cycle by deferring one BelongsToOne key that points to a `#ref`
166
169
  // model: Its owner is inserted without the key, which is patched in once
167
- // the referenced model is inserted. Without `#ref`, the graph is a tree,
168
- // and only waits for the rows that move out of slots can close a cycle,
169
- // like rows that swap slots. One of these isn't waited for then.
170
+ // the referenced model is inserted. Without `#ref` models, the graph is a
171
+ // tree, and only `#ref{}` copies of changed values and the waits for the
172
+ // rows that move out of slots can close a cycle, like rows that swap slots.
173
+ // One of these isn't waited for then, the copies first.
170
174
  const breakCycle = () => {
171
175
  for (const canDefer of [
172
176
  (dependency, node) => this._canDefer(dependency, node),
177
+ (dependency) => dependency.isCopy,
173
178
  (dependency) => dependency.isSlotMove,
174
179
  ]) {
175
180
  for (const node of nodes) {
@@ -511,6 +516,15 @@ function createDependencies(plan, keys) {
511
516
  }
512
517
  }
513
518
 
519
+ // A `#ref{}` that copies a value that the graph changes waits for the
520
+ // change, as a foreign key may reference it, like the keys (#491). As the
521
+ // copied value may be no key, the wait is deferred first to break cycles:
522
+ for (const reference of node.propertyReferences) {
523
+ for (const changingNode of keys.getChangingNodes(node, reference)) {
524
+ addDependency(node, { node: changingNode, isKeyChange: true, isCopy: true });
525
+ }
526
+ }
527
+
514
528
  // A row that goes into the slot of a HasOneRelation waits for the relate
515
529
  // that moves the current row out of it, as the foreign key may be unique.
516
530
  const moverNode = getSlotMover(plan, node);
@@ -2,7 +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 { removeFieldExpressions } from '../operations/fieldExpressions.js';
5
+ import { getJsonKeyPatch } from '../operations/fieldExpressions.js';
6
6
  import { getMaxRows, hasBatchInsert } from '../../utils/knexUtils.js';
7
7
  import { isInternalProp } from '../../utils/internalPropUtils.js';
8
8
  import {
@@ -10,6 +10,7 @@ import {
10
10
  difference,
11
11
  get,
12
12
  groupBy,
13
+ isEmpty,
13
14
  isObject,
14
15
  jsonEquals,
15
16
  nonStrictEquals,
@@ -26,6 +27,10 @@ export class GraphWriter {
26
27
  constructor(schedule) {
27
28
  this.schedule = schedule;
28
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();
29
34
  }
30
35
 
31
36
  async write(builder) {
@@ -154,6 +159,7 @@ export class GraphWriter {
154
159
  }
155
160
 
156
161
  const insertNodes = nodes.filter((node) => actions.get(node) === 'insert');
162
+ await this._clearSlots(builder, insertNodes);
157
163
 
158
164
  if (insertNodes.length > 0) {
159
165
  const [{ modelClass }] = insertNodes;
@@ -179,6 +185,10 @@ export class GraphWriter {
179
185
  await fetchGeneratedKeys(builder, modelClass, insertNodes);
180
186
  this.schedule.keys.setInserted(insertNodes);
181
187
  }
188
+
189
+ for (const node of insertNodes) {
190
+ this.writtenRows.add(node);
191
+ }
182
192
  }
183
193
 
184
194
  _writeDeferredKeys(builder, dependencies) {
@@ -253,7 +263,60 @@ export class GraphWriter {
253
263
  });
254
264
  }
255
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
+
256
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
+
257
320
  // The patches leave out the props that don't change, but the models that
258
321
  // the graph returns get them back once the step is written (#371):
259
322
  const omittedProps = new Map(nodes.map((node) => [node, node.obj.$omitFromDatabaseJson()]));
@@ -277,8 +340,6 @@ export class GraphWriter {
277
340
  } finally {
278
341
  for (const [node, props] of omittedProps) {
279
342
  restoreOmitFromDatabaseJson(node.obj, props);
280
- // The keys of `setKeyPaths()` stay where a hook resolves the patch:
281
- removeFieldExpressions(node.modelClass, node.obj);
282
343
  }
283
344
  }
284
345
  }
@@ -314,24 +375,27 @@ export class GraphWriter {
314
375
  ? node.relation.relatedProp.props
315
376
  : []),
316
377
  ];
317
- // The keys in json paths that the write sets or clears:
318
- let pathKeys = new Map();
378
+ // The path updates of the keys in json columns that the write sets or
379
+ // clears, by column:
380
+ let jsonKeyPatches = new Map();
319
381
  // Changing the id never makes sense with `upsertGraph()`. A many-to-many
320
382
  // `#ref` model whose model writes its row only writes its join row:
321
383
  const idProps = node.modelClass.getIdPropertyArray();
322
384
  const isJoinRowOnly =
323
385
  !!referencedNode && node.relation.keySide === 'join' && this.plan.isCoveredByModel(node);
324
- const isWritable = (prop) =>
325
- !idProps.includes(prop) &&
326
- !pathKeys.has(prop) &&
327
- !this.schedule.keys.isOnlyRead(node, prop) &&
328
- (!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
+ };
329
396
 
330
397
  const getPropsToUpdate = (changedProps) =>
331
- (shouldPatch || shouldUpdate
332
- ? changedProps
333
- : keyProps.flatMap((prop) => pathKeys.get(prop) || prop)
334
- ).filter(isWritable);
398
+ (shouldPatch || shouldUpdate ? changedProps : keyProps).filter(isWritable);
335
399
 
336
400
  // Leave out the props that don't change, and return true if any do.
337
401
  const handleUpdate = ({ changedProps, unchangedProps } = this._findChanges(node)) => {
@@ -353,7 +417,7 @@ export class GraphWriter {
353
417
  // relates are no updates (#2233, #2605).
354
418
  const hasBeforeUpdate = hasHook(node.obj, 'beforeUpdate');
355
419
 
356
- let changes = this._findChanges(node);
420
+ const changes = this._findChanges(node);
357
421
 
358
422
  if (getPropsToUpdate(changes.changedProps).length === 0) {
359
423
  return null;
@@ -365,9 +429,8 @@ export class GraphWriter {
365
429
  });
366
430
 
367
431
  // 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, [
432
+ // nothing else, which keeps its other values (#415):
433
+ jsonKeyPatches = getJsonKeyPatches(node, [
371
434
  ...this.schedule.keys.getSetCells(node),
372
435
  ...this.plan
373
436
  .getRemovedOwnerRelations(node)
@@ -376,10 +439,6 @@ export class GraphWriter {
376
439
  ),
377
440
  ]);
378
441
 
379
- if (pathKeys.size > 0) {
380
- changes = this._findChanges(node);
381
- }
382
-
383
442
  const propsToUpdate = getPropsToUpdate(changes.changedProps);
384
443
 
385
444
  if (propsToUpdate.length === 0) {
@@ -402,7 +461,7 @@ export class GraphWriter {
402
461
  hasBeforeUpdate,
403
462
  handleUpdate,
404
463
  propsToUpdate,
405
- fieldExpressionKeys: [...pathKeys.values()].flat(),
464
+ jsonKeyPatches,
406
465
  isPatchedThroughRelation,
407
466
  };
408
467
  }
@@ -415,7 +474,7 @@ export class GraphWriter {
415
474
  hasBeforeUpdate,
416
475
  handleUpdate,
417
476
  propsToUpdate,
418
- fieldExpressionKeys,
477
+ jsonKeyPatches,
419
478
  isPatchedThroughRelation,
420
479
  },
421
480
  ) {
@@ -439,8 +498,7 @@ export class GraphWriter {
439
498
  // Join rows are patched with the extras among the props to update. The
440
499
  // extras of new join rows are written with them, see `_findChanges()`:
441
500
  joinTableExtras: node.relation?.keySide === 'join' && !node.current ? [] : propsToUpdate,
442
- // The keys in json columns, also in updates:
443
- fieldExpressionKeys,
501
+ jsonKeyPatches,
444
502
  })
445
503
  .copyFrom(builder, isReturningAll);
446
504
 
@@ -796,12 +854,12 @@ async function fetchGeneratedKeys(builder, modelClass, nodes) {
796
854
  }
797
855
  }
798
856
 
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();
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();
805
863
  const pathCells = cells.filter(({ prop, index }) => prop.isJsonKey(index));
806
864
 
807
865
  for (const [column, columnCells] of groupBy(pathCells, ({ prop, index }) => prop.props[index])) {
@@ -822,12 +880,11 @@ function setKeyPaths(node, cells) {
822
880
  }
823
881
  }
824
882
 
825
- Object.assign(node.obj, patch);
826
- keysByColumn.set(column, Object.keys(patch));
883
+ patchesByColumn.set(column, { keys: keys[column], patch });
827
884
  }
828
885
  }
829
886
 
830
- return keysByColumn;
887
+ return patchesByColumn;
831
888
  }
832
889
 
833
890
  function hasOwnerKeyArray(relation) {