uql-orm 0.79.0 → 0.80.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 (78) hide show
  1. package/dist/browser/uql-browser.min.js.map +2 -2
  2. package/dist/cockroachdb/cockroachDialect.js +5 -1
  3. package/dist/dialect/abstractDialect.d.ts +1 -31
  4. package/dist/dialect/abstractDialect.js +3 -27
  5. package/dist/dialect/abstractSqlDialect.d.ts +25 -56
  6. package/dist/dialect/abstractSqlDialect.js +78 -145
  7. package/dist/dialect/aliases.d.ts +5 -0
  8. package/dist/dialect/aliases.js +5 -0
  9. package/dist/dialect/mysqlLikeSqlDialect.d.ts +4 -2
  10. package/dist/dialect/mysqlLikeSqlDialect.js +14 -1
  11. package/dist/dialect/operators.d.ts +66 -0
  12. package/dist/dialect/operators.js +129 -0
  13. package/dist/dialect/pgLikeSqlDialect.d.ts +4 -1
  14. package/dist/dialect/pgLikeSqlDialect.js +16 -3
  15. package/dist/entity/decorator/entity.d.ts +6 -1
  16. package/dist/entity/decorator/entity.js +12 -1
  17. package/dist/entity/index.d.ts +1 -1
  18. package/dist/entity/index.js +1 -1
  19. package/dist/entity/metadata/definition.d.ts +6 -1
  20. package/dist/entity/metadata/definition.js +19 -0
  21. package/dist/migrate/codegen/entityTypes.js +1 -2
  22. package/dist/migrate/ddl/mssqlIndexDdl.d.ts +5 -0
  23. package/dist/migrate/ddl/mssqlIndexDdl.js +10 -0
  24. package/dist/migrate/ddl/mssqlTableDdl.d.ts +2 -0
  25. package/dist/migrate/ddl/mssqlTableDdl.js +5 -0
  26. package/dist/migrate/ddl/tableDdl.d.ts +2 -0
  27. package/dist/migrate/ddl/tableDdl.js +4 -0
  28. package/dist/migrate/generator/mongoSchemaGenerator.d.ts +4 -0
  29. package/dist/migrate/generator/mongoSchemaGenerator.js +10 -0
  30. package/dist/migrate/introspection/abstractSqlSchemaIntrospector.d.ts +13 -1
  31. package/dist/migrate/introspection/abstractSqlSchemaIntrospector.js +21 -0
  32. package/dist/migrate/introspection/mongoIntrospector.d.ts +3 -1
  33. package/dist/migrate/introspection/mongoIntrospector.js +4 -0
  34. package/dist/migrate/introspection/mssqlIntrospector.d.ts +1 -0
  35. package/dist/migrate/introspection/mssqlIntrospector.js +8 -0
  36. package/dist/migrate/introspection/mysqlIntrospector.d.ts +1 -0
  37. package/dist/migrate/introspection/mysqlIntrospector.js +9 -0
  38. package/dist/migrate/introspection/postgresIntrospector.d.ts +1 -0
  39. package/dist/migrate/introspection/postgresIntrospector.js +10 -0
  40. package/dist/migrate/introspection/sqliteIntrospector.d.ts +1 -0
  41. package/dist/migrate/introspection/sqliteIntrospector.js +3 -0
  42. package/dist/migrate/migrator.d.ts +28 -1
  43. package/dist/migrate/migrator.js +88 -9
  44. package/dist/migrate/schemaGenerator.d.ts +12 -1
  45. package/dist/migrate/schemaGenerator.js +47 -5
  46. package/dist/migrate/storage/databaseStorage.d.ts +4 -0
  47. package/dist/migrate/storage/databaseStorage.js +14 -8
  48. package/dist/migrate/triggerSql.d.ts +24 -0
  49. package/dist/migrate/triggerSql.js +229 -0
  50. package/dist/mongo/mongoDialect.d.ts +0 -21
  51. package/dist/mongo/mongoDialect.js +105 -100
  52. package/dist/mongo/mongodbQuerier.js +17 -1
  53. package/dist/mssql/mssqlDialect.d.ts +18 -7
  54. package/dist/mssql/mssqlDialect.js +77 -33
  55. package/dist/mssql/mssqlQuerier.js +2 -2
  56. package/dist/schema/canonicalType.d.ts +6 -1
  57. package/dist/schema/canonicalType.js +14 -0
  58. package/dist/schema/schemaASTBuilder.d.ts +2 -8
  59. package/dist/schema/schemaASTBuilder.js +6 -20
  60. package/dist/sqlite/sqliteDialect.d.ts +1 -1
  61. package/dist/sqlite/sqliteDialect.js +12 -3
  62. package/dist/type/dialect.d.ts +69 -9
  63. package/dist/type/entity.d.ts +96 -3
  64. package/dist/type/migration.d.ts +17 -0
  65. package/dist/type/query.d.ts +13 -4
  66. package/dist/type/queryWhere.d.ts +4 -2
  67. package/dist/util/field.util.d.ts +9 -1
  68. package/dist/util/field.util.js +14 -2
  69. package/dist/util/fieldOption.util.d.ts +2 -2
  70. package/dist/util/fieldOption.util.js +2 -2
  71. package/dist/util/raw.d.ts +9 -1
  72. package/dist/util/raw.js +36 -11
  73. package/dist/util/sql.util.d.ts +12 -0
  74. package/dist/util/sql.util.js +24 -3
  75. package/dist/util/uqlError.d.ts +2 -0
  76. package/dist/util/uqlError.js +4 -0
  77. package/package.json +4 -4
  78. package/skills/uql-orm/SKILL.md +11 -6
@@ -36,9 +36,6 @@ export declare class MongoDialect extends AbstractDialect {
36
36
  readonly features: DialectFeatures;
37
37
  readonly dialectName = "mongodb";
38
38
  readonly insertIdSource = "returning";
39
- private static readonly ID_KEY;
40
- /** Atlas rejects a `$vectorSearch` asking for more candidates than this. */
41
- private static readonly MAX_NUM_CANDIDATES;
42
39
  /**
43
40
  * MongoDB stores the primary key as `_id`; everything else resolves as usual. Projections, sorts,
44
41
  * `$group` refs and `$where` keys all map through here, so no read path can address a property name
@@ -70,14 +67,6 @@ export declare class MongoDialect extends AbstractDialect {
70
67
  * relation subquery can no more read out-of-scope rows than a direct query on the target can.
71
68
  */
72
69
  private appendRelationLookup;
73
- /** Adds `expr` to `filter`'s `$expr`, `AND`ed with any already there. */
74
- private static andExpr;
75
- /**
76
- * `$size` against bounds, which MongoDB's own `$size` takes only as a number: the array at `path` counted
77
- * in an `$expr`, which no other value satisfies. `$and` may evaluate every operand, so the count reads an
78
- * empty array in place of any other value.
79
- */
80
- private static arraySize;
81
70
  /**
82
71
  * The correlated `$lookup` for the target rows of one relation `where` narrows, as `temp`: straight at
83
72
  * the target, or for a many-to-many from inside its junction's rows. The caller's filter bypass is not
@@ -91,8 +80,6 @@ export declare class MongoDialect extends AbstractDialect {
91
80
  * Each end is one field matched against one `_id`, so both sides must be sole-keyed.
92
81
  */
93
82
  private junctionOf;
94
- /** `count` compared with `size`, a number or its bounds, as an aggregation expression. */
95
- private static compareCount;
96
83
  /** Whether a query subtracts `key` from the projection, via `$exclude` or a negative `$select`. */
97
84
  private subtractsKey;
98
85
  /** `raw()` renders SQL, so it has no MongoDB equivalent - say so instead of emitting `{}`. */
@@ -103,10 +90,6 @@ export declare class MongoDialect extends AbstractDialect {
103
90
  * apply to JSON paths.
104
91
  */
105
92
  private assertKnownPathRoot;
106
- /** String operators -> { pattern: (v) => regex, caseInsensitive } */
107
- private static readonly REGEX_OP_MAP;
108
- /** MongoDB native operators - pass through as-is. */
109
- private static readonly NATIVE_OPS;
110
93
  /**
111
94
  * Transform UQL operators to MongoDB operators.
112
95
  */
@@ -271,10 +254,6 @@ export declare class MongoDialect extends AbstractDialect {
271
254
  * filters included, so each relation aggregate among them is put on the document first.
272
255
  */
273
256
  private buildGroupSpec;
274
- /** `1` where `ref` holds a value, `0` where it is null or missing, which an expression tells apart. */
275
- private static countOf;
276
- /** Whether `ref` is null or missing: an expression compares a missing field as neither. */
277
- private static isNullExpr;
278
257
  /** The field a grouped path reads, on the document or on the joined one its lookup unwound. */
279
258
  private groupedPath;
280
259
  /**
@@ -1,6 +1,7 @@
1
1
  import { ObjectId } from 'mongodb';
2
2
  import { AbstractDialect } from '../dialect/abstractDialect.js';
3
3
  import { AGGREGATE_VALUE_ALIAS, REL_NESTED_KEY, REL_TEMP_PREFIX, SUM_COUNT_ALIAS, nullsSortField, sortAggregateField, TEXT_SCORE_ALIAS, } from '../dialect/aliases.js';
4
+ import { GROUP_OPS, groupClauses, isGroupOp } from '../dialect/operators.js';
4
5
  import { aggregateColumnField, groupPathField, resolveGroupJoins, relationSortTerms, resolveQueryJoins, resolveSortableJoin, } from '../dialect/queryJoins.js';
5
6
  import { assertSoleId, fieldOf, getMeta, relationOf, soleIdOf } from '../entity/index.js';
6
7
  import { COUNT_RESULT_KEY } from '../type/query.js';
@@ -15,7 +16,6 @@ const EMPTY_SORT_PLAN = { sort: {}, stages: [], fields: [] };
15
16
  const MONGO_ARITHMETIC = { $inc: '$add', $mul: '$multiply' };
16
17
  /** Default {@link DialectFeatures} for MongoDB. */
17
18
  export const mongoDialectFeatures = {
18
- ifNotExists: false,
19
19
  indexIfNotExists: false,
20
20
  schemas: false, // the connection picks the database, and a collection name takes no dot
21
21
  dropTableCascade: false,
@@ -39,29 +39,93 @@ const HEX_24 = /^[0-9a-f]{24}$/i;
39
39
  function declaredTypeName(type) {
40
40
  return typeof type === 'function' ? type.name : String(type);
41
41
  }
42
+ const ID_KEY = '_id';
43
+ /** Atlas rejects a `$vectorSearch` asking for more candidates than this. */
44
+ const MAX_NUM_CANDIDATES = 10_000;
45
+ /** Adds `expr` to `filter`'s `$expr`, `AND`ed with any already there. */
46
+ function andExpr(filter, expr) {
47
+ filter['$expr'] = filter['$expr'] ? { $and: [filter['$expr'], expr] } : expr;
48
+ }
49
+ /**
50
+ * `$size` against bounds, which MongoDB's own `$size` takes only as a number: the array at `path` counted
51
+ * in an `$expr`, which no other value satisfies. `$and` may evaluate every operand, so the count reads an
52
+ * empty array in place of any other value.
53
+ */
54
+ function arraySize(path, size) {
55
+ const value = `$${path}`;
56
+ const count = { $size: { $cond: [{ $isArray: value }, value, []] } };
57
+ return { $and: [{ $isArray: value }, compareCount(count, size)] };
58
+ }
59
+ /** `count` compared with `size`, a number or its bounds, as an aggregation expression. */
60
+ function compareCount(count, size) {
61
+ if (typeof size === 'number') {
62
+ return { $eq: [count, size] };
63
+ }
64
+ const comparisons = Object.entries(size)
65
+ .filter(([, bound]) => bound !== undefined)
66
+ .flatMap(([op, bound]) => op === '$between' && Array.isArray(bound)
67
+ ? [{ $gte: [count, bound[0]] }, { $lte: [count, bound[1]] }]
68
+ : [{ [op]: [count, bound] }]);
69
+ if (!comparisons.length) {
70
+ throw new UqlUsageError('$size needs at least one comparison');
71
+ }
72
+ return comparisons.length === 1 ? comparisons[0] : { $and: comparisons };
73
+ }
74
+ /** String operators -> { pattern: (v) => regex, caseInsensitive } */
75
+ const REGEX_OP_MAP = new Map([
76
+ ['$startsWith', { wrap: (v) => `^${v}`, ci: false }],
77
+ ['$istartsWith', { wrap: (v) => `^${v}`, ci: true }],
78
+ ['$endsWith', { wrap: (v) => `${v}$`, ci: false }],
79
+ ['$iendsWith', { wrap: (v) => `${v}$`, ci: true }],
80
+ ['$includes', { wrap: (v) => String(v), ci: false }],
81
+ ['$iincludes', { wrap: (v) => String(v), ci: true }],
82
+ ['$like', { wrap: (v) => String(v).replace(/%/g, '.*').replace(/_/g, '.'), ci: false }],
83
+ ['$ilike', { wrap: (v) => String(v).replace(/%/g, '.*').replace(/_/g, '.'), ci: true }],
84
+ ]);
85
+ /** MongoDB native operators - pass through as-is. */
86
+ const NATIVE_OPS = new Set([
87
+ '$all',
88
+ '$size',
89
+ '$elemMatch',
90
+ '$eq',
91
+ '$ne',
92
+ '$lt',
93
+ '$lte',
94
+ '$gt',
95
+ '$gte',
96
+ '$in',
97
+ '$nin',
98
+ '$regex',
99
+ '$not',
100
+ ]);
101
+ /** `1` where `ref` holds a value, `0` where it is null or missing, which an expression tells apart. */
102
+ function countOf(ref) {
103
+ return { $cond: [isNullExpr(ref), 0, 1] };
104
+ }
105
+ /** Whether `ref` is null or missing: an expression compares a missing field as neither. */
106
+ function isNullExpr(ref) {
107
+ return { $eq: [{ $ifNull: [ref, null] }, null] };
108
+ }
42
109
  export class MongoDialect extends AbstractDialect {
43
110
  features = mongoDialectFeatures;
44
111
  dialectName = 'mongodb';
45
112
  // The MongoDB driver reports the exact `_id` of every inserted document (`insertedIds`).
46
113
  insertIdSource = 'returning';
47
- static ID_KEY = '_id';
48
- /** Atlas rejects a `$vectorSearch` asking for more candidates than this. */
49
- static MAX_NUM_CANDIDATES = 10_000;
50
114
  /**
51
115
  * MongoDB stores the primary key as `_id`; everything else resolves as usual. Projections, sorts,
52
116
  * `$group` refs and `$where` keys all map through here, so no read path can address a property name
53
117
  * the document does not use.
54
118
  */
55
119
  columnOf(meta, key) {
56
- if (key === MongoDialect.ID_KEY) {
57
- return MongoDialect.ID_KEY;
120
+ if (key === ID_KEY) {
121
+ return ID_KEY;
58
122
  }
59
123
  if (meta.fields[key]?.isId) {
60
124
  // A document has one `_id`. A composite key is a different document shape - a sub-document,
61
125
  // whose field order decides equality - rather than a translation, so it is refused here, which
62
126
  // every read reaches, and in `getPersistables`, which every write reaches.
63
127
  assertSoleId(meta, 'MongoDB');
64
- return MongoDialect.ID_KEY;
128
+ return ID_KEY;
65
129
  }
66
130
  return super.columnOf(meta, key);
67
131
  }
@@ -99,7 +163,7 @@ export class MongoDialect extends AbstractDialect {
99
163
  for (const [rawKey, rawVal] of Object.entries(where)) {
100
164
  let key = rawKey;
101
165
  let val = rawVal;
102
- if (MongoDialect.isGroupOp(key)) {
166
+ if (isGroupOp(key)) {
103
167
  this.appendLogicalOperator(filter, entity, key, val, lookups);
104
168
  }
105
169
  else if (key === '$text') {
@@ -126,7 +190,7 @@ export class MongoDialect extends AbstractDialect {
126
190
  }
127
191
  const isReference = !!meta.fields[key]?.references;
128
192
  key = this.pathOf(meta, key);
129
- if ((key === MongoDialect.ID_KEY || isReference) && !isOperatorObject(val)) {
193
+ if ((key === ID_KEY || isReference) && !isOperatorObject(val)) {
130
194
  val = this.toWireId(val);
131
195
  }
132
196
  if (!isOperatorObject(val)) {
@@ -139,7 +203,7 @@ export class MongoDialect extends AbstractDialect {
139
203
  filter[key] = this.transformOperators(val);
140
204
  }
141
205
  else {
142
- MongoDialect.andExpr(filter, MongoDialect.arraySize(key, size));
206
+ andExpr(filter, arraySize(key, size));
143
207
  if (hasKeys(ops)) {
144
208
  filter[key] = this.transformOperators(ops);
145
209
  }
@@ -153,8 +217,8 @@ export class MongoDialect extends AbstractDialect {
153
217
  * wrapped in one `$and`), dropping empty clauses, since MongoDB refuses an empty operator.
154
218
  */
155
219
  appendLogicalOperator(filter, entity, key, val, lookups) {
156
- const { join, negate } = MongoDialect.GROUP_OPS[key];
157
- const parts = MongoDialect.groupClauses(key, val)
220
+ const { join, negate } = GROUP_OPS[key];
221
+ const parts = groupClauses(key, val)
158
222
  .map((filterIt) => {
159
223
  this.assertNoRaw(filterIt);
160
224
  return this.renderFilter(entity, filterIt, lookups);
@@ -187,23 +251,9 @@ export class MongoDialect extends AbstractDialect {
187
251
  filter[`${temp}.0`] = { $exists: true };
188
252
  }
189
253
  else {
190
- MongoDialect.andExpr(filter, MongoDialect.compareCount(this.tally(temp), sizeVal));
254
+ andExpr(filter, compareCount(this.tally(temp), sizeVal));
191
255
  }
192
256
  }
193
- /** Adds `expr` to `filter`'s `$expr`, `AND`ed with any already there. */
194
- static andExpr(filter, expr) {
195
- filter['$expr'] = filter['$expr'] ? { $and: [filter['$expr'], expr] } : expr;
196
- }
197
- /**
198
- * `$size` against bounds, which MongoDB's own `$size` takes only as a number: the array at `path` counted
199
- * in an `$expr`, which no other value satisfies. `$and` may evaluate every operand, so the count reads an
200
- * empty array in place of any other value.
201
- */
202
- static arraySize(path, size) {
203
- const value = `$${path}`;
204
- const count = { $size: { $cond: [{ $isArray: value }, value, []] } };
205
- return { $and: [{ $isArray: value }, MongoDialect.compareCount(count, size)] };
206
- }
207
257
  /**
208
258
  * The correlated `$lookup` for the target rows of one relation `where` narrows, as `temp`: straight at
209
259
  * the target, or for a many-to-many from inside its junction's rows. The caller's filter bypass is not
@@ -226,7 +276,7 @@ export class MongoDialect extends AbstractDialect {
226
276
  $lookup: {
227
277
  from,
228
278
  localField: junction.target,
229
- foreignField: MongoDialect.ID_KEY,
279
+ foreignField: ID_KEY,
230
280
  pipeline: [...targetMatch, { $limit: 1 }],
231
281
  as: REL_NESTED_KEY,
232
282
  },
@@ -254,28 +304,13 @@ export class MongoDialect extends AbstractDialect {
254
304
  return {
255
305
  lookup: {
256
306
  from: this.resolveTableName(throughMeta),
257
- localField: MongoDialect.ID_KEY,
307
+ localField: ID_KEY,
258
308
  foreignField: this.columnOf(throughMeta, parentJoin.joined),
259
309
  },
260
310
  scope: hasKeys(scope) ? [{ $match: scope }] : [],
261
311
  target: this.columnOf(throughMeta, targetColumn),
262
312
  };
263
313
  }
264
- /** `count` compared with `size`, a number or its bounds, as an aggregation expression. */
265
- static compareCount(count, size) {
266
- if (typeof size === 'number') {
267
- return { $eq: [count, size] };
268
- }
269
- const comparisons = Object.entries(size)
270
- .filter(([, bound]) => bound !== undefined)
271
- .flatMap(([op, bound]) => op === '$between' && Array.isArray(bound)
272
- ? [{ $gte: [count, bound[0]] }, { $lte: [count, bound[1]] }]
273
- : [{ [op]: [count, bound] }]);
274
- if (!comparisons.length) {
275
- throw new UqlUsageError('$size needs at least one comparison');
276
- }
277
- return comparisons.length === 1 ? comparisons[0] : { $and: comparisons };
278
- }
279
314
  /** Whether a query subtracts `key` from the projection, via `$exclude` or a negative `$select`. */
280
315
  subtractsKey(key, select, exclude) {
281
316
  const at = (map) => map?.[key];
@@ -294,38 +329,11 @@ export class MongoDialect extends AbstractDialect {
294
329
  */
295
330
  assertKnownPathRoot(meta, key) {
296
331
  const root = key.includes('.') ? key.slice(0, key.indexOf('.')) : key;
297
- if (root === MongoDialect.ID_KEY || meta.fields[root]) {
332
+ if (root === ID_KEY || meta.fields[root]) {
298
333
  return;
299
334
  }
300
335
  throw new UqlUsageError(`path ${key} does not exist in ${entityName(meta)}`);
301
336
  }
302
- /** String operators -> { pattern: (v) => regex, caseInsensitive } */
303
- static REGEX_OP_MAP = new Map([
304
- ['$startsWith', { wrap: (v) => `^${v}`, ci: false }],
305
- ['$istartsWith', { wrap: (v) => `^${v}`, ci: true }],
306
- ['$endsWith', { wrap: (v) => `${v}$`, ci: false }],
307
- ['$iendsWith', { wrap: (v) => `${v}$`, ci: true }],
308
- ['$includes', { wrap: (v) => String(v), ci: false }],
309
- ['$iincludes', { wrap: (v) => String(v), ci: true }],
310
- ['$like', { wrap: (v) => String(v).replace(/%/g, '.*').replace(/_/g, '.'), ci: false }],
311
- ['$ilike', { wrap: (v) => String(v).replace(/%/g, '.*').replace(/_/g, '.'), ci: true }],
312
- ]);
313
- /** MongoDB native operators - pass through as-is. */
314
- static NATIVE_OPS = new Set([
315
- '$all',
316
- '$size',
317
- '$elemMatch',
318
- '$eq',
319
- '$ne',
320
- '$lt',
321
- '$lte',
322
- '$gt',
323
- '$gte',
324
- '$in',
325
- '$nin',
326
- '$regex',
327
- '$not',
328
- ]);
329
337
  /**
330
338
  * Transform UQL operators to MongoDB operators.
331
339
  */
@@ -339,6 +347,11 @@ export class MongoDialect extends AbstractDialect {
339
347
  result[op] = this.held(val);
340
348
  continue;
341
349
  }
350
+ // `$not` wraps a condition too, so a uql-only operator inside it (`$isNull`, `$startsWith`) is mapped.
351
+ if (op === '$not' && isOperatorObject(val)) {
352
+ result[op] = this.transformOperators(val);
353
+ continue;
354
+ }
342
355
  // An object or an array is matched by what it holds, as the SQL engines read it, where native `$all`
343
356
  // compares the whole element. MongoDB takes `$elemMatch` there only when every value is one.
344
357
  if (op === '$all' && Array.isArray(val) && val.some((value) => Array.isArray(value) || isOperatorMap(value))) {
@@ -346,12 +359,12 @@ export class MongoDialect extends AbstractDialect {
346
359
  continue;
347
360
  }
348
361
  // Native MongoDB operators - pass through directly
349
- if (MongoDialect.NATIVE_OPS.has(op)) {
362
+ if (NATIVE_OPS.has(op)) {
350
363
  result[op] = val;
351
364
  continue;
352
365
  }
353
366
  // String/pattern -> regex operators (8 variants including $like/$ilike)
354
- const regexEntry = MongoDialect.REGEX_OP_MAP.get(op);
367
+ const regexEntry = REGEX_OP_MAP.get(op);
355
368
  if (regexEntry) {
356
369
  result['$regex'] = regexEntry.wrap(val);
357
370
  if (regexEntry.ci)
@@ -443,7 +456,7 @@ export class MongoDialect extends AbstractDialect {
443
456
  // `_id: 0` - the one inclusion/exclusion mix MongoDB allows - or `$exclude: { id: true }` would
444
457
  // have no effect at all.
445
458
  if (this.subtractsKey(soleIdOf(meta, 'MongoDB'), selectMap, exclude)) {
446
- projection[MongoDialect.ID_KEY] = 0;
459
+ projection[ID_KEY] = 0;
447
460
  }
448
461
  return projection;
449
462
  }
@@ -669,7 +682,7 @@ export class MongoDialect extends AbstractDialect {
669
682
  $lookup: {
670
683
  from,
671
684
  localField: `${temp}.${junction.target}`,
672
- foreignField: MongoDialect.ID_KEY,
685
+ foreignField: ID_KEY,
673
686
  ...pipeline,
674
687
  as: relKey,
675
688
  },
@@ -846,7 +859,7 @@ export class MongoDialect extends AbstractDialect {
846
859
  const relationProjection = this.pipelineProjection(join.entity, join.query);
847
860
  // MongoDB returns `_id` unless a projection subtracts it, so dropping the key from the map is
848
861
  // how a joined document keeps its own id, as it does on the SQL dialects.
849
- delete relationProjection?.[MongoDialect.ID_KEY];
862
+ delete relationProjection?.[ID_KEY];
850
863
  const lookupPipeline = [
851
864
  ...(hasKeys(relationFilter) ? [{ $match: relationFilter }] : []),
852
865
  ...this.lookupStages(join.meta, joins, join),
@@ -879,9 +892,9 @@ export class MongoDialect extends AbstractDialect {
879
892
  // rows of every key that agrees on it. The other branch is refused by `columnOf` below, whose
880
893
  // key *is* an id column; this one names a plain foreign key, so it has to say so itself.
881
894
  assertSoleId(relMeta, 'MongoDB');
882
- return { localField: this.columnOf(meta, relOpts.references[0].local), foreignField: MongoDialect.ID_KEY };
895
+ return { localField: this.columnOf(meta, relOpts.references[0].local), foreignField: ID_KEY };
883
896
  }
884
- return { localField: MongoDialect.ID_KEY, foreignField: this.columnOf(relMeta, relOpts.references[0].foreign) };
897
+ return { localField: ID_KEY, foreignField: this.columnOf(relMeta, relOpts.references[0].foreign) };
885
898
  }
886
899
  /** `[column, key]` for the fields whose stored name differs from their property name, memoized per entity. */
887
900
  renamedColumns(meta) {
@@ -914,7 +927,7 @@ export class MongoDialect extends AbstractDialect {
914
927
  return doc;
915
928
  }
916
929
  const res = doc;
917
- const _id = MongoDialect.ID_KEY;
930
+ const _id = ID_KEY;
918
931
  // `!== undefined`, not truthiness: `0` is a key MongoDB accepts and a truthy test dropped it.
919
932
  if (res[_id] !== undefined) {
920
933
  const idKey = soleIdOf(meta, 'MongoDB');
@@ -1079,7 +1092,7 @@ export class MongoDialect extends AbstractDialect {
1079
1092
  // Nothing named the key, so the database is being asked to mint one.
1080
1093
  this.assertMintableKey(meta, fieldOf(meta, idKey));
1081
1094
  }
1082
- const doc = named ? { [MongoDialect.ID_KEY]: this.toWireId(it[idKey]) } : {};
1095
+ const doc = named ? { [ID_KEY]: this.toWireId(it[idKey]) } : {};
1083
1096
  for (const key of filterFieldKeys(meta, it, callbackKey)) {
1084
1097
  if (key === idKey)
1085
1098
  continue;
@@ -1166,7 +1179,7 @@ export class MongoDialect extends AbstractDialect {
1166
1179
  }
1167
1180
  else if (entry.op === '$count') {
1168
1181
  // COUNT(field) counts the non-null values, as SQL does.
1169
- accumulators[entry.alias] = { $sum: read(MongoDialect.countOf(ref), 0) };
1182
+ accumulators[entry.alias] = { $sum: read(countOf(ref), 0) };
1170
1183
  }
1171
1184
  else {
1172
1185
  // `$sum`, `$avg`, `$min` and `$max` are MongoDB accumulators of the same name, and skip a null.
@@ -1174,20 +1187,12 @@ export class MongoDialect extends AbstractDialect {
1174
1187
  }
1175
1188
  if (entry.op === '$sum' && !entry.distinct) {
1176
1189
  const counted = `${SUM_COUNT_ALIAS}_${entry.alias}`;
1177
- accumulators[counted] = { $sum: read(MongoDialect.countOf(ref), 0) };
1190
+ accumulators[counted] = { $sum: read(countOf(ref), 0) };
1178
1191
  columns[entry.alias] = { $cond: [{ $eq: [`$${counted}`, 0] }, null, `$${entry.alias}`] };
1179
1192
  }
1180
1193
  }
1181
1194
  return { groupId, accumulators, columns, named };
1182
1195
  }
1183
- /** `1` where `ref` holds a value, `0` where it is null or missing, which an expression tells apart. */
1184
- static countOf(ref) {
1185
- return { $cond: [MongoDialect.isNullExpr(ref), 0, 1] };
1186
- }
1187
- /** Whether `ref` is null or missing: an expression compares a missing field as neither. */
1188
- static isNullExpr(ref) {
1189
- return { $eq: [{ $ifNull: [ref, null] }, null] };
1190
- }
1191
1196
  /** The field a grouped path reads, on the document or on the joined one its lookup unwound. */
1192
1197
  groupedPath(meta, joins, path, named) {
1193
1198
  const { key, join } = groupPathField(joins, path);
@@ -1209,9 +1214,9 @@ export class MongoDialect extends AbstractDialect {
1209
1214
  const terms = getKeys(where)
1210
1215
  .filter((key) => where[key] !== undefined)
1211
1216
  .map((key) => {
1212
- if (MongoDialect.isGroupOp(key)) {
1213
- const { join, negate } = MongoDialect.GROUP_OPS[key];
1214
- const clauses = MongoDialect.groupClauses(key, where[key]).map((clause) => {
1217
+ if (isGroupOp(key)) {
1218
+ const { join, negate } = GROUP_OPS[key];
1219
+ const clauses = groupClauses(key, where[key]).map((clause) => {
1215
1220
  if (clause instanceof QueryRaw) {
1216
1221
  throw new UqlUsageError('raw SQL is not supported in an aggregate $where on MongoDB');
1217
1222
  }
@@ -1225,19 +1230,19 @@ export class MongoDialect extends AbstractDialect {
1225
1230
  const val = where[key];
1226
1231
  named.push(key);
1227
1232
  const path = this.pathOf(meta, key);
1228
- const wire = (value) => path === MongoDialect.ID_KEY || meta.fields[key]?.references ? this.toWireId(value) : value;
1233
+ const wire = (value) => path === ID_KEY || meta.fields[key]?.references ? this.toWireId(value) : value;
1229
1234
  return this.fieldExpression(`$${path}`, val, wire);
1230
1235
  });
1231
1236
  return terms.length === 1 ? terms[0] : { $and: terms };
1232
1237
  }
1233
1238
  /** One field's condition as an expression: a value it equals, a list it is in, or a map of comparisons. */
1234
1239
  fieldExpression(ref, val, wire) {
1235
- const equals = (value) => (value === null ? MongoDialect.isNullExpr(ref) : { $eq: [ref, wire(value)] });
1240
+ const equals = (value) => (value === null ? isNullExpr(ref) : { $eq: [ref, wire(value)] });
1236
1241
  if (!isOperatorMap(val)) {
1237
1242
  return Array.isArray(val) ? { $in: [ref, wire(val)] } : equals(val);
1238
1243
  }
1239
1244
  // A null or missing field compares below every value in an expression, where SQL leaves it unmatched.
1240
- const present = { $not: [MongoDialect.isNullExpr(ref)] };
1245
+ const present = { $not: [isNullExpr(ref)] };
1241
1246
  const terms = Object.entries(val).map(([op, operand]) => {
1242
1247
  switch (op) {
1243
1248
  case '$eq':
@@ -1259,9 +1264,9 @@ export class MongoDialect extends AbstractDialect {
1259
1264
  return { $and: [{ $gte: [ref, wire(min)] }, { $lte: [ref, wire(max)] }] };
1260
1265
  }
1261
1266
  case '$isNull':
1262
- return operand ? MongoDialect.isNullExpr(ref) : present;
1267
+ return operand ? isNullExpr(ref) : present;
1263
1268
  case '$isNotNull':
1264
- return operand ? present : MongoDialect.isNullExpr(ref);
1269
+ return operand ? present : isNullExpr(ref);
1265
1270
  default:
1266
1271
  throw new UqlUsageError(`aggregate $where operator '${op}' is not supported on MongoDB`);
1267
1272
  }
@@ -1331,7 +1336,7 @@ export class MongoDialect extends AbstractDialect {
1331
1336
  queryVector: [...search.$vector],
1332
1337
  // `$candidates` is the caller's own budget; the fallback is 10x the limit, which is what Atlas
1333
1338
  // suggests as a floor. Either way it is clamped: Atlas rejects a stage asking for more.
1334
- numCandidates: Math.min(candidates ?? limit * 10, MongoDialect.MAX_NUM_CANDIDATES),
1339
+ numCandidates: Math.min(candidates ?? limit * 10, MAX_NUM_CANDIDATES),
1335
1340
  limit,
1336
1341
  };
1337
1342
  // Pre-filter: merge $where into $vectorSearch.filter
@@ -2,7 +2,8 @@ import { AGGREGATE_VALUE_ALIAS } from '../dialect/aliases.js';
2
2
  import { hasRequiredJoin } from '../dialect/queryJoins.js';
3
3
  import { fieldOf, getMeta, namesKey, soleIdOf } from '../entity/index.js';
4
4
  import { AbstractQuerier, enrichError } from '../querier/index.js';
5
- import { clone, getKeys, getSoftDeleteValue, hasKeys, populatesRelations, textSortOf, throwNoPendingTransaction, throwPendingTransaction, vectorCandidates, withoutSoftDeleteFilter, } from '../util/index.js';
5
+ import { clone, getKeys, getSoftDeleteValue, hasKeys, hasTriggers, populatesRelations, textSortOf, throwNoPendingTransaction, throwPendingTransaction, vectorCandidates, withoutSoftDeleteFilter, } from '../util/index.js';
6
+ import { UqlUsageError } from '../util/uqlError.js';
6
7
  /**
7
8
  * `$limit: 0` asks for no rows, the way it does on every SQL dialect - but MongoDB reads `limit(0)`
8
9
  * as *unlimited*, so a read that passed it straight to the driver came back with the whole
@@ -11,6 +12,16 @@ import { clone, getKeys, getSoftDeleteValue, hasKeys, populatesRelations, textSo
11
12
  function asksForNoRows(q) {
12
13
  return q.$limit === 0;
13
14
  }
15
+ /**
16
+ * MongoDB has no triggers, so a write to an entity declaring one - a stamp included - would skip it
17
+ * silently. Refused instead, as a query naming SQL is.
18
+ */
19
+ function refuseTriggers(entity) {
20
+ if (hasTriggers(getMeta(entity))) {
21
+ throw new UqlUsageError(`'${entity.name}' declares triggers, which MongoDB has none of: a write here would skip them. ` +
22
+ 'Keep the entity on a SQL engine, or drop its triggers and stamps.');
23
+ }
24
+ }
14
25
  export class MongodbQuerier extends AbstractQuerier {
15
26
  dialect;
16
27
  conn;
@@ -173,6 +184,7 @@ export class MongodbQuerier extends AbstractQuerier {
173
184
  return this.timed('estimatedCount', undefined, async () => this.execute((session) => this.collection(entity).estimatedDocumentCount({ session })));
174
185
  }
175
186
  async internalInsertMany(entity, rows) {
187
+ refuseTriggers(entity);
176
188
  return this.timed('internalInsertMany', undefined, async () => {
177
189
  const meta = getMeta(entity);
178
190
  const persistables = this.dialect.getPersistables(meta, rows, 'onInsert');
@@ -186,6 +198,7 @@ export class MongodbQuerier extends AbstractQuerier {
186
198
  });
187
199
  }
188
200
  async internalUpdateMany(entity, qm, payload, opts) {
201
+ refuseTriggers(entity);
189
202
  return this.timed('internalUpdateMany', undefined, async () => {
190
203
  const persistable = this.dialect.getPersistable(getMeta(entity), payload, 'onUpdate');
191
204
  const filter = this.dialect.where(entity, qm.$where, opts);
@@ -217,6 +230,7 @@ export class MongodbQuerier extends AbstractQuerier {
217
230
  return this.dialect.where(entity, where);
218
231
  }
219
232
  async internalUpsertOne(entity, conflictPaths, payload) {
233
+ refuseTriggers(entity);
220
234
  return this.timed('upsertOne', undefined, async () => {
221
235
  payload = clone(payload);
222
236
  const meta = getMeta(entity);
@@ -237,6 +251,7 @@ export class MongodbQuerier extends AbstractQuerier {
237
251
  });
238
252
  }
239
253
  async internalUpsertMany(entity, conflictPaths, payload) {
254
+ refuseTriggers(entity);
240
255
  return this.timed('upsertMany', undefined, async () => {
241
256
  if (!payload?.length) {
242
257
  return { changes: 0 };
@@ -271,6 +286,7 @@ export class MongodbQuerier extends AbstractQuerier {
271
286
  });
272
287
  }
273
288
  async internalDeleteMany(entity, qm, opts = {}) {
289
+ refuseTriggers(entity);
274
290
  return this.timed('internalDeleteMany', undefined, async () => {
275
291
  const meta = getMeta(entity);
276
292
  // Soft-delete (stamp) unless `hardDelete` is requested or the entity has no soft-delete field.
@@ -1,7 +1,7 @@
1
1
  import { type RelationRows } from '../dialect/abstractSqlDialect.js';
2
2
  import { type JsonAccessMode, type JsonSlot } from '../dialect/jsonSql.js';
3
3
  import { MergeSqlDialect } from '../dialect/mergeSqlDialect.js';
4
- import type { EntityMeta, FieldOptions, InsertIdSource, Query, QueryContext, QueryOptions, QueryPager, SqlDialectFeatures, Type, VectorDistance, VectorMetric } from '../type/index.js';
4
+ import type { EntityMeta, FieldOptions, InsertIdSource, Query, QueryConflictPaths, QueryContext, QueryOptions, QueryPager, SqlDialectFeatures, Type, VectorDistance, VectorMetric } from '../type/index.js';
5
5
  /** Microsoft SQL Server 2017 and up. Identifiers are `"`-quoted, the ANSI spelling `tedious` enables. */
6
6
  export declare class MsSqlDialect extends MergeSqlDialect {
7
7
  #private;
@@ -23,6 +23,8 @@ export declare class MsSqlDialect extends MergeSqlDialect {
23
23
  readonly maxBindValues = 2100;
24
24
  /** `OUTPUT` has no trailing form: it sits between the column list and `VALUES`. */
25
25
  readonly returningPosition = "after-target";
26
+ /** `OUTPUT` reads the written row off the `INSERTED` pseudo-table. */
27
+ protected readonly returnedRowPrefix = "INSERTED.";
26
28
  readonly insertIdSource: InsertIdSource;
27
29
  /** Holds the update key lock across the insert; without it two concurrent upserts of one key race. */
28
30
  protected readonly mergeTargetHint = " WITH (HOLDLOCK)";
@@ -39,10 +41,6 @@ export declare class MsSqlDialect extends MergeSqlDialect {
39
41
  pager(ctx: QueryContext, opts: QueryPager & {
40
42
  $distinct?: boolean;
41
43
  }, sorted?: boolean): void;
42
- /**
43
- * `SET IDENTITY_INSERT` around an insert that states a key the engine would generate, which it otherwise
44
- * refuses; turned off in the same batch, since one table per session may hold it.
45
- */
46
44
  insert<E>(ctx: QueryContext, entity: Type<E>, payload: E | E[], opts?: QueryOptions): void;
47
45
  /** The table to toggle, or nothing when no record writes a key the engine would have generated. */
48
46
  private identityInsertTarget;
@@ -65,9 +63,20 @@ export declare class MsSqlDialect extends MergeSqlDialect {
65
63
  };
66
64
  /** Named parameters, which `tedious` binds by name rather than by position. */
67
65
  placeholder(index: number): string;
68
- /** `OUTPUT` reads the written row out of the `INSERTED` pseudo-table rather than `RETURNING` it. */
66
+ /**
67
+ * `OUTPUT` reads the written row out of the `INSERTED` pseudo-table rather than `RETURNING` it, and
68
+ * always `INTO` a table variable: SQL Server refuses an `OUTPUT` straight back to the client on a table
69
+ * carrying a trigger, and through a table it works whether one is there or not.
70
+ */
69
71
  returningId<E>(meta: EntityMeta<E>): string;
70
- protected returningIdExpression<E>(meta: EntityMeta<E>): string;
72
+ upsert<E>(ctx: QueryContext, entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: E | E[], extraReturning?: string): void;
73
+ /**
74
+ * A statement writing rows, `write`, wrapped in what SQL Server needs around one. The table variable its
75
+ * `OUTPUT` fills, typed as the id column is, and the read handing that back, uncounted so the batch
76
+ * reports the rows written alone; none on a composite key, which has no id to hand back. And `SET
77
+ * IDENTITY_INSERT` where a row states a key the engine would generate, turned off in the same batch.
78
+ */
79
+ private writeRows;
71
80
  protected mergeReturning(expression: string): string;
72
81
  /** A row lock is a hint on the table here, which `rowLocks.placement` says instead of a trailing clause. */
73
82
  protected lockHint<E>(q: Query<E>): string;
@@ -98,6 +107,8 @@ export declare class MsSqlDialect extends MergeSqlDialect {
98
107
  createSchemaSql(schema: string): string;
99
108
  /** The estimate the engine already keeps per partition, live without a stats refresh. */
100
109
  estimatedCount<E>(ctx: QueryContext, entity: Type<E>): void;
110
+ /** Null-safe on every version, `EXCEPT` comparing NULLs as equal: `IS DISTINCT FROM` needs 2022. */
111
+ neExpr(field: string, ph: string): string;
101
112
  protected numericCast(expr: string): string;
102
113
  /**
103
114
  * `OPENJSON` at the path's parent, matching its last segment as a key, in either reading: `JSON_VALUE`