uql-orm 0.27.0 → 0.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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, 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 { NO_JOINS, resolveQueryJoins, resolveSortableJoin, } 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,35 @@ 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
+ if (val !== undefined && !Array.isArray(val)) {
327
+ // Not covered by the types: `/http` casts client JSON straight to `Query`, so this arrives untyped.
328
+ throw TypeError(`${key} expects an array, got ${val === null ? 'null' : typeof val}`);
329
+ }
330
+ const items = val ?? [];
331
+ // With more than one item each is an operand of the operator joining them, so a compound item
332
+ // parenthesizes itself and precedence never applies; a lone item is this group verbatim, so it
333
+ // inherits the group's own position. A negation always makes its subject an operand.
334
+ const childOperand = items.length > 1 || negate || opts.operand;
335
+ // Rendered before anything is appended, because an item that contributes no SQL (`{}`, an
336
+ // `undefined` entry) must leave no dangling separator behind, and how many terms this fragment
337
+ // really emits is what decides whether it needs parentheses.
338
+ const parts = items
339
+ .map((entry) => this.buildFragment(ctx, (fragmentCtx) => {
340
+ if (entry instanceof QueryRaw) {
341
+ this.getRawValue(fragmentCtx, { value: entry });
367
342
  }
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
- });
343
+ else if (entry) {
344
+ this.renderWhere(fragmentCtx, entity, entry, { prefix: opts.prefix, operand: childOperand, clause: false });
374
345
  }
375
- });
376
- if ((opts.usePrecedence || negate) && hasManyItems) {
377
- ctx.append(')');
346
+ }))
347
+ .filter((part) => part !== '');
348
+ if (!parts.length) {
349
+ return;
378
350
  }
351
+ const body = parts.join(op === '$or' ? ' OR ' : ' AND ');
352
+ const parenthesize = parts.length > 1 && (opts.operand || negate);
353
+ ctx.append((negate ? 'NOT ' : '') + (parenthesize ? `(${body})` : body));
379
354
  }
380
355
  /** Memoizes {@link escapedColumnName}; see there for why it is per dialect instance. */
381
356
  escapedColumns = new WeakMap();
@@ -389,47 +364,70 @@ export class AbstractSqlDialect extends IndexSqlDialect {
389
364
  ['$lt', ' < '],
390
365
  ['$lte', ' <= '],
391
366
  ]);
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
367
  /**
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`.
368
+ * Every `$like`-family operator: the pattern it wraps its value in, and whether it ignores case.
369
+ * Each case-sensitive operator is paired here with the `$i` twin that shares its pattern, so the
370
+ * two can never drift apart - and neither one decides case folding, which is
371
+ * {@link caseInsensitiveMatch}'s single call.
406
372
  */
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. */
373
+ static LIKE_OPS = new Map([
374
+ ['$like', '$ilike', (v) => v],
375
+ ['$startsWith', '$istartsWith', (v) => `${v}%`],
376
+ ['$endsWith', '$iendsWith', (v) => `%${v}`],
377
+ ['$includes', '$iincludes', (v) => `%${v}%`],
378
+ ].flatMap(([sensitive, insensitive, pattern]) => [
379
+ [sensitive, { pattern, insensitive: false }],
380
+ [insensitive, { pattern, insensitive: true }],
381
+ ]));
382
+ /**
383
+ * How this engine matches case-insensitively. One decision, not two: folding the pattern while the
384
+ * comparison leaves the column alone matches neither case, which is what `$istartsWith: 'Some'`
385
+ * used to do wherever `LIKE` is case-sensitive.
386
+ *
387
+ * - `ilike`: the engine has a case-insensitive operator (`ILIKE`), so the pattern goes through as written.
388
+ * - `native`: plain `LIKE` already ignores case (SQLite, for ASCII). Folding the pattern in JS would
389
+ * only break the non-ASCII characters the engine cannot fold anyway - `'É'` would become an `'é'`
390
+ * that matches nothing.
391
+ * - `fold`: nothing ignores case on its own, so both sides are lowered explicitly. Not indexable as
392
+ * such; an expression index over `LOWER(column)` is what makes it so.
393
+ */
394
+ caseInsensitiveMatch = 'fold';
395
+ /**
396
+ * A `$like`-family condition, or `undefined` when `op` is not one of them. Shared by columns and
397
+ * JSON paths, and the only place a pattern is folded - always together with the column it is
398
+ * compared against.
399
+ */
400
+ likeCondition(ctx, operand, op, val) {
401
+ const like = AbstractSqlDialect.LIKE_OPS.get(op);
402
+ if (!like) {
403
+ return undefined;
404
+ }
405
+ const fold = like.insensitive && this.caseInsensitiveMatch === 'fold';
406
+ const value = String(val);
407
+ const ph = this.addValue(ctx.values, like.pattern(fold ? value.toLowerCase() : value));
408
+ const matchOp = like.insensitive && this.caseInsensitiveMatch === 'ilike' ? 'ILIKE' : this.likeFn;
409
+ return `${fold ? `LOWER(${operand})` : operand} ${matchOp} ${ph}`;
410
+ }
411
+ /** Builds `prefix.column` from an already-resolved field, through the same memo writes use. */
414
412
  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);
413
+ return this.escapeId(prefix, true, true) + this.escapedColumnOf(key, field);
418
414
  }
419
415
  /**
420
- * Resolves the SQL operand for a field comparison.
421
- * For QueryRaw virtuals, appends the raw expression to ctx and returns undefined.
416
+ * The SQL a field comparison reads its left-hand side from. A virtual field builds its expression
417
+ * as text rather than appending it, so every operator gets a real operand to wrap - `LOWER(...)`,
418
+ * `NOT (... <=> ...)` - instead of having to fall back to a form that takes none.
422
419
  */
423
420
  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;
421
+ const field = getMeta(entity).fields[key];
422
+ const virtual = field?.virtual;
423
+ if (virtual) {
424
+ return this.buildFragment(ctx, (fragmentCtx) => this.getRawValue(fragmentCtx, {
425
+ value: virtual,
426
+ prefix: opts.prefix,
427
+ escapedPrefix: this.escapeId(opts.prefix, true, true),
428
+ }));
428
429
  }
429
- return this.columnWithPrefix(key, col, opts.prefix);
430
- }
431
- appendFieldSql(ctx, field, sql) {
432
- ctx.append(field ? `${field}${sql}` : sql);
430
+ return this.columnWithPrefix(key, field, opts.prefix);
433
431
  }
434
432
  compareFieldOperator(ctx, entity, key, op, val, opts = {}) {
435
433
  const field = this.resolveOperandField(ctx, entity, key, opts);
@@ -443,102 +441,75 @@ export class AbstractSqlDialect extends IndexSqlDialect {
443
441
  ctx.append(')');
444
442
  break;
445
443
  case '$all':
446
- ctx.append(this.jsonAll(ctx, field ?? '', val));
444
+ ctx.append(this.jsonAll(ctx, field, val));
447
445
  break;
448
446
  case '$size':
449
- ctx.append(this.jsonSize(ctx, field ?? '', val));
447
+ ctx.append(this.jsonSize(ctx, field, val));
450
448
  break;
451
449
  case '$elemMatch':
452
- ctx.append(this.jsonElemMatch(ctx, field ?? '', val));
450
+ ctx.append(this.jsonElemMatch(ctx, field, val));
453
451
  break;
454
452
  default:
455
453
  throw TypeError(`unknown operator: ${op}`);
456
454
  }
457
455
  }
458
456
  /**
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.
457
+ * `<operand> <op> <value>` for every operator that needs nothing but its left-hand SQL, or
458
+ * `undefined` when `op` is not one of them.
461
459
  *
462
460
  * 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.
461
+ * expression, and a `$size` count (whose expression is already in the context, so it passes an
462
+ * empty operand). They previously disagreed - HAVING carried a second comparison-operator map and
463
+ * threw `unsupported HAVING operator` on the `$like` that `QueryHavingMap` accepts, and neither of
464
+ * the other two turned `$eq: null` into `IS NULL` the way the WHERE path does.
467
465
  *
468
466
  * The operators kept out are the ones that need more than an operand: `$not` recurses through the
469
467
  * entity, and `$all`/`$size`/`$elemMatch` address a JSON document.
470
468
  */
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;
469
+ operatorCondition(ctx, operand, op, val) {
470
+ const compareOp = AbstractSqlDialect.COMPARE_OP_MAP.get(op);
471
+ if (compareOp) {
472
+ return `${operand}${compareOp}${this.addValue(ctx.values, val)}`;
476
473
  }
477
- const likeWrap = AbstractSqlDialect.LIKE_OP_MAP.get(op);
478
- if (likeWrap) {
479
- this.appendLikeOp(ctx, operand, op, likeWrap(val));
480
- return true;
474
+ const like = this.likeCondition(ctx, operand, op, val);
475
+ if (like) {
476
+ return like;
481
477
  }
482
478
  switch (op) {
483
479
  case '$eq':
480
+ return val === null ? `${operand} IS NULL` : `${operand} = ${this.addValue(ctx.values, val)}`;
484
481
  case '$ne':
485
- this.appendEqNe(ctx, operand, op, val);
486
- return true;
482
+ return val === null ? `${operand} IS NOT NULL` : this.neExpr(operand, this.addValue(ctx.values, val));
487
483
  case '$regex':
488
- this.appendFieldSql(ctx, operand, ` ${this.regexpOp} ${this.addValue(ctx.values, val)}`);
489
- return true;
484
+ return `${operand} ${this.regexpOp} ${this.addValue(ctx.values, val)}`;
490
485
  case '$in':
491
- case '$nin':
492
- this.appendInNin(ctx, operand, op, val);
493
- return true;
486
+ case '$nin': {
487
+ if (!Array.isArray(val)) {
488
+ // Not covered by the types: `/http` casts client JSON straight to `Query`, so this arrives untyped.
489
+ throw TypeError(`${op} expects an array, got ${val === null ? 'null' : typeof val}`);
490
+ }
491
+ return operand + this.formatIn(ctx, val, op === '$nin');
492
+ }
494
493
  case '$between': {
495
494
  const [min, max] = val;
496
- this.appendFieldSql(ctx, operand, ` BETWEEN ${this.addValue(ctx.values, min)} AND ${this.addValue(ctx.values, max)}`);
497
- return true;
495
+ return `${operand} BETWEEN ${this.addValue(ctx.values, min)} AND ${this.addValue(ctx.values, max)}`;
498
496
  }
499
497
  case '$isNull':
500
- this.appendFieldSql(ctx, operand, val ? ' IS NULL' : ' IS NOT NULL');
501
- return true;
498
+ return operand + (val ? ' IS NULL' : ' IS NOT NULL');
502
499
  case '$isNotNull':
503
- this.appendFieldSql(ctx, operand, val ? ' IS NOT NULL' : ' IS NULL');
504
- return true;
500
+ return operand + (val ? ' IS NOT NULL' : ' IS NULL');
505
501
  default:
506
- return false;
507
- }
508
- }
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}`);
502
+ return undefined;
517
503
  }
518
504
  }
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}`);
505
+ /** {@link operatorCondition}, appended; `false` when `op` needs more than an operand. */
506
+ appendOperatorCondition(ctx, operand, op, val) {
507
+ const condition = this.operatorCondition(ctx, operand, op, val);
508
+ if (condition === undefined) {
509
+ return false;
540
510
  }
541
- this.appendFieldSql(ctx, field, this.formatIn(ctx, val, op === '$nin'));
511
+ ctx.append(condition);
512
+ return true;
542
513
  }
543
514
  /**
544
515
  * Build a comparison condition for a JSON field.
@@ -547,6 +518,11 @@ export class AbstractSqlDialect extends IndexSqlDialect {
547
518
  */
548
519
  buildJsonFieldCondition(ctx, fieldAccessor, jsonPath, op, value, asJson = false) {
549
520
  const jsonField = fieldAccessor(jsonPath);
521
+ // The `$like` family reads a JSON path exactly as it reads a column, case folding included.
522
+ const like = this.likeCondition(ctx, jsonField, op, value);
523
+ if (like) {
524
+ return like;
525
+ }
550
526
  switch (op) {
551
527
  case '$eq':
552
528
  if (value === null)
@@ -564,22 +540,6 @@ export class AbstractSqlDialect extends IndexSqlDialect {
564
540
  return `${this.numericCast(jsonField)} < ${this.addValue(ctx.values, value)}`;
565
541
  case '$lte':
566
542
  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
543
  case '$regex':
584
544
  return `${jsonField} ${this.regexpOp} ${this.addValue(ctx.values, value)}`;
585
545
  case '$in':
@@ -673,70 +633,69 @@ export class AbstractSqlDialect extends IndexSqlDialect {
673
633
  ctx.pushValue(JSON.stringify(value));
674
634
  return this.jsonCast('?');
675
635
  }
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));
636
+ /** {@link resolveOperandField}, appended. */
637
+ getComparisonKey(ctx, entity, key, opts = {}) {
638
+ ctx.append(this.resolveOperandField(ctx, entity, key, opts));
690
639
  }
691
- sort(ctx, entity, sort, { prefix }) {
640
+ sort(ctx, entity, sort, opts = {}) {
692
641
  if (!hasKeys(sort)) {
693
642
  return;
694
643
  }
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
- }
644
+ // Collected before anything is appended so an unorderable key is reported instead of half a
645
+ // clause, and because a vector distance is the primary ordering wherever it appears in the map.
646
+ const vectors = [];
647
+ const columns = [];
648
+ this.collectSortTerms(ctx, getMeta(entity), sort, opts, vectors, columns);
649
+ const terms = [...vectors, ...columns];
650
+ if (terms.length) {
651
+ ctx.append(` ORDER BY ${terms.join(', ')}`);
708
652
  }
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);
653
+ }
654
+ /**
655
+ * Walks `$sort` against the metadata of the entity each level addresses, rather than flattening it
656
+ * to dotted strings and reading every key off the root: only that way does a related column resolve
657
+ * through its own `@Field({ name })`, and only that way is `tax.category` the one alias the join
658
+ * carries instead of two quoted identifiers.
659
+ */
660
+ collectSortTerms(ctx, meta, sort, opts, vectors, columns, path = '') {
661
+ // Below the first level the alias a column is qualified by *is* the path walked to reach it.
662
+ const prefix = path || opts.prefix;
663
+ for (const [key, value] of Object.entries(sort)) {
664
+ const relation = meta.relations[key];
665
+ if (relation) {
666
+ const relPath = path ? `${path}.${key}` : key;
667
+ const { join, sort: relationSort } = resolveSortableJoin(relation, relPath, value, opts.joins ?? NO_JOINS, `cannot $sort by relation '${relPath}': this statement joins no relations`);
668
+ // `SELECT DISTINCT` can only order by what it selected, on every engine here, so a join
669
+ // brought in for the sort alone has nothing to order by. Populating it selects its columns.
670
+ if (opts.distinct && !join.projected) {
671
+ throw new TypeError(`cannot $sort by relation '${relPath}' with $distinct unless '${relPath}' is populated: SELECT DISTINCT orders only by selected columns`);
726
672
  }
727
- return;
673
+ this.collectSortTerms(ctx, join.meta, relationSort, opts, vectors, columns, relPath);
674
+ continue;
728
675
  }
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;
676
+ if (isVectorSearch(value)) {
677
+ if (path) {
678
+ throw new TypeError(`$vector sort is only supported on the queried entity, not on relation '${path}'`);
679
+ }
680
+ // Already projected in the SELECT list: order by that alias rather than recomputing it.
681
+ vectors.push(value.$project
682
+ ? this.escapeId(value.$project)
683
+ : this.buildFragment(ctx, (fragmentCtx) => this.appendVectorSort(fragmentCtx, meta, key, value)));
684
+ continue;
735
685
  }
736
- const field = meta.fields[key];
737
- const name = this.resolveColumnName(key, field);
738
- ctx.append(this.escapeId(name) + direction);
739
- });
686
+ columns.push(this.sortColumn(meta, key, prefix) + this.resolveSortDirection(value));
687
+ }
688
+ }
689
+ /**
690
+ * The `ORDER BY` operand for one key. A key that is not a column of `meta` - a virtual field, a
691
+ * `raw()` projection - is an output alias, which is never table-qualified and needs no resolving.
692
+ */
693
+ sortColumn(meta, key, prefix) {
694
+ const field = meta.fields[key];
695
+ if (field) {
696
+ return field.virtual ? this.escapeId(key) : this.columnWithPrefix(key, field, prefix);
697
+ }
698
+ return this.resolveJsonDotPath(meta, key, prefix)?.accessor() ?? this.escapeId(key);
740
699
  }
741
700
  pager(ctx, opts) {
742
701
  if (opts.$limit) {
@@ -750,20 +709,17 @@ export class AbstractSqlDialect extends IndexSqlDialect {
750
709
  supportsRowLocks = true;
751
710
  /** MariaDB is the one engine here that cannot narrow a lock to one table of a join. */
752
711
  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
712
  /** Validated before the querier checks for a transaction, so the clearer error wins. */
758
- assertLockSupported(entity, q) {
713
+ assertLockSupported(entity, q, joins) {
759
714
  if (!parseQueryLock(q.$lock)) {
760
715
  return;
761
716
  }
762
717
  if (!this.supportsRowLocks) {
763
718
  throw new TypeError(`${this.dialectName} does not support row-level locking ($lock)`);
764
719
  }
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`);
720
+ joins ??= resolveQueryJoins(getMeta(entity), q);
721
+ if (!this.supportsLockOf && joins.size > 0) {
722
+ throw new TypeError(`${this.dialectName} cannot narrow a row lock to one table, so $lock cannot be combined with a joined relation`);
767
723
  }
768
724
  }
769
725
  /**
@@ -771,21 +727,22 @@ export class AbstractSqlDialect extends IndexSqlDialect {
771
727
  * joined: Postgres refuses a bare `FOR UPDATE` over the nullable side of an outer join outright,
772
728
  * and the other engines quietly widen the lock to the joined rows.
773
729
  */
774
- appendLock(ctx, entity, q) {
730
+ appendLock(ctx, entity, q, joins = NO_JOINS) {
775
731
  const wait = parseQueryLock(q.$lock);
776
732
  if (!wait) {
777
733
  return;
778
734
  }
779
- this.assertLockSupported(entity, q);
735
+ this.assertLockSupported(entity, q, joins);
780
736
  const meta = getMeta(entity);
781
- const target = this.joinsRelations(meta, q) ? ` OF ${this.escapeId(this.resolveTableName(entity, meta))}` : '';
737
+ const target = joins.size > 0 ? ` OF ${this.escapeId(this.resolveTableName(entity, meta))}` : '';
782
738
  const suffix = wait === 'skip' ? ' SKIP LOCKED' : wait === 'nowait' ? ' NOWAIT' : '';
783
739
  ctx.append(` FOR UPDATE${target}${suffix}`);
784
740
  }
785
741
  count(ctx, entity, q, opts) {
786
742
  const search = { ...q };
743
+ // A count joins nothing and orders nothing: how many rows match is the same either way.
787
744
  delete search.$sort;
788
- this.select(ctx, entity, [raw('COUNT(*)', 'count')]);
745
+ this.select(ctx, entity, { $select: [raw('COUNT(*)', 'count')] });
789
746
  this.search(ctx, entity, search, opts);
790
747
  }
791
748
  /** `$group` aggregate operator → SQL function name. An allowlist, not a formatter: the op key
@@ -834,20 +791,24 @@ export class AbstractSqlDialect extends IndexSqlDialect {
834
791
  if (q.$having) {
835
792
  this.having(ctx, q.$having, aggregateExpressions);
836
793
  }
837
- this.aggregateSort(ctx, q.$sort, aggregateExpressions);
794
+ this.aggregateSort(ctx, meta, q.$sort, aggregateExpressions);
838
795
  this.pager(ctx, q);
839
796
  }
840
797
  /**
841
- * ORDER BY for aggregate queries - handles both entity-field and alias references.
798
+ * ORDER BY for aggregate queries - handles both entity-field and alias references. A grouped
799
+ * statement has no joins to address, so a relation key is rejected rather than emitted as an alias
800
+ * nothing defines.
842
801
  */
843
- aggregateSort(ctx, sort, aggregateExpressions) {
844
- const sortMap = buildSortMap(sort);
845
- if (!hasKeys(sortMap))
802
+ aggregateSort(ctx, meta, sort, aggregateExpressions) {
803
+ if (!hasKeys(sort))
846
804
  return;
847
805
  ctx.append(' ORDER BY ');
848
- Object.entries(sortMap).forEach(([key, dir], index) => {
806
+ Object.entries(sort).forEach(([key, dir], index) => {
849
807
  if (index > 0)
850
808
  ctx.append(', ');
809
+ if (meta.relations[key]) {
810
+ throw new TypeError(`cannot $sort by relation '${key}' in an aggregate query: it groups rows, it joins none`);
811
+ }
851
812
  const direction = this.resolveSortDirection(dir);
852
813
  const ref = aggregateExpressions[key] ?? this.escapeId(key);
853
814
  ctx.append(ref + direction);
@@ -894,11 +855,14 @@ export class AbstractSqlDialect extends IndexSqlDialect {
894
855
  });
895
856
  }
896
857
  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);
858
+ // The one statement that can join, so the one that resolves the join set; everything else renders
859
+ // against `NO_JOINS` and rejects a `$sort` that would need one.
860
+ const joins = resolveQueryJoins(getMeta(entity), q);
861
+ this.select(ctx, entity, q, opts, joins);
862
+ this.search(ctx, entity, q, opts, joins);
899
863
  // Appended here rather than in `search`, which `count`/`update`/`delete` share: a lock belongs
900
864
  // to a SELECT alone. Every engine spells it after LIMIT/OFFSET, so it goes last.
901
- this.appendLock(ctx, entity, q);
865
+ this.appendLock(ctx, entity, q, joins);
902
866
  }
903
867
  insert(ctx, entity, payload, opts) {
904
868
  this.appendInsertValues(ctx, entity, payload, opts);
@@ -1358,18 +1322,20 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1358
1322
  * metadata is shared between dialects while this result is not, since `escapeIdChar` and the naming
1359
1323
  * strategy differ. Weakly keyed so a transient entity's metadata stays collectable.
1360
1324
  */
1361
- escapedColumnName(meta, key) {
1362
- const field = meta.fields[key];
1325
+ escapedColumnOf(key, field) {
1363
1326
  if (!field) {
1364
- return this.escapeId(this.columnOf(meta, key));
1327
+ return this.escapeId(this.resolveColumnName(key, field));
1365
1328
  }
1366
1329
  let escaped = this.escapedColumns.get(field);
1367
1330
  if (escaped === undefined) {
1368
- escaped = this.escapeId(this.columnOf(meta, key));
1331
+ escaped = this.escapeId(this.resolveColumnName(key, field));
1369
1332
  this.escapedColumns.set(field, escaped);
1370
1333
  }
1371
1334
  return escaped;
1372
1335
  }
1336
+ escapedColumnName(meta, key) {
1337
+ return this.escapedColumnOf(key, meta.fields[key]);
1338
+ }
1373
1339
  escapedColumn(table, meta, key) {
1374
1340
  return this.escapeId(table, false, true) + this.escapedColumnName(meta, key);
1375
1341
  }
@@ -1483,10 +1449,10 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1483
1449
  // A COUNT is never NULL, so equality stays plain here instead of taking the shared renderer's
1484
1450
  // null-safe `$ne` (`IS DISTINCT FROM` on Postgres, `IS NOT` on SQLite). Same rows, shorter SQL.
1485
1451
  if (op === '$eq' || op === '$ne') {
1486
- this.appendFieldSql(ctx, undefined, ` ${op === '$eq' ? '=' : '<>'} ${this.addValue(ctx.values, val)}`);
1452
+ ctx.append(` ${op === '$eq' ? '=' : '<>'} ${this.addValue(ctx.values, val)}`);
1487
1453
  return;
1488
1454
  }
1489
- this.appendOperatorCondition(ctx, undefined, op, val);
1455
+ this.appendOperatorCondition(ctx, '', op, val);
1490
1456
  }
1491
1457
  /** ANSI-style single-quote escaping. MySQL-family dialects override this for backslash escaping. */
1492
1458
  escape(value) {
@@ -1508,9 +1474,6 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1508
1474
  neExpr(field, ph) {
1509
1475
  return `${field} ${this.neOp} ${ph}`;
1510
1476
  }
1511
- ilikeExpr(f, ph) {
1512
- return `LOWER(${f}) LIKE ${ph}`;
1513
- }
1514
1477
  /**
1515
1478
  * Formats an IN/NOT IN expression, binding each value individually.
1516
1479
  * Postgres overrides to use `= ANY($1)` / `<> ALL($1)` with a single array parameter.