objection 3.3.0 → 3.4.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.
@@ -6,6 +6,7 @@ const { Validator } = require('./Validator');
6
6
  const { ValidationErrorType } = require('../model/ValidationError');
7
7
  const { isObject, once, cloneDeep: lodashCloneDeep, omit } = require('../utils/objectUtils');
8
8
  const { parseFieldExpression } = require('../utils/parseFieldExpression');
9
+ const { getQueryPropKeys } = require('./modelQueryProps');
9
10
 
10
11
  class AjvValidator extends Validator {
11
12
  static init(self, conf) {
@@ -51,6 +52,10 @@ class AjvValidator extends Validator {
51
52
 
52
53
  setupAjv(self.ajv);
53
54
  setupAjv(self.ajvNoDefaults);
55
+
56
+ // Created when needed by `compileQueryPropValidator()`.
57
+ self.ajvQueryProps = null;
58
+ self.setupAjv = setupAjv;
54
59
  }
55
60
 
56
61
  beforeValidate({ json, model, options, ctx }) {
@@ -75,7 +80,12 @@ class AjvValidator extends Validator {
75
80
  }
76
81
 
77
82
  const modelClass = model.constructor;
78
- const validator = this.getValidator(modelClass, ctx.jsonSchema, !!options.patch);
83
+ const validator = this.getValidator(
84
+ modelClass,
85
+ ctx.jsonSchema,
86
+ !!options.patch,
87
+ options.patch ? null : getMissingQueryPropKeys(model, json, ctx.jsonSchema),
88
+ );
79
89
 
80
90
  // We need to clone the input json if we are about to set default values.
81
91
  if (!options.mutable && !options.patch && setsDefaultValues(ctx.jsonSchema)) {
@@ -103,7 +113,7 @@ class AjvValidator extends Validator {
103
113
  return json;
104
114
  }
105
115
 
106
- getValidator(modelClass, jsonSchema, isPatchObject) {
116
+ getValidator(modelClass, jsonSchema, isPatchObject, queryPropKeys = null) {
107
117
  // Use the Ajv custom serializer if provided.
108
118
  const createCacheKey = this.ajvOptions.serialize || JSON.stringify;
109
119
 
@@ -136,6 +146,10 @@ class AjvValidator extends Validator {
136
146
 
137
147
  // Validator created for the unmodified schema.
138
148
  normalValidator: null,
149
+
150
+ // Validators created for the schema object without the `required`
151
+ // properties that are given as query properties, keyed by their names.
152
+ queryPropValidators: null,
139
153
  };
140
154
 
141
155
  this.cache.set(cacheKey, validators);
@@ -148,6 +162,17 @@ class AjvValidator extends Validator {
148
162
  validator = this.compilePatchValidator(jsonSchema);
149
163
  validators.patchValidator = validator;
150
164
  }
165
+ } else if (queryPropKeys) {
166
+ const key = JSON.stringify(queryPropKeys);
167
+ validators.queryPropValidators = validators.queryPropValidators || new Map();
168
+ validator = validators.queryPropValidators.get(key);
169
+
170
+ if (!validator) {
171
+ validator = this.compileQueryPropValidator(
172
+ jsonSchemaWithoutRequiredKeys(jsonSchema, queryPropKeys),
173
+ );
174
+ validators.queryPropValidators.set(key, validator);
175
+ }
151
176
  } else {
152
177
  validator = validators.normalValidator;
153
178
 
@@ -169,6 +194,29 @@ class AjvValidator extends Validator {
169
194
  compileNormalValidator(jsonSchema) {
170
195
  return this.ajv.compile(jsonSchema);
171
196
  }
197
+
198
+ compileQueryPropValidator(jsonSchema) {
199
+ // The schema is a modified copy that keeps the `$id`s of the original, so
200
+ // it's compiled on an instance that doesn't add compiled schemas by their
201
+ // `$id`. Otherwise ajv would throw because the ids already exist.
202
+ if (!this.ajvQueryProps) {
203
+ this.ajvQueryProps = new Ajv(
204
+ Object.assign(
205
+ {
206
+ useDefaults: true,
207
+ },
208
+ this.ajvOptions,
209
+ {
210
+ addUsedSchema: false,
211
+ },
212
+ ),
213
+ );
214
+
215
+ this.setupAjv(this.ajvQueryProps);
216
+ }
217
+
218
+ return this.ajvQueryProps.compile(jsonSchema);
219
+ }
172
220
  }
173
221
 
174
222
  function parseValidationError(errors, modelClass, options, ajvOptions) {
@@ -397,6 +445,30 @@ function objectHasDefaults(obj) {
397
445
  return false;
398
446
  }
399
447
 
448
+ // Returns the keys of the query properties of `model` (`raw()`, `val()`,
449
+ // `ref()`, subqueries...) that are required by the schema but missing from
450
+ // `json`, sorted, or `null` if there are none. Query properties are split from
451
+ // the json before validation, but they do provide a value for the property.
452
+ function getMissingQueryPropKeys(model, json, jsonSchema) {
453
+ const required = jsonSchema.required;
454
+
455
+ if (!Array.isArray(required) || required.length === 0) {
456
+ return null;
457
+ }
458
+
459
+ const keys = getQueryPropKeys(model).filter(
460
+ (key) => required.includes(key) && !Object.prototype.hasOwnProperty.call(json, key),
461
+ );
462
+
463
+ return keys.length > 0 ? keys.sort() : null;
464
+ }
465
+
466
+ function jsonSchemaWithoutRequiredKeys(jsonSchema, keys) {
467
+ return Object.assign({}, jsonSchema, {
468
+ required: jsonSchema.required.filter((key) => !keys.includes(key)),
469
+ });
470
+ }
471
+
400
472
  function jsonSchemaWithoutRequired(jsonSchema) {
401
473
  // `not` is intentionally left untouched: Stripping `required` inside it
402
474
  // would turn it into a schema that rejects everything (e.g.
@@ -436,11 +508,20 @@ function definitionsWithoutRequired(jsonSchema, prop) {
436
508
  function subSchemaWithoutRequired(jsonSchema, prop) {
437
509
  if (jsonSchema[prop]) {
438
510
  if (Array.isArray(jsonSchema[prop])) {
439
- const schemaArray = jsonSchemaArrayWithoutRequired(jsonSchema[prop]);
440
-
441
- if (schemaArray.length !== 0) {
511
+ const schemaArray = jsonSchema[prop].map(jsonSchemaWithoutRequired);
512
+ const nonEmptySchemaArray = schemaArray.filter(isNotEmptyObject);
513
+
514
+ // An `anyOf` or `oneOf` option that only consisted of `required` is
515
+ // 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.
520
+ if (prop !== 'allOf' && nonEmptySchemaArray.length !== schemaArray.length) {
521
+ return {};
522
+ } else if (nonEmptySchemaArray.length !== 0) {
442
523
  return {
443
- [prop]: schemaArray,
524
+ [prop]: nonEmptySchemaArray,
444
525
  };
445
526
  } else {
446
527
  return {};
@@ -467,10 +548,6 @@ function subSchemaWithoutRequired(jsonSchema, prop) {
467
548
  }
468
549
  }
469
550
 
470
- function jsonSchemaArrayWithoutRequired(jsonSchemaArray) {
471
- return jsonSchemaArray.map(jsonSchemaWithoutRequired).filter(isNotEmptyObject);
472
- }
473
-
474
551
  function isNotEmptyObject(obj) {
475
552
  return Object.keys(obj).length !== 0;
476
553
  }
@@ -839,11 +839,13 @@ function getColumnNameMappers(modelClass) {
839
839
  }
840
840
 
841
841
  function getIdRelationProperty(modelClass) {
842
- const idColumn = asArray(modelClass.getIdColumn());
842
+ // `idColumn = null` means that the model has no primary key.
843
+ const idColumn = modelClass.getIdColumn() === null ? [] : asArray(modelClass.getIdColumn());
843
844
 
844
845
  return new RelationProperty(
845
846
  idColumn.map((idCol) => `${modelClass.getTableName()}.${idCol}`),
846
847
  () => modelClass,
848
+ { allowEmpty: true },
847
849
  );
848
850
  }
849
851
 
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { assertHasIdColumn } = require('../utils/assert');
4
+
3
5
  function getSetId(model, maybeId) {
4
6
  if (maybeId !== undefined) {
5
7
  return setId(model, maybeId);
@@ -9,10 +11,13 @@ function getSetId(model, maybeId) {
9
11
  }
10
12
 
11
13
  function hasId(model) {
12
- return model.$hasProps(model.constructor.getIdPropertyArray());
14
+ const idProps = model.constructor.getIdPropertyArray();
15
+ // Models without an `idColumn` never have an id.
16
+ return idProps.length > 0 && model.$hasProps(idProps);
13
17
  }
14
18
 
15
19
  function setId(model, id) {
20
+ assertHasIdColumn(model.constructor);
16
21
  const idProp = model.constructor.getIdProperty();
17
22
  const isCompositeId = Array.isArray(idProp);
18
23
 
@@ -49,7 +54,10 @@ function getId(model) {
49
54
  const idProp = model.constructor.getIdProperty();
50
55
  const isCompositeId = Array.isArray(idProp);
51
56
 
52
- if (isCompositeId) {
57
+ if (isCompositeId && idProp.length === 0) {
58
+ // Models without an `idColumn` have no id.
59
+ return undefined;
60
+ } else if (isCompositeId) {
53
61
  return model.$values(idProp);
54
62
  } else {
55
63
  return model[idProp];
@@ -108,6 +108,12 @@ function convertAndMergeHiddenQueryProps(model, json, omitProps, builder) {
108
108
  return json;
109
109
  }
110
110
 
111
+ // Returns the keys of the query properties that were split from `model`.
112
+ function getQueryPropKeys(model) {
113
+ const queryProps = model[QUERY_PROPS_PROPERTY];
114
+ return queryProps ? Object.keys(queryProps) : [];
115
+ }
116
+
111
117
  // Converts a query property into a knex `raw` instance.
112
118
  function queryPropToKnexRaw(queryProp, builder) {
113
119
  if (!queryProp) {
@@ -144,4 +150,5 @@ function isKnexRawConvertable(queryProp) {
144
150
  module.exports = {
145
151
  splitQueryProps,
146
152
  mergeQueryProps,
153
+ getQueryPropKeys,
147
154
  };
@@ -1,13 +1,13 @@
1
1
  'use strict';
2
2
 
3
- const { wrapError } = require('db-errors');
3
+ const { wrapDbError } = require('../utils/wrapDbError');
4
4
 
5
5
  const { raw } = require('./RawBuilder');
6
6
  const { createModifier } = require('../utils/createModifier');
7
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
- const { assertIdNotUndefined } = require('../utils/assert');
10
+ const { assertIdNotUndefined, assertHasIdColumn } = require('../utils/assert');
11
11
 
12
12
  const { Selection } = require('./operations/select/Selection');
13
13
 
@@ -125,6 +125,8 @@ class QueryBuilder extends QueryBuilderBase {
125
125
  }
126
126
 
127
127
  fullIdColumnFor(modelClass) {
128
+ assertHasIdColumn(modelClass);
129
+
128
130
  const tableName = this.tableRefFor(modelClass);
129
131
  const idColumn = modelClass.getIdColumn();
130
132
 
@@ -1408,7 +1410,7 @@ function handleExecuteError(builder, err) {
1408
1410
  return Promise.resolve(err.value);
1409
1411
  }
1410
1412
 
1411
- let promise = Promise.reject(wrapError(err));
1413
+ let promise = Promise.reject(wrapDbError(err));
1412
1414
 
1413
1415
  builder.forEachOperation(true, (op) => {
1414
1416
  if (op.hasOnError()) {
@@ -153,9 +153,7 @@ function createManyToManyExtraSelectionIfNeeded(builder, column, node) {
153
153
  const extra = relation.joinTableExtras.find((extra) => extra.aliasCol === column);
154
154
 
155
155
  if (extra) {
156
- return `${builder.tableRefFor(relation.joinModelClass)}.${extra.joinTableCol} as ${
157
- extra.aliasCol
158
- }`;
156
+ return `${relation.joinTableAliasFor(builder)}.${extra.joinTableCol} as ${extra.aliasCol}`;
159
157
  }
160
158
  }
161
159
 
@@ -13,6 +13,20 @@ const INSERT_MISSING = 'insertMissing';
13
13
  const FETCH_STRATEGY = 'fetchStrategy';
14
14
  const ALLOW_REFS = 'allowRefs';
15
15
 
16
+ const OPTION_NAMES = [
17
+ NO_RELATE,
18
+ NO_UNRELATE,
19
+ NO_INSERT,
20
+ NO_UPDATE,
21
+ NO_DELETE,
22
+ UPDATE,
23
+ RELATE,
24
+ UNRELATE,
25
+ INSERT_MISSING,
26
+ FETCH_STRATEGY,
27
+ ALLOW_REFS,
28
+ ];
29
+
16
30
  const FetchStrategy = {
17
31
  OnlyIdentifiers: 'OnlyIdentifiers',
18
32
  Everything: 'Everything',
@@ -64,7 +78,7 @@ class GraphOptions {
64
78
  !getCurrentNode(node, graphData) &&
65
79
  !!node.parentEdge &&
66
80
  !!node.parentEdge.relation &&
67
- node.parentEdge.relation.hasRelateProp(node.obj) &&
81
+ hasRelateProp(node) &&
68
82
  graphData.nodeDbExistence.doesNodeExistInDb(node)
69
83
  );
70
84
  }
@@ -212,6 +226,20 @@ function getCurrentNode(node, graphData) {
212
226
  return graphData.currentGraph.nodeForNode(node);
213
227
  }
214
228
 
229
+ // For `HasMany` and `HasOne` relations, the relate prop is the id of the
230
+ // related model. Use the id state of the input graph, since the parent's
231
+ // insert copies the foreign key into the related model, which can complete
232
+ // a composite id that contains it and would turn an insert into a relate (#2544).
233
+ function hasRelateProp(node) {
234
+ const relation = node.parentEdge.relation;
235
+
236
+ if (relation.isObjectionHasManyRelation) {
237
+ return node.hasId;
238
+ } else {
239
+ return relation.hasRelateProp(node.obj);
240
+ }
241
+ }
242
+
215
243
  function getNode(currentNode, graph) {
216
244
  if (!graph || !currentNode) {
217
245
  return null;
@@ -222,5 +250,6 @@ function getNode(currentNode, graph) {
222
250
 
223
251
  module.exports = {
224
252
  GraphOptions,
253
+ OPTION_NAMES,
225
254
  FetchStrategy,
226
255
  };
@@ -8,7 +8,7 @@ const { GraphInsert } = require('./insert/GraphInsert');
8
8
  const { GraphPatch } = require('./patch/GraphPatch');
9
9
  const { GraphDelete } = require('./delete/GraphDelete');
10
10
  const { GraphRecursiveUpsert } = require('./recursiveUpsert/GraphRecursiveUpsert');
11
- const { GraphOptions } = require('./GraphOptions');
11
+ const { GraphOptions, OPTION_NAMES } = require('./GraphOptions');
12
12
  const { ValidationErrorType } = require('../../model/ValidationError');
13
13
  const { RelationExpression } = require('../RelationExpression');
14
14
  const { GraphNodeDbExistence } = require('./GraphNodeDbExistence');
@@ -19,6 +19,7 @@ const { deprecate } = require('../../utils/deprecate');
19
19
  class GraphUpsert {
20
20
  constructor({ rootModelClass, objects, upsertOptions }) {
21
21
  checkCanBeConvertedToModels(rootModelClass, objects);
22
+ warnOnUnknownOptions(upsertOptions);
22
23
 
23
24
  this.objects = rootModelClass.ensureModelArray(objects, GraphUpsert.modelOptions);
24
25
  this.isArray = Array.isArray(objects);
@@ -61,6 +62,20 @@ class GraphUpsert {
61
62
  }
62
63
  }
63
64
 
65
+ // Misspelled options are silently ignored, so the operation runs with the
66
+ // default behaviour instead (#84). A warning is logged once per option.
67
+ // TODO: Throw an error instead in objection 4.0.
68
+ function warnOnUnknownOptions(upsertOptions) {
69
+ for (const name of Object.keys(upsertOptions)) {
70
+ if (!OPTION_NAMES.includes(name)) {
71
+ deprecate(
72
+ `Unknown graph option "${name}" is ignored. ` +
73
+ `Valid options are: ${OPTION_NAMES.join(', ')}. This will throw in objection 4.0.`,
74
+ );
75
+ }
76
+ }
77
+ }
78
+
64
79
  function checkCanBeConvertedToModels(modelClass, objects) {
65
80
  asArray(objects).forEach((obj) => {
66
81
  if (!isObject(obj)) {
@@ -3,7 +3,7 @@
3
3
  const { getModel } = require('../../../model/getModel');
4
4
  const { GraphAction } = require('../GraphAction');
5
5
  const { isInternalProp } = require('../../../utils/internalPropUtils');
6
- const { difference, isObject, jsonEquals } = require('../../../utils/objectUtils');
6
+ const { difference, isObject, isPlainObject, jsonEquals } = require('../../../utils/objectUtils');
7
7
  const promiseUtils = require('../../../utils/promiseUtils');
8
8
 
9
9
  class GraphPatchAction extends GraphAction {
@@ -251,13 +251,19 @@ function childQueryOptions() {
251
251
  }
252
252
 
253
253
  function nonStrictEquals(val1, val2) {
254
- if (val1 == val2) {
254
+ // Only use loose equality for non-JSON values, so that for example `false`
255
+ // and `[]` are not considered equal. See issue #1874.
256
+ if (!isJsonValue(val1) && !isJsonValue(val2) && val1 == val2) {
255
257
  return true;
256
258
  }
257
259
 
258
260
  return jsonEquals(val1, val2);
259
261
  }
260
262
 
263
+ function isJsonValue(value) {
264
+ return Array.isArray(value) || isPlainObject(value);
265
+ }
266
+
261
267
  function isToBeUnrelatedOrDeleted(node) {
262
268
  return !!node && node.toBeUnrelatedOrDeleted;
263
269
  }
@@ -8,7 +8,7 @@ const { ValidationErrorType } = require('../../model/ValidationError');
8
8
 
9
9
  const { TableTree } = require('./TableTree');
10
10
  const { JoinResultParser } = require('./JoinResultParser');
11
- const { ID_LENGTH_LIMIT } = require('./utils');
11
+ const { getIdentifierLengthLimit } = require('./utils');
12
12
 
13
13
  /**
14
14
  * Given a relation expression, builds a query using joins to fetch it.
@@ -179,8 +179,10 @@ class RelationJoiner {
179
179
  userSelectQueries,
180
180
  });
181
181
 
182
+ const lengthLimit = getIdentifierLengthLimit(builder.knex());
183
+
182
184
  for (const selection of selections) {
183
- checkAliasLength(tableNode.modelClass, selection.name);
185
+ checkAliasLength(tableNode.modelClass, selection.name, lengthLimit);
184
186
  }
185
187
 
186
188
  // Save the selections that were added internally (not by the user)
@@ -393,11 +395,13 @@ function selectsOnlyAliasesKeepingSelectAll(selections) {
393
395
  return selections.length > 0 && selections.every((it) => it.keepsSelectAll);
394
396
  }
395
397
 
396
- function checkAliasLength(modelClass, alias) {
397
- if (alias.length > ID_LENGTH_LIMIT) {
398
+ function checkAliasLength(modelClass, alias, lengthLimit) {
399
+ if (alias.length > lengthLimit) {
398
400
  throw modelClass.createValidationError({
399
401
  type: ValidationErrorType.RelationExpression,
400
- message: `identifier ${alias} is over ${ID_LENGTH_LIMIT} characters long and would be truncated by the database engine.`,
402
+ message:
403
+ `identifier ${alias} is over ${lengthLimit} characters long and would be truncated by the database engine. ` +
404
+ 'Use the `minimize` option of withGraphJoined() to shorten the aliases.',
401
405
  });
402
406
  }
403
407
  }
@@ -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
 
@@ -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,12 @@ 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.selectMissingJoinColumns(builder);
28
+ }
24
29
  }
25
30
  }
26
31
 
@@ -44,6 +49,8 @@ class RelationFindOperation extends FindOperation {
44
49
  arr.push(rel);
45
50
  }
46
51
 
52
+ this.warnOnMissingRelatedProps(related);
53
+
47
54
  for (let i = 0, l = owners.length; i < l; ++i) {
48
55
  const own = owners[i];
49
56
  const key = this.relation.ownerProp.propKey(own);
@@ -75,6 +82,43 @@ class RelationFindOperation extends FindOperation {
75
82
  return super.onAfter3(builder, related);
76
83
  }
77
84
 
85
+ // Owner models without the join columns, e.g. loaded with a partial
86
+ // `select()`, silently get no related models (#1832). Only `undefined`
87
+ // is reported, as `null` is a legit value for a missing relation.
88
+ // TODO: Consider throwing an error instead in objection 4.0.
89
+ warnOnMissingOwnerProps() {
90
+ const { ownerProp } = this.relation;
91
+
92
+ for (let c = 0, lc = ownerProp.size; c < lc; ++c) {
93
+ if (this.owner.modelArray.some((owner) => ownerProp.getProp(owner, c) === undefined)) {
94
+ deprecate(
95
+ `Fetching relation "${this.relation.name}" of ${this.relation.ownerModelClass.name}: ` +
96
+ `some owner models are missing the join property "${ownerProp.propDescription(c)}", ` +
97
+ 'so no related models are found for them. Select the column in the owner query.',
98
+ );
99
+ }
100
+ }
101
+ }
102
+
103
+ // Related models without the join properties can't be assigned to their
104
+ // owners and are silently dropped (#2258). This happens for example when the
105
+ // relation mapping uses column names but the rows come back with different
106
+ // property names, e.g. snake_case mappings with `knexSnakeCaseMappers`.
107
+ warnOnMissingRelatedProps(related) {
108
+ const { relatedProp } = this.relation;
109
+
110
+ for (let c = 0, lc = relatedProp.size; c < lc; ++c) {
111
+ if (related.some((rel) => relatedProp.getProp(rel, c) === undefined)) {
112
+ deprecate(
113
+ `Fetching relation "${this.relation.name}" of ${this.relation.ownerModelClass.name}: ` +
114
+ `some related models are missing the join property "${relatedProp.propDescription(c)}", ` +
115
+ 'so they are not assigned to any owner. Check that the relation mapping uses the ' +
116
+ 'property names of the models, e.g. camelCase names when using knexSnakeCaseMappers.',
117
+ );
118
+ }
119
+ }
120
+ }
121
+
78
122
  selectMissingJoinColumns(builder) {
79
123
  const relatedProp = this.relation.relatedProp;
80
124
  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,
@@ -29,7 +29,7 @@ class ManyToManyFindOperation extends RelationFindOperation {
29
29
 
30
30
  // Also select all extra columns.
31
31
  for (const extra of this.relation.joinTableExtras) {
32
- const joinTable = builder.tableRefFor(this.relation.joinTable);
32
+ const joinTable = this.relation.joinTableAliasFor(builder);
33
33
  builder.select(`${joinTable}.${extra.joinTableCol} as ${extra.aliasCol}`);
34
34
  }
35
35
  }
@@ -88,7 +88,9 @@ class ManyToManyFindOperation extends RelationFindOperation {
88
88
  // We must select the owner join columns so that we know for which owner model the related
89
89
  // models belong to after the requests.
90
90
  joinTableOwnerProp.forEach((i) => {
91
- const joinTableOwnerRef = joinTableOwnerProp.ref(builder, i);
91
+ const joinTableOwnerRef = joinTableOwnerProp
92
+ .ref(builder, i)
93
+ .table(this.relation.joinTableAliasFor(builder));
92
94
  const propName = relatedModelClass.columnNameToPropertyName(this.ownerJoinColumnAlias[i]);
93
95
 
94
96
  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) {
@@ -1,6 +1,14 @@
1
1
  'use strict';
2
2
 
3
+ function assertHasIdColumn(modelClass) {
4
+ if (modelClass.getIdColumnArray().length === 0) {
5
+ throw new Error(`model ${modelClass.name} has no idColumn`);
6
+ }
7
+ }
8
+
3
9
  function assertHasId(model) {
10
+ assertHasIdColumn(model.constructor);
11
+
4
12
  if (!model.$hasId()) {
5
13
  const modelClass = model.constructor;
6
14
  const ids = modelClass.getIdColumnArray().join(', ');
@@ -21,5 +29,6 @@ function assertIdNotUndefined(id, message) {
21
29
 
22
30
  module.exports = {
23
31
  assertHasId,
32
+ assertHasIdColumn,
24
33
  assertIdNotUndefined,
25
34
  };
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ const { wrapError, DBError, UniqueViolationError } = require('db-errors');
4
+
5
+ // db-errors only recognizes mssql unique key violations, but the same error
6
+ // number (2627) is also used for primary key violations (#2688).
7
+ const MSSQL_PRIMARY_KEY_REGEX =
8
+ /Violation of PRIMARY KEY constraint '(.+)'\. Cannot insert duplicate key in object '(.+)\.(.+)'\. The duplicate key value is \((.+)\)/;
9
+
10
+ function wrapDbError(err) {
11
+ const wrapped = wrapError(err);
12
+
13
+ if (wrapped.constructor === DBError && wrapped.client === 'mssql') {
14
+ const { nativeError } = wrapped;
15
+ const match =
16
+ nativeError.info &&
17
+ nativeError.info.number === 2627 &&
18
+ MSSQL_PRIMARY_KEY_REGEX.exec(nativeError.message);
19
+
20
+ if (match) {
21
+ return new UniqueViolationError({
22
+ nativeError,
23
+ client: 'mssql',
24
+ table: match[3],
25
+ schema: match[2],
26
+ constraint: match[1],
27
+ });
28
+ }
29
+ }
30
+
31
+ return wrapped;
32
+ }
33
+
34
+ module.exports = {
35
+ wrapDbError,
36
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "objection",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "description": "An SQL-friendly ORM for Node.js",
5
5
  "main": "lib/objection.js",
6
6
  "license": "MIT",
@@ -63,11 +63,10 @@
63
63
  "db-errors": "^0.2.3"
64
64
  },
65
65
  "peerDependencies": {
66
- "knex": ">=1.0.1"
66
+ "knex": ">=2.3.0"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@types/node": "^22.15.3",
70
- "bluebird": "^3.7.2",
71
70
  "chai": "^4.3.10",
72
71
  "chai-subset": "^1.6.0",
73
72
  "eslint": "^8.54.0",
@@ -75,14 +74,12 @@
75
74
  "eslint-plugin-prettier": "^5.3.1",
76
75
  "expect.js": "^0.3.1",
77
76
  "knex": "^3.3.0",
78
- "lodash": "^4.18.1",
79
77
  "mocha": "^11.2.2",
80
78
  "mysql": "^2.18.1",
81
79
  "pg": "^8.15.6",
82
80
  "prettier": "3.9.9",
83
81
  "sqlite3": "^5.1.7",
84
82
  "typescript": ">=5.8.3",
85
- "uuid": "^9.0.1",
86
83
  "vitepress": "1.6.4"
87
84
  }
88
85
  }
@@ -590,7 +590,7 @@ declare namespace Objection {
590
590
  }
591
591
 
592
592
  interface FromMethod<QB extends AnyQueryBuilder> {
593
- (table: TableRef<QB>): QB;
593
+ (table: TableRef<QB>, options?: { only?: boolean }): QB;
594
594
  }
595
595
 
596
596
  interface FromRawMethod<QB extends AnyQueryBuilder> extends RawInterface<QB> {}