uql-orm 0.26.3 → 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.
Files changed (41) hide show
  1. package/README.md +4 -4
  2. package/dist/browser/uql-browser.min.js +2 -2
  3. package/dist/browser/uql-browser.min.js.map +3 -3
  4. package/dist/cockroachdb/cockroachDialect.d.ts +5 -0
  5. package/dist/cockroachdb/cockroachDialect.js +5 -0
  6. package/dist/dialect/abstractSqlDialect.d.ts +81 -40
  7. package/dist/dialect/abstractSqlDialect.js +317 -305
  8. package/dist/dialect/mysqlLikeSqlDialect.d.ts +4 -1
  9. package/dist/dialect/mysqlLikeSqlDialect.js +4 -3
  10. package/dist/dialect/pgLikeSqlDialect.d.ts +1 -1
  11. package/dist/dialect/pgLikeSqlDialect.js +1 -3
  12. package/dist/dialect/queryContext.d.ts +7 -5
  13. package/dist/dialect/queryContext.js +11 -6
  14. package/dist/dialect/queryJoins.d.ts +44 -0
  15. package/dist/dialect/queryJoins.js +73 -0
  16. package/dist/dialect/vectorSqlDialect.d.ts +1 -1
  17. package/dist/http/query.js +10 -0
  18. package/dist/maria/mariaDialect.d.ts +2 -0
  19. package/dist/maria/mariaDialect.js +2 -0
  20. package/dist/mongo/mongoDialect.d.ts +14 -2
  21. package/dist/mongo/mongoDialect.js +56 -18
  22. package/dist/mongo/mongodbQuerier.js +6 -2
  23. package/dist/querier/abstractSqlQuerier.d.ts +11 -0
  24. package/dist/querier/abstractSqlQuerier.js +21 -0
  25. package/dist/sqlite/sqliteDialect.d.ts +3 -1
  26. package/dist/sqlite/sqliteDialect.js +5 -3
  27. package/dist/type/dialect.d.ts +11 -3
  28. package/dist/type/entity.d.ts +4 -4
  29. package/dist/type/index.d.ts +1 -0
  30. package/dist/type/index.js +1 -0
  31. package/dist/type/query.d.ts +15 -4
  32. package/dist/type/queryLock.d.ts +21 -0
  33. package/dist/type/queryLock.js +19 -0
  34. package/dist/type/utility.d.ts +9 -5
  35. package/dist/util/dialect.util.d.ts +1 -2
  36. package/dist/util/dialect.util.js +0 -3
  37. package/dist/util/relationQuery.util.d.ts +15 -3
  38. package/dist/util/relationQuery.util.js +37 -2
  39. package/dist/util/sql.util.d.ts +1 -2
  40. package/dist/util/sql.util.js +1 -12
  41. package/package.json +4 -4
@@ -1,11 +1,12 @@
1
1
  import { getMeta } from '../entity/index.js';
2
- import { 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';
2
+ import { parseQueryLock, QueryRaw, RAW_ALIAS, RAW_VALUE, } from '../type/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 ');
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 });
362
338
  }
363
- if (whereEntry instanceof QueryRaw) {
364
- this.getRawValue(ctx, {
365
- value: whereEntry,
366
- });
339
+ else if (entry) {
340
+ this.renderWhere(fragmentCtx, entity, entry, { prefix: opts.prefix, operand: childOperand, clause: false });
367
341
  }
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
- });
374
- }
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;
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}`);
498
+ return undefined;
517
499
  }
518
500
  }
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) {
@@ -746,10 +715,44 @@ export class AbstractSqlDialect extends IndexSqlDialect {
746
715
  ctx.append(` OFFSET ${Number(opts.$skip)}`);
747
716
  }
748
717
  }
718
+ /** Whether this engine has row locks at all. The SQLite family locks the database instead. */
719
+ supportsRowLocks = true;
720
+ /** MariaDB is the one engine here that cannot narrow a lock to one table of a join. */
721
+ supportsLockOf = true;
722
+ /** Validated before the querier checks for a transaction, so the clearer error wins. */
723
+ assertLockSupported(entity, q, joins) {
724
+ if (!parseQueryLock(q.$lock)) {
725
+ return;
726
+ }
727
+ if (!this.supportsRowLocks) {
728
+ throw new TypeError(`${this.dialectName} does not support row-level locking ($lock)`);
729
+ }
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`);
733
+ }
734
+ }
735
+ /**
736
+ * The trailing `FOR UPDATE`. Narrowing to the queried table is not a nicety once a relation is
737
+ * joined: Postgres refuses a bare `FOR UPDATE` over the nullable side of an outer join outright,
738
+ * and the other engines quietly widen the lock to the joined rows.
739
+ */
740
+ appendLock(ctx, entity, q, joins = NO_JOINS) {
741
+ const wait = parseQueryLock(q.$lock);
742
+ if (!wait) {
743
+ return;
744
+ }
745
+ this.assertLockSupported(entity, q, joins);
746
+ const meta = getMeta(entity);
747
+ const target = joins.size > 0 ? ` OF ${this.escapeId(this.resolveTableName(entity, meta))}` : '';
748
+ const suffix = wait === 'skip' ? ' SKIP LOCKED' : wait === 'nowait' ? ' NOWAIT' : '';
749
+ ctx.append(` FOR UPDATE${target}${suffix}`);
750
+ }
749
751
  count(ctx, entity, q, opts) {
750
752
  const search = { ...q };
753
+ // A count joins nothing and orders nothing: how many rows match is the same either way.
751
754
  delete search.$sort;
752
- this.select(ctx, entity, [raw('COUNT(*)', 'count')]);
755
+ this.select(ctx, entity, { $select: [raw('COUNT(*)', 'count')] });
753
756
  this.search(ctx, entity, search, opts);
754
757
  }
755
758
  /** `$group` aggregate operator → SQL function name. An allowlist, not a formatter: the op key
@@ -798,20 +801,24 @@ export class AbstractSqlDialect extends IndexSqlDialect {
798
801
  if (q.$having) {
799
802
  this.having(ctx, q.$having, aggregateExpressions);
800
803
  }
801
- this.aggregateSort(ctx, q.$sort, aggregateExpressions);
804
+ this.aggregateSort(ctx, meta, q.$sort, aggregateExpressions);
802
805
  this.pager(ctx, q);
803
806
  }
804
807
  /**
805
- * 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.
806
811
  */
807
- aggregateSort(ctx, sort, aggregateExpressions) {
808
- const sortMap = buildSortMap(sort);
809
- if (!hasKeys(sortMap))
812
+ aggregateSort(ctx, meta, sort, aggregateExpressions) {
813
+ if (!hasKeys(sort))
810
814
  return;
811
815
  ctx.append(' ORDER BY ');
812
- Object.entries(sortMap).forEach(([key, dir], index) => {
816
+ Object.entries(sort).forEach(([key, dir], index) => {
813
817
  if (index > 0)
814
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
+ }
815
822
  const direction = this.resolveSortDirection(dir);
816
823
  const ref = aggregateExpressions[key] ?? this.escapeId(key);
817
824
  ctx.append(ref + direction);
@@ -858,8 +865,14 @@ export class AbstractSqlDialect extends IndexSqlDialect {
858
865
  });
859
866
  }
860
867
  find(ctx, entity, q = {}, opts) {
861
- this.select(ctx, entity, q.$select, q.$exclude, q.$populate, opts, q.$distinct, q.$sort);
862
- 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);
873
+ // Appended here rather than in `search`, which `count`/`update`/`delete` share: a lock belongs
874
+ // to a SELECT alone. Every engine spells it after LIMIT/OFFSET, so it goes last.
875
+ this.appendLock(ctx, entity, q, joins);
863
876
  }
864
877
  insert(ctx, entity, payload, opts) {
865
878
  this.appendInsertValues(ctx, entity, payload, opts);
@@ -1319,18 +1332,20 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1319
1332
  * metadata is shared between dialects while this result is not, since `escapeIdChar` and the naming
1320
1333
  * strategy differ. Weakly keyed so a transient entity's metadata stays collectable.
1321
1334
  */
1322
- escapedColumnName(meta, key) {
1323
- const field = meta.fields[key];
1335
+ escapedColumnOf(key, field) {
1324
1336
  if (!field) {
1325
- return this.escapeId(this.columnOf(meta, key));
1337
+ return this.escapeId(this.resolveColumnName(key, field));
1326
1338
  }
1327
1339
  let escaped = this.escapedColumns.get(field);
1328
1340
  if (escaped === undefined) {
1329
- escaped = this.escapeId(this.columnOf(meta, key));
1341
+ escaped = this.escapeId(this.resolveColumnName(key, field));
1330
1342
  this.escapedColumns.set(field, escaped);
1331
1343
  }
1332
1344
  return escaped;
1333
1345
  }
1346
+ escapedColumnName(meta, key) {
1347
+ return this.escapedColumnOf(key, meta.fields[key]);
1348
+ }
1334
1349
  escapedColumn(table, meta, key) {
1335
1350
  return this.escapeId(table, false, true) + this.escapedColumnName(meta, key);
1336
1351
  }
@@ -1444,10 +1459,10 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1444
1459
  // A COUNT is never NULL, so equality stays plain here instead of taking the shared renderer's
1445
1460
  // null-safe `$ne` (`IS DISTINCT FROM` on Postgres, `IS NOT` on SQLite). Same rows, shorter SQL.
1446
1461
  if (op === '$eq' || op === '$ne') {
1447
- this.appendFieldSql(ctx, undefined, ` ${op === '$eq' ? '=' : '<>'} ${this.addValue(ctx.values, val)}`);
1462
+ ctx.append(` ${op === '$eq' ? '=' : '<>'} ${this.addValue(ctx.values, val)}`);
1448
1463
  return;
1449
1464
  }
1450
- this.appendOperatorCondition(ctx, undefined, op, val);
1465
+ this.appendOperatorCondition(ctx, '', op, val);
1451
1466
  }
1452
1467
  /** ANSI-style single-quote escaping. MySQL-family dialects override this for backslash escaping. */
1453
1468
  escape(value) {
@@ -1469,9 +1484,6 @@ export class AbstractSqlDialect extends IndexSqlDialect {
1469
1484
  neExpr(field, ph) {
1470
1485
  return `${field} ${this.neOp} ${ph}`;
1471
1486
  }
1472
- ilikeExpr(f, ph) {
1473
- return `LOWER(${f}) LIKE ${ph}`;
1474
- }
1475
1487
  /**
1476
1488
  * Formats an IN/NOT IN expression, binding each value individually.
1477
1489
  * Postgres overrides to use `= ANY($1)` / `<> ALL($1)` with a single array parameter.