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.
- package/README.md +6 -6
- package/dist/dialect/abstractSqlDialect.d.ts +69 -44
- package/dist/dialect/abstractSqlDialect.js +278 -315
- package/dist/dialect/mysqlLikeSqlDialect.d.ts +0 -1
- package/dist/dialect/mysqlLikeSqlDialect.js +0 -3
- package/dist/dialect/pgLikeSqlDialect.d.ts +1 -1
- package/dist/dialect/pgLikeSqlDialect.js +1 -3
- package/dist/dialect/queryContext.d.ts +7 -5
- package/dist/dialect/queryContext.js +11 -6
- package/dist/dialect/queryJoins.d.ts +53 -0
- package/dist/dialect/queryJoins.js +91 -0
- package/dist/dialect/vectorSqlDialect.d.ts +1 -1
- package/dist/entity/metadata/definition.js +2 -2
- package/dist/mongo/mongoDialect.d.ts +38 -8
- package/dist/mongo/mongoDialect.js +103 -64
- package/dist/mongo/mongodbQuerier.js +19 -42
- package/dist/sqlite/sqliteDialect.d.ts +1 -1
- package/dist/sqlite/sqliteDialect.js +3 -3
- package/dist/type/dialect.d.ts +11 -3
- package/dist/type/query.d.ts +3 -3
- package/dist/util/dialect.util.d.ts +1 -2
- package/dist/util/dialect.util.js +0 -3
- package/dist/util/relationQuery.util.d.ts +17 -2
- package/dist/util/relationQuery.util.js +35 -2
- package/dist/util/sql.util.d.ts +1 -2
- package/dist/util/sql.util.js +1 -12
- package/package.json +2 -2
|
@@ -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,
|
|
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 =
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
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,
|
|
174
|
+
this.selectRelationFields(ctx, joins);
|
|
174
175
|
// Inject vector distance projections when $project is set
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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,
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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,
|
|
195
|
-
|
|
196
|
-
});
|
|
198
|
+
this.selectFields(ctx, join.entity, join.query.$select, { prefix: join.path, autoPrefixAlias: true }, join.query.$exclude);
|
|
199
|
+
}
|
|
197
200
|
}
|
|
198
|
-
selectRelationJoins(ctx,
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
ctx.append(`${joinAlias}.${
|
|
216
|
-
|
|
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
|
|
221
|
-
//
|
|
222
|
-
this.where(ctx,
|
|
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 {
|
|
230
|
+
const { clause = 'WHERE' } = opts;
|
|
257
231
|
where = buildQueryWhereAsMap(meta, where);
|
|
258
|
-
|
|
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
|
-
|
|
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
|
-
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
const childOpts = opts.
|
|
272
|
-
|
|
273
|
-
|
|
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,
|
|
281
|
-
appended = true;
|
|
256
|
+
this.compare(ctx, entity, key, where[key], childOpts);
|
|
282
257
|
});
|
|
283
|
-
if (
|
|
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)
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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 (
|
|
369
|
-
this.renderWhere(
|
|
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
|
-
|
|
377
|
-
|
|
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
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
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
|
|
408
|
-
'$
|
|
409
|
-
'$
|
|
410
|
-
'$
|
|
411
|
-
'$
|
|
412
|
-
])
|
|
413
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
421
|
-
*
|
|
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
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
return
|
|
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,
|
|
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
|
|
444
|
+
ctx.append(this.jsonAll(ctx, field, val));
|
|
447
445
|
break;
|
|
448
446
|
case '$size':
|
|
449
|
-
ctx.append(this.jsonSize(ctx, field
|
|
447
|
+
ctx.append(this.jsonSize(ctx, field, val));
|
|
450
448
|
break;
|
|
451
449
|
case '$elemMatch':
|
|
452
|
-
ctx.append(this.jsonElemMatch(ctx, field
|
|
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
|
-
*
|
|
460
|
-
*
|
|
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 (
|
|
464
|
-
*
|
|
465
|
-
* `unsupported HAVING operator` on the `$like` that `QueryHavingMap` accepts, and neither of
|
|
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
|
-
|
|
472
|
-
const
|
|
473
|
-
if (
|
|
474
|
-
|
|
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
|
|
478
|
-
if (
|
|
479
|
-
|
|
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.
|
|
486
|
-
return true;
|
|
482
|
+
return val === null ? `${operand} IS NOT NULL` : this.neExpr(operand, this.addValue(ctx.values, val));
|
|
487
483
|
case '$regex':
|
|
488
|
-
|
|
489
|
-
return true;
|
|
484
|
+
return `${operand} ${this.regexpOp} ${this.addValue(ctx.values, val)}`;
|
|
490
485
|
case '$in':
|
|
491
|
-
case '$nin':
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
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
|
-
|
|
501
|
-
return true;
|
|
498
|
+
return operand + (val ? ' IS NULL' : ' IS NOT NULL');
|
|
502
499
|
case '$isNotNull':
|
|
503
|
-
|
|
504
|
-
return true;
|
|
500
|
+
return operand + (val ? ' IS NOT NULL' : ' IS NULL');
|
|
505
501
|
default:
|
|
506
|
-
return
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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,
|
|
640
|
+
sort(ctx, entity, sort, opts = {}) {
|
|
692
641
|
if (!hasKeys(sort)) {
|
|
693
642
|
return;
|
|
694
643
|
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
const
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
|
|
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
|
-
|
|
673
|
+
this.collectSortTerms(ctx, join.meta, relationSort, opts, vectors, columns, relPath);
|
|
674
|
+
continue;
|
|
728
675
|
}
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
|
|
766
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
898
|
-
|
|
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
|
-
|
|
1362
|
-
const field = meta.fields[key];
|
|
1325
|
+
escapedColumnOf(key, field) {
|
|
1363
1326
|
if (!field) {
|
|
1364
|
-
return this.escapeId(this.
|
|
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.
|
|
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
|
-
|
|
1452
|
+
ctx.append(` ${op === '$eq' ? '=' : '<>'} ${this.addValue(ctx.values, val)}`);
|
|
1487
1453
|
return;
|
|
1488
1454
|
}
|
|
1489
|
-
this.appendOperatorCondition(ctx,
|
|
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.
|