objection 3.5.0 → 3.7.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 (41) hide show
  1. package/README.md +3 -3
  2. package/lib/model/AjvValidator.js +33 -10
  3. package/lib/model/modelClone.js +0 -2
  4. package/lib/queryBuilder/InternalOptions.js +2 -0
  5. package/lib/queryBuilder/QueryBuilder.js +56 -10
  6. package/lib/queryBuilder/RelationExpression.js +6 -4
  7. package/lib/queryBuilder/graph/GraphOptions.js +141 -22
  8. package/lib/queryBuilder/graph/GraphUpsert.js +57 -0
  9. package/lib/queryBuilder/graph/insert/GraphInsert.js +2 -0
  10. package/lib/queryBuilder/graph/patch/GraphPatchAction.js +3 -0
  11. package/lib/queryBuilder/operations/InstanceUpdateOperation.js +6 -1
  12. package/lib/queryBuilder/operations/UpdateOperation.js +11 -0
  13. package/lib/queryBuilder/operations/whereInComposite/WhereInCompositeMsSqlOperation.js +3 -1
  14. package/lib/queryBuilder/operations/whereInComposite/WhereInCompositeSqliteOperation.js +4 -9
  15. package/lib/queryBuilder/parsers/jsonFieldExpressionParser.js +434 -364
  16. package/lib/queryBuilder/parsers/relationExpressionParser.js +513 -548
  17. package/lib/queryBuilder/parsers/relationExpressionParser.pegjs +1 -1
  18. package/lib/queryBuilder/transformations/WrapMysqlModifySubqueryTransformation.js +4 -7
  19. package/lib/relations/Relation.js +7 -0
  20. package/lib/relations/RelationFindOperation.js +27 -40
  21. package/lib/relations/belongsToOne/BelongsToOneDeleteOperation.js +4 -2
  22. package/lib/relations/belongsToOne/BelongsToOneRelation.js +9 -0
  23. package/lib/relations/manyToMany/ManyToManyModifyMixin.js +41 -13
  24. package/lib/relations/manyToMany/ManyToManyMySqlModifyMixin.js +31 -42
  25. package/lib/relations/manyToMany/ManyToManyRelation.js +15 -18
  26. package/lib/relations/manyToMany/ManyToManySqliteModifyMixin.js +0 -13
  27. package/lib/relations/manyToMany/find/ManyToManyFindOperation.js +2 -3
  28. package/lib/relations/manyToMany/unrelate/ManyToManyUnrelateMySqlOperation.js +1 -5
  29. package/lib/relations/manyToMany/unrelate/ManyToManyUnrelateOperation.js +1 -5
  30. package/lib/relations/manyToMany/unrelate/ManyToManyUnrelateOperationBase.js +9 -0
  31. package/lib/relations/manyToMany/unrelate/ManyToManyUnrelateSqliteOperation.js +1 -5
  32. package/lib/relations/manyToMany/update/ManyToManyUpdateOperationBase.js +28 -2
  33. package/lib/utils/identifierMapping.js +8 -17
  34. package/lib/utils/objectUtils.js +109 -2
  35. package/lib/utils/parseFieldExpression.js +10 -3
  36. package/package.json +5 -2
  37. package/typings/objection/index.d.ts +56 -36
  38. package/lib/relations/manyToMany/ManyToManyPostgresModifyMixin.js +0 -27
  39. package/lib/relations/manyToMany/unrelate/ManyToManyUnrelatePostgresOperation.js +0 -16
  40. package/lib/relations/manyToMany/update/ManyToManyUpdatePostgresOperation.js +0 -12
  41. package/lib/utils/clone.js +0 -2329
package/README.md CHANGED
@@ -36,9 +36,9 @@ What objection.js **doesn't** give you:
36
36
  For simple things it is useful that the database schema is automatically generated from the model definitions,
37
37
  but usually just gets in your way when doing anything non-trivial. Objection.js leaves the schema related things
38
38
  to you. knex has a great [migration tool](https://knexjs.org/guide/migrations.html) that we recommend for this job. Check
39
- out the [example project](https://github.com/Vincit/objection.js/tree/main/examples/koa-ts).
39
+ out the [example project](https://github.com/ditojs/objection/tree/main/examples/koa-ts).
40
40
 
41
- The best way to get started is to clone our [example project](https://github.com/Vincit/objection.js/tree/main/examples/koa) and start playing with it. There's also a [typescript version](https://github.com/Vincit/objection.js/tree/main/examples/koa-ts) available.
41
+ The best way to get started is to clone our [example project](https://github.com/ditojs/objection/tree/main/examples/koa) and start playing with it. There's also a [typescript version](https://github.com/ditojs/objection/tree/main/examples/koa-ts) available.
42
42
 
43
43
  Check out [this issue](https://github.com/Vincit/objection.js/issues/1069) to see who is using objection and what they think about it.
44
44
 
@@ -46,7 +46,7 @@ Shortcuts:
46
46
 
47
47
  - [Who uses objection.js](https://github.com/Vincit/objection.js/discussions/2464)
48
48
  - [API reference](https://ditojs.github.io/objection/api/query-builder/)
49
- - [Example projects](https://github.com/Vincit/objection.js/tree/main/examples)
49
+ - [Example projects](https://github.com/ditojs/objection/tree/main/examples)
50
50
  - [Changelog](https://ditojs.github.io/objection/release-notes/changelog.html)
51
51
  - [v1 -> v2 -> v3 migration guide](https://ditojs.github.io/objection/release-notes/migration.html)
52
52
  - [Contribution guide](https://ditojs.github.io/objection/guide/contributing.html)
@@ -215,7 +215,22 @@ class AjvValidator extends Validator {
215
215
  this.setupAjv(this.ajvQueryProps);
216
216
  }
217
217
 
218
- return this.ajvQueryProps.compile(jsonSchema);
218
+ for (;;) {
219
+ try {
220
+ return this.ajvQueryProps.compile(jsonSchema);
221
+ } catch (err) {
222
+ // The schemas of other model classes that `$ref`s point to aren't added
223
+ // to this instance. Copy them over from the normal instance when needed.
224
+ const validator =
225
+ err instanceof Ajv.MissingRefError && this.ajv.getSchema(err.missingSchema);
226
+
227
+ if (!validator) {
228
+ throw err;
229
+ }
230
+
231
+ this.ajvQueryProps.addSchema(validator.schema, err.missingSchema);
232
+ }
233
+ }
219
234
  }
220
235
  }
221
236
 
@@ -357,7 +372,9 @@ function fieldExpressionPath(key) {
357
372
  return [key];
358
373
  }
359
374
 
360
- if (parsed.access.some((access) => access.type !== 'object')) {
375
+ // Numeric keys like the `0` in `tags:0` also access array elements when the
376
+ // json value is an array, so treat them like `[0]`.
377
+ if (parsed.access.some((access) => access.type !== 'object' || /^-?\d+$/.test(access.ref))) {
361
378
  return null;
362
379
  }
363
380
 
@@ -470,6 +487,11 @@ function jsonSchemaWithoutRequiredKeys(jsonSchema, keys) {
470
487
  }
471
488
 
472
489
  function jsonSchemaWithoutRequired(jsonSchema) {
490
+ // Boolean schemas (`true` / `false`) have no `required` to strip.
491
+ if (!isObject(jsonSchema)) {
492
+ return jsonSchema;
493
+ }
494
+
473
495
  // `not` is intentionally left untouched: Stripping `required` inside it
474
496
  // would turn it into a schema that rejects everything (e.g.
475
497
  // `not: { required: ['a'] }` -> `not: {}`). Kept as is, it still holds for
@@ -506,17 +528,18 @@ function definitionsWithoutRequired(jsonSchema, prop) {
506
528
  }
507
529
 
508
530
  function subSchemaWithoutRequired(jsonSchema, prop) {
509
- if (jsonSchema[prop]) {
531
+ if (jsonSchema[prop] !== undefined) {
510
532
  if (Array.isArray(jsonSchema[prop])) {
511
533
  const schemaArray = jsonSchema[prop].map(jsonSchemaWithoutRequired);
512
- const nonEmptySchemaArray = schemaArray.filter(isNotEmptyObject);
534
+ const nonEmptySchemaArray = schemaArray.filter((schema) => !matchesAnything(schema));
513
535
 
514
536
  // An `anyOf` or `oneOf` option that only consisted of `required` is
515
537
  // empty once `required` is stripped, and an empty schema matches any
516
- // patch, just like the stripped `required` keywords elsewhere. Drop the
517
- // whole keyword then: requiring the patch to match one of the remaining
518
- // options would reject valid patches, and with `oneOf` keeping the empty
519
- // option would fail every patch that also matches another option.
538
+ // patch, just like the stripped `required` keywords elsewhere and the
539
+ // `true` schema. Drop the whole keyword then: requiring the patch to
540
+ // match one of the remaining options would reject valid patches, and
541
+ // with `oneOf` keeping the empty option would fail every patch that also
542
+ // matches another option.
520
543
  if (prop !== 'allOf' && nonEmptySchemaArray.length !== schemaArray.length) {
521
544
  return {};
522
545
  } else if (nonEmptySchemaArray.length !== 0) {
@@ -548,8 +571,8 @@ function subSchemaWithoutRequired(jsonSchema, prop) {
548
571
  }
549
572
  }
550
573
 
551
- function isNotEmptyObject(obj) {
552
- return Object.keys(obj).length !== 0;
574
+ function matchesAnything(schema) {
575
+ return schema === true || (isObject(schema) && Object.keys(schema).length === 0);
553
576
  }
554
577
 
555
578
  module.exports = {
@@ -81,8 +81,6 @@ function cloneObject(value) {
81
81
  return cloneArray(value);
82
82
  } else if (value.$isObjectionModel) {
83
83
  return clone(value, false, false);
84
- } else if (Buffer.isBuffer(value)) {
85
- return new Buffer(value);
86
84
  } else {
87
85
  return cloneDeep(value);
88
86
  }
@@ -6,6 +6,7 @@ class InternalOptions {
6
6
  this.keepImplicitJoinProps = false;
7
7
  this.returnImmediatelyValue = undefined;
8
8
  this.isInternalQuery = false;
9
+ this.skipRelationFilter = false;
9
10
  this.debug = false;
10
11
  this.schema = undefined;
11
12
  }
@@ -17,6 +18,7 @@ class InternalOptions {
17
18
  copy.keepImplicitJoinProps = this.keepImplicitJoinProps;
18
19
  copy.returnImmediatelyValue = this.returnImmediatelyValue;
19
20
  copy.isInternalQuery = this.isInternalQuery;
21
+ copy.skipRelationFilter = this.skipRelationFilter;
20
22
  copy.debug = this.debug;
21
23
  copy.schema = this.schema;
22
24
 
@@ -8,6 +8,7 @@ const { ValidationErrorType } = require('../model/ValidationError');
8
8
  const { isObject, isString, isFunction, last, flatten } = require('../utils/objectUtils');
9
9
  const { RelationExpression, DuplicateRelationError } = require('./RelationExpression');
10
10
  const { assertIdNotUndefined, assertHasIdColumn } = require('../utils/assert');
11
+ const { deprecate } = require('../utils/deprecate');
11
12
 
12
13
  const { Selection } = require('./operations/select/Selection');
13
14
 
@@ -176,7 +177,7 @@ class QueryBuilder extends QueryBuilderBase {
176
177
  }
177
178
 
178
179
  isExplicitlyResolvedOrRejected() {
179
- return !!(this._explicitRejectValue || this._explicitResolveValue);
180
+ return this._explicitRejectValue !== null || this._explicitResolveValue !== null;
180
181
  }
181
182
 
182
183
  isExecutable() {
@@ -261,7 +262,14 @@ class QueryBuilder extends QueryBuilderBase {
261
262
  eagerOp.graphOptions = { ...eagerOp.graphOptions, ...graphOptions };
262
263
 
263
264
  if (joinOperation) {
264
- eagerOp.joinOperations.push({ expression: parsedExp, joinOperation });
265
+ if (eagerOp instanceof JoinEagerOperation) {
266
+ eagerOp.joinOperations.push({ expression: parsedExp, joinOperation });
267
+ } else {
268
+ deprecate(
269
+ 'The joinOperation option only applies to withGraphJoined() and is ignored for the ' +
270
+ `fetched relations "${parsedExp.toString()}".`,
271
+ );
272
+ }
265
273
  }
266
274
 
267
275
  checkMixedEager(this);
@@ -501,17 +509,31 @@ class QueryBuilder extends QueryBuilderBase {
501
509
  await joinEagerOp.onBefore3(builder);
502
510
  }
503
511
 
504
- const countQuery = knex.count('* as count').from((knexBuilder) => {
505
- // Also clear the order on the built knex query, since `orderBy` calls
506
- // can be added at build time, e.g. by a relation's `modify`.
507
- knexBuilder = builder.toKnexQuery(knexBuilder).clearOrder();
512
+ // Also clear the order on the built knex query, since `orderBy` calls
513
+ // can be added at build time, e.g. by a relation's `modify`.
514
+ const buildQuery = (knexBuilder) => builder.toKnexQuery(knexBuilder).clearOrder();
508
515
 
509
- if (joinEagerOp) {
516
+ const countQuery = knex.count('* as count').from((knexBuilder) => {
517
+ if (!joinEagerOp) {
518
+ buildQuery(knexBuilder);
519
+ } else {
510
520
  // The joins of `withGraphJoined` return one row per joined child.
511
521
  // Count the distinct root models instead.
512
- const tableRef = builder.tableRef();
513
522
  const idColumns = builder.modelClass().getIdColumnArray();
514
- knexBuilder.clearSelect().distinct(idColumns.map((col) => `${tableRef}.${col}`));
523
+
524
+ if (refersToSelections(builder)) {
525
+ // `having`, and `groupBy` with an alias, may refer to aliased
526
+ // selections, so keep them and select the distinct ids from the
527
+ // result instead.
528
+ knexBuilder.distinct(idColumns).from((knexBuilder) => {
529
+ buildQuery(knexBuilder).as('inner');
530
+ });
531
+ } else {
532
+ const tableRef = builder.tableRef();
533
+ buildQuery(knexBuilder)
534
+ .clearSelect()
535
+ .distinct(idColumns.map((col) => `${tableRef}.${col}`));
536
+ }
515
537
  }
516
538
 
517
539
  knexBuilder.as('temp');
@@ -1171,6 +1193,29 @@ function getEagerAlgorithmByName(builder, name) {
1171
1193
  }
1172
1194
  }
1173
1195
 
1196
+ // Returns true if the `having` or `groupBy` clauses of `builder` may refer to
1197
+ // aliased selections (#2329).
1198
+ function refersToSelections(builder) {
1199
+ if (builder.has(/having/)) {
1200
+ return true;
1201
+ }
1202
+
1203
+ const aliases = builder
1204
+ .findAllSelections()
1205
+ .map((selection) => selection.alias)
1206
+ .filter((alias) => !!alias);
1207
+
1208
+ let refersToAlias = false;
1209
+
1210
+ builder.forEachOperation(/^groupBy$/, (op) => {
1211
+ if (flatten(op.args).some((arg) => aliases.includes(arg))) {
1212
+ refersToAlias = true;
1213
+ }
1214
+ });
1215
+
1216
+ return refersToAlias;
1217
+ }
1218
+
1174
1219
  function getEagerAlgorithmOfOperation(builder, eagerOp) {
1175
1220
  if (eagerOp instanceof JoinEagerOperation) {
1176
1221
  return getJoinEagerAlgorithm(builder);
@@ -1198,7 +1243,8 @@ function groupByEagerAlgorithm(builder, expression) {
1198
1243
 
1199
1244
  for (const childName of node.$childNames) {
1200
1245
  const eagerOp = findEagerOperations(builder).find(
1201
- (op) => op.expression.node.$allRecursive || op.expression.node[childName],
1246
+ (op) =>
1247
+ op.expression.node.$allRecursive || op.expression.node.$childNames.includes(childName),
1202
1248
  );
1203
1249
 
1204
1250
  const algorithm = eagerOp ? getEagerAlgorithmOfOperation(builder, eagerOp) : defaultAlgorithm;
@@ -129,7 +129,8 @@ class RelationExpression {
129
129
  return new RelationExpression(this.node, this.recursionDepth + 1);
130
130
  }
131
131
 
132
- const child = this.node[childName];
132
+ // Don't mistake inherited properties like `constructor` for children.
133
+ const child = this.node.$childNames.includes(childName) ? this.node[childName] : null;
133
134
 
134
135
  if (child) {
135
136
  return new RelationExpression(child);
@@ -292,7 +293,7 @@ function modelToNode(model, node) {
292
293
  const relName = relationNames[r];
293
294
 
294
295
  if (model[relName] !== undefined) {
295
- let childNode = node[relName];
296
+ let childNode = node.$childNames.includes(relName) ? node[relName] : null;
296
297
 
297
298
  if (!childNode) {
298
299
  childNode = newNode(relName);
@@ -402,8 +403,9 @@ function mergeNodes(node1, node2) {
402
403
  node.$childNames = union(node1.$childNames, node2.$childNames);
403
404
 
404
405
  for (const childName of node.$childNames) {
405
- const child1 = node1[childName];
406
- const child2 = node2[childName];
406
+ // Don't mistake inherited properties like `constructor` for children.
407
+ const child1 = node1.$childNames.includes(childName) ? node1[childName] : null;
408
+ const child2 = node2.$childNames.includes(childName) ? node2[childName] : null;
407
409
 
408
410
  if (child1 && child2) {
409
411
  node[childName] = mergeNodes(child1, child2);
@@ -1,5 +1,8 @@
1
1
  'use strict';
2
2
 
3
+ const { RelationExpression } = require('../RelationExpression');
4
+ const { isString } = require('../../utils/objectUtils');
5
+
3
6
  const NO_RELATE = 'noRelate';
4
7
  const NO_UNRELATE = 'noUnrelate';
5
8
  const NO_INSERT = 'noInsert';
@@ -12,6 +15,7 @@ const UNRELATE = 'unrelate';
12
15
  const INSERT_MISSING = 'insertMissing';
13
16
  const FETCH_STRATEGY = 'fetchStrategy';
14
17
  const ALLOW_REFS = 'allowRefs';
18
+ const ALLOW_MARKERS = 'allowMarkers';
15
19
 
16
20
  const OPTION_NAMES = [
17
21
  NO_RELATE,
@@ -25,6 +29,21 @@ const OPTION_NAMES = [
25
29
  INSERT_MISSING,
26
30
  FETCH_STRATEGY,
27
31
  ALLOW_REFS,
32
+ ALLOW_MARKERS,
33
+ ];
34
+
35
+ // The options that are matched against the relation path of each node, see
36
+ // `_hasOption()`.
37
+ const NODE_OPTION_NAMES = [
38
+ NO_RELATE,
39
+ NO_UNRELATE,
40
+ NO_INSERT,
41
+ NO_UPDATE,
42
+ NO_DELETE,
43
+ UPDATE,
44
+ RELATE,
45
+ UNRELATE,
46
+ INSERT_MISSING,
28
47
  ];
29
48
 
30
49
  const FetchStrategy = {
@@ -34,11 +53,16 @@ const FetchStrategy = {
34
53
  };
35
54
 
36
55
  class GraphOptions {
37
- constructor(options) {
56
+ // `rootRelationPath` is the relation path of the root node of a recursive
57
+ // upsert in the original graph, against which the relation paths and
58
+ // expressions of the options match.
59
+ constructor(options, rootRelationPath = []) {
38
60
  if (options instanceof GraphOptions) {
39
61
  this.options = options.options;
62
+ this.rootRelationPath = options.rootRelationPath;
40
63
  } else {
41
- this.options = options;
64
+ this.options = parseRelationExpressions(options);
65
+ this.rootRelationPath = rootRelationPath;
42
66
  }
43
67
  }
44
68
 
@@ -181,41 +205,136 @@ class GraphOptions {
181
205
  return !!this.options[ALLOW_REFS];
182
206
  }
183
207
 
184
- rebasedOptions(newRoot) {
185
- const newOpt = {};
186
- const newRootRelationPath = newRoot.relationPathKey;
187
-
188
- for (const name of Object.keys(this.options)) {
189
- const value = this.options[name];
190
-
191
- if (Array.isArray(value)) {
192
- newOpt[name] = value
193
- .filter((it) => it.startsWith(newRootRelationPath))
194
- .map((it) => it.slice(newRootRelationPath.length + 1))
195
- .filter((it) => !!it);
196
- } else {
197
- newOpt[name] = value;
198
- }
199
- }
208
+ allowMarkers() {
209
+ return !!this.options[ALLOW_MARKERS];
210
+ }
200
211
 
201
- return new GraphOptions(newOpt);
212
+ // The options of recursive upserts keep matching against the relation paths
213
+ // of the original graph, which the relation paths of the new root lead to.
214
+ rebasedOptions(newRoot) {
215
+ return new GraphOptions(this.options, [...this.rootRelationPath, ...newRoot.relationPath]);
202
216
  }
203
217
 
204
218
  _hasOption(node, optionName) {
205
219
  const option = this.options[optionName];
206
220
 
207
- if (Array.isArray(option)) {
208
- return option.indexOf(node.relationPathKey) !== -1;
221
+ if (isRelationExpression(option)) {
222
+ return matchesRelationExpression(option, this._relationPath(node));
223
+ } else if (Array.isArray(option)) {
224
+ return option.includes(this._relationPath(node).join('.'));
209
225
  } else if (typeof option === 'boolean') {
210
226
  return option;
211
227
  } else if (option === undefined) {
212
228
  return false;
213
229
  } else {
214
230
  throw new Error(
215
- `expected ${optionName} option value "${option}" to be an instance of boolean or array of strings`,
231
+ `expected ${optionName} option value "${option}" to be a boolean, an array of relation paths or a relation expression`,
216
232
  );
217
233
  }
218
234
  }
235
+
236
+ // The relation path of the node in the original graph, see `rebasedOptions()`.
237
+ _relationPath(node) {
238
+ return [...this.rootRelationPath, ...node.relationPath];
239
+ }
240
+ }
241
+
242
+ // Parses the string values of the node options into relation expressions,
243
+ // once per operation.
244
+ function parseRelationExpressions(options) {
245
+ let parsedOptions = options;
246
+
247
+ for (const optionName of NODE_OPTION_NAMES) {
248
+ const option = options[optionName];
249
+
250
+ if (isString(option)) {
251
+ if (parsedOptions === options) {
252
+ parsedOptions = { ...options };
253
+ }
254
+
255
+ parsedOptions[optionName] = parseRelationExpression(optionName, option);
256
+ }
257
+ }
258
+
259
+ return parsedOptions;
260
+ }
261
+
262
+ function parseRelationExpression(optionName, expression) {
263
+ const createError = (message) => {
264
+ return new Error(
265
+ `invalid relation expression "${expression}" in ${optionName} option: ${message}`,
266
+ );
267
+ };
268
+
269
+ let parsedExpression;
270
+
271
+ try {
272
+ parsedExpression = RelationExpression.create(expression);
273
+ } catch (err) {
274
+ throw createError(err.message);
275
+ }
276
+
277
+ if (parsedExpression.isEmpty && !parsedExpression.node.$allRecursive) {
278
+ throw createError('the expression is empty');
279
+ }
280
+
281
+ // Relation paths are matched by relation name, so aliases would never match.
282
+ const alias = findAlias(parsedExpression.node);
283
+
284
+ if (alias) {
285
+ throw createError(`aliases like "${alias.$relation} as ${alias.$name}" are not supported`);
286
+ }
287
+
288
+ return parsedExpression;
289
+ }
290
+
291
+ function findAlias(node) {
292
+ for (const childName of node.$childNames) {
293
+ const child = node[childName];
294
+
295
+ if (child.$name !== child.$relation) {
296
+ return child;
297
+ }
298
+
299
+ const alias = findAlias(child);
300
+
301
+ if (alias) {
302
+ return alias;
303
+ }
304
+ }
305
+
306
+ return null;
307
+ }
308
+
309
+ // A relation path matches a relation expression when it leads to a leaf of
310
+ // the expression, like it would match the same relation paths in an array:
311
+ // `'movies.reviews'` matches `movies.reviews` but not `movies`. `'*'` matches
312
+ // all relations below it and recursive expressions like `'parent.^'` match
313
+ // every level of the recursion. The root has no relation path and never
314
+ // matches.
315
+ function matchesRelationExpression(expression, relationPath) {
316
+ if (relationPath.length === 0) {
317
+ return false;
318
+ }
319
+
320
+ for (const relationName of relationPath) {
321
+ expression = expression.childExpression(relationName);
322
+
323
+ if (!expression) {
324
+ return false;
325
+ }
326
+ }
327
+
328
+ // Below `*`, the expression stays on the same node with increasing depth.
329
+ if (expression.node.$allRecursive) {
330
+ return expression.recursionDepth > 0;
331
+ } else {
332
+ return expression.isEmpty;
333
+ }
334
+ }
335
+
336
+ function isRelationExpression(value) {
337
+ return !!value && value.isObjectionRelationExpression === true;
219
338
  }
220
339
 
221
340
  function getCurrentNode(node, graphData) {
@@ -57,6 +57,7 @@ class GraphUpsert {
57
57
  return createGraphData(builder, graphOptions, graph)
58
58
  .then(checkForErrors(builder))
59
59
  .then(pruneGraphs())
60
+ .then(checkForJoinTableRelatedTableErrors(builder))
60
61
  .then(executeOperations(builder))
61
62
  .then(returnResult(this.objects, this.isArray));
62
63
  }
@@ -66,6 +67,11 @@ class GraphUpsert {
66
67
  // default behaviour instead (#84). A warning is logged once per option.
67
68
  // TODO: Throw an error instead in objection 4.0.
68
69
  function warnOnUnknownOptions(upsertOptions) {
70
+ // Recursive upserts pass on the already checked options of their parent.
71
+ if (upsertOptions instanceof GraphOptions) {
72
+ return;
73
+ }
74
+
69
75
  for (const name of Object.keys(upsertOptions)) {
70
76
  if (!OPTION_NAMES.includes(name)) {
71
77
  deprecate(
@@ -239,6 +245,7 @@ function checkForErrors(builder) {
239
245
  checkForNotFoundErrors(graphData, builder);
240
246
  checkForUnallowedRelationErrors(graphData, builder);
241
247
  checkForUnallowedReferenceErrors(graphData, builder);
248
+ checkForUnallowedMarkerErrors(graphData, builder);
242
249
  checkForRootUnrelateOrDeleteErrors(graphData, builder);
243
250
 
244
251
  if (graphData.graphOptions.isInsertOnly()) {
@@ -249,6 +256,35 @@ function checkForErrors(builder) {
249
256
  };
250
257
  }
251
258
 
259
+ // Inserting or relating a node of a many-to-many relation inserts a join row,
260
+ // which would be a new related row if the join table is also the related
261
+ // table (#133). Check this before this graph is written. It runs after
262
+ // pruning, as the branches below related nodes are upserted and checked by
263
+ // nested `upsertGraph` calls against their own current graphs.
264
+ function checkForJoinTableRelatedTableErrors(builder) {
265
+ return (graphData) => {
266
+ const { graphOptions, currentGraph, graph } = graphData;
267
+
268
+ for (const node of graph.nodes) {
269
+ const relation = node.parentEdge && node.parentEdge.relation;
270
+
271
+ if (
272
+ relation &&
273
+ relation.isObjectionManyToManyRelation &&
274
+ !currentGraph.nodeForNode(node) &&
275
+ graphOptions.shouldInsertOrRelate(node, graphData)
276
+ ) {
277
+ relation.assertCanModifyJoinTable(
278
+ builder,
279
+ graphOptions.shouldRelate(node, graphData) ? 'relate' : 'insert',
280
+ );
281
+ }
282
+ }
283
+
284
+ return graphData;
285
+ };
286
+ }
287
+
252
288
  function checkForNotFoundErrors(graphData, builder) {
253
289
  const { graphOptions, currentGraph, graph } = graphData;
254
290
 
@@ -309,6 +345,27 @@ function checkForUnallowedReferenceErrors(graphData, builder) {
309
345
  }
310
346
  }
311
347
 
348
+ // `#unrelate` and `#delete` markers come from the graph data, which may be
349
+ // untrusted input. Like `#ref`, they have to be allowed explicitly (#2410).
350
+ function checkForUnallowedMarkerErrors(graphData, builder) {
351
+ const { graph, graphOptions } = graphData;
352
+
353
+ if (graphOptions.allowMarkers()) {
354
+ return;
355
+ }
356
+
357
+ const node = graph.nodes.find((node) => node.toBeUnrelatedOrDeleted);
358
+
359
+ if (node) {
360
+ const { graphUnrelateProp, graphDeleteProp } = node.modelClass;
361
+
362
+ throw builder.modelClass().createValidationError({
363
+ type: ValidationErrorType.InvalidGraph,
364
+ message: `${graphUnrelateProp} and ${graphDeleteProp} markers are not allowed in a graph by default. see the allowMarkers insert/upsert graph option`,
365
+ });
366
+ }
367
+ }
368
+
312
369
  function checkForRootUnrelateOrDeleteErrors(graphData, builder) {
313
370
  for (const node of graphData.graph.nodes) {
314
371
  if (!node.parentEdge && node.toBeUnrelatedOrDeleted) {
@@ -140,6 +140,8 @@ class GraphInsert extends GraphOperation {
140
140
  edge.relation.isObjectionBelongsToOneRelation &&
141
141
  edge.isOwnerNode(node) &&
142
142
  edge.relatedNode.isReference &&
143
+ // The owner is patched by its id, which models without one don't have (#2305).
144
+ node.modelClass.getIdColumnArray().length > 0 &&
143
145
  // Both ends need to be inserted for the foreign key to be patched in later.
144
146
  this.graphOptions.shouldInsert(node, this.graphData) &&
145
147
  this.graphOptions.shouldInsert(edge.relatedNode.referencedNode, this.graphData) &&
@@ -85,6 +85,9 @@ class GraphPatchAction extends GraphAction {
85
85
 
86
86
  const updateBuilder = this._createBuilder(node)
87
87
  .childQueryOf(builder, childQueryOptions())
88
+ // The current graph was fetched through the relations, so the patched
89
+ // models are known to be related to their parents.
90
+ .internalOptions({ skipRelationFilter: true })
88
91
  .copyFrom(builder, GraphAction.ReturningAllSelector);
89
92
 
90
93
  if (hasBeforeUpdate) {
@@ -37,7 +37,12 @@ class InstanceUpdateOperation extends UpdateOperation {
37
37
  }
38
38
 
39
39
  result = await super.onAfter2(builder, result);
40
- this.instance.$set(this.model);
40
+
41
+ // A query resolved without updating anything, e.g. with `none()`, leaves
42
+ // the instance untouched.
43
+ if (!this.isResolvedWithoutUpdate(builder)) {
44
+ this.instance.$set(this.model);
45
+ }
41
46
 
42
47
  if (isObject(result)) {
43
48
  this.instance.$set(result);
@@ -14,6 +14,7 @@ class UpdateOperation extends QueryBuilderOperation {
14
14
 
15
15
  this.model = null;
16
16
  this.modelOptions = Object.assign({}, this.opt.modelOptions || {});
17
+ this.hasNothingToUpdate = false;
17
18
  }
18
19
 
19
20
  onAdd(builder, args) {
@@ -34,10 +35,19 @@ class UpdateOperation extends QueryBuilderOperation {
34
35
 
35
36
  if (isEmpty(row)) {
36
37
  // Resolve the query if there is nothing to update.
38
+ this.hasNothingToUpdate = !builder.isExplicitlyResolvedOrRejected();
37
39
  builder.resolve(0);
38
40
  }
39
41
  }
40
42
 
43
+ // Returns true if the query was resolved without updating anything, e.g.
44
+ // with `none()` or `resolve()` in a hook, but not if it was only resolved
45
+ // because there is nothing to update in the table, as the update still
46
+ // applies to everything else (#2184).
47
+ isResolvedWithoutUpdate(builder) {
48
+ return builder.isExplicitlyResolvedOrRejected() && !this.hasNothingToUpdate;
49
+ }
50
+
41
51
  onBuildKnex(knexBuilder, builder) {
42
52
  const json = this.model.$toDatabaseJson(builder);
43
53
  const convertedJson = convertFieldExpressionsToRaw(builder, this.model, json);
@@ -56,6 +66,7 @@ class UpdateOperation extends QueryBuilderOperation {
56
66
  clone() {
57
67
  const clone = super.clone();
58
68
  clone.model = this.model;
69
+ clone.hasNothingToUpdate = this.hasNothingToUpdate;
59
70
  return clone;
60
71
  }
61
72
  }
@@ -42,7 +42,9 @@ class WhereInCompositeMsSqlOperation extends ObjectionToKnexConvertingOperation
42
42
  knexBuilder,
43
43
  columns,
44
44
  helperColumns,
45
- values.as(knex.raw(`V(${helperColumns.map((_) => '??')})`, helperColumns)),
45
+ // knex only accepts strings as aliases, so the subquery is aliased in
46
+ // a raw query.
47
+ knex.raw(`? AS V(${helperColumns.map((_) => '??')})`, [values, ...helperColumns]),
46
48
  );
47
49
  }
48
50
  }