objection 3.7.0 → 3.8.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 (43) hide show
  1. package/lib/model/AjvValidator.js +101 -44
  2. package/lib/queryBuilder/QueryBuilder.js +91 -33
  3. package/lib/queryBuilder/QueryBuilderOperationSupport.js +15 -3
  4. package/lib/queryBuilder/StaticHookArguments.js +4 -1
  5. package/lib/queryBuilder/graph/GraphOptions.js +11 -26
  6. package/lib/queryBuilder/graph/GraphUpsert.js +16 -10
  7. package/lib/queryBuilder/join/RelationJoiner.js +104 -12
  8. package/lib/queryBuilder/join/utils.js +2 -2
  9. package/lib/queryBuilder/operations/DelegateOperation.js +22 -0
  10. package/lib/queryBuilder/operations/FirstOperation.js +5 -3
  11. package/lib/queryBuilder/operations/InsertGraphOperation.js +1 -0
  12. package/lib/queryBuilder/operations/InstanceDeleteOperation.js +2 -1
  13. package/lib/queryBuilder/operations/InstanceFindOperation.js +2 -1
  14. package/lib/queryBuilder/operations/InstanceUpdateOperation.js +2 -1
  15. package/lib/queryBuilder/operations/LimitPerParentOperation.js +145 -0
  16. package/lib/queryBuilder/operations/QueryBuilderOperation.js +31 -0
  17. package/lib/queryBuilder/operations/UpdateAndFetchOperation.js +22 -5
  18. package/lib/queryBuilder/operations/UpdateOperation.js +29 -3
  19. package/lib/queryBuilder/operations/WhereGroupOperation.js +2 -8
  20. package/lib/queryBuilder/operations/eager/JoinEagerOperation.js +5 -0
  21. package/lib/queryBuilder/operations/eager/WhereInEagerOperation.js +28 -2
  22. package/lib/queryBuilder/operations/jsonApi/postgresJsonApi.js +2 -2
  23. package/lib/queryBuilder/operations/select/SelectOperation.js +8 -4
  24. package/lib/queryBuilder/operations/select/Selection.js +21 -20
  25. package/lib/queryBuilder/parsers/relationExpressionParser.js +5 -3
  26. package/lib/queryBuilder/parsers/relationExpressionParser.pegjs +6 -2
  27. package/lib/queryBuilder/whereGroups.js +35 -40
  28. package/lib/queryBuilder/withoutOrder.js +33 -0
  29. package/lib/relations/Relation.js +3 -0
  30. package/lib/relations/RelationFindOperation.js +9 -4
  31. package/lib/relations/manyToMany/ManyToManyModifyMixin.js +31 -9
  32. package/lib/relations/manyToMany/ManyToManyRelation.js +7 -0
  33. package/lib/relations/manyToMany/find/ManyToManyFindOperation.js +7 -2
  34. package/lib/relations/manyToMany/update/ManyToManyUpdateMySqlOperation.js +16 -1
  35. package/lib/relations/manyToMany/update/ManyToManyUpdateOperationBase.js +87 -17
  36. package/lib/utils/identifierMapping.js +2 -2
  37. package/lib/utils/knexUtils.js +14 -0
  38. package/lib/utils/parseFieldExpression.js +12 -0
  39. package/lib/utils/warnings.js +26 -0
  40. package/lib/utils/wrapDbError.js +34 -20
  41. package/package.json +5 -4
  42. package/typings/objection/index.d.ts +10 -2
  43. package/lib/utils/deprecate.js +0 -21
@@ -2,7 +2,7 @@
2
2
 
3
3
  const { RelationFindOperation } = require('../../RelationFindOperation');
4
4
  const { getTempColumn } = require('../../../utils/tmpColumnUtils');
5
- const { deprecate } = require('../../../utils/deprecate');
5
+ const { warnOnce } = require('../../../utils/warnings');
6
6
 
7
7
  class ManyToManyFindOperation extends RelationFindOperation {
8
8
  constructor(name, opt) {
@@ -83,6 +83,11 @@ class ManyToManyFindOperation extends RelationFindOperation {
83
83
  return related;
84
84
  }
85
85
 
86
+ // @override
87
+ ownerKeyColumns() {
88
+ return this.ownerJoinColumnAlias;
89
+ }
90
+
86
91
  // The related models are assigned to their owners by the owner join columns
87
92
  // selected from the join table. If they get lost, e.g. in a custom
88
93
  // `$parseDatabaseJson()`, the related models are silently dropped (#2258).
@@ -93,7 +98,7 @@ class ManyToManyFindOperation extends RelationFindOperation {
93
98
  const alias = this.ownerJoinColumnAlias[c];
94
99
 
95
100
  if (related.some((rel) => rel[alias] === undefined)) {
96
- deprecate(
101
+ warnOnce(
97
102
  `Fetching relation "${this.relation.name}" of ${this.relation.ownerModelClass.name}: ` +
98
103
  `some related models are missing the join table column ` +
99
104
  `"${joinTableOwnerProp.propDescription(c)}" selected as "${alias}", so they are not ` +
@@ -3,9 +3,24 @@
3
3
  const { ManyToManyUpdateOperationBase } = require('./ManyToManyUpdateOperationBase');
4
4
  const { ManyToManyMySqlModifyMixin } = require('../ManyToManyMySqlModifyMixin');
5
5
 
6
+ const LimitSelector = /^(orderBy\w*|limit|offset)$/;
7
+
6
8
  class ManyToManyUpdateMySqlOperation extends ManyToManyMySqlModifyMixin(
7
9
  ManyToManyUpdateOperationBase,
8
- ) {}
10
+ ) {
11
+ // MySQL is the only database that supports a limit in updates. Updates that
12
+ // modify the join rows too update the related rows by their ids, so the limit
13
+ // and the order pick these ids instead of limiting each batch of them. Without
14
+ // a limit or an offset, the order picks nothing and the query stays as it is.
15
+ onBuild(builder) {
16
+ super.onBuild(builder);
17
+
18
+ if (this.relatedIdsQuery && builder.has(/^(limit|offset)$/)) {
19
+ this.relatedIdsQuery.copyFrom(builder, LimitSelector);
20
+ builder.clear(LimitSelector);
21
+ }
22
+ }
23
+ }
9
24
 
10
25
  module.exports = {
11
26
  ManyToManyUpdateMySqlOperation,
@@ -2,6 +2,11 @@
2
2
 
3
3
  const { UpdateOperation } = require('../../../queryBuilder/operations/UpdateOperation');
4
4
  const { addWhereGroup } = require('../../../queryBuilder/whereGroups');
5
+ const { chunk } = require('../../../utils/objectUtils');
6
+
7
+ // Stay below the limits of bound values per statement of SQLite before 3.32
8
+ // (999) and SQL Server (2100), leaving room for the patched values:
9
+ const maxBatchValues = 500;
5
10
 
6
11
  class ManyToManyUpdateOperationBase extends UpdateOperation {
7
12
  constructor(name, opt) {
@@ -11,6 +16,8 @@ class ManyToManyUpdateOperationBase extends UpdateOperation {
11
16
  this.owner = opt.owner;
12
17
 
13
18
  this.hasExtraProps = false;
19
+ this.relatedIdsQuery = null;
20
+ this.relatedIds = null;
14
21
  this.joinTablePatch = {};
15
22
  this.joinTablePatchFilterQuery = null;
16
23
  }
@@ -50,32 +57,93 @@ class ManyToManyUpdateOperationBase extends UpdateOperation {
50
57
  return res;
51
58
  }
52
59
 
53
- async onAfter1(builder, result) {
54
- // A query resolved without updating anything, e.g. with `none()`, leaves
55
- // the join rows untouched too.
60
+ onBuild(builder) {
61
+ if (this.modifiesJoinTable && this.modifyFilterSubquery) {
62
+ // The related rows to update are identified by their ids first, see
63
+ // `onAfterBuild()`.
64
+ this.relatedIdsQuery = this.createRelatedIdsQuery(builder);
65
+ }
66
+
67
+ return super.onBuild(builder);
68
+ }
69
+
70
+ // Selects the ids of the related rows to update. Fetch raw rows, so that no
71
+ // hooks run for them.
72
+ createRelatedIdsQuery(builder) {
73
+ const relatedIdsQuery = this.relation.relatedModelClass.query().childQueryOf(builder);
74
+ const idRefs = this.relation.relatedModelClass.getIdRelationProperty().refs(relatedIdsQuery);
75
+
76
+ return this.applyModifyFilterForRelatedTable(relatedIdsQuery).select(idRefs);
77
+ }
78
+
79
+ // Update the join rows before the related rows, while the filters still
80
+ // match them (#161). A query resolved without updating anything, e.g. with
81
+ // `none()`, leaves the join rows untouched too.
82
+ async onAfterBuild(builder) {
56
83
  if (this.hasExtraProps && !this.isResolvedWithoutUpdate(builder)) {
57
- const joinTableUpdateQuery = this.relation
58
- .getJoinModelClass(builder.knex())
59
- .query()
60
- .childQueryOf(builder)
61
- .patch(this.joinTablePatch);
84
+ if (this.relatedIdsQuery) {
85
+ // The filters can also reference the extras that the join rows change,
86
+ // so identify the related rows to modify first.
87
+ const rows = await this.relatedIdsQuery.toKnexQuery();
88
+ this.relatedIds = rows.map((row) => Object.values(row));
89
+ }
62
90
 
91
+ if (this.relatedIdsQuery?.has(/^(limit|offset)$/)) {
92
+ // The filters match the join rows of more related rows than the limit
93
+ // picks, so only update the join rows of the picked ones.
94
+ for (const ids of this.relatedIdBatches()) {
95
+ await this.updateJoinRows(builder, ids);
96
+ }
97
+ } else {
98
+ await this.updateJoinRows(builder);
99
+ }
100
+ }
101
+ }
102
+
103
+ updateJoinRows(builder, relatedIds = null) {
104
+ const joinTableUpdateQuery = this.relation
105
+ .getJoinModelClass(builder.knex())
106
+ .query()
107
+ .childQueryOf(builder)
108
+ .patch(this.joinTablePatch);
109
+
110
+ addWhereGroup(joinTableUpdateQuery, () =>
111
+ this.skipRelationFilter
112
+ ? this.applyIdFilterForJoinTable(joinTableUpdateQuery)
113
+ : this.applyModifyFilterForJoinTable(joinTableUpdateQuery),
114
+ );
115
+ if (relatedIds) {
63
116
  addWhereGroup(joinTableUpdateQuery, () =>
64
- this.skipRelationFilter
65
- ? this.applyIdFilterForJoinTable(joinTableUpdateQuery)
66
- : this.applyModifyFilterForJoinTable(joinTableUpdateQuery),
67
- );
68
- addWhereGroup(joinTableUpdateQuery, () =>
69
- joinTableUpdateQuery.modify(this.relation.joinTableModify),
117
+ joinTableUpdateQuery.whereInComposite(
118
+ this.relation.joinTableRelatedProp.refs(joinTableUpdateQuery),
119
+ relatedIds,
120
+ ),
70
121
  );
122
+ }
123
+ addWhereGroup(joinTableUpdateQuery, () =>
124
+ joinTableUpdateQuery.modify(this.relation.joinTableModify),
125
+ );
126
+
127
+ return joinTableUpdateQuery;
128
+ }
71
129
 
72
- await joinTableUpdateQuery;
73
- return result;
130
+ // Update the related rows in batches of their ids, as databases limit the
131
+ // number of values in one statement.
132
+ queryBatches(builder) {
133
+ if (this.relatedIds) {
134
+ const batches = this.relatedIdBatches();
135
+ // Without ids, still execute the query once for the dialect's result:
136
+ return batches.length > 0 ? batches : [[]];
74
137
  } else {
75
- return result;
138
+ return null;
76
139
  }
77
140
  }
78
141
 
142
+ relatedIdBatches() {
143
+ const idSize = this.relation.relatedModelClass.getIdRelationProperty().size;
144
+ return chunk(this.relatedIds, Math.floor(maxBatchValues / idSize));
145
+ }
146
+
79
147
  // Filters the join rows by the ids of the owner and the updated model.
80
148
  applyIdFilterForJoinTable(builder) {
81
149
  return builder
@@ -103,6 +171,8 @@ class ManyToManyUpdateOperationBase extends UpdateOperation {
103
171
  const clone = super.clone();
104
172
 
105
173
  clone.hasExtraProps = this.hasExtraProps;
174
+ clone.relatedIdsQuery = this.relatedIdsQuery;
175
+ clone.relatedIds = this.relatedIds;
106
176
  clone.joinTablePatch = this.joinTablePatch;
107
177
  clone.joinTablePatchFilterQuery = this.joinTablePatchFilterQuery;
108
178
 
@@ -171,8 +171,8 @@ function keyMapper(mapper) {
171
171
  // Returns a function that only calls `mapper` for the part of the input string
172
172
  // before the first `separator`, and leaves the rest untouched. For field
173
173
  // expressions like `jsonColumn:some.jsonPath` this maps the column but keeps
174
- // the json path as is. Column names can't contain `:`, so the first one marks
175
- // the boundary.
174
+ // the json path as is. The mapper doesn't know the schema, so the first `:`
175
+ // marks the boundary also in a declared column name like `ns:name`.
176
176
  function mapFirstPart(mapper, separator) {
177
177
  return (str) => {
178
178
  const idx = str.indexOf(separator);
@@ -36,6 +36,19 @@ function isMsSql(knex) {
36
36
  return getDialect(knex) === 'mssql';
37
37
  }
38
38
 
39
+ // MySQL supports window functions since 8.0, MariaDB since 10.2. Without a
40
+ // version, knex assumes the latest one, and so do we.
41
+ function hasWindowFunctions(knex) {
42
+ const { version, isMariaDB } = knex.client;
43
+
44
+ if (isMySql(knex) && version) {
45
+ const [major, minor] = version.split('.').map(Number);
46
+ return isMariaDB ? major > 10 || (major === 10 && minor >= 2) : major >= 8;
47
+ } else {
48
+ return true;
49
+ }
50
+ }
51
+
39
52
  function isKnexQueryBuilder(value) {
40
53
  return (
41
54
  hasConstructor(value) &&
@@ -69,6 +82,7 @@ module.exports = {
69
82
  isSqlite,
70
83
  isMsSql,
71
84
  isOracle,
85
+ hasWindowFunctions,
72
86
  isKnexQueryBuilder,
73
87
  isKnexJoinBuilder,
74
88
  isKnexRaw,
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const jsonFieldExpressionParser = require('../queryBuilder/parsers/jsonFieldExpressionParser');
4
+ const { isObject } = require('./objectUtils');
4
5
  const cache = new Map();
5
6
 
6
7
  function parseFieldExpression(expr) {
@@ -46,6 +47,16 @@ function freezeParsedExpr(parsedExpr) {
46
47
  return parsedExpr;
47
48
  }
48
49
 
50
+ // Keys with a ':' in the data of patches and updates are field expressions like
51
+ // `'meta:a.b'` that write into json columns, unless they are `declared`: the
52
+ // `properties` of the schema, or an object with their column names as keys.
53
+ function isFieldExpressionKey(key, declared) {
54
+ return (
55
+ key.includes(':') &&
56
+ !(isObject(declared) && Object.prototype.hasOwnProperty.call(declared, key))
57
+ );
58
+ }
59
+
49
60
  // Formats the json path of a parsed field expression as a Postgres text array
50
61
  // literal, e.g. `{a,"",b}`. Keys that would otherwise be misread (empty keys,
51
62
  // keys with whitespace, delimiters, quotes or backslashes and `NULL`) are
@@ -81,6 +92,7 @@ function toPostgresJsonPathLiteral(access) {
81
92
 
82
93
  module.exports = {
83
94
  parseFieldExpression,
95
+ isFieldExpressionKey,
84
96
  toPostgresJsonPath,
85
97
  toPostgresJsonPathLiteral,
86
98
  };
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ const LOGGED_WARNINGS = new Set();
4
+
5
+ // Log each warning only once.
6
+ function warnOnce(message) {
7
+ if (!LOGGED_WARNINGS.has(message)) {
8
+ LOGGED_WARNINGS.add(message);
9
+ console.warn(`[objection] ${message}`);
10
+ }
11
+ }
12
+
13
+ function deprecate(message) {
14
+ warnOnce(message);
15
+ }
16
+
17
+ // Only meant for tests that need to see a warning again.
18
+ function resetWarnings() {
19
+ LOGGED_WARNINGS.clear();
20
+ }
21
+
22
+ module.exports = {
23
+ warnOnce,
24
+ deprecate,
25
+ resetWarnings,
26
+ };
@@ -2,35 +2,49 @@
2
2
 
3
3
  const { wrapError, DBError, UniqueViolationError } = require('db-errors');
4
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).
5
+ // db-errors only recognizes mssql unique key violations, but primary key
6
+ // violations have the same error number, with a different message (#2688).
7
7
  const MSSQL_PRIMARY_KEY_REGEX =
8
8
  /Violation of PRIMARY KEY constraint '(.+)'\. Cannot insert duplicate key in object '(.+)\.(.+)'\. The duplicate key value is \((.+)\)/;
9
9
 
10
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
- }
11
+ if (!isTediousError(err)) {
12
+ return wrapError(err);
13
+ }
14
+
15
+ // db-errors parses the errors of the `mssql` package, which wraps the errors
16
+ // of tedious and keeps their number and class in `originalError.info`. knex
17
+ // uses tedious directly, so its errors are passed on in that shape.
18
+ const wrapped = wrapError({
19
+ code: err.code,
20
+ originalError: { message: err.message, info: { number: err.number, class: err.class } },
21
+ });
22
+
23
+ if (!(wrapped instanceof DBError)) {
24
+ return err;
25
+ }
26
+
27
+ wrapped.nativeError = err;
28
+
29
+ const match = wrapped.constructor === DBError && MSSQL_PRIMARY_KEY_REGEX.exec(err.message);
30
+
31
+ if (match) {
32
+ return new UniqueViolationError({
33
+ nativeError: err,
34
+ client: 'mssql',
35
+ table: match[3],
36
+ schema: match[2],
37
+ constraint: match[1],
38
+ });
29
39
  }
30
40
 
31
41
  return wrapped;
32
42
  }
33
43
 
44
+ function isTediousError(err) {
45
+ return err?.code === 'EREQUEST' && Number.isInteger(err.number);
46
+ }
47
+
34
48
  module.exports = {
35
49
  wrapDbError,
36
50
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "objection",
3
- "version": "3.7.0",
3
+ "version": "3.8.0",
4
4
  "description": "An SQL-friendly ORM for Node.js",
5
5
  "main": "lib/objection.js",
6
6
  "license": "MIT",
@@ -9,7 +9,7 @@
9
9
  "test:fast": "mocha --slow 10 --timeout 15000 --reporter spec --recursive tests --bail --exclude \"tests/unit/relations/files/**\"",
10
10
  "test:typings": "tsc && prettier --check \"typings/**/*.ts\" \"tests/ts/**/*.ts\"",
11
11
  "db:up": "docker compose up -d --wait",
12
- "db:down": "docker compose down -v",
12
+ "db:down": "docker compose --profile mssql down -v",
13
13
  "build:parsers": "npx --yes peggy@5.1.0 --format commonjs -o lib/queryBuilder/parsers/jsonFieldExpressionParser.js lib/queryBuilder/parsers/jsonFieldExpressionParser.pegjs && npx --yes peggy@5.1.0 --format commonjs -o lib/queryBuilder/parsers/relationExpressionParser.js lib/queryBuilder/parsers/relationExpressionParser.pegjs && prettier --write \"lib/queryBuilder/parsers/*Parser.js\"",
14
14
  "prettier": "prettier --write \"{examples,lib,tests,testUtils,typings,doc}/**/*.{js,ts}\"",
15
15
  "eslint": "eslint --format codeframe \"examples/**/*.js\" \"lib/**/*.js\" \"tests/**/*.js\" \"testUtils/**/*.js\" \"*.js\"",
@@ -66,7 +66,7 @@
66
66
  "db-errors": "^0.2.3"
67
67
  },
68
68
  "peerDependencies": {
69
- "knex": ">=2.3.0"
69
+ "knex": ">=2.3.0 <4"
70
70
  },
71
71
  "devDependencies": {
72
72
  "@types/node": "^22.15.3",
@@ -82,7 +82,8 @@
82
82
  "pg": "^8.15.6",
83
83
  "prettier": "3.9.9",
84
84
  "sqlite3": "^5.1.7",
85
- "typescript": ">=5.8.3",
85
+ "tedious": "^20.3.7",
86
+ "typescript": "^5.8.3",
86
87
  "vitepress": "1.6.4"
87
88
  }
88
89
  }
@@ -1022,8 +1022,11 @@ declare namespace Objection {
1022
1022
  /**
1023
1023
  * With `returning()`, `relate()` returns the inserted join rows for
1024
1024
  * many-to-many relations, the updated related rows for has-many relations
1025
- * and the updated owner rows for belongs-to-one relations. The typings can't
1026
- * tell these apart, so the rows are typed as `any`.
1025
+ * and the updated owner rows for belongs-to-one relations. MySQL has no
1026
+ * `returning`: there, many-to-many relations return the join rows built from
1027
+ * the input, without columns generated by the database, and the other
1028
+ * relations still return the number of affected rows. The typings can't tell
1029
+ * these apart, so the rows are typed as `any`.
1027
1030
  */
1028
1031
  interface RelateReturningMethod<M extends Model> {
1029
1032
  (...args: ReturningArgs): QueryBuilder<M, any[]>;
@@ -1085,6 +1088,10 @@ declare namespace Objection {
1085
1088
  export interface InsertGraphOptions {
1086
1089
  relate?: boolean | string | string[];
1087
1090
  allowRefs?: boolean;
1091
+ /**
1092
+ * @deprecated Has no effect, since `insertGraph()` rejects `#unrelate` and
1093
+ * `#delete` markers. Removed in objection 4.0.
1094
+ */
1088
1095
  allowMarkers?: boolean;
1089
1096
  }
1090
1097
 
@@ -1140,6 +1147,7 @@ declare namespace Objection {
1140
1147
  aliases?: { [key: string]: string };
1141
1148
  joinOperation?: string;
1142
1149
  maxBatchSize?: number;
1150
+ limitPerParent?: boolean;
1143
1151
  }
1144
1152
 
1145
1153
  export interface WithGraphOptions extends GraphOptions {
@@ -1,21 +0,0 @@
1
- 'use strict';
2
-
3
- const LOGGED_DEPRECATIONS = new Set();
4
-
5
- function deprecate(message) {
6
- // Only log deprecation messages once.
7
- if (!LOGGED_DEPRECATIONS.has(message)) {
8
- LOGGED_DEPRECATIONS.add(message);
9
- console.warn(message);
10
- }
11
- }
12
-
13
- // Only meant for tests that need to see a deprecation message again.
14
- function resetDeprecations() {
15
- LOGGED_DEPRECATIONS.clear();
16
- }
17
-
18
- module.exports = {
19
- deprecate,
20
- resetDeprecations,
21
- };