objection 3.3.0 → 3.4.1

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 (29) hide show
  1. package/lib/model/AjvValidator.js +87 -10
  2. package/lib/model/Model.js +3 -1
  3. package/lib/model/modelId.js +10 -2
  4. package/lib/model/modelQueryProps.js +7 -0
  5. package/lib/queryBuilder/QueryBuilder.js +5 -3
  6. package/lib/queryBuilder/QueryBuilderBase.js +44 -9
  7. package/lib/queryBuilder/ReferenceBuilder.js +8 -1
  8. package/lib/queryBuilder/graph/GraphFetcher.js +1 -3
  9. package/lib/queryBuilder/graph/GraphOptions.js +30 -1
  10. package/lib/queryBuilder/graph/GraphUpsert.js +16 -1
  11. package/lib/queryBuilder/graph/patch/GraphPatchAction.js +8 -2
  12. package/lib/queryBuilder/join/RelationJoiner.js +62 -9
  13. package/lib/queryBuilder/join/utils.js +20 -2
  14. package/lib/queryBuilder/operations/InsertOperation.js +11 -4
  15. package/lib/queryBuilder/operations/UpdateOperation.js +2 -0
  16. package/lib/queryBuilder/operations/jsonApi/WhereJsonHasPostgresOperation.js +1 -0
  17. package/lib/queryBuilder/operations/jsonApi/WhereJsonNotObjectPostgresOperation.js +1 -0
  18. package/lib/queryBuilder/operations/jsonApi/WhereJsonPostgresOperation.js +33 -0
  19. package/lib/queryBuilder/operations/jsonApi/postgresJsonApi.js +16 -1
  20. package/lib/queryBuilder/operations/select/SelectOperation.js +39 -1
  21. package/lib/relations/RelationFindOperation.js +66 -2
  22. package/lib/relations/RelationProperty.js +3 -2
  23. package/lib/relations/manyToMany/ManyToManyRelation.js +34 -3
  24. package/lib/relations/manyToMany/find/ManyToManyFindOperation.js +34 -4
  25. package/lib/relations/manyToMany/update/ManyToManyUpdateOperationBase.js +4 -0
  26. package/lib/utils/assert.js +9 -0
  27. package/lib/utils/wrapDbError.js +36 -0
  28. package/package.json +2 -5
  29. package/typings/objection/index.d.ts +1 -1
@@ -2,13 +2,14 @@
2
2
 
3
3
  const { uniqBy } = require('../../utils/objectUtils');
4
4
  const { Selection } = require('../operations/select/Selection');
5
+ const { SelectOperation } = require('../operations/select/SelectOperation');
5
6
  const { createModifier } = require('../../utils/createModifier');
6
7
  const { map: mapPromise } = require('../../utils/promiseUtils');
7
8
  const { ValidationErrorType } = require('../../model/ValidationError');
8
9
 
9
10
  const { TableTree } = require('./TableTree');
10
11
  const { JoinResultParser } = require('./JoinResultParser');
11
- const { ID_LENGTH_LIMIT } = require('./utils');
12
+ const { getIdentifierLengthLimit } = require('./utils');
12
13
 
13
14
  /**
14
15
  * Given a relation expression, builds a query using joins to fetch it.
@@ -124,7 +125,7 @@ class RelationJoiner {
124
125
  // also contain selects.
125
126
  userSelectQuery.modify(tableNode.relation.modify);
126
127
 
127
- if (selectsOnlyAliasesKeepingSelectAll(userSelectQuery.findAllSelections())) {
128
+ if (selectsOnlyAliasesKeepingSelectAll(findJoinSelections(userSelectQuery))) {
128
129
  // All columns are selected from the joined subquery in the main query,
129
130
  // so the subquery needs to select them too.
130
131
  subqueryToJoin.select(`${subqueryToJoin.tableRef()}.*`);
@@ -179,8 +180,10 @@ class RelationJoiner {
179
180
  userSelectQueries,
180
181
  });
181
182
 
183
+ const lengthLimit = getIdentifierLengthLimit(builder.knex());
184
+
182
185
  for (const selection of selections) {
183
- checkAliasLength(tableNode.modelClass, selection.name);
186
+ checkAliasLength(tableNode.modelClass, selection.name, lengthLimit);
184
187
  }
185
188
 
186
189
  // Save the selections that were added internally (not by the user)
@@ -266,7 +269,7 @@ function ensureIdAndRelationPropsAreSelected({ builder, tableNode }) {
266
269
  ];
267
270
 
268
271
  const selectStrings = uniqBy(cols)
269
- .filter((col) => !builder.hasSelectionAs(col, col))
272
+ .filter((col) => !hasSelectionAs(builder, col))
270
273
  .map((col) => `${tableRef}.${col}`);
271
274
 
272
275
  return builder.select(selectStrings);
@@ -274,7 +277,7 @@ function ensureIdAndRelationPropsAreSelected({ builder, tableNode }) {
274
277
 
275
278
  function getSelectionsForNode({ builder, tableNode, userSelectQueries }) {
276
279
  const userSelectQuery = userSelectQueries.get(tableNode);
277
- const userSelections = userSelectQuery.findAllSelections();
280
+ const userSelections = findUserSelections(userSelectQuery, tableNode);
278
281
  const userSelectedAllColumns = isSelectAllSelectionSet(userSelections);
279
282
 
280
283
  let selections = [];
@@ -296,7 +299,7 @@ function getSelectionsForNode({ builder, tableNode, userSelectQueries }) {
296
299
  const idSelections = getIdSelectionsForNode({ tableNode });
297
300
 
298
301
  for (const idSelection of idSelections) {
299
- if (!userSelectQuery.hasSelectionAs(idSelection.column, idSelection.column)) {
302
+ if (!hasUserSelectionAs(userSelectQuery, tableNode, idSelection.column)) {
300
303
  selections.push(idSelection);
301
304
  internalSelections.push(idSelection);
302
305
  }
@@ -316,6 +319,54 @@ function getSelectionsForNode({ builder, tableNode, userSelectQueries }) {
316
319
  };
317
320
  }
318
321
 
322
+ // Like `builder.findAllSelections()`, but aggregates select their aliases
323
+ // instead of the columns they're passed. Aggregates without an alias are left
324
+ // out, as their column name differs between databases.
325
+ function findJoinSelections(builder) {
326
+ let selections = [];
327
+
328
+ builder.forEachOperation(true, (op) => {
329
+ if (op.constructor === SelectOperation) {
330
+ selections = selections.concat(op.aggregateSelections || op.selections);
331
+ }
332
+ });
333
+
334
+ return selections;
335
+ }
336
+
337
+ // Like `builder.hasSelectionAs(column, column)`, but ignoring the columns
338
+ // passed to aggregates, as they don't select them.
339
+ function hasSelectionAs(builder, column) {
340
+ if (builder.has(isAggregateOperation)) {
341
+ // Explicit, as a query with only aggregates doesn't select all columns.
342
+ return builder.clone().clear(isAggregateOperation).hasSelectionAs(column, column, true);
343
+ } else {
344
+ return builder.hasSelectionAs(column, column);
345
+ }
346
+ }
347
+
348
+ // Only the joined subqueries select aggregates by their aliases. The root
349
+ // query keeps treating the columns passed to them as selected.
350
+ function findUserSelections(builder, tableNode) {
351
+ if (tableNode.parentNode) {
352
+ return findJoinSelections(builder);
353
+ } else {
354
+ return builder.findAllSelections();
355
+ }
356
+ }
357
+
358
+ function hasUserSelectionAs(builder, tableNode, column) {
359
+ if (tableNode.parentNode) {
360
+ return hasSelectionAs(builder, column);
361
+ } else {
362
+ return builder.hasSelectionAs(column, column);
363
+ }
364
+ }
365
+
366
+ function isAggregateOperation(op) {
367
+ return op.constructor === SelectOperation && op.aggregateSelections !== null;
368
+ }
369
+
319
370
  function mapUserSelectionsFromSubqueryToMainQuery({ userSelections, tableNode }) {
320
371
  return userSelections.filter(isNotSelectAll).map((selection) => {
321
372
  return new Selection(
@@ -393,11 +444,13 @@ function selectsOnlyAliasesKeepingSelectAll(selections) {
393
444
  return selections.length > 0 && selections.every((it) => it.keepsSelectAll);
394
445
  }
395
446
 
396
- function checkAliasLength(modelClass, alias) {
397
- if (alias.length > ID_LENGTH_LIMIT) {
447
+ function checkAliasLength(modelClass, alias, lengthLimit) {
448
+ if (alias.length > lengthLimit) {
398
449
  throw modelClass.createValidationError({
399
450
  type: ValidationErrorType.RelationExpression,
400
- message: `identifier ${alias} is over ${ID_LENGTH_LIMIT} characters long and would be truncated by the database engine.`,
451
+ message:
452
+ `identifier ${alias} is over ${lengthLimit} characters long and would be truncated by the database engine. ` +
453
+ 'Use the `minimize` option of withGraphJoined() to shorten the aliases.',
401
454
  });
402
455
  }
403
456
  }
@@ -1,10 +1,27 @@
1
1
  'use strict';
2
2
 
3
3
  const { ValidationErrorType } = require('../../model/ValidationError');
4
+ const { isPostgres, isMySql, isMsSql, isSqlite } = require('../../utils/knexUtils');
4
5
 
5
- const ID_LENGTH_LIMIT = 63;
6
6
  const RELATION_RECURSION_LIMIT = 64;
7
7
 
8
+ // The maximum length of the column aliases of the joined relations, after
9
+ // which the database would truncate them. Unknown databases use the strictest
10
+ // of the known limits, the one of postgres.
11
+ function getIdentifierLengthLimit(knex) {
12
+ if (isPostgres(knex)) {
13
+ return 63;
14
+ } else if (isMySql(knex)) {
15
+ return 256;
16
+ } else if (isMsSql(knex)) {
17
+ return 128;
18
+ } else if (isSqlite(knex)) {
19
+ return Infinity;
20
+ } else {
21
+ return 63;
22
+ }
23
+ }
24
+
8
25
  // Given a relation expression, goes through all first level children.
9
26
  function forEachChildExpression(expr, modelClass, callback) {
10
27
  if (expr.node.$allRecursive || expr.maxRecursionDepth > RELATION_RECURSION_LIMIT) {
@@ -18,8 +35,9 @@ function forEachChildExpression(expr, modelClass, callback) {
18
35
  }
19
36
 
20
37
  module.exports = {
21
- ID_LENGTH_LIMIT,
22
38
  RELATION_RECURSION_LIMIT,
23
39
 
40
+ getIdentifierLengthLimit,
41
+
24
42
  forEachChildExpression,
25
43
  };
@@ -38,18 +38,20 @@ class InsertOperation extends QueryBuilderOperation {
38
38
  onBuildKnex(knexBuilder, builder) {
39
39
  const knex = builder.knex();
40
40
 
41
- if (!builder.has(/returning/)) {
41
+ const defaultReturning = getDefaultReturning(builder);
42
+
43
+ if (!builder.has(/returning/) && defaultReturning !== null) {
42
44
  if (!isSqlite(knex) && !isMySql(knex)) {
43
45
  // If the user hasn't specified a `returning` clause, we make sure
44
46
  // that at least the identifier is returned. With `onConflict()` the
45
47
  // conflict columns are returned too, so that the returned rows can
46
48
  // be matched to the models when some of the rows are ignored.
47
- knexBuilder = knexBuilder.returning(getDefaultReturning(builder));
49
+ knexBuilder = knexBuilder.returning(defaultReturning);
48
50
  } else if (isSqlite(knex) && builder.has('onConflict')) {
49
51
  // Without a `returning` clause, SQLite returns the id of the last
50
52
  // inserted row, which belongs to a previous insert if the row was
51
53
  // ignored. Use `returning` to find out what was actually inserted.
52
- knexBuilder = knexBuilder.returning(getDefaultReturning(builder));
54
+ knexBuilder = knexBuilder.returning(defaultReturning);
53
55
  }
54
56
  }
55
57
 
@@ -77,7 +79,7 @@ class InsertOperation extends QueryBuilderOperation {
77
79
  this.models[i].$setDatabaseJson(ret[i]);
78
80
  }
79
81
  }
80
- } else {
82
+ } else if (builder.modelClass().getIdColumnArray().length > 0) {
81
83
  // If the return value is not an array of objects, we assume it is an array of identifiers.
82
84
  for (let i = 0, l = this.models.length; i < l; ++i) {
83
85
  const model = this.models[i];
@@ -113,10 +115,15 @@ class InsertOperation extends QueryBuilderOperation {
113
115
  }
114
116
  }
115
117
 
118
+ // Returns `null` if there's nothing to return, e.g. for models without an `idColumn`.
116
119
  function getDefaultReturning(builder) {
117
120
  const idColumns = builder.modelClass().getIdColumnArray();
118
121
  const columns = Array.from(new Set(idColumns.concat(getConflictColumns(builder))));
119
122
 
123
+ if (columns.length === 0) {
124
+ return null;
125
+ }
126
+
120
127
  return columns.length === 1 ? columns[0] : columns;
121
128
  }
122
129
 
@@ -5,6 +5,7 @@ const { isEmpty } = require('../../utils/objectUtils');
5
5
  const { toPostgresJsonPathLiteral } = require('../../utils/parseFieldExpression');
6
6
  const { isKnexRaw, isKnexQueryBuilder } = require('../../utils/knexUtils');
7
7
  const { QueryBuilderOperation } = require('./QueryBuilderOperation');
8
+ const { warnIfNotPostgres } = require('./jsonApi/postgresJsonApi');
8
9
  const { StaticHookArguments } = require('../StaticHookArguments');
9
10
 
10
11
  class UpdateOperation extends QueryBuilderOperation {
@@ -103,6 +104,7 @@ function convertFieldExpressionsToRaw(builder, model, json) {
103
104
  if (key.indexOf(':') > -1) {
104
105
  // 'col:attr' : ref('other:lol') is transformed to
105
106
  // "col" : raw(`jsonb_set("col", '{attr}', to_jsonb("other"#>'{lol}'), true)`)
107
+ warnIfNotPostgres(knex);
106
108
 
107
109
  let parsed = ref(key);
108
110
  let jsonPath = toPostgresJsonPathLiteral(parsed.parsedExpr.access);
@@ -5,6 +5,7 @@ const { ObjectionToKnexConvertingOperation } = require('../ObjectionToKnexConver
5
5
 
6
6
  class WhereJsonHasPostgresOperation extends ObjectionToKnexConvertingOperation {
7
7
  onBuildKnex(knexBuilder, builder) {
8
+ jsonApi.warnIfNotPostgres(builder.knex());
8
9
  const args = this.getKnexArgs(builder);
9
10
 
10
11
  const rawArgs = jsonApi.whereJsonFieldRightStringArrayOnLeftQuery(
@@ -5,6 +5,7 @@ const { ObjectionToKnexConvertingOperation } = require('../ObjectionToKnexConver
5
5
 
6
6
  class WhereJsonNotObjectPostgresOperation extends ObjectionToKnexConvertingOperation {
7
7
  onBuildKnex(knexBuilder, builder) {
8
+ jsonApi.warnIfNotPostgres(builder.knex());
8
9
  return this.whereJsonNotObject(knexBuilder, builder.knex(), this.getKnexArgs(builder)[0]);
9
10
  }
10
11
 
@@ -2,9 +2,28 @@
2
2
 
3
3
  const jsonApi = require('./postgresJsonApi');
4
4
  const { ObjectionToKnexConvertingOperation } = require('../ObjectionToKnexConvertingOperation');
5
+ const { parseFieldExpression } = require('../../../utils/parseFieldExpression');
6
+ const { isPlainObject, isString, isFunction } = require('../../../utils/objectUtils');
7
+ const { isMySql } = require('../../../utils/knexUtils');
5
8
 
6
9
  class WhereJsonPostgresOperation extends ObjectionToKnexConvertingOperation {
7
10
  onBuildKnex(knexBuilder, builder) {
11
+ const knex = builder.knex();
12
+
13
+ if (isMySql(knex) && this.canDelegateToKnex(knexBuilder)) {
14
+ // On MySQL, knex's version of the method works (ditojs#113). The `or`
15
+ // variants are wrapped in `orWhere()`, as knex < 3 ignores their `or`.
16
+ const [column, value] = this.getKnexArgs(builder);
17
+ const { knexMethod } = this.opt;
18
+
19
+ if (this.opt.bool === 'or') {
20
+ return knexBuilder.orWhere((qb) => qb[knexMethod](column, value));
21
+ } else {
22
+ return knexBuilder[knexMethod](column, value);
23
+ }
24
+ }
25
+
26
+ jsonApi.warnIfNotPostgres(knex);
8
27
  const args = this.getKnexArgs(builder);
9
28
 
10
29
  const rawArgs = jsonApi.whereJsonbRefOnLeftJsonbValOrRefOnRightRawQueryParams(
@@ -22,6 +41,20 @@ class WhereJsonPostgresOperation extends ObjectionToKnexConvertingOperation {
22
41
 
23
42
  return knexBuilder;
24
43
  }
44
+
45
+ // Only the simple form with a plain column and a plain object or array value
46
+ // can be passed on to knex, as knex doesn't understand field expressions.
47
+ canDelegateToKnex(knexBuilder) {
48
+ const [column, value] = this.args;
49
+
50
+ return (
51
+ !!this.opt.knexMethod &&
52
+ isFunction(knexBuilder[this.opt.knexMethod]) &&
53
+ isString(column) &&
54
+ parseFieldExpression(column).access.length === 0 &&
55
+ (isPlainObject(value) || Array.isArray(value))
56
+ );
57
+ }
25
58
  }
26
59
 
27
60
  module.exports = {
@@ -2,7 +2,8 @@
2
2
 
3
3
  const parser = require('../../../utils/parseFieldExpression');
4
4
  const { asArray, isObject, isString } = require('../../../utils/objectUtils');
5
- const { isKnexRaw, isKnexQueryBuilder } = require('../../../utils/knexUtils');
5
+ const { isKnexRaw, isKnexQueryBuilder, isPostgres } = require('../../../utils/knexUtils');
6
+ const { deprecate } = require('../../../utils/deprecate');
6
7
 
7
8
  /**
8
9
  * @typedef {String} FieldExpression
@@ -44,6 +45,19 @@ const { isKnexRaw, isKnexQueryBuilder } = require('../../../utils/knexUtils');
44
45
  * not currently supported
45
46
  */
46
47
 
48
+ // JSON field expressions and the `whereJson*()` methods generate Postgres-only
49
+ // SQL (ditojs#113). As some of it happens to run on other databases, only warn.
50
+ // TODO: Consider throwing an error instead in objection 4.0.
51
+ function warnIfNotPostgres(knex) {
52
+ if (!isPostgres(knex)) {
53
+ deprecate(
54
+ 'JSON field expressions and the whereJson*() methods of objection are only supported ' +
55
+ 'on PostgreSQL. Use the JSON methods of knex like whereJsonPath() or jsonExtract() ' +
56
+ 'with other databases.',
57
+ );
58
+ }
59
+ }
60
+
47
61
  // Returns the SQL and bindings of a field expression. The column is passed as an
48
62
  // identifier binding (`??`), so that knex's `wrapIdentifier` (e.g. used by
49
63
  // `knexSnakeCaseMappers()`) is applied to it.
@@ -168,6 +182,7 @@ function normalizeOperator(knex, operator) {
168
182
  }
169
183
 
170
184
  module.exports = {
185
+ warnIfNotPostgres,
171
186
  parseFieldExpression: parseFieldExpression,
172
187
  whereJsonbRefOnLeftJsonbValOrRefOnRight: whereJsonbRefOnLeftJsonbValOrRefOnRight,
173
188
  whereJsonbRefOnLeftJsonbValOrRefOnRightRawQueryParams:
@@ -1,15 +1,30 @@
1
1
  'use strict';
2
2
 
3
- const { flatten } = require('../../../utils/objectUtils');
3
+ const { flatten, isString, isPlainObject } = require('../../../utils/objectUtils');
4
4
  const { Selection } = require('./Selection');
5
5
  const { ObjectionToKnexConvertingOperation } = require('../ObjectionToKnexConvertingOperation');
6
6
 
7
7
  const COUNT_REGEX = /count/i;
8
+ const ALIAS_REGEX = /\s+as\s+/i;
9
+
10
+ const AGGREGATE_METHODS = new Set([
11
+ 'count',
12
+ 'countDistinct',
13
+ 'min',
14
+ 'max',
15
+ 'sum',
16
+ 'sumDistinct',
17
+ 'avg',
18
+ 'avgDistinct',
19
+ ]);
8
20
 
9
21
  class SelectOperation extends ObjectionToKnexConvertingOperation {
10
22
  constructor(name, opt) {
11
23
  super(name, opt);
12
24
  this.selections = [];
25
+ // The selections that aggregates select, by their aliases. `null` for
26
+ // non-aggregate operations.
27
+ this.aggregateSelections = AGGREGATE_METHODS.has(name) ? [] : null;
13
28
  }
14
29
 
15
30
  onAdd(builder, args) {
@@ -31,6 +46,12 @@ class SelectOperation extends ObjectionToKnexConvertingOperation {
31
46
  }
32
47
  }
33
48
 
49
+ if (this.aggregateSelections) {
50
+ this.aggregateSelections = getAggregateAliases(selections).map(
51
+ (alias) => new Selection(null, null, alias),
52
+ );
53
+ }
54
+
34
55
  return ret;
35
56
  }
36
57
 
@@ -57,10 +78,27 @@ class SelectOperation extends ObjectionToKnexConvertingOperation {
57
78
  clone() {
58
79
  const clone = super.clone();
59
80
  clone.selections = this.selections.slice();
81
+ clone.aggregateSelections = this.aggregateSelections && this.aggregateSelections.slice();
60
82
  return clone;
61
83
  }
62
84
  }
63
85
 
86
+ // Returns the aliases of the columns an aggregate selects, supporting the same
87
+ // argument forms as knex: `count('col as alias')`, `count('col', { as: 'alias' })`
88
+ // and `count({ alias: 'col' })`. Aggregates without an alias are skipped, as
89
+ // their column name differs between databases.
90
+ function getAggregateAliases([column, options]) {
91
+ if (isString(column) && ALIAS_REGEX.test(column)) {
92
+ return [column.split(ALIAS_REGEX)[1].trim()];
93
+ } else if (isPlainObject(options) && isString(options.as)) {
94
+ return [options.as];
95
+ } else if (isPlainObject(column)) {
96
+ return Object.keys(column);
97
+ } else {
98
+ return [];
99
+ }
100
+ }
101
+
64
102
  module.exports = {
65
103
  SelectOperation,
66
104
  };
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { FindOperation } = require('../queryBuilder/operations/FindOperation');
4
+ const { deprecate } = require('../utils/deprecate');
4
5
 
5
6
  class RelationFindOperation extends FindOperation {
6
7
  constructor(name, opt) {
@@ -19,8 +20,13 @@ class RelationFindOperation extends FindOperation {
19
20
  this.maybeApplyAlias(builder);
20
21
  this.relation.findQuery(builder, this.owner);
21
22
 
22
- if (this.assignResultToOwner && this.owner.isModels) {
23
- this.selectMissingJoinColumns(builder);
23
+ if (this.owner.isModels) {
24
+ this.warnOnMissingOwnerProps();
25
+
26
+ if (this.assignResultToOwner) {
27
+ this.warnOnArrayOwnerProps();
28
+ this.selectMissingJoinColumns(builder);
29
+ }
24
30
  }
25
31
  }
26
32
 
@@ -44,6 +50,8 @@ class RelationFindOperation extends FindOperation {
44
50
  arr.push(rel);
45
51
  }
46
52
 
53
+ this.warnOnMissingRelatedProps(related);
54
+
47
55
  for (let i = 0, l = owners.length; i < l; ++i) {
48
56
  const own = owners[i];
49
57
  const key = this.relation.ownerProp.propKey(own);
@@ -75,6 +83,62 @@ class RelationFindOperation extends FindOperation {
75
83
  return super.onAfter3(builder, related);
76
84
  }
77
85
 
86
+ // Owner models without the join columns, e.g. loaded with a partial
87
+ // `select()`, silently get no related models (#1832). Only `undefined`
88
+ // is reported, as `null` is a legit value for a missing relation.
89
+ // TODO: Consider throwing an error instead in objection 4.0.
90
+ warnOnMissingOwnerProps() {
91
+ const { ownerProp } = this.relation;
92
+
93
+ for (let c = 0, lc = ownerProp.size; c < lc; ++c) {
94
+ if (this.owner.modelArray.some((owner) => ownerProp.getProp(owner, c) === undefined)) {
95
+ deprecate(
96
+ `Fetching relation "${this.relation.name}" of ${this.relation.ownerModelClass.name}: ` +
97
+ `some owner models are missing the join property "${ownerProp.propDescription(c)}", ` +
98
+ 'so no related models are found for them. Select the column in the owner query.',
99
+ );
100
+ }
101
+ }
102
+ }
103
+
104
+ // Owner join properties holding an array of keys, e.g. a JSON array column,
105
+ // aren't supported: The query finds the related models, but they can't be
106
+ // assigned to their owners, so all owners silently get no related models
107
+ // (#2667). `$relatedQuery()` without assigning the results isn't affected.
108
+ warnOnArrayOwnerProps() {
109
+ const { ownerProp } = this.relation;
110
+
111
+ for (let c = 0, lc = ownerProp.size; c < lc; ++c) {
112
+ if (this.owner.modelArray.some((owner) => Array.isArray(ownerProp.getProp(owner, c)))) {
113
+ deprecate(
114
+ `Fetching relation "${this.relation.name}" of ${this.relation.ownerModelClass.name}: ` +
115
+ `the join property "${ownerProp.propDescription(c)}" of some owner models holds ` +
116
+ 'an array, which is not supported, so no related models are found for them. ' +
117
+ 'Use a ManyToManyRelation through a join table instead.',
118
+ );
119
+ }
120
+ }
121
+ }
122
+
123
+ // Related models without the join properties can't be assigned to their
124
+ // owners and are silently dropped (#2258). This happens for example when the
125
+ // relation mapping uses column names but the rows come back with different
126
+ // property names, e.g. snake_case mappings with `knexSnakeCaseMappers`.
127
+ warnOnMissingRelatedProps(related) {
128
+ const { relatedProp } = this.relation;
129
+
130
+ for (let c = 0, lc = relatedProp.size; c < lc; ++c) {
131
+ if (related.some((rel) => relatedProp.getProp(rel, c) === undefined)) {
132
+ deprecate(
133
+ `Fetching relation "${this.relation.name}" of ${this.relation.ownerModelClass.name}: ` +
134
+ `some related models are missing the join property "${relatedProp.propDescription(c)}", ` +
135
+ 'so they are not assigned to any owner. Check that the relation mapping uses the ' +
136
+ 'property names of the models, e.g. camelCase names when using knexSnakeCaseMappers.',
137
+ );
138
+ }
139
+ }
140
+ }
141
+
78
142
  selectMissingJoinColumns(builder) {
79
143
  const relatedProp = this.relation.relatedProp;
80
144
  const addedSelects = [];
@@ -31,10 +31,11 @@ class RelationProperty {
31
31
  //
32
32
  // modelClassResolver must be a function that takes a table name
33
33
  // and returns a model class.
34
- constructor(references, modelClassResolver) {
34
+ constructor(references, modelClassResolver, { allowEmpty = false } = {}) {
35
35
  const refs = createRefs(asArray(references));
36
36
  const paths = createPaths(refs, modelClassResolver);
37
- const modelClass = resolveModelClass(paths);
37
+ const modelClass =
38
+ allowEmpty && refs.length === 0 ? modelClassResolver(null) : resolveModelClass(paths);
38
39
 
39
40
  this._refs = refs.map((ref) => ref.model(modelClass));
40
41
  this._modelClass = modelClass;
@@ -75,10 +75,39 @@ class ManyToManyRelation extends Relation {
75
75
  return [];
76
76
  }
77
77
 
78
+ isJoinTableRelatedTable(builder) {
79
+ return builder.tableNameFor(this.joinTable) === builder.tableNameFor(this.relatedModelClass);
80
+ }
81
+
82
+ // Returns the alias of the join table in `findQuery`. If the join table is
83
+ // also the related table, it needs an alias of its own (#1803).
84
+ joinTableAliasFor(builder) {
85
+ const joinTableRef = builder.tableRefFor(this.joinTable);
86
+
87
+ if (this.isJoinTableRelatedTable(builder)) {
88
+ return this.ownerModelClass.joinTableAlias(joinTableRef);
89
+ } else {
90
+ return joinTableRef;
91
+ }
92
+ }
93
+
94
+ // The modify operations can't tell the join rows apart from the related rows
95
+ // if the join table is also the related table, so they would modify the
96
+ // wrong rows (#1803).
97
+ assertCanModifyJoinTable(builder, operationName) {
98
+ if (this.isJoinTableRelatedTable(builder)) {
99
+ throw this.createError(
100
+ `${operationName} is not supported when the join table is also the related table`,
101
+ );
102
+ }
103
+ }
104
+
78
105
  findQuery(builder, owner) {
79
- const joinTableOwnerRefs = this.joinTableOwnerProp.refs(builder);
80
106
  const joinTable = builder.tableNameFor(this.joinTable);
81
- const joinTableAlias = builder.tableRefFor(this.joinTable);
107
+ const joinTableAlias = this.joinTableAliasFor(builder);
108
+ const joinTableOwnerRefs = this.joinTableOwnerProp
109
+ .refs(builder)
110
+ .map((ref) => ref.table(joinTableAlias));
82
111
 
83
112
  let joinTableSelect = this.joinTableModelClass
84
113
  .query()
@@ -92,7 +121,7 @@ class ManyToManyRelation extends Relation {
92
121
  builder.join(joinTableSelect, (join) => {
93
122
  this.relatedProp.forEach((i) => {
94
123
  const relatedRef = this.relatedProp.ref(builder, i);
95
- const joinTableRelatedRef = this.joinTableRelatedProp.ref(builder, i);
124
+ const joinTableRelatedRef = this.joinTableRelatedProp.ref(builder, i).table(joinTableAlias);
96
125
 
97
126
  join.on(relatedRef, joinTableRelatedRef);
98
127
  });
@@ -196,6 +225,8 @@ class ManyToManyRelation extends Relation {
196
225
  }
197
226
 
198
227
  unrelate(builder, owner) {
228
+ this.assertCanModifyJoinTable(builder, 'unrelate');
229
+
199
230
  return new (getUnrelateOperationClass(builder))('unrelate', {
200
231
  relation: this,
201
232
  owner,
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { RelationFindOperation } = require('../../RelationFindOperation');
4
4
  const { getTempColumn } = require('../../../utils/tmpColumnUtils');
5
+ const { deprecate } = require('../../../utils/deprecate');
5
6
 
6
7
  class ManyToManyFindOperation extends RelationFindOperation {
7
8
  constructor(name, opt) {
@@ -29,13 +30,17 @@ class ManyToManyFindOperation extends RelationFindOperation {
29
30
 
30
31
  // Also select all extra columns.
31
32
  for (const extra of this.relation.joinTableExtras) {
32
- const joinTable = builder.tableRefFor(this.relation.joinTable);
33
+ const joinTable = this.relation.joinTableAliasFor(builder);
33
34
  builder.select(`${joinTable}.${extra.joinTableCol} as ${extra.aliasCol}`);
34
35
  }
35
36
  }
36
37
 
37
- if (this.assignResultToOwner && this.owner.isModels) {
38
- this.selectMissingJoinColumns(builder);
38
+ if (this.owner.isModels) {
39
+ this.warnOnMissingOwnerProps();
40
+
41
+ if (this.assignResultToOwner) {
42
+ this.selectMissingJoinColumns(builder);
43
+ }
39
44
  }
40
45
  }
41
46
 
@@ -60,6 +65,8 @@ class ManyToManyFindOperation extends RelationFindOperation {
60
65
  arr.push(rel);
61
66
  }
62
67
 
68
+ this.warnOnMissingRelatedProps(related);
69
+
63
70
  for (let i = 0, l = owners.length; i < l; ++i) {
64
71
  const own = owners[i];
65
72
  const key = ownerProp.propKey(own);
@@ -76,6 +83,27 @@ class ManyToManyFindOperation extends RelationFindOperation {
76
83
  return related;
77
84
  }
78
85
 
86
+ // The related models are assigned to their owners by the owner join columns
87
+ // selected from the join table. If they get lost, e.g. in a custom
88
+ // `$parseDatabaseJson()`, the related models are silently dropped (#2258).
89
+ warnOnMissingRelatedProps(related) {
90
+ const { joinTableOwnerProp } = this.relation;
91
+
92
+ for (let c = 0, lc = this.ownerJoinColumnAlias.length; c < lc; ++c) {
93
+ const alias = this.ownerJoinColumnAlias[c];
94
+
95
+ if (related.some((rel) => rel[alias] === undefined)) {
96
+ deprecate(
97
+ `Fetching relation "${this.relation.name}" of ${this.relation.ownerModelClass.name}: ` +
98
+ `some related models are missing the join table column ` +
99
+ `"${joinTableOwnerProp.propDescription(c)}" selected as "${alias}", so they are not ` +
100
+ 'assigned to any owner. Check that $parseDatabaseJson() and the column name mappers ' +
101
+ 'of the related model keep it.',
102
+ );
103
+ }
104
+ }
105
+ }
106
+
79
107
  clone() {
80
108
  const clone = super.clone();
81
109
  clone.ownerJoinColumnAlias = this.ownerJoinColumnAlias.slice();
@@ -88,7 +116,9 @@ class ManyToManyFindOperation extends RelationFindOperation {
88
116
  // We must select the owner join columns so that we know for which owner model the related
89
117
  // models belong to after the requests.
90
118
  joinTableOwnerProp.forEach((i) => {
91
- const joinTableOwnerRef = joinTableOwnerProp.ref(builder, i);
119
+ const joinTableOwnerRef = joinTableOwnerProp
120
+ .ref(builder, i)
121
+ .table(this.relation.joinTableAliasFor(builder));
92
122
  const propName = relatedModelClass.columnNameToPropertyName(this.ownerJoinColumnAlias[i]);
93
123
 
94
124
  builder.select(joinTableOwnerRef.as(this.ownerJoinColumnAlias[i]));
@@ -26,6 +26,10 @@ class ManyToManyUpdateOperationBase extends UpdateOperation {
26
26
  }
27
27
  }
28
28
 
29
+ if (this.hasExtraProps) {
30
+ this.relation.assertCanModifyJoinTable(builder, `${this.name} with extra properties`);
31
+ }
32
+
29
33
  const res = super.onAdd(builder, args);
30
34
 
31
35
  if (this.hasExtraProps) {