uql-orm 0.27.0 → 0.28.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.
@@ -1,11 +1,12 @@
1
1
  import { getMeta } from '../entity/index.js';
2
2
  import { parseQueryLock, QueryRaw, RAW_ALIAS, RAW_VALUE, } from '../type/index.js';
3
- import { asSelectMap, buildQueryWhereAsMap, buildSortMap, escapeSqlId, fillOnFields, filterFieldKeys, flatObject, getInsertFieldKeys, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, hasMultipleKeys, isBooleanType, isJsonType, isJsonUpdateOp, isNumericType, isOperatorObject, isOperatorOnlyObject, isPopulatingRelations, isVectorSearch, normalizeScalarFieldSelection, parseGroupMap, parseRelationAtKey, parseRelationSize, raw, someValue, withoutSoftDeleteFilter, } from '../util/index.js';
3
+ import { asSelectMap, buildQueryWhereAsMap, escapeSqlId, fillOnFields, filterFieldKeys, getInsertFieldKeys, getKeys, getSoftDeleteValue, hasKeys, isBooleanType, isJsonType, isJsonUpdateOp, isNumericType, isOperatorObject, isOperatorOnlyObject, isToManyRelation, isVectorSearch, normalizeScalarFieldSelection, parseGroupMap, parseRelationSize, populatesRelations, raw, someValue, withoutSoftDeleteFilter, } from '../util/index.js';
4
4
  import { escapeAnsiSqlLiteral, escapeSingleQuotes } from '../util/sqlLiteral.js';
5
5
  import { IndexSqlDialect } from './indexSqlDialect.js';
6
6
  import { buildElemMatchConditions } from './jsonArrayElemMatchUtils.js';
7
7
  import { isJsonbOp, JSON_ELEM_ALIAS_PREFIX, jsonCompareMode, jsonElemExists } from './jsonSql.js';
8
8
  import { SqlQueryContext } from './queryContext.js';
9
+ import { isSortMap, NO_JOINS, resolveQueryJoins, } from './queryJoins.js';
9
10
  import { isVectorFieldType, resolveVectorCast } from './vectorCast.js';
10
11
  export class AbstractSqlDialect extends IndexSqlDialect {
11
12
  isolationLevelStrategy = 'inline';
@@ -41,10 +42,11 @@ export class AbstractSqlDialect extends IndexSqlDialect {
41
42
  * straight into `ctx`'s own values array - shared by reference, not copied - so `addValue` numbers
42
43
  * its placeholder correctly against the real query from the start; a fresh, empty array would
43
44
  * instead number from `1` regardless of how many values `ctx` already has, misnumbering every
44
- * bound value on `$n`-placeholder dialects once `ctx` isn't otherwise empty.
45
+ * bound value on `$n`-placeholder dialects once `ctx` isn't otherwise empty. Generated aliases are
46
+ * shared for the same reason - see {@link SqlQueryContext}.
45
47
  */
46
48
  buildFragment(ctx, build) {
47
- const fragmentCtx = new SqlQueryContext(this, ctx.values);
49
+ const fragmentCtx = ctx.createFragment();
48
50
  build(fragmentCtx);
49
51
  return fragmentCtx.sql;
50
52
  }
@@ -85,15 +87,15 @@ export class AbstractSqlDialect extends IndexSqlDialect {
85
87
  const idName = this.columnOf(meta, idKey);
86
88
  return `RETURNING ${this.escapeId(idName)} ${this.escapeId('id')}`;
87
89
  }
88
- search(ctx, entity, q = {}, opts = {}) {
90
+ search(ctx, entity, q = {}, opts = {}, joins = NO_JOINS) {
89
91
  const meta = getMeta(entity);
90
92
  const tableName = this.resolveTableName(entity, meta);
91
- const prefix = this.resolveRelationAwarePrefix(tableName, meta, opts, asSelectMap(q.$select), q.$populate);
93
+ const prefix = this.resolveRelationAwarePrefix(tableName, meta, opts, q.$populate, joins);
92
94
  if (opts.prefix !== prefix) {
93
95
  opts = { ...opts, prefix };
94
96
  }
95
97
  this.where(ctx, entity, q.$where, opts);
96
- this.sort(ctx, entity, q.$sort, opts);
98
+ this.sort(ctx, entity, q.$sort, { prefix, joins, distinct: q.$distinct });
97
99
  this.pager(ctx, q);
98
100
  }
99
101
  selectFields(ctx, entity, select, opts = {}, exclude) {
@@ -162,87 +164,59 @@ export class AbstractSqlDialect extends IndexSqlDialect {
162
164
  appendTextSearch(_ctx, _entity, _meta, _search) {
163
165
  throw new TypeError(`${this.dialectName} does not support $text full-text search`);
164
166
  }
165
- select(ctx, entity, select, exclude, populate, opts = {}, distinct, sort) {
167
+ select(ctx, entity, q, opts = {}, joins = NO_JOINS) {
166
168
  const meta = getMeta(entity);
167
169
  const tableName = this.resolveTableName(entity, meta);
168
- const mapSelect = asSelectMap(select);
169
- const prefix = this.resolveRelationAwarePrefix(tableName, meta, opts, mapSelect, populate);
170
- ctx.append(distinct ? 'SELECT DISTINCT ' : 'SELECT ');
171
- this.selectFields(ctx, entity, select, { prefix }, exclude);
170
+ const prefix = this.resolveRelationAwarePrefix(tableName, meta, opts, q.$populate, joins);
171
+ ctx.append(q.$distinct ? 'SELECT DISTINCT ' : 'SELECT ');
172
+ this.selectFields(ctx, entity, q.$select, { prefix }, q.$exclude);
172
173
  // Add related fields BEFORE FROM clause
173
- this.selectRelationFields(ctx, entity, mapSelect, populate, { prefix });
174
+ this.selectRelationFields(ctx, joins);
174
175
  // Inject vector distance projections when $project is set
175
- if (sort) {
176
- const sortMap = buildSortMap(sort);
177
- for (const [key, val] of Object.entries(sortMap)) {
178
- if (isVectorSearch(val) && val.$project) {
179
- ctx.append(', ');
180
- this.appendVectorProjection(ctx, meta, key, val);
181
- }
176
+ for (const [key, val] of Object.entries(q.$sort ?? {})) {
177
+ if (isVectorSearch(val) && val.$project) {
178
+ ctx.append(', ');
179
+ this.appendVectorProjection(ctx, meta, key, val);
182
180
  }
183
181
  }
184
182
  ctx.append(` FROM ${this.escapeId(tableName)}`);
185
183
  // Add JOINs AFTER FROM clause
186
- this.selectRelationJoins(ctx, entity, mapSelect, populate, { prefix });
187
- }
188
- resolveRelationAwarePrefix(tableName, meta, opts, select, populate) {
189
- return (opts.prefix ?? (opts.autoPrefix || isPopulatingRelations(meta, populate))) ? tableName : undefined;
190
- }
191
- selectRelationFields(ctx, entity, select, populate, opts = {}) {
192
- this.forEachJoinableRelation(entity, select, populate, opts, (relEntity, relQuery, joinRelAlias) => {
184
+ this.selectRelationJoins(ctx, meta, tableName, joins);
185
+ }
186
+ /** Columns are alias-qualified once anything else is in play: a join, or a to-many being filled. */
187
+ resolveRelationAwarePrefix(tableName, meta, opts, populate, joins) {
188
+ return (opts.prefix ?? (opts.autoPrefix || joins.size > 0 || populatesRelations(meta, populate)))
189
+ ? tableName
190
+ : undefined;
191
+ }
192
+ selectRelationFields(ctx, joins) {
193
+ for (const join of joins.values()) {
194
+ // A join `$sort` asked for adds no columns: it orders the rows, it does not widen them.
195
+ if (!join.projected)
196
+ continue;
193
197
  ctx.append(', ');
194
- this.selectFields(ctx, relEntity, relQuery.$select, { prefix: joinRelAlias, autoPrefixAlias: true }, relQuery.$exclude);
195
- this.selectRelationFields(ctx, relEntity, relQuery.$select, relQuery.$populate, { prefix: joinRelAlias });
196
- });
198
+ this.selectFields(ctx, join.entity, join.query.$select, { prefix: join.path, autoPrefixAlias: true }, join.query.$exclude);
199
+ }
197
200
  }
198
- selectRelationJoins(ctx, entity, select, populate, opts = {}) {
199
- this.forEachJoinableRelation(entity, select, populate, opts, (relEntity, relQuery, joinRelAlias, relOpts, meta, tableName, required) => {
200
- const relMeta = getMeta(relEntity);
201
- const relTableName = this.resolveTableName(relEntity, relMeta);
202
- const relEntityName = this.escapeId(relTableName);
203
- const relPath = opts.prefix ? this.escapeId(opts.prefix, true) : this.escapeId(tableName);
204
- const joinType = required ? 'INNER' : 'LEFT';
205
- const joinAlias = this.escapeId(joinRelAlias, true);
206
- ctx.append(` ${joinType} JOIN ${relEntityName} ${joinAlias} ON `);
207
- let refAppended = false;
208
- for (const it of relOpts.references) {
209
- if (refAppended)
201
+ selectRelationJoins(ctx, meta, tableName, joins) {
202
+ for (const join of joins.values()) {
203
+ const joinAlias = this.escapeId(join.path, true);
204
+ const parentAlias = join.parent ? this.escapeId(join.parent.path, true) : this.escapeId(tableName);
205
+ ctx.append(` ${join.required ? 'INNER' : 'LEFT'} JOIN ${this.escapeId(this.resolveTableName(join.entity, join.meta))} ${joinAlias} ON `);
206
+ join.relation.references.forEach((reference, index) => {
207
+ if (index > 0)
210
208
  ctx.append(' AND ');
211
- const relField = relMeta.fields[it.foreign];
212
- const field = meta.fields[it.local];
213
- const foreignColumnName = this.resolveColumnName(it.foreign, relField);
214
- const localColumnName = this.resolveColumnName(it.local, field);
215
- ctx.append(`${joinAlias}.${this.escapeId(foreignColumnName)} = ${relPath}.${this.escapeId(localColumnName)}`);
216
- refAppended = true;
217
- }
218
- // Unconditional, not gated by `relQuery.$where`: a joined relation's own filters (in
209
+ const foreign = this.escapeId(this.columnOf(join.meta, reference.foreign));
210
+ // Two calls rather than one over a union: the parent is either another join's entity or the
211
+ // queried one, and their metadata types have nothing in common.
212
+ const local = this.escapeId(join.parent ? this.columnOf(join.parent.meta, reference.local) : this.columnOf(meta, reference.local));
213
+ ctx.append(`${joinAlias}.${foreign} = ${parentAlias}.${local}`);
214
+ });
215
+ // Unconditional, not gated by `join.query.$where`: a joined relation's own filters (in
219
216
  // particular `security: true` ones) must apply even to a bare `$populate: { rel: true }`
220
- // with no explicit `$where`. `where()` -> `renderWhere()` no-ops cleanly (appends nothing)
221
- // when there is truly nothing to add, so this is a pure superset of the old behavior.
222
- this.where(ctx, relEntity, relQuery.$where ?? {}, { prefix: joinRelAlias, clause: 'AND' });
223
- this.selectRelationJoins(ctx, relEntity, relQuery.$select, relQuery.$populate, { prefix: joinRelAlias });
224
- });
225
- }
226
- /**
227
- * Iterates over joinable (11/m1) relations for a given select, resolving shared metadata.
228
- * Used by both `selectRelationFields` and `selectRelationJoins` to avoid duplicated iteration logic.
229
- */
230
- forEachJoinableRelation(entity, select, populate, opts, callback) {
231
- if (!select && !populate)
232
- return;
233
- const meta = getMeta(entity);
234
- const tableName = this.resolveTableName(entity, meta);
235
- const relKeys = getRelationRequestSummary(meta, populate).joinableKeys;
236
- const prefix = opts.prefix;
237
- for (const relKey of relKeys) {
238
- const relOpts = meta.relations[relKey];
239
- if (!relOpts)
240
- continue;
241
- const isFirstLevel = prefix === tableName;
242
- const joinRelAlias = isFirstLevel ? relKey : prefix ? `${prefix}.${relKey}` : relKey;
243
- const relEntity = relOpts.entity();
244
- const { query: relQuery, required } = parseRelationAtKey(relKey, populate);
245
- callback(relEntity, relQuery, joinRelAlias, relOpts, meta, tableName, required);
217
+ // with no explicit `$where` - and equally to a join `$sort` brought in on its own.
218
+ // `where()` -> `renderWhere()` no-ops cleanly (appends nothing) when there is nothing to add.
219
+ this.where(ctx, join.entity, join.query.$where ?? {}, { prefix: join.path, clause: 'AND' });
246
220
  }
247
221
  }
248
222
  where(ctx, entity, where = {}, opts = {}) {
@@ -253,34 +227,35 @@ export class AbstractSqlDialect extends IndexSqlDialect {
253
227
  /** Renders a `$where` tree without applying entity filters (used for same-scope `$and`/`$or` recursion). */
254
228
  renderWhere(ctx, entity, where = {}, opts = {}) {
255
229
  const meta = getMeta(entity);
256
- const { usePrecedence, clause = 'WHERE' } = opts;
230
+ const { clause = 'WHERE' } = opts;
257
231
  where = buildQueryWhereAsMap(meta, where);
258
- const whereKeys = getKeys(where);
232
+ // An `undefined` value emits nothing, so it must not count towards the terms either: it decides
233
+ // both where the `AND`s go and whether this fragment needs parentheses.
234
+ const whereKeys = getKeys(where).filter((key) => where[key] !== undefined);
259
235
  if (!whereKeys.length) {
260
236
  return;
261
237
  }
262
238
  if (clause) {
263
239
  ctx.append(` ${clause} `);
264
240
  }
265
- if (usePrecedence) {
241
+ const multipleKeys = whereKeys.length > 1;
242
+ // This fragment joins its own keys with `AND`, so appending it after one (a JOIN's `ON`) needs no
243
+ // parentheses - but anything nested in it is still an operand, since that may be an `OR`.
244
+ const parenthesize = multipleKeys && opts.operand;
245
+ if (parenthesize) {
266
246
  ctx.append('(');
267
247
  }
268
- const multipleKeys = whereKeys.length > 1;
269
- // `usePrecedence` is the only field that changes for the children and it is constant across
270
- // them, so resolve the child options once instead of spreading `opts` per key.
271
- const childOpts = opts.usePrecedence === multipleKeys ? opts : { ...opts, usePrecedence: multipleKeys };
272
- let appended = false;
273
- whereKeys.forEach((key) => {
274
- const val = where[key];
275
- if (val === undefined)
276
- return;
277
- if (appended) {
248
+ // Each key is an operand of the `AND` joining them; a lone key emits this fragment verbatim, so
249
+ // it inherits this one's position instead.
250
+ const childOperand = multipleKeys || opts.operand || clause === 'AND';
251
+ const childOpts = opts.operand === childOperand ? opts : { ...opts, operand: childOperand };
252
+ whereKeys.forEach((key, index) => {
253
+ if (index > 0) {
278
254
  ctx.append(' AND ');
279
255
  }
280
- this.compare(ctx, entity, key, val, childOpts);
281
- appended = true;
256
+ this.compare(ctx, entity, key, where[key], childOpts);
282
257
  });
283
- if (usePrecedence) {
258
+ if (parenthesize) {
284
259
  ctx.append(')');
285
260
  }
286
261
  }
@@ -347,35 +322,31 @@ export class AbstractSqlDialect extends IndexSqlDialect {
347
322
  }
348
323
  compareLogicalOperator(ctx, entity, key, val, opts) {
349
324
  const op = AbstractSqlDialect.NEGATE_OP_MAP.get(key) ?? key;
350
- const negate = AbstractSqlDialect.NEGATE_OP_MAP.has(key) ? 'NOT' : '';
351
- const valArr = val ?? [];
352
- const hasManyItems = valArr.length > 1;
353
- if ((opts.usePrecedence || negate) && hasManyItems) {
354
- ctx.append((negate ? negate + ' ' : '') + '(');
355
- }
356
- else if (negate) {
357
- ctx.append(negate + ' ');
358
- }
359
- valArr.forEach((whereEntry, index) => {
360
- if (index > 0) {
361
- ctx.append(op === '$or' ? ' OR ' : ' AND ');
362
- }
363
- if (whereEntry instanceof QueryRaw) {
364
- this.getRawValue(ctx, {
365
- value: whereEntry,
366
- });
325
+ const negate = AbstractSqlDialect.NEGATE_OP_MAP.has(key);
326
+ const items = val ?? [];
327
+ // With more than one item each is an operand of the operator joining them, so a compound item
328
+ // parenthesizes itself and precedence never applies; a lone item is this group verbatim, so it
329
+ // inherits the group's own position. A negation always makes its subject an operand.
330
+ const childOperand = items.length > 1 || negate || opts.operand;
331
+ // Rendered before anything is appended, because an item that contributes no SQL (`{}`, an
332
+ // `undefined` entry) must leave no dangling separator behind, and how many terms this fragment
333
+ // really emits is what decides whether it needs parentheses.
334
+ const parts = items
335
+ .map((entry) => this.buildFragment(ctx, (fragmentCtx) => {
336
+ if (entry instanceof QueryRaw) {
337
+ this.getRawValue(fragmentCtx, { value: entry });
367
338
  }
368
- else if (whereEntry) {
369
- this.renderWhere(ctx, entity, whereEntry, {
370
- prefix: opts.prefix,
371
- usePrecedence: hasManyItems && !Array.isArray(whereEntry) && hasMultipleKeys(whereEntry),
372
- clause: false,
373
- });
339
+ else if (entry) {
340
+ this.renderWhere(fragmentCtx, entity, entry, { prefix: opts.prefix, operand: childOperand, clause: false });
374
341
  }
375
- });
376
- if ((opts.usePrecedence || negate) && hasManyItems) {
377
- ctx.append(')');
342
+ }))
343
+ .filter((part) => part !== '');
344
+ if (!parts.length) {
345
+ return;
378
346
  }
347
+ const body = parts.join(op === '$or' ? ' OR ' : ' AND ');
348
+ const parenthesize = parts.length > 1 && (opts.operand || negate);
349
+ ctx.append((negate ? 'NOT ' : '') + (parenthesize ? `(${body})` : body));
379
350
  }
380
351
  /** Memoizes {@link escapedColumnName}; see there for why it is per dialect instance. */
381
352
  escapedColumns = new WeakMap();
@@ -389,47 +360,70 @@ export class AbstractSqlDialect extends IndexSqlDialect {
389
360
  ['$lt', ' < '],
390
361
  ['$lte', ' <= '],
391
362
  ]);
392
- static LIKE_OP_MAP = new Map([
393
- ['$startsWith', (v) => `${v}%`],
394
- ['$istartsWith', (v) => `${v.toLowerCase()}%`],
395
- ['$endsWith', (v) => `%${v}`],
396
- ['$iendsWith', (v) => `%${v.toLowerCase()}`],
397
- ['$includes', (v) => `%${v}%`],
398
- ['$iincludes', (v) => `%${v.toLowerCase()}%`],
399
- ['$like', (v) => v],
400
- ['$ilike', (v) => v.toLowerCase()],
401
- ]);
402
363
  /**
403
- * The case-insensitive `LIKE_OP_MAP` keys - the value is lowercased, so the comparison must use
404
- * `ilikeExpr` (Postgres's `ILIKE`) rather than `LIKE`. `$includes` is deliberately excluded even
405
- * though it starts with the substring `$i`: it is case-sensitive, unlike `$iincludes`.
364
+ * Every `$like`-family operator: the pattern it wraps its value in, and whether it ignores case.
365
+ * Each case-sensitive operator is paired here with the `$i` twin that shares its pattern, so the
366
+ * two can never drift apart - and neither one decides case folding, which is
367
+ * {@link caseInsensitiveMatch}'s single call.
406
368
  */
407
- static LIKE_CASE_INSENSITIVE_OPS = new Set([
408
- '$istartsWith',
409
- '$iendsWith',
410
- '$iincludes',
411
- '$ilike',
412
- ]);
413
- /** Builds `prefix.column` from an already-resolved field. */
369
+ static LIKE_OPS = new Map([
370
+ ['$like', '$ilike', (v) => v],
371
+ ['$startsWith', '$istartsWith', (v) => `${v}%`],
372
+ ['$endsWith', '$iendsWith', (v) => `%${v}`],
373
+ ['$includes', '$iincludes', (v) => `%${v}%`],
374
+ ].flatMap(([sensitive, insensitive, pattern]) => [
375
+ [sensitive, { pattern, insensitive: false }],
376
+ [insensitive, { pattern, insensitive: true }],
377
+ ]));
378
+ /**
379
+ * How this engine matches case-insensitively. One decision, not two: folding the pattern while the
380
+ * comparison leaves the column alone matches neither case, which is what `$istartsWith: 'Some'`
381
+ * used to do wherever `LIKE` is case-sensitive.
382
+ *
383
+ * - `ilike`: the engine has a case-insensitive operator (`ILIKE`), so the pattern goes through as written.
384
+ * - `native`: plain `LIKE` already ignores case (SQLite, for ASCII). Folding the pattern in JS would
385
+ * only break the non-ASCII characters the engine cannot fold anyway - `'É'` would become an `'é'`
386
+ * that matches nothing.
387
+ * - `fold`: nothing ignores case on its own, so both sides are lowered explicitly. Not indexable as
388
+ * such; an expression index over `LOWER(column)` is what makes it so.
389
+ */
390
+ caseInsensitiveMatch = 'fold';
391
+ /**
392
+ * A `$like`-family condition, or `undefined` when `op` is not one of them. Shared by columns and
393
+ * JSON paths, and the only place a pattern is folded - always together with the column it is
394
+ * compared against.
395
+ */
396
+ likeCondition(ctx, operand, op, val) {
397
+ const like = AbstractSqlDialect.LIKE_OPS.get(op);
398
+ if (!like) {
399
+ return undefined;
400
+ }
401
+ const fold = like.insensitive && this.caseInsensitiveMatch === 'fold';
402
+ const value = String(val);
403
+ const ph = this.addValue(ctx.values, like.pattern(fold ? value.toLowerCase() : value));
404
+ const matchOp = like.insensitive && this.caseInsensitiveMatch === 'ilike' ? 'ILIKE' : this.likeFn;
405
+ return `${fold ? `LOWER(${operand})` : operand} ${matchOp} ${ph}`;
406
+ }
407
+ /** Builds `prefix.column` from an already-resolved field, through the same memo writes use. */
414
408
  columnWithPrefix(key, field, prefix) {
415
- const columnName = this.resolveColumnName(key, field);
416
- const escapedPrefix = this.escapeId(prefix, true, true);
417
- return escapedPrefix + this.escapeId(columnName);
409
+ return this.escapeId(prefix, true, true) + this.escapedColumnOf(key, field);
418
410
  }
419
411
  /**
420
- * Resolves the SQL operand for a field comparison.
421
- * For QueryRaw virtuals, appends the raw expression to ctx and returns undefined.
412
+ * The SQL a field comparison reads its left-hand side from. A virtual field builds its expression
413
+ * as text rather than appending it, so every operator gets a real operand to wrap - `LOWER(...)`,
414
+ * `NOT (... <=> ...)` - instead of having to fall back to a form that takes none.
422
415
  */
423
416
  resolveOperandField(ctx, entity, key, opts) {
424
- const col = getMeta(entity).fields[key];
425
- if (col?.virtual) {
426
- this.getComparisonKey(ctx, entity, key, opts);
427
- return undefined;
417
+ const field = getMeta(entity).fields[key];
418
+ const virtual = field?.virtual;
419
+ if (virtual) {
420
+ return this.buildFragment(ctx, (fragmentCtx) => this.getRawValue(fragmentCtx, {
421
+ value: virtual,
422
+ prefix: opts.prefix,
423
+ escapedPrefix: this.escapeId(opts.prefix, true, true),
424
+ }));
428
425
  }
429
- return this.columnWithPrefix(key, col, opts.prefix);
430
- }
431
- appendFieldSql(ctx, field, sql) {
432
- ctx.append(field ? `${field}${sql}` : sql);
426
+ return this.columnWithPrefix(key, field, opts.prefix);
433
427
  }
434
428
  compareFieldOperator(ctx, entity, key, op, val, opts = {}) {
435
429
  const field = this.resolveOperandField(ctx, entity, key, opts);
@@ -443,102 +437,75 @@ export class AbstractSqlDialect extends IndexSqlDialect {
443
437
  ctx.append(')');
444
438
  break;
445
439
  case '$all':
446
- ctx.append(this.jsonAll(ctx, field ?? '', val));
440
+ ctx.append(this.jsonAll(ctx, field, val));
447
441
  break;
448
442
  case '$size':
449
- ctx.append(this.jsonSize(ctx, field ?? '', val));
443
+ ctx.append(this.jsonSize(ctx, field, val));
450
444
  break;
451
445
  case '$elemMatch':
452
- ctx.append(this.jsonElemMatch(ctx, field ?? '', val));
446
+ ctx.append(this.jsonElemMatch(ctx, field, val));
453
447
  break;
454
448
  default:
455
449
  throw TypeError(`unknown operator: ${op}`);
456
450
  }
457
451
  }
458
452
  /**
459
- * Render `<operand> <op> <value>` for every operator that needs nothing but its left-hand SQL, and
460
- * report whether `op` was one of them.
453
+ * `<operand> <op> <value>` for every operator that needs nothing but its left-hand SQL, or
454
+ * `undefined` when `op` is not one of them.
461
455
  *
462
456
  * One implementation for three callers that each had their own: a WHERE column, a HAVING aggregate
463
- * expression, and a `$size` count (which passes no operand, since its expression is already in the
464
- * context). They previously disagreed - HAVING carried a second comparison-operator map and threw
465
- * `unsupported HAVING operator` on the `$like` that `QueryHavingMap` accepts, and neither of the
466
- * other two turned `$eq: null` into `IS NULL` the way the WHERE path does.
457
+ * expression, and a `$size` count (whose expression is already in the context, so it passes an
458
+ * empty operand). They previously disagreed - HAVING carried a second comparison-operator map and
459
+ * threw `unsupported HAVING operator` on the `$like` that `QueryHavingMap` accepts, and neither of
460
+ * the other two turned `$eq: null` into `IS NULL` the way the WHERE path does.
467
461
  *
468
462
  * The operators kept out are the ones that need more than an operand: `$not` recurses through the
469
463
  * entity, and `$all`/`$size`/`$elemMatch` address a JSON document.
470
464
  */
471
- appendOperatorCondition(ctx, operand, op, val) {
472
- const simpleOp = AbstractSqlDialect.COMPARE_OP_MAP.get(op);
473
- if (simpleOp) {
474
- this.appendFieldSql(ctx, operand, `${simpleOp}${this.addValue(ctx.values, val)}`);
475
- return true;
465
+ operatorCondition(ctx, operand, op, val) {
466
+ const compareOp = AbstractSqlDialect.COMPARE_OP_MAP.get(op);
467
+ if (compareOp) {
468
+ return `${operand}${compareOp}${this.addValue(ctx.values, val)}`;
476
469
  }
477
- const likeWrap = AbstractSqlDialect.LIKE_OP_MAP.get(op);
478
- if (likeWrap) {
479
- this.appendLikeOp(ctx, operand, op, likeWrap(val));
480
- return true;
470
+ const like = this.likeCondition(ctx, operand, op, val);
471
+ if (like) {
472
+ return like;
481
473
  }
482
474
  switch (op) {
483
475
  case '$eq':
476
+ return val === null ? `${operand} IS NULL` : `${operand} = ${this.addValue(ctx.values, val)}`;
484
477
  case '$ne':
485
- this.appendEqNe(ctx, operand, op, val);
486
- return true;
478
+ return val === null ? `${operand} IS NOT NULL` : this.neExpr(operand, this.addValue(ctx.values, val));
487
479
  case '$regex':
488
- this.appendFieldSql(ctx, operand, ` ${this.regexpOp} ${this.addValue(ctx.values, val)}`);
489
- return true;
480
+ return `${operand} ${this.regexpOp} ${this.addValue(ctx.values, val)}`;
490
481
  case '$in':
491
- case '$nin':
492
- this.appendInNin(ctx, operand, op, val);
493
- return true;
482
+ case '$nin': {
483
+ if (!Array.isArray(val)) {
484
+ // Not covered by the types: `/http` casts client JSON straight to `Query`, so this arrives untyped.
485
+ throw TypeError(`${op} expects an array, got ${val === null ? 'null' : typeof val}`);
486
+ }
487
+ return operand + this.formatIn(ctx, val, op === '$nin');
488
+ }
494
489
  case '$between': {
495
490
  const [min, max] = val;
496
- this.appendFieldSql(ctx, operand, ` BETWEEN ${this.addValue(ctx.values, min)} AND ${this.addValue(ctx.values, max)}`);
497
- return true;
491
+ return `${operand} BETWEEN ${this.addValue(ctx.values, min)} AND ${this.addValue(ctx.values, max)}`;
498
492
  }
499
493
  case '$isNull':
500
- this.appendFieldSql(ctx, operand, val ? ' IS NULL' : ' IS NOT NULL');
501
- return true;
494
+ return operand + (val ? ' IS NULL' : ' IS NOT NULL');
502
495
  case '$isNotNull':
503
- this.appendFieldSql(ctx, operand, val ? ' IS NOT NULL' : ' IS NULL');
504
- return true;
496
+ return operand + (val ? ' IS NOT NULL' : ' IS NULL');
505
497
  default:
506
- return false;
498
+ return undefined;
507
499
  }
508
500
  }
509
- appendLikeOp(ctx, field, op, wrappedVal) {
510
- const isIlike = AbstractSqlDialect.LIKE_CASE_INSENSITIVE_OPS.has(op);
511
- const ph = this.addValue(ctx.values, wrappedVal);
512
- if (isIlike && field) {
513
- ctx.append(this.ilikeExpr(field, ph));
514
- }
515
- else {
516
- this.appendFieldSql(ctx, field, ` ${this.likeFn} ${ph}`);
517
- }
518
- }
519
- appendEqNe(ctx, field, op, val) {
520
- if (val === null) {
521
- this.appendFieldSql(ctx, field, op === '$eq' ? ' IS NULL' : ' IS NOT NULL');
522
- return;
523
- }
524
- const ph = this.addValue(ctx.values, val);
525
- if (op === '$eq') {
526
- this.appendFieldSql(ctx, field, ` = ${ph}`);
527
- return;
528
- }
529
- if (field) {
530
- ctx.append(this.neExpr(field, ph));
531
- }
532
- else {
533
- this.appendFieldSql(ctx, field, ` ${this.neOp} ${ph}`);
534
- }
535
- }
536
- appendInNin(ctx, field, op, val) {
537
- if (!Array.isArray(val)) {
538
- // Not covered by the types: `/http` casts client JSON straight to `Query`, so this arrives untyped.
539
- throw TypeError(`${op} expects an array, got ${val === null ? 'null' : typeof val}`);
501
+ /** {@link operatorCondition}, appended; `false` when `op` needs more than an operand. */
502
+ appendOperatorCondition(ctx, operand, op, val) {
503
+ const condition = this.operatorCondition(ctx, operand, op, val);
504
+ if (condition === undefined) {
505
+ return false;
540
506
  }
541
- this.appendFieldSql(ctx, field, this.formatIn(ctx, val, op === '$nin'));
507
+ ctx.append(condition);
508
+ return true;
542
509
  }
543
510
  /**
544
511
  * Build a comparison condition for a JSON field.
@@ -547,6 +514,11 @@ export class AbstractSqlDialect extends IndexSqlDialect {
547
514
  */
548
515
  buildJsonFieldCondition(ctx, fieldAccessor, jsonPath, op, value, asJson = false) {
549
516
  const jsonField = fieldAccessor(jsonPath);
517
+ // The `$like` family reads a JSON path exactly as it reads a column, case folding included.
518
+ const like = this.likeCondition(ctx, jsonField, op, value);
519
+ if (like) {
520
+ return like;
521
+ }
550
522
  switch (op) {
551
523
  case '$eq':
552
524
  if (value === null)
@@ -564,22 +536,6 @@ export class AbstractSqlDialect extends IndexSqlDialect {
564
536
  return `${this.numericCast(jsonField)} < ${this.addValue(ctx.values, value)}`;
565
537
  case '$lte':
566
538
  return `${this.numericCast(jsonField)} <= ${this.addValue(ctx.values, value)}`;
567
- case '$like':
568
- return `${jsonField} ${this.likeFn} ${this.addValue(ctx.values, value)}`;
569
- case '$ilike':
570
- return this.ilikeExpr(jsonField, this.addValue(ctx.values, value.toLowerCase()));
571
- case '$startsWith':
572
- return `${jsonField} ${this.likeFn} ${this.addValue(ctx.values, `${value}%`)}`;
573
- case '$istartsWith':
574
- return this.ilikeExpr(jsonField, this.addValue(ctx.values, `${value.toLowerCase()}%`));
575
- case '$endsWith':
576
- return `${jsonField} ${this.likeFn} ${this.addValue(ctx.values, `%${value}`)}`;
577
- case '$iendsWith':
578
- return this.ilikeExpr(jsonField, this.addValue(ctx.values, `%${value.toLowerCase()}`));
579
- case '$includes':
580
- return `${jsonField} ${this.likeFn} ${this.addValue(ctx.values, `%${value}%`)}`;
581
- case '$iincludes':
582
- return this.ilikeExpr(jsonField, this.addValue(ctx.values, `%${value.toLowerCase()}%`));
583
539
  case '$regex':
584
540
  return `${jsonField} ${this.regexpOp} ${this.addValue(ctx.values, value)}`;
585
541
  case '$in':
@@ -673,70 +629,83 @@ export class AbstractSqlDialect extends IndexSqlDialect {
673
629
  ctx.pushValue(JSON.stringify(value));
674
630
  return this.jsonCast('?');
675
631
  }
676
- getComparisonKey(ctx, entity, key, { prefix } = {}) {
677
- const meta = getMeta(entity);
678
- const escapedPrefix = this.escapeId(prefix, true, true);
679
- const field = meta.fields[key];
680
- if (field?.virtual) {
681
- this.getRawValue(ctx, {
682
- value: field.virtual,
683
- prefix,
684
- escapedPrefix,
685
- });
686
- return;
687
- }
688
- const columnName = this.resolveColumnName(key, field);
689
- ctx.append(escapedPrefix + this.escapeId(columnName));
632
+ /** {@link resolveOperandField}, appended. */
633
+ getComparisonKey(ctx, entity, key, opts = {}) {
634
+ ctx.append(this.resolveOperandField(ctx, entity, key, opts));
690
635
  }
691
- sort(ctx, entity, sort, { prefix }) {
636
+ sort(ctx, entity, sort, opts = {}) {
692
637
  if (!hasKeys(sort)) {
693
638
  return;
694
639
  }
695
- const sortMap = buildSortMap(sort);
696
- const meta = getMeta(entity);
697
- // Separate vector search entries from direction entries before flattening,
698
- // because flatObject recursively destructures objects - it would break QueryVectorSearch.
699
- const vectorEntries = [];
700
- const directionEntries = {};
701
- for (const [key, val] of Object.entries(sortMap)) {
702
- if (isVectorSearch(val)) {
703
- vectorEntries.push([key, val]);
704
- }
705
- else {
706
- directionEntries[key] = val;
707
- }
640
+ // Collected before anything is appended so an unorderable key is reported instead of half a
641
+ // clause, and because a vector distance is the primary ordering wherever it appears in the map.
642
+ const vectors = [];
643
+ const columns = [];
644
+ this.collectSortTerms(ctx, getMeta(entity), sort, opts, vectors, columns);
645
+ const terms = [...vectors, ...columns];
646
+ if (terms.length) {
647
+ ctx.append(` ORDER BY ${terms.join(', ')}`);
708
648
  }
709
- const flattenedSort = flatObject(directionEntries, prefix);
710
- // Merge: vector entries first (primary ordering), then flattened direction entries.
711
- const allEntries = [...vectorEntries, ...Object.entries(flattenedSort)];
712
- if (!allEntries.length)
713
- return;
714
- ctx.append(' ORDER BY ');
715
- allEntries.forEach(([key, sort], index) => {
716
- if (index > 0) {
717
- ctx.append(', ');
718
- }
719
- if (isVectorSearch(sort)) {
720
- if (sort.$project) {
721
- // Distance already projected in SELECT - reference the alias to avoid recomputation
722
- ctx.append(this.escapeId(sort.$project));
723
- }
724
- else {
725
- this.appendVectorSort(ctx, meta, key, sort);
649
+ }
650
+ /**
651
+ * Walks `$sort` against the metadata of the entity each level addresses, rather than flattening it
652
+ * to dotted strings and reading every key off the root: only that way does a related column resolve
653
+ * through its own `@Field({ name })`, and only that way is `tax.category` the one alias the join
654
+ * carries instead of two quoted identifiers.
655
+ */
656
+ collectSortTerms(ctx, meta, sort, opts, vectors, columns, path = '') {
657
+ // Below the first level the alias a column is qualified by *is* the path walked to reach it.
658
+ const prefix = path || opts.prefix;
659
+ for (const [key, value] of Object.entries(sort)) {
660
+ const relation = meta.relations[key];
661
+ if (relation) {
662
+ const relPath = path ? `${path}.${key}` : key;
663
+ if (!isSortMap(value)) {
664
+ throw new TypeError(`$sort by relation '${relPath}' expects a map of its fields, got ${String(value)}`);
726
665
  }
727
- return;
666
+ const join = this.resolveSortJoin(relation, relPath, opts);
667
+ this.collectSortTerms(ctx, join.meta, value, opts, vectors, columns, relPath);
668
+ continue;
728
669
  }
729
- const direction = this.resolveSortDirection(sort);
730
- // Detect JSONB dot-notation: 'column.path'
731
- const jsonDot = this.resolveJsonDotPath(meta, key);
732
- if (jsonDot) {
733
- ctx.append(jsonDot.accessor() + direction);
734
- return;
670
+ if (isVectorSearch(value)) {
671
+ if (path) {
672
+ throw new TypeError(`$vector sort is only supported on the queried entity, not on relation '${path}'`);
673
+ }
674
+ // Already projected in the SELECT list: order by that alias rather than recomputing it.
675
+ vectors.push(value.$project
676
+ ? this.escapeId(value.$project)
677
+ : this.buildFragment(ctx, (fragmentCtx) => this.appendVectorSort(fragmentCtx, meta, key, value)));
678
+ continue;
735
679
  }
736
- const field = meta.fields[key];
737
- const name = this.resolveColumnName(key, field);
738
- ctx.append(this.escapeId(name) + direction);
739
- });
680
+ columns.push(this.sortColumn(meta, key, prefix) + this.resolveSortDirection(value));
681
+ }
682
+ }
683
+ /** The join an `ORDER BY` term addresses, or why the statement cannot order by it. */
684
+ resolveSortJoin(relation, path, opts) {
685
+ if (isToManyRelation(relation)) {
686
+ throw new TypeError(`cannot $sort by '${path}': a parent has many of them, so there is no single value to order by. Sort the relation's own rows inside $populate instead.`);
687
+ }
688
+ const join = opts.joins?.get(path);
689
+ if (!join) {
690
+ throw new TypeError(`cannot $sort by relation '${path}': this statement joins no relations`);
691
+ }
692
+ // `SELECT DISTINCT` can only order by what it selected, on every engine here, so a join brought in
693
+ // for the sort alone has nothing to order by. Populating it puts its columns in the select list.
694
+ if (opts.distinct && !join.projected) {
695
+ throw new TypeError(`cannot $sort by relation '${path}' with $distinct unless '${path}' is populated: SELECT DISTINCT orders only by selected columns`);
696
+ }
697
+ return join;
698
+ }
699
+ /**
700
+ * The `ORDER BY` operand for one key. A key that is not a column of `meta` - a virtual field, a
701
+ * `raw()` projection - is an output alias, which is never table-qualified and needs no resolving.
702
+ */
703
+ sortColumn(meta, key, prefix) {
704
+ const field = meta.fields[key];
705
+ if (field) {
706
+ return field.virtual ? this.escapeId(key) : this.columnWithPrefix(key, field, prefix);
707
+ }
708
+ return this.resolveJsonDotPath(meta, key, prefix)?.accessor() ?? this.escapeId(key);
740
709
  }
741
710
  pager(ctx, opts) {
742
711
  if (opts.$limit) {
@@ -750,20 +719,17 @@ export class AbstractSqlDialect extends IndexSqlDialect {
750
719
  supportsRowLocks = true;
751
720
  /** MariaDB is the one engine here that cannot narrow a lock to one table of a join. */
752
721
  supportsLockOf = true;
753
- /** Whether this statement joins, which is what forces the lock to be narrowed to one table. */
754
- joinsRelations(meta, q) {
755
- return getRelationRequestSummary(meta, q.$populate).joinableKeys.length > 0;
756
- }
757
722
  /** Validated before the querier checks for a transaction, so the clearer error wins. */
758
- assertLockSupported(entity, q) {
723
+ assertLockSupported(entity, q, joins) {
759
724
  if (!parseQueryLock(q.$lock)) {
760
725
  return;
761
726
  }
762
727
  if (!this.supportsRowLocks) {
763
728
  throw new TypeError(`${this.dialectName} does not support row-level locking ($lock)`);
764
729
  }
765
- if (!this.supportsLockOf && this.joinsRelations(getMeta(entity), q)) {
766
- throw new TypeError(`${this.dialectName} cannot narrow a row lock to one table, so $lock cannot be combined with a joined $populate`);
730
+ joins ??= resolveQueryJoins(getMeta(entity), q);
731
+ if (!this.supportsLockOf && joins.size > 0) {
732
+ throw new TypeError(`${this.dialectName} cannot narrow a row lock to one table, so $lock cannot be combined with a joined relation`);
767
733
  }
768
734
  }
769
735
  /**
@@ -771,21 +737,22 @@ export class AbstractSqlDialect extends IndexSqlDialect {
771
737
  * joined: Postgres refuses a bare `FOR UPDATE` over the nullable side of an outer join outright,
772
738
  * and the other engines quietly widen the lock to the joined rows.
773
739
  */
774
- appendLock(ctx, entity, q) {
740
+ appendLock(ctx, entity, q, joins = NO_JOINS) {
775
741
  const wait = parseQueryLock(q.$lock);
776
742
  if (!wait) {
777
743
  return;
778
744
  }
779
- this.assertLockSupported(entity, q);
745
+ this.assertLockSupported(entity, q, joins);
780
746
  const meta = getMeta(entity);
781
- const target = this.joinsRelations(meta, q) ? ` OF ${this.escapeId(this.resolveTableName(entity, meta))}` : '';
747
+ const target = joins.size > 0 ? ` OF ${this.escapeId(this.resolveTableName(entity, meta))}` : '';
782
748
  const suffix = wait === 'skip' ? ' SKIP LOCKED' : wait === 'nowait' ? ' NOWAIT' : '';
783
749
  ctx.append(` FOR UPDATE${target}${suffix}`);
784
750
  }
785
751
  count(ctx, entity, q, opts) {
786
752
  const search = { ...q };
753
+ // A count joins nothing and orders nothing: how many rows match is the same either way.
787
754
  delete search.$sort;
788
- this.select(ctx, entity, [raw('COUNT(*)', 'count')]);
755
+ this.select(ctx, entity, { $select: [raw('COUNT(*)', 'count')] });
789
756
  this.search(ctx, entity, search, opts);
790
757
  }
791
758
  /** `$group` aggregate operator → SQL function name. An allowlist, not a formatter: the op key
@@ -834,20 +801,24 @@ export class AbstractSqlDialect extends IndexSqlDialect {
834
801
  if (q.$having) {
835
802
  this.having(ctx, q.$having, aggregateExpressions);
836
803
  }
837
- this.aggregateSort(ctx, q.$sort, aggregateExpressions);
804
+ this.aggregateSort(ctx, meta, q.$sort, aggregateExpressions);
838
805
  this.pager(ctx, q);
839
806
  }
840
807
  /**
841
- * ORDER BY for aggregate queries - handles both entity-field and alias references.
808
+ * ORDER BY for aggregate queries - handles both entity-field and alias references. A grouped
809
+ * statement has no joins to address, so a relation key is rejected rather than emitted as an alias
810
+ * nothing defines.
842
811
  */
843
- aggregateSort(ctx, sort, aggregateExpressions) {
844
- const sortMap = buildSortMap(sort);
845
- if (!hasKeys(sortMap))
812
+ aggregateSort(ctx, meta, sort, aggregateExpressions) {
813
+ if (!hasKeys(sort))
846
814
  return;
847
815
  ctx.append(' ORDER BY ');
848
- Object.entries(sortMap).forEach(([key, dir], index) => {
816
+ Object.entries(sort).forEach(([key, dir], index) => {
849
817
  if (index > 0)
850
818
  ctx.append(', ');
819
+ if (meta.relations[key]) {
820
+ throw new TypeError(`cannot $sort by relation '${key}' in an aggregate query: it groups rows, it joins none`);
821
+ }
851
822
  const direction = this.resolveSortDirection(dir);
852
823
  const ref = aggregateExpressions[key] ?? this.escapeId(key);
853
824
  ctx.append(ref + direction);
@@ -894,11 +865,14 @@ export class AbstractSqlDialect extends IndexSqlDialect {
894
865
  });
895
866
  }
896
867
  find(ctx, entity, q = {}, opts) {
897
- this.select(ctx, entity, q.$select, q.$exclude, q.$populate, opts, q.$distinct, q.$sort);
898
- this.search(ctx, entity, q, opts);
868
+ // The one statement that can join, so the one that resolves the join set; everything else renders
869
+ // against `NO_JOINS` and rejects a `$sort` that would need one.
870
+ const joins = resolveQueryJoins(getMeta(entity), q);
871
+ this.select(ctx, entity, q, opts, joins);
872
+ this.search(ctx, entity, q, opts, joins);
899
873
  // Appended here rather than in `search`, which `count`/`update`/`delete` share: a lock belongs
900
874
  // to a SELECT alone. Every engine spells it after LIMIT/OFFSET, so it goes last.
901
- this.appendLock(ctx, entity, q);
875
+ this.appendLock(ctx, entity, q, joins);
902
876
  }
903
877
  insert(ctx, entity, payload, opts) {
904
878
  this.appendInsertValues(ctx, entity, payload, opts);
@@ -1358,18 +1332,20 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1358
1332
  * metadata is shared between dialects while this result is not, since `escapeIdChar` and the naming
1359
1333
  * strategy differ. Weakly keyed so a transient entity's metadata stays collectable.
1360
1334
  */
1361
- escapedColumnName(meta, key) {
1362
- const field = meta.fields[key];
1335
+ escapedColumnOf(key, field) {
1363
1336
  if (!field) {
1364
- return this.escapeId(this.columnOf(meta, key));
1337
+ return this.escapeId(this.resolveColumnName(key, field));
1365
1338
  }
1366
1339
  let escaped = this.escapedColumns.get(field);
1367
1340
  if (escaped === undefined) {
1368
- escaped = this.escapeId(this.columnOf(meta, key));
1341
+ escaped = this.escapeId(this.resolveColumnName(key, field));
1369
1342
  this.escapedColumns.set(field, escaped);
1370
1343
  }
1371
1344
  return escaped;
1372
1345
  }
1346
+ escapedColumnName(meta, key) {
1347
+ return this.escapedColumnOf(key, meta.fields[key]);
1348
+ }
1373
1349
  escapedColumn(table, meta, key) {
1374
1350
  return this.escapeId(table, false, true) + this.escapedColumnName(meta, key);
1375
1351
  }
@@ -1483,10 +1459,10 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1483
1459
  // A COUNT is never NULL, so equality stays plain here instead of taking the shared renderer's
1484
1460
  // null-safe `$ne` (`IS DISTINCT FROM` on Postgres, `IS NOT` on SQLite). Same rows, shorter SQL.
1485
1461
  if (op === '$eq' || op === '$ne') {
1486
- this.appendFieldSql(ctx, undefined, ` ${op === '$eq' ? '=' : '<>'} ${this.addValue(ctx.values, val)}`);
1462
+ ctx.append(` ${op === '$eq' ? '=' : '<>'} ${this.addValue(ctx.values, val)}`);
1487
1463
  return;
1488
1464
  }
1489
- this.appendOperatorCondition(ctx, undefined, op, val);
1465
+ this.appendOperatorCondition(ctx, '', op, val);
1490
1466
  }
1491
1467
  /** ANSI-style single-quote escaping. MySQL-family dialects override this for backslash escaping. */
1492
1468
  escape(value) {
@@ -1508,9 +1484,6 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1508
1484
  neExpr(field, ph) {
1509
1485
  return `${field} ${this.neOp} ${ph}`;
1510
1486
  }
1511
- ilikeExpr(f, ph) {
1512
- return `LOWER(${f}) LIKE ${ph}`;
1513
- }
1514
1487
  /**
1515
1488
  * Formats an IN/NOT IN expression, binding each value individually.
1516
1489
  * Postgres overrides to use `= ANY($1)` / `<> ALL($1)` with a single array parameter.