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.
- package/README.md +4 -4
- package/dist/browser/uql-browser.min.js +2 -2
- package/dist/browser/uql-browser.min.js.map +3 -3
- package/dist/cockroachdb/cockroachDialect.d.ts +5 -0
- package/dist/cockroachdb/cockroachDialect.js +5 -0
- package/dist/dialect/abstractSqlDialect.d.ts +81 -40
- package/dist/dialect/abstractSqlDialect.js +317 -305
- package/dist/dialect/mysqlLikeSqlDialect.d.ts +4 -1
- package/dist/dialect/mysqlLikeSqlDialect.js +4 -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 +44 -0
- package/dist/dialect/queryJoins.js +73 -0
- package/dist/dialect/vectorSqlDialect.d.ts +1 -1
- package/dist/http/query.js +10 -0
- package/dist/maria/mariaDialect.d.ts +2 -0
- package/dist/maria/mariaDialect.js +2 -0
- package/dist/mongo/mongoDialect.d.ts +14 -2
- package/dist/mongo/mongoDialect.js +56 -18
- package/dist/mongo/mongodbQuerier.js +6 -2
- package/dist/querier/abstractSqlQuerier.d.ts +11 -0
- package/dist/querier/abstractSqlQuerier.js +21 -0
- package/dist/sqlite/sqliteDialect.d.ts +3 -1
- package/dist/sqlite/sqliteDialect.js +5 -3
- package/dist/type/dialect.d.ts +11 -3
- package/dist/type/entity.d.ts +4 -4
- package/dist/type/index.d.ts +1 -0
- package/dist/type/index.js +1 -0
- package/dist/type/query.d.ts +15 -4
- package/dist/type/queryLock.d.ts +21 -0
- package/dist/type/queryLock.js +19 -0
- package/dist/type/utility.d.ts +9 -5
- package/dist/util/dialect.util.d.ts +1 -2
- package/dist/util/dialect.util.js +0 -3
- package/dist/util/relationQuery.util.d.ts +15 -3
- package/dist/util/relationQuery.util.js +37 -2
- package/dist/util/sql.util.d.ts +1 -2
- package/dist/util/sql.util.js +1 -12
- 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,
|
|
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 =
|
|
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,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)
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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 (
|
|
364
|
-
this.
|
|
365
|
-
value: whereEntry,
|
|
366
|
-
});
|
|
339
|
+
else if (entry) {
|
|
340
|
+
this.renderWhere(fragmentCtx, entity, entry, { prefix: opts.prefix, operand: childOperand, clause: false });
|
|
367
341
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
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
|
|
408
|
-
'$
|
|
409
|
-
'$
|
|
410
|
-
'$
|
|
411
|
-
'$
|
|
412
|
-
])
|
|
413
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
421
|
-
*
|
|
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
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
return
|
|
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,
|
|
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
|
|
440
|
+
ctx.append(this.jsonAll(ctx, field, val));
|
|
447
441
|
break;
|
|
448
442
|
case '$size':
|
|
449
|
-
ctx.append(this.jsonSize(ctx, field
|
|
443
|
+
ctx.append(this.jsonSize(ctx, field, val));
|
|
450
444
|
break;
|
|
451
445
|
case '$elemMatch':
|
|
452
|
-
ctx.append(this.jsonElemMatch(ctx, field
|
|
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
|
-
*
|
|
460
|
-
*
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
472
|
-
const
|
|
473
|
-
if (
|
|
474
|
-
|
|
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
|
|
478
|
-
if (
|
|
479
|
-
|
|
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.
|
|
486
|
-
return true;
|
|
478
|
+
return val === null ? `${operand} IS NOT NULL` : this.neExpr(operand, this.addValue(ctx.values, val));
|
|
487
479
|
case '$regex':
|
|
488
|
-
|
|
489
|
-
return true;
|
|
480
|
+
return `${operand} ${this.regexpOp} ${this.addValue(ctx.values, val)}`;
|
|
490
481
|
case '$in':
|
|
491
|
-
case '$nin':
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
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
|
-
|
|
501
|
-
return true;
|
|
494
|
+
return operand + (val ? ' IS NULL' : ' IS NOT NULL');
|
|
502
495
|
case '$isNotNull':
|
|
503
|
-
|
|
504
|
-
return true;
|
|
496
|
+
return operand + (val ? ' IS NOT NULL' : ' IS NULL');
|
|
505
497
|
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}`);
|
|
498
|
+
return undefined;
|
|
517
499
|
}
|
|
518
500
|
}
|
|
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}`);
|
|
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
|
-
|
|
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
|
-
|
|
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));
|
|
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,
|
|
636
|
+
sort(ctx, entity, sort, opts = {}) {
|
|
692
637
|
if (!hasKeys(sort)) {
|
|
693
638
|
return;
|
|
694
639
|
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
const
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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
|
-
|
|
666
|
+
const join = this.resolveSortJoin(relation, relPath, opts);
|
|
667
|
+
this.collectSortTerms(ctx, join.meta, value, opts, vectors, columns, relPath);
|
|
668
|
+
continue;
|
|
728
669
|
}
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
862
|
-
|
|
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
|
-
|
|
1323
|
-
const field = meta.fields[key];
|
|
1335
|
+
escapedColumnOf(key, field) {
|
|
1324
1336
|
if (!field) {
|
|
1325
|
-
return this.escapeId(this.
|
|
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.
|
|
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
|
-
|
|
1462
|
+
ctx.append(` ${op === '$eq' ? '=' : '<>'} ${this.addValue(ctx.values, val)}`);
|
|
1448
1463
|
return;
|
|
1449
1464
|
}
|
|
1450
|
-
this.appendOperatorCondition(ctx,
|
|
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.
|