turbine-orm 0.19.1 → 0.21.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 +156 -24
- package/dist/adapters/cockroachdb.js +4 -9
- package/dist/cjs/adapters/cockroachdb.js +4 -9
- package/dist/cjs/cli/index.js +23 -12
- package/dist/cjs/cli/observe-ui.js +12 -2
- package/dist/cjs/cli/observe.js +6 -7
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.js +6 -37
- package/dist/cjs/client.js +45 -33
- package/dist/cjs/dialect.js +116 -0
- package/dist/cjs/errors.js +19 -1
- package/dist/cjs/generate.js +4 -0
- package/dist/cjs/index.js +3 -2
- package/dist/cjs/introspect.js +20 -0
- package/dist/cjs/mssql.js +1338 -0
- package/dist/cjs/mysql.js +1052 -0
- package/dist/cjs/query/builder.js +408 -122
- package/dist/cjs/query/utils.js +1 -0
- package/dist/cjs/sqlite.js +849 -0
- package/dist/cjs/typed-sql.js +9 -7
- package/dist/cli/index.js +23 -12
- package/dist/cli/migrate.d.ts +2 -2
- package/dist/cli/observe-ui.d.ts +1 -1
- package/dist/cli/observe-ui.js +12 -2
- package/dist/cli/observe.js +7 -8
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +0 -10
- package/dist/cli/studio.js +7 -37
- package/dist/client.d.ts +35 -14
- package/dist/client.js +46 -34
- package/dist/dialect.d.ts +258 -1
- package/dist/dialect.js +83 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +17 -0
- package/dist/generate.js +4 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +1 -1
- package/dist/introspect.d.ts +18 -0
- package/dist/introspect.js +19 -0
- package/dist/mssql.d.ts +233 -0
- package/dist/mssql.js +1298 -0
- package/dist/mysql.d.ts +174 -0
- package/dist/mysql.js +1012 -0
- package/dist/query/builder.d.ts +105 -6
- package/dist/query/builder.js +409 -123
- package/dist/query/index.d.ts +2 -2
- package/dist/query/types.d.ts +62 -12
- package/dist/query/utils.js +1 -0
- package/dist/sqlite.d.ts +144 -0
- package/dist/sqlite.js +842 -0
- package/dist/typed-sql.d.ts +7 -5
- package/dist/typed-sql.js +9 -7
- package/package.json +32 -3
package/dist/query/builder.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* metadata — nothing is hardcoded.
|
|
12
12
|
*/
|
|
13
13
|
import { postgresDialect } from '../dialect.js';
|
|
14
|
-
import { CircularRelationError, NotFoundError, OptimisticLockError, RelationError, TimeoutError, ValidationError, wrapPgError, } from '../errors.js';
|
|
14
|
+
import { CircularRelationError, NotFoundError, OptimisticLockError, RelationError, TimeoutError, UnsupportedFeatureError, ValidationError, wrapPgError, } from '../errors.js';
|
|
15
15
|
import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from '../nested-write.js';
|
|
16
16
|
import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
|
|
17
17
|
import { escapeLike, LRUCache, OPERATOR_KEYS, sqlToPreparedName } from './utils.js';
|
|
@@ -44,6 +44,53 @@ function isUnmatchedPlainObject(value) {
|
|
|
44
44
|
const proto = Object.getPrototypeOf(value);
|
|
45
45
|
return proto === Object.prototype || proto === null;
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Fingerprint the SHAPE of a where-operator object. Null-valued `equals` /
|
|
49
|
+
* `not` compile to parameterless `IS NULL` / `IS NOT NULL` (different SQL, no
|
|
50
|
+
* param pushed), so null-ness is part of the shape — without it a cache entry
|
|
51
|
+
* warmed by `{ not: 5 }` would serve `{ not: null }` with a desynced param list.
|
|
52
|
+
*/
|
|
53
|
+
function fingerprintOperatorShape(value) {
|
|
54
|
+
const obj = value;
|
|
55
|
+
const opKeys = Object.keys(obj)
|
|
56
|
+
.filter((k) => k !== 'mode')
|
|
57
|
+
.map((k) => ((k === 'equals' || k === 'not') && obj[k] === null ? `${k}:null` : k))
|
|
58
|
+
.sort();
|
|
59
|
+
const modeStr = value.mode === 'insensitive' ? ':i' : '';
|
|
60
|
+
return `op(${opKeys.join(',')}${modeStr})`;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Guard for the value of an `equals` operator reaching the plain-equality
|
|
64
|
+
* operator path. A plain object literal can only legitimately be an equality
|
|
65
|
+
* value on a json/jsonb column — and those route to the JSONB filter branch
|
|
66
|
+
* BEFORE the operator branch, so any plain object that reaches here is a
|
|
67
|
+
* mistake (e.g. `{ equals: { foo: 1 } }` on a text column). Shared by the
|
|
68
|
+
* SQL-build path and the cache-hit param-collect path so a warmed cache can
|
|
69
|
+
* never skip the check.
|
|
70
|
+
*/
|
|
71
|
+
function assertBindableEqualsOperand(value, column) {
|
|
72
|
+
if (!isUnmatchedPlainObject(value))
|
|
73
|
+
return;
|
|
74
|
+
throw new ValidationError(`[turbine] Plain-object value for operator 'equals' on ${column}: ` +
|
|
75
|
+
`objects are only valid 'equals' values on JSON (json/jsonb) columns, ` +
|
|
76
|
+
`where 'equals' is the JSONB containment filter.`);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Object keys in sorted order, mirroring the canonical order used by every
|
|
80
|
+
* cache fingerprint. The SQL-build and cache-hit param-collect paths MUST
|
|
81
|
+
* enumerate object keys in this exact order: fingerprints sort keys, so two
|
|
82
|
+
* where clauses with the same fields in different insertion order share one
|
|
83
|
+
* cache entry — if build/collect iterated insertion order, the cached SQL's
|
|
84
|
+
* `$N` placeholders would bind the wrong values (cross-tenant-leak class).
|
|
85
|
+
* Array order (OR/AND members) is positional and is never sorted.
|
|
86
|
+
*/
|
|
87
|
+
function sortedKeys(obj) {
|
|
88
|
+
return Object.keys(obj).sort();
|
|
89
|
+
}
|
|
90
|
+
/** {@link sortedKeys}, but yielding `[key, value]` pairs. */
|
|
91
|
+
function sortedEntries(obj) {
|
|
92
|
+
return Object.entries(obj).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
93
|
+
}
|
|
47
94
|
/** Known atomic-update operator keys — used to detect operator objects vs plain JSON values */
|
|
48
95
|
const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
|
|
49
96
|
/** Known JSONB operator keys */
|
|
@@ -53,9 +100,11 @@ const JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
|
|
|
53
100
|
* appear in any other where-filter shape, so the presence of one of these is
|
|
54
101
|
* an unambiguous signal that the user meant a JSON filter. Used by the
|
|
55
102
|
* strict-validation path so that `{ contains: 'foo' }` (which is also a valid
|
|
56
|
-
* `WhereOperator` for LIKE) is not misclassified.
|
|
103
|
+
* `WhereOperator` for LIKE) is not misclassified. Note `equals` is NOT in this
|
|
104
|
+
* set: on non-JSON columns it is a plain equality operator (`WhereOperator`),
|
|
105
|
+
* so it must fall through instead of throwing.
|
|
57
106
|
*/
|
|
58
|
-
const JSONB_UNIQUE_KEYS = new Set(['path', '
|
|
107
|
+
const JSONB_UNIQUE_KEYS = new Set(['path', 'hasKey']);
|
|
59
108
|
/** Check if a value is a JSONB filter object */
|
|
60
109
|
function isJsonFilter(value) {
|
|
61
110
|
if (value === null ||
|
|
@@ -252,6 +301,89 @@ export class QueryInterface {
|
|
|
252
301
|
p(index) {
|
|
253
302
|
return this.dialect.paramPlaceholder(index);
|
|
254
303
|
}
|
|
304
|
+
/**
|
|
305
|
+
* Cast an aggregate expression to an integer/float result type through the
|
|
306
|
+
* active dialect. PostgreSQL keeps the historical postfix cast (`expr::int` /
|
|
307
|
+
* `expr::float`); SQLite (no `::` operator) maps to `CAST(expr AS INTEGER/REAL)`.
|
|
308
|
+
* Falls back to the Postgres postfix cast for dialects without the hook.
|
|
309
|
+
*/
|
|
310
|
+
castAgg(expr, target) {
|
|
311
|
+
return this.dialect.castAggregate?.(expr, target) ?? `${expr}::${target}`;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Build the trailing pagination clause for an OUTER SELECT. PostgreSQL/MySQL/
|
|
315
|
+
* SQLite use ` LIMIT <ph>` and/or ` OFFSET <ph>`. SQL Server has no `LIMIT`, so
|
|
316
|
+
* its dialect implements {@link Dialect.buildLimitOffset} to emit
|
|
317
|
+
* `[ORDER BY (SELECT NULL)] OFFSET <off> ROWS [FETCH NEXT <lim> ROWS ONLY]`.
|
|
318
|
+
* Param-push order (limit before offset) is owned by the caller and unchanged —
|
|
319
|
+
* this only varies the SQL text, so PG output stays byte-identical.
|
|
320
|
+
*/
|
|
321
|
+
buildPagination(limitPh, offsetPh, hasOrderBy) {
|
|
322
|
+
if (this.dialect.buildLimitOffset) {
|
|
323
|
+
return this.dialect.buildLimitOffset({ limitPlaceholder: limitPh, offsetPlaceholder: offsetPh, hasOrderBy });
|
|
324
|
+
}
|
|
325
|
+
let s = '';
|
|
326
|
+
if (limitPh !== undefined)
|
|
327
|
+
s += ` LIMIT ${limitPh}`;
|
|
328
|
+
if (offsetPh !== undefined)
|
|
329
|
+
s += ` OFFSET ${offsetPh}`;
|
|
330
|
+
return s;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* The single-row limit appended to findUnique / findFirst-style lookups. ` LIMIT 1`
|
|
334
|
+
* for PG/MySQL/SQLite; SQL Server routes through {@link Dialect.buildLimitOffset}
|
|
335
|
+
* (literal `1`, no params) → ` ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 1
|
|
336
|
+
* ROWS ONLY`. No params are pushed, so the collect path is unaffected.
|
|
337
|
+
*/
|
|
338
|
+
limitOneClause() {
|
|
339
|
+
if (this.dialect.buildLimitOffset) {
|
|
340
|
+
return this.dialect.buildLimitOffset({ limitPlaceholder: '1', offsetPlaceholder: undefined, hasOrderBy: false });
|
|
341
|
+
}
|
|
342
|
+
return ' LIMIT 1';
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Validate a LIMIT/OFFSET value as a non-negative integer and return it as an
|
|
346
|
+
* inline SQL literal. Used only on `dialect.inlineLimitOffset` engines (MySQL).
|
|
347
|
+
* The input is always a Turbine-controlled pagination value, never a raw user
|
|
348
|
+
* string — and this guard guarantees the output is `String` of a validated
|
|
349
|
+
* integer, so inlining cannot inject SQL.
|
|
350
|
+
*/
|
|
351
|
+
limitOffsetLiteral(value) {
|
|
352
|
+
const n = Number(value);
|
|
353
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
354
|
+
throw new ValidationError(`LIMIT/OFFSET must be a non-negative integer, received: ${String(value)}`);
|
|
355
|
+
}
|
|
356
|
+
return String(n);
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Resolve a LIMIT/OFFSET value to either an inline literal (no param pushed, on
|
|
360
|
+
* `dialect.inlineLimitOffset` engines) or a bound placeholder (the value is
|
|
361
|
+
* pushed to `params`). Build and collect paths both gate on the same flag so
|
|
362
|
+
* the param order stays mirrored; PG/SQLite/SQL Server keep parameterizing and
|
|
363
|
+
* stay byte-identical.
|
|
364
|
+
*/
|
|
365
|
+
paginationRef(value, params) {
|
|
366
|
+
if (this.dialect.inlineLimitOffset) {
|
|
367
|
+
return this.limitOffsetLiteral(value);
|
|
368
|
+
}
|
|
369
|
+
params.push(Number(value));
|
|
370
|
+
return this.p(params.length);
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Build an `IN` / `NOT IN` predicate through the active dialect. PostgreSQL
|
|
374
|
+
* keeps the array-param form (`expr = ANY($n)` / `expr != ALL($n)`); other
|
|
375
|
+
* engines (SQLite) use a length-independent single-placeholder form. Paired
|
|
376
|
+
* with {@link inParam}, which supplies the single bound value.
|
|
377
|
+
*/
|
|
378
|
+
inClause(expr, paramRef, negated) {
|
|
379
|
+
if (this.dialect.buildInClause)
|
|
380
|
+
return this.dialect.buildInClause(expr, paramRef, negated);
|
|
381
|
+
return negated ? `${expr} != ALL(${paramRef})` : `${expr} = ANY(${paramRef})`;
|
|
382
|
+
}
|
|
383
|
+
/** The single bound parameter for an `IN` list (PG: the array; SQLite: a JSON string). */
|
|
384
|
+
inParam(values) {
|
|
385
|
+
return this.dialect.inClauseParam ? this.dialect.inClauseParam(values) : values;
|
|
386
|
+
}
|
|
255
387
|
/**
|
|
256
388
|
* Return cache hit/miss statistics for this QueryInterface instance.
|
|
257
389
|
* Useful for monitoring and benchmarking.
|
|
@@ -352,14 +484,59 @@ export class QueryInterface {
|
|
|
352
484
|
clearTimeout(timer);
|
|
353
485
|
}
|
|
354
486
|
}
|
|
487
|
+
/**
|
|
488
|
+
* Execute a write `DeferredQuery` (create/update/delete/upsert) according to
|
|
489
|
+
* the active dialect's {@link Dialect.resultStrategy}, then apply its
|
|
490
|
+
* transform.
|
|
491
|
+
*
|
|
492
|
+
* - `'returning'` / `'output'`: the statement returns its own affected rows
|
|
493
|
+
* (`RETURNING *` / `OUTPUT INSERTED.*`). Byte-identical to the historical
|
|
494
|
+
* single `queryWithTimeout` + `transform(result)` path — the PostgreSQL
|
|
495
|
+
* route is unchanged.
|
|
496
|
+
* - `'reselect'`: the engine cannot return rows from a write, so the build
|
|
497
|
+
* method attached a {@link DeferredQuery.reselect} plan that runs the
|
|
498
|
+
* write and a follow-up SELECT; the SELECT's rows feed the transform.
|
|
499
|
+
*/
|
|
500
|
+
async executeMutation(deferred, timeout) {
|
|
501
|
+
if (this.dialect.resultStrategy === 'reselect' && deferred.reselect) {
|
|
502
|
+
const exec = (sql, params, preparedName) => this.queryWithTimeout(sql, params, timeout, preparedName);
|
|
503
|
+
const result = await deferred.reselect(exec);
|
|
504
|
+
return deferred.transform(result);
|
|
505
|
+
}
|
|
506
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, timeout, deferred.preparedName);
|
|
507
|
+
return deferred.transform(result);
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Build a `SELECT * ... WHERE <predicate>` that re-fetches the row(s) matched
|
|
511
|
+
* by a write's `where` clause. Used by the `'reselect'` result strategy to
|
|
512
|
+
* return rows from non-RETURNING engines. Reuses the same parameterized WHERE
|
|
513
|
+
* builder as reads, so no user value is interpolated.
|
|
514
|
+
*/
|
|
515
|
+
buildReselectByWhere(whereObj) {
|
|
516
|
+
const params = [];
|
|
517
|
+
const clause = this.buildWhereClause(whereObj, params);
|
|
518
|
+
const where = clause ? ` WHERE ${clause}` : '';
|
|
519
|
+
return { sql: `SELECT * FROM ${this.q(this.table)}${where}`, params };
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Best-effort extraction of an auto-generated primary key from a write
|
|
523
|
+
* result for `'reselect'` engines (e.g. mysql2's `insertId`). Returns
|
|
524
|
+
* `undefined` when the driver exposes no such field.
|
|
525
|
+
*/
|
|
526
|
+
mutationInsertId(result) {
|
|
527
|
+
const r = result;
|
|
528
|
+
return r.insertId ?? r.lastID;
|
|
529
|
+
}
|
|
355
530
|
/**
|
|
356
531
|
* Execute a query through the middleware chain.
|
|
357
532
|
* If no middlewares are registered, executes directly.
|
|
358
533
|
*
|
|
359
|
-
* Middleware can inspect and log query parameters,
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
534
|
+
* Middleware can inspect and log query parameters, measure timing, and
|
|
535
|
+
* transform the result returned by `next()`. Note: query SQL is generated
|
|
536
|
+
* BEFORE middleware runs — `params.args` is a read-only snapshot, and
|
|
537
|
+
* mutating it does NOT change the executed SQL. Cross-cutting filters
|
|
538
|
+
* (e.g. soft deletes) belong in the query itself: pass an explicit
|
|
539
|
+
* `where: { deletedAt: null }` or wrap the table accessor in a small helper.
|
|
363
540
|
*/
|
|
364
541
|
async executeWithMiddleware(action, args, executor) {
|
|
365
542
|
this.currentAction = action;
|
|
@@ -398,8 +575,12 @@ export class QueryInterface {
|
|
|
398
575
|
const withFp = args.with ? this.withFingerprint(args.with) : '';
|
|
399
576
|
const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}`;
|
|
400
577
|
const params = [];
|
|
401
|
-
// Check if all where values are simple (plain equality, no operators/null/OR)
|
|
402
|
-
|
|
578
|
+
// Check if all where values are simple (plain equality, no operators/null/OR).
|
|
579
|
+
// Keys are sorted to match fingerprintWhere — insertion order here would let
|
|
580
|
+
// permuted where literals share a cache entry with misaligned params.
|
|
581
|
+
const whereKeys = Object.keys(whereObj)
|
|
582
|
+
.filter((k) => whereObj[k] !== undefined)
|
|
583
|
+
.sort();
|
|
403
584
|
const isSimpleWhere = !whereObj.OR &&
|
|
404
585
|
!whereObj.AND &&
|
|
405
586
|
!whereObj.NOT &&
|
|
@@ -416,7 +597,7 @@ export class QueryInterface {
|
|
|
416
597
|
const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
|
|
417
598
|
const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ') : `${qt}.*`;
|
|
418
599
|
void tempParams; // params are positional, SQL is value-invariant
|
|
419
|
-
return `SELECT ${selectExpr} FROM ${qt}${whereSql}
|
|
600
|
+
return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}`;
|
|
420
601
|
});
|
|
421
602
|
// Collect params (same order as build)
|
|
422
603
|
for (const k of whereKeys) {
|
|
@@ -441,7 +622,7 @@ export class QueryInterface {
|
|
|
441
622
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
442
623
|
const qt = this.q(this.table);
|
|
443
624
|
const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ') : `${qt}.*`;
|
|
444
|
-
return `SELECT ${selectExpr} FROM ${qt}${whereSql}
|
|
625
|
+
return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}`;
|
|
445
626
|
});
|
|
446
627
|
// Collect params
|
|
447
628
|
this.collectWhereParams(whereObj, params);
|
|
@@ -466,7 +647,7 @@ export class QueryInterface {
|
|
|
466
647
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
467
648
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
468
649
|
const selectClause = this.buildSelectWithRelations(this.table, args.with, freshParams, columnsList);
|
|
469
|
-
return `SELECT ${selectClause} FROM ${this.q(this.table)}${whereSql}
|
|
650
|
+
return `SELECT ${selectClause} FROM ${this.q(this.table)}${whereSql}${this.limitOneClause()}`;
|
|
470
651
|
});
|
|
471
652
|
// Collect params in exact build order: where first, then with-clause relations
|
|
472
653
|
this.collectWhereParams(whereObj, params);
|
|
@@ -603,7 +784,8 @@ export class QueryInterface {
|
|
|
603
784
|
}
|
|
604
785
|
let sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${freshWhereSql}`;
|
|
605
786
|
if (args?.cursor) {
|
|
606
|
-
|
|
787
|
+
// Sorted (canonical) order — MUST match cursorFp and the cache-hit collect below.
|
|
788
|
+
const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
|
|
607
789
|
if (cursorEntries.length > 0) {
|
|
608
790
|
const cursorConditions = cursorEntries.map(([k, v]) => {
|
|
609
791
|
const col = this.toSqlColumn(k);
|
|
@@ -625,14 +807,18 @@ export class QueryInterface {
|
|
|
625
807
|
// vector at the correct position (after cursor params, before LIMIT).
|
|
626
808
|
sql += ` ORDER BY ${this.buildOrderBy(args.orderBy, freshParams)}`;
|
|
627
809
|
}
|
|
810
|
+
// Pagination — push params in the same order the collect path mirrors
|
|
811
|
+
// (limit before offset); the SQL TEXT shape is dialect-owned via
|
|
812
|
+
// buildPagination (PG: ` LIMIT $n`/` OFFSET $n`; SQL Server: OFFSET/FETCH).
|
|
813
|
+
let limitPh;
|
|
814
|
+
let offsetPh;
|
|
628
815
|
if (effectiveLimit !== undefined) {
|
|
629
|
-
|
|
630
|
-
sql += ` LIMIT ${this.p(freshParams.length)}`;
|
|
816
|
+
limitPh = this.paginationRef(effectiveLimit, freshParams);
|
|
631
817
|
}
|
|
632
818
|
if (args?.offset !== undefined) {
|
|
633
|
-
|
|
634
|
-
sql += ` OFFSET ${this.p(freshParams.length)}`;
|
|
819
|
+
offsetPh = this.paginationRef(args.offset, freshParams);
|
|
635
820
|
}
|
|
821
|
+
sql += this.buildPagination(limitPh, offsetPh, !!args?.orderBy);
|
|
636
822
|
return sql;
|
|
637
823
|
});
|
|
638
824
|
// Collect params in exact build order:
|
|
@@ -644,9 +830,9 @@ export class QueryInterface {
|
|
|
644
830
|
if (args?.with) {
|
|
645
831
|
this.collectWithParams(args.with, params);
|
|
646
832
|
}
|
|
647
|
-
// 3. Cursor params
|
|
833
|
+
// 3. Cursor params — sorted (canonical) order, matching cursorFp and the build path.
|
|
648
834
|
if (args?.cursor) {
|
|
649
|
-
const cursorEntries =
|
|
835
|
+
const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
|
|
650
836
|
for (const [, v] of cursorEntries) {
|
|
651
837
|
params.push(v);
|
|
652
838
|
}
|
|
@@ -656,12 +842,13 @@ export class QueryInterface {
|
|
|
656
842
|
if (args?.orderBy) {
|
|
657
843
|
this.collectOrderByParams(args.orderBy, params);
|
|
658
844
|
}
|
|
659
|
-
// 5. LIMIT param
|
|
660
|
-
|
|
845
|
+
// 5. LIMIT param — skipped when the dialect inlines pagination (build path
|
|
846
|
+
// mirrors via paginationRef → no placeholder, no param).
|
|
847
|
+
if (effectiveLimit !== undefined && !this.dialect.inlineLimitOffset) {
|
|
661
848
|
params.push(Number(effectiveLimit));
|
|
662
849
|
}
|
|
663
|
-
// 6. OFFSET param
|
|
664
|
-
if (args?.offset !== undefined) {
|
|
850
|
+
// 6. OFFSET param — same inline gate as LIMIT above.
|
|
851
|
+
if (args?.offset !== undefined && !this.dialect.inlineLimitOffset) {
|
|
665
852
|
params.push(Number(args.offset));
|
|
666
853
|
}
|
|
667
854
|
return {
|
|
@@ -721,34 +908,19 @@ export class QueryInterface {
|
|
|
721
908
|
}
|
|
722
909
|
// --- Overflow: fall back to cursor path from scratch ---
|
|
723
910
|
const deferred = this.buildFindMany(args);
|
|
724
|
-
// Acquire a dedicated connection — cursors require a single connection in a
|
|
911
|
+
// Acquire a dedicated connection — cursors require a single connection in a
|
|
912
|
+
// transaction. The dialect owns the streaming SQL (Postgres: BEGIN → DECLARE
|
|
913
|
+
// … NO SCROLL CURSOR FOR → FETCH n → CLOSE → COMMIT, ROLLBACK on error); we
|
|
914
|
+
// just parse + yield the row batches it produces.
|
|
725
915
|
const client = await this.pool.connect();
|
|
726
|
-
const cursorName = `turbine_cursor_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
727
|
-
const quotedCursor = this.q(cursorName);
|
|
728
916
|
try {
|
|
729
|
-
await client.
|
|
730
|
-
|
|
731
|
-
while (true) {
|
|
732
|
-
const batch = await client.query(`FETCH ${batchSize} FROM ${quotedCursor}`);
|
|
733
|
-
if (batch.rows.length === 0)
|
|
734
|
-
break;
|
|
735
|
-
for (const row of batch.rows) {
|
|
917
|
+
for await (const batch of this.dialect.openStream(client, deferred.sql, deferred.params, batchSize)) {
|
|
918
|
+
for (const row of batch) {
|
|
736
919
|
yield (hasRelations ? this.parseNestedRow(row, this.table) : this.parseRow(row, this.table));
|
|
737
920
|
}
|
|
738
|
-
if (batch.rows.length < batchSize)
|
|
739
|
-
break;
|
|
740
921
|
}
|
|
741
|
-
await client.query(`CLOSE ${quotedCursor}`);
|
|
742
|
-
await client.query('COMMIT');
|
|
743
922
|
}
|
|
744
923
|
catch (err) {
|
|
745
|
-
// Rollback on error (also closes cursor implicitly)
|
|
746
|
-
try {
|
|
747
|
-
await client.query('ROLLBACK');
|
|
748
|
-
}
|
|
749
|
-
catch {
|
|
750
|
-
// Connection may already be broken — ignore rollback error
|
|
751
|
-
}
|
|
752
924
|
// Wrap pg constraint errors so streaming surfaces typed errors like the rest of the API
|
|
753
925
|
throw wrapPgError(err);
|
|
754
926
|
}
|
|
@@ -850,8 +1022,7 @@ export class QueryInterface {
|
|
|
850
1022
|
return this.nestedCreate(args);
|
|
851
1023
|
}
|
|
852
1024
|
const deferred = this.buildCreate(args);
|
|
853
|
-
|
|
854
|
-
return deferred.transform(result);
|
|
1025
|
+
return this.executeMutation(deferred, args.timeout);
|
|
855
1026
|
});
|
|
856
1027
|
}
|
|
857
1028
|
buildCreate(args) {
|
|
@@ -880,6 +1051,33 @@ export class QueryInterface {
|
|
|
880
1051
|
return this.parseRow(row, this.table);
|
|
881
1052
|
},
|
|
882
1053
|
tag: `${this.table}.create`,
|
|
1054
|
+
// Non-RETURNING engines: INSERT, then re-fetch the new row by primary key
|
|
1055
|
+
// (provided value, else the driver's generated insert id).
|
|
1056
|
+
reselect: this.makeCreateReselect(sql, params, args.data),
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
/**
|
|
1060
|
+
* Build the `'reselect'` plan for {@link buildCreate}: run the INSERT, then
|
|
1061
|
+
* `SELECT * WHERE pk = ?`. Returns `undefined` (skipped) unless the active
|
|
1062
|
+
* dialect's result strategy is `'reselect'`, so the PostgreSQL/RETURNING path
|
|
1063
|
+
* pays nothing. Not yet wired to a real non-RETURNING engine.
|
|
1064
|
+
*/
|
|
1065
|
+
makeCreateReselect(insertSql, insertParams, data) {
|
|
1066
|
+
if (this.dialect.resultStrategy !== 'reselect')
|
|
1067
|
+
return undefined;
|
|
1068
|
+
return async (exec) => {
|
|
1069
|
+
const writeResult = await exec(insertSql, insertParams);
|
|
1070
|
+
const insertId = this.mutationInsertId(writeResult);
|
|
1071
|
+
const conds = [];
|
|
1072
|
+
const selParams = [];
|
|
1073
|
+
let idx = 1;
|
|
1074
|
+
for (const pk of this.tableMeta.primaryKey) {
|
|
1075
|
+
const field = this.tableMeta.reverseColumnMap[pk] ?? snakeToCamel(pk);
|
|
1076
|
+
selParams.push(data[field] ?? data[pk] ?? insertId);
|
|
1077
|
+
conds.push(`${this.q(pk)} = ${this.p(idx++)}`);
|
|
1078
|
+
}
|
|
1079
|
+
const where = conds.length > 0 ? ` WHERE ${conds.join(' AND ')}` : '';
|
|
1080
|
+
return exec(`SELECT * FROM ${this.q(this.table)}${where}`, selParams);
|
|
883
1081
|
};
|
|
884
1082
|
}
|
|
885
1083
|
// -------------------------------------------------------------------------
|
|
@@ -935,8 +1133,7 @@ export class QueryInterface {
|
|
|
935
1133
|
return this.nestedUpdate(args);
|
|
936
1134
|
}
|
|
937
1135
|
const deferred = this.buildUpdate(args);
|
|
938
|
-
|
|
939
|
-
return deferred.transform(result);
|
|
1136
|
+
return this.executeMutation(deferred, args.timeout);
|
|
940
1137
|
});
|
|
941
1138
|
}
|
|
942
1139
|
buildUpdate(args) {
|
|
@@ -964,7 +1161,12 @@ export class QueryInterface {
|
|
|
964
1161
|
whereSql = whereSql ? `${whereSql} AND ${versionCheck}` : ` WHERE ${versionCheck}`;
|
|
965
1162
|
}
|
|
966
1163
|
this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
|
|
967
|
-
|
|
1164
|
+
// Engines that inject their returning shape MID-statement (SQL Server
|
|
1165
|
+
// `OUTPUT INSERTED.*` between SET and WHERE) override buildUpdateStatement;
|
|
1166
|
+
// absent → the trailing-clause PG/SQLite/MySQL form (byte-identical).
|
|
1167
|
+
return this.dialect.buildUpdateStatement
|
|
1168
|
+
? this.dialect.buildUpdateStatement({ table: this.q(this.table), setClauses, whereSql, returning: '*' })
|
|
1169
|
+
: `UPDATE ${this.q(this.table)} SET ${setClauses.join(', ')}${whereSql}${this.dialect.buildReturningClause('*')}`;
|
|
968
1170
|
};
|
|
969
1171
|
let sql;
|
|
970
1172
|
let preparedName;
|
|
@@ -1008,6 +1210,26 @@ export class QueryInterface {
|
|
|
1008
1210
|
},
|
|
1009
1211
|
tag: `${this.table}.update`,
|
|
1010
1212
|
preparedName,
|
|
1213
|
+
// Non-RETURNING engines: UPDATE, then re-fetch the row by the same where.
|
|
1214
|
+
reselect: this.dialect.resultStrategy === 'reselect'
|
|
1215
|
+
? async (exec) => {
|
|
1216
|
+
const writeResult = await exec(sql, params, preparedName);
|
|
1217
|
+
// Optimistic-lock conflict: the version-checked UPDATE matched no
|
|
1218
|
+
// row. The re-fetch below uses `where` WITHOUT the version
|
|
1219
|
+
// predicate, so it would return the stale row and silently mask
|
|
1220
|
+
// the conflict — detect it from affected-rows here instead, to
|
|
1221
|
+
// match the OptimisticLockError thrown on RETURNING/OUTPUT engines.
|
|
1222
|
+
if (lock && (writeResult.rowCount ?? 0) === 0) {
|
|
1223
|
+
throw new OptimisticLockError({
|
|
1224
|
+
table: this.table,
|
|
1225
|
+
versionField: lock.field,
|
|
1226
|
+
expectedVersion: lock.expected,
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
const sel = this.buildReselectByWhere(whereObj);
|
|
1230
|
+
return exec(sel.sql, sel.params);
|
|
1231
|
+
}
|
|
1232
|
+
: undefined,
|
|
1011
1233
|
};
|
|
1012
1234
|
}
|
|
1013
1235
|
// -------------------------------------------------------------------------
|
|
@@ -1039,19 +1261,19 @@ export class QueryInterface {
|
|
|
1039
1261
|
async runInImplicitTx(fn) {
|
|
1040
1262
|
const client = await this.pool.connect();
|
|
1041
1263
|
try {
|
|
1042
|
-
await client.query(
|
|
1264
|
+
await client.query(this.dialect.beginStatement());
|
|
1043
1265
|
const { TransactionClient } = await import('../client.js');
|
|
1044
1266
|
// biome-ignore lint/suspicious/noExplicitAny: MiddlewareFn and Middleware are structurally identical
|
|
1045
1267
|
const tx = new TransactionClient(client, this.schema, this.middlewares, this.options);
|
|
1046
1268
|
// biome-ignore lint/suspicious/noExplicitAny: TransactionClient satisfies NestedWriteContext['tx'] at runtime
|
|
1047
1269
|
const ctx = { schema: this.schema, tx: tx };
|
|
1048
1270
|
const result = await fn(ctx);
|
|
1049
|
-
await client.query(
|
|
1271
|
+
await client.query(this.dialect.commitStatement());
|
|
1050
1272
|
return result;
|
|
1051
1273
|
}
|
|
1052
1274
|
catch (err) {
|
|
1053
1275
|
try {
|
|
1054
|
-
await client.query(
|
|
1276
|
+
await client.query(this.dialect.rollbackStatement());
|
|
1055
1277
|
}
|
|
1056
1278
|
catch {
|
|
1057
1279
|
// Best-effort rollback — connection may have died.
|
|
@@ -1084,8 +1306,7 @@ export class QueryInterface {
|
|
|
1084
1306
|
async delete(args) {
|
|
1085
1307
|
return this.executeWithMiddleware('delete', args, async () => {
|
|
1086
1308
|
const deferred = this.buildDelete(args);
|
|
1087
|
-
|
|
1088
|
-
return deferred.transform(result);
|
|
1309
|
+
return this.executeMutation(deferred, args.timeout);
|
|
1089
1310
|
});
|
|
1090
1311
|
}
|
|
1091
1312
|
buildDelete(args) {
|
|
@@ -1100,7 +1321,11 @@ export class QueryInterface {
|
|
|
1100
1321
|
const clause = this.buildWhereClause(whereObj, freshParams);
|
|
1101
1322
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
1102
1323
|
this.assertMutationHasPredicate('delete', whereSql, args.allowFullTableScan);
|
|
1103
|
-
|
|
1324
|
+
// SQL Server injects `OUTPUT DELETED.*` between `DELETE FROM <t>` and WHERE;
|
|
1325
|
+
// absent override → the trailing-clause PG/SQLite/MySQL form (byte-identical).
|
|
1326
|
+
return this.dialect.buildDeleteStatement
|
|
1327
|
+
? this.dialect.buildDeleteStatement({ table: this.q(this.table), whereSql, returning: '*' })
|
|
1328
|
+
: `DELETE FROM ${this.q(this.table)}${whereSql}${this.dialect.buildReturningClause('*')}`;
|
|
1104
1329
|
});
|
|
1105
1330
|
// On cache hit, still validate the predicate
|
|
1106
1331
|
if (whereFp === '') {
|
|
@@ -1123,6 +1348,16 @@ export class QueryInterface {
|
|
|
1123
1348
|
},
|
|
1124
1349
|
tag: `${this.table}.delete`,
|
|
1125
1350
|
preparedName: entry.name,
|
|
1351
|
+
// Non-RETURNING engines: the row is gone after DELETE, so pre-SELECT it
|
|
1352
|
+
// by the same where, then run the DELETE, returning the captured row.
|
|
1353
|
+
reselect: this.dialect.resultStrategy === 'reselect'
|
|
1354
|
+
? async (exec) => {
|
|
1355
|
+
const sel = this.buildReselectByWhere(whereObj);
|
|
1356
|
+
const pre = await exec(sel.sql, sel.params);
|
|
1357
|
+
await exec(entry.sql, params, entry.name);
|
|
1358
|
+
return pre;
|
|
1359
|
+
}
|
|
1360
|
+
: undefined,
|
|
1126
1361
|
};
|
|
1127
1362
|
}
|
|
1128
1363
|
// -------------------------------------------------------------------------
|
|
@@ -1131,8 +1366,7 @@ export class QueryInterface {
|
|
|
1131
1366
|
async upsert(args) {
|
|
1132
1367
|
return this.executeWithMiddleware('upsert', args, async () => {
|
|
1133
1368
|
const deferred = this.buildUpsert(args);
|
|
1134
|
-
|
|
1135
|
-
return deferred.transform(result);
|
|
1369
|
+
return this.executeMutation(deferred, args.timeout);
|
|
1136
1370
|
});
|
|
1137
1371
|
}
|
|
1138
1372
|
buildUpsert(args) {
|
|
@@ -1178,6 +1412,14 @@ export class QueryInterface {
|
|
|
1178
1412
|
return this.parseRow(row, this.table);
|
|
1179
1413
|
},
|
|
1180
1414
|
tag: `${this.table}.upsert`,
|
|
1415
|
+
// Non-RETURNING engines: run the upsert, then re-fetch by the where keys.
|
|
1416
|
+
reselect: this.dialect.resultStrategy === 'reselect'
|
|
1417
|
+
? async (exec) => {
|
|
1418
|
+
await exec(sql, params);
|
|
1419
|
+
const sel = this.buildReselectByWhere(args.where);
|
|
1420
|
+
return exec(sel.sql, sel.params);
|
|
1421
|
+
}
|
|
1422
|
+
: undefined,
|
|
1181
1423
|
};
|
|
1182
1424
|
}
|
|
1183
1425
|
// -------------------------------------------------------------------------
|
|
@@ -1272,7 +1514,7 @@ export class QueryInterface {
|
|
|
1272
1514
|
const freshParams = [];
|
|
1273
1515
|
const clause = args?.where ? this.buildWhereClause(whereObj, freshParams) : null;
|
|
1274
1516
|
const whereSql = clause ? ` WHERE ${clause}` : '';
|
|
1275
|
-
return `SELECT COUNT(*)
|
|
1517
|
+
return `SELECT ${this.castAgg('COUNT(*)', 'int')} AS count FROM ${this.q(this.table)}${whereSql}`;
|
|
1276
1518
|
});
|
|
1277
1519
|
if (args?.where) {
|
|
1278
1520
|
this.collectWhereParams(whereObj, params);
|
|
@@ -1312,7 +1554,7 @@ export class QueryInterface {
|
|
|
1312
1554
|
// _count
|
|
1313
1555
|
if (args._count === true || args._count === undefined) {
|
|
1314
1556
|
// default: always include count
|
|
1315
|
-
selectExprs.push('COUNT(*)
|
|
1557
|
+
selectExprs.push(`${this.castAgg('COUNT(*)', 'int')} AS _count`);
|
|
1316
1558
|
}
|
|
1317
1559
|
// _sum
|
|
1318
1560
|
if (args._sum) {
|
|
@@ -1328,7 +1570,7 @@ export class QueryInterface {
|
|
|
1328
1570
|
for (const [field, enabled] of Object.entries(args._avg)) {
|
|
1329
1571
|
if (enabled) {
|
|
1330
1572
|
const col = this.toColumn(field);
|
|
1331
|
-
selectExprs.push(`AVG(${this.q(col)})
|
|
1573
|
+
selectExprs.push(`${this.castAgg(`AVG(${this.q(col)})`, 'float')} AS ${this.q(`_avg_${col}`)}`);
|
|
1332
1574
|
}
|
|
1333
1575
|
}
|
|
1334
1576
|
}
|
|
@@ -1529,12 +1771,12 @@ export class QueryInterface {
|
|
|
1529
1771
|
clauses.push(`${expr} <= ${this.p(params.length)}`);
|
|
1530
1772
|
}
|
|
1531
1773
|
if (op.in !== undefined) {
|
|
1532
|
-
params.push(op.in);
|
|
1533
|
-
clauses.push(
|
|
1774
|
+
params.push(this.inParam(op.in));
|
|
1775
|
+
clauses.push(this.inClause(expr, this.p(params.length), false));
|
|
1534
1776
|
}
|
|
1535
1777
|
if (op.notIn !== undefined) {
|
|
1536
|
-
params.push(op.notIn);
|
|
1537
|
-
clauses.push(
|
|
1778
|
+
params.push(this.inParam(op.notIn));
|
|
1779
|
+
clauses.push(this.inClause(expr, this.p(params.length), true));
|
|
1538
1780
|
}
|
|
1539
1781
|
return clauses;
|
|
1540
1782
|
}
|
|
@@ -1572,13 +1814,13 @@ export class QueryInterface {
|
|
|
1572
1814
|
const selectExprs = [];
|
|
1573
1815
|
// _count
|
|
1574
1816
|
if (args._count === true) {
|
|
1575
|
-
selectExprs.push('COUNT(*)
|
|
1817
|
+
selectExprs.push(`${this.castAgg('COUNT(*)', 'int')} AS _count`);
|
|
1576
1818
|
}
|
|
1577
1819
|
else if (args._count && typeof args._count === 'object') {
|
|
1578
1820
|
for (const [field, enabled] of Object.entries(args._count)) {
|
|
1579
1821
|
if (enabled) {
|
|
1580
1822
|
const col = this.toColumn(field);
|
|
1581
|
-
selectExprs.push(`COUNT(${this.q(col)})
|
|
1823
|
+
selectExprs.push(`${this.castAgg(`COUNT(${this.q(col)})`, 'int')} AS ${this.q(`_count_${col}`)}`);
|
|
1582
1824
|
}
|
|
1583
1825
|
}
|
|
1584
1826
|
}
|
|
@@ -1596,7 +1838,7 @@ export class QueryInterface {
|
|
|
1596
1838
|
for (const [field, enabled] of Object.entries(args._avg)) {
|
|
1597
1839
|
if (enabled) {
|
|
1598
1840
|
const col = this.toColumn(field);
|
|
1599
|
-
selectExprs.push(`AVG(${this.q(col)})
|
|
1841
|
+
selectExprs.push(`${this.castAgg(`AVG(${this.q(col)})`, 'float')} AS ${this.q(`_avg_${col}`)}`);
|
|
1600
1842
|
}
|
|
1601
1843
|
}
|
|
1602
1844
|
}
|
|
@@ -1619,7 +1861,7 @@ export class QueryInterface {
|
|
|
1619
1861
|
}
|
|
1620
1862
|
}
|
|
1621
1863
|
if (selectExprs.length === 0) {
|
|
1622
|
-
selectExprs.push('COUNT(*)
|
|
1864
|
+
selectExprs.push(`${this.castAgg('COUNT(*)', 'int')} AS _count`);
|
|
1623
1865
|
}
|
|
1624
1866
|
const sql = `SELECT ${selectExprs.join(', ')} FROM ${this.q(this.table)}${whereSql}`;
|
|
1625
1867
|
return {
|
|
@@ -1886,12 +2128,7 @@ export class QueryInterface {
|
|
|
1886
2128
|
}
|
|
1887
2129
|
// Operator objects
|
|
1888
2130
|
if (isWhereOperator(value)) {
|
|
1889
|
-
|
|
1890
|
-
.filter((k) => k !== 'mode')
|
|
1891
|
-
.sort();
|
|
1892
|
-
const mode = value.mode;
|
|
1893
|
-
const modeStr = mode === 'insensitive' ? ':i' : '';
|
|
1894
|
-
parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
|
|
2131
|
+
parts.push(`${key}:${fingerprintOperatorShape(value)}`);
|
|
1895
2132
|
continue;
|
|
1896
2133
|
}
|
|
1897
2134
|
// Vector distance filter — metric (operator) and present comparators
|
|
@@ -1964,12 +2201,7 @@ export class QueryInterface {
|
|
|
1964
2201
|
parts.push(`${key}:null`);
|
|
1965
2202
|
}
|
|
1966
2203
|
else if (isWhereOperator(value)) {
|
|
1967
|
-
|
|
1968
|
-
.filter((k) => k !== 'mode')
|
|
1969
|
-
.sort();
|
|
1970
|
-
const mode = value.mode;
|
|
1971
|
-
const modeStr = mode === 'insensitive' ? ':i' : '';
|
|
1972
|
-
parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
|
|
2204
|
+
parts.push(`${key}:${fingerprintOperatorShape(value)}`);
|
|
1973
2205
|
}
|
|
1974
2206
|
else if (isUnmatchedPlainObject(value)) {
|
|
1975
2207
|
parts.push(`${key}:obj(${Object.keys(value)
|
|
@@ -1990,7 +2222,8 @@ export class QueryInterface {
|
|
|
1990
2222
|
* @internal Exposed as package-private for testing.
|
|
1991
2223
|
*/
|
|
1992
2224
|
collectWhereParams(where, params) {
|
|
1993
|
-
|
|
2225
|
+
// Sorted (canonical) order — MUST match fingerprintWhere and buildWhereClause.
|
|
2226
|
+
const keys = sortedKeys(where);
|
|
1994
2227
|
for (const key of keys) {
|
|
1995
2228
|
const value = where[key];
|
|
1996
2229
|
if (value === undefined)
|
|
@@ -2075,7 +2308,7 @@ export class QueryInterface {
|
|
|
2075
2308
|
}
|
|
2076
2309
|
// Operator objects
|
|
2077
2310
|
if (isWhereOperator(value)) {
|
|
2078
|
-
this.collectOperatorParams(value, params);
|
|
2311
|
+
this.collectOperatorParams(rawColumn, value, params);
|
|
2079
2312
|
continue;
|
|
2080
2313
|
}
|
|
2081
2314
|
// Plain equality — same strict validation as the build path, so a
|
|
@@ -2089,22 +2322,28 @@ export class QueryInterface {
|
|
|
2089
2322
|
const meta = this.schema.tables[targetTable];
|
|
2090
2323
|
if (!meta)
|
|
2091
2324
|
return;
|
|
2092
|
-
|
|
2325
|
+
// Sorted (canonical) order — MUST match fingerprintRelFilter and buildSubWhereForRelation.
|
|
2326
|
+
for (const field of sortedKeys(subWhere)) {
|
|
2327
|
+
const value = subWhere[field];
|
|
2093
2328
|
if (value === undefined)
|
|
2094
2329
|
continue;
|
|
2095
2330
|
if (value === null)
|
|
2096
2331
|
continue;
|
|
2332
|
+
const col = meta.columnMap[field] ?? camelToSnake(field);
|
|
2097
2333
|
if (isWhereOperator(value)) {
|
|
2098
|
-
this.collectOperatorParams(value, params);
|
|
2334
|
+
this.collectOperatorParams(col, value, params);
|
|
2099
2335
|
continue;
|
|
2100
2336
|
}
|
|
2101
|
-
const col = meta.columnMap[field] ?? camelToSnake(field);
|
|
2102
2337
|
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
|
|
2103
2338
|
params.push(value);
|
|
2104
2339
|
}
|
|
2105
2340
|
}
|
|
2106
2341
|
/** Collect params from operator clauses. Mirrors buildOperatorClauses. */
|
|
2107
|
-
collectOperatorParams(op, params) {
|
|
2342
|
+
collectOperatorParams(column, op, params) {
|
|
2343
|
+
if (op.equals !== undefined && op.equals !== null) {
|
|
2344
|
+
assertBindableEqualsOperand(op.equals, `"${column}"`);
|
|
2345
|
+
params.push(op.equals);
|
|
2346
|
+
}
|
|
2108
2347
|
if (op.gt !== undefined)
|
|
2109
2348
|
params.push(op.gt);
|
|
2110
2349
|
if (op.gte !== undefined)
|
|
@@ -2116,9 +2355,9 @@ export class QueryInterface {
|
|
|
2116
2355
|
if (op.not !== undefined && op.not !== null)
|
|
2117
2356
|
params.push(op.not);
|
|
2118
2357
|
if (op.in !== undefined)
|
|
2119
|
-
params.push(op.in);
|
|
2358
|
+
params.push(this.inParam(op.in));
|
|
2120
2359
|
if (op.notIn !== undefined)
|
|
2121
|
-
params.push(op.notIn);
|
|
2360
|
+
params.push(this.inParam(op.notIn));
|
|
2122
2361
|
if (op.contains !== undefined)
|
|
2123
2362
|
params.push(`%${escapeLike(op.contains)}%`);
|
|
2124
2363
|
if (op.startsWith !== undefined)
|
|
@@ -2260,7 +2499,7 @@ export class QueryInterface {
|
|
|
2260
2499
|
const meta = this.schema.tables[table ?? this.table];
|
|
2261
2500
|
if (!meta)
|
|
2262
2501
|
return;
|
|
2263
|
-
for (const [relName, relSpec] of
|
|
2502
|
+
for (const [relName, relSpec] of sortedEntries(withClause)) {
|
|
2264
2503
|
const relDef = meta.relations[relName];
|
|
2265
2504
|
if (!relDef)
|
|
2266
2505
|
continue;
|
|
@@ -2283,11 +2522,11 @@ export class QueryInterface {
|
|
|
2283
2522
|
if (spec.where) {
|
|
2284
2523
|
this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
|
|
2285
2524
|
}
|
|
2286
|
-
if (spec.limit !== undefined) {
|
|
2525
|
+
if (spec.limit !== undefined && !this.dialect.inlineLimitOffset) {
|
|
2287
2526
|
params.push(Number(spec.limit));
|
|
2288
2527
|
}
|
|
2289
2528
|
if (spec.with) {
|
|
2290
|
-
for (const [nestedRelName, nestedSpec] of
|
|
2529
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
2291
2530
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
2292
2531
|
if (!nestedRelDef)
|
|
2293
2532
|
continue;
|
|
@@ -2301,7 +2540,7 @@ export class QueryInterface {
|
|
|
2301
2540
|
const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
|
|
2302
2541
|
// Non-wrapped path: nested relations BEFORE where/limit
|
|
2303
2542
|
if (!willWrap && spec.with) {
|
|
2304
|
-
for (const [nestedRelName, nestedSpec] of
|
|
2543
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
2305
2544
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
2306
2545
|
if (!nestedRelDef)
|
|
2307
2546
|
continue;
|
|
@@ -2316,12 +2555,12 @@ export class QueryInterface {
|
|
|
2316
2555
|
// buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
|
|
2317
2556
|
// pushing one here would orphan a param and desync the collect path.
|
|
2318
2557
|
// `limit: 0` pushes (LIMIT 0 is honored), so check !== undefined.
|
|
2319
|
-
if (relDef.type === 'hasMany' && spec.limit !== undefined) {
|
|
2558
|
+
if (relDef.type === 'hasMany' && spec.limit !== undefined && !this.dialect.inlineLimitOffset) {
|
|
2320
2559
|
params.push(Number(spec.limit));
|
|
2321
2560
|
}
|
|
2322
2561
|
// Wrapped path: nested relations AFTER where/limit (inside inner subquery)
|
|
2323
2562
|
if (willWrap && spec.with) {
|
|
2324
|
-
for (const [nestedRelName, nestedSpec] of
|
|
2563
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
2325
2564
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
2326
2565
|
if (!nestedRelDef)
|
|
2327
2566
|
continue;
|
|
@@ -2403,7 +2642,8 @@ export class QueryInterface {
|
|
|
2403
2642
|
* Supports: equality, operators, NULL, OR, AND, NOT, relation filters (some/every/none).
|
|
2404
2643
|
*/
|
|
2405
2644
|
buildWhereClause(where, params) {
|
|
2406
|
-
|
|
2645
|
+
// Sorted (canonical) order — MUST match fingerprintWhere and collectWhereParams.
|
|
2646
|
+
const keys = sortedKeys(where);
|
|
2407
2647
|
if (keys.length === 0)
|
|
2408
2648
|
return null;
|
|
2409
2649
|
const andClauses = [];
|
|
@@ -2610,7 +2850,9 @@ export class QueryInterface {
|
|
|
2610
2850
|
return null;
|
|
2611
2851
|
const qt = this.q(targetTable);
|
|
2612
2852
|
const conditions = [];
|
|
2613
|
-
|
|
2853
|
+
// Sorted (canonical) order — MUST match fingerprintRelFilter and collectRelFilterParams.
|
|
2854
|
+
for (const field of sortedKeys(subWhere)) {
|
|
2855
|
+
const value = subWhere[field];
|
|
2614
2856
|
if (value === undefined)
|
|
2615
2857
|
continue;
|
|
2616
2858
|
const col = meta.columnMap[field] ?? camelToSnake(field);
|
|
@@ -2675,7 +2917,9 @@ export class QueryInterface {
|
|
|
2675
2917
|
*/
|
|
2676
2918
|
buildAliasWhere(targetTable, targetMeta, alias, where, params) {
|
|
2677
2919
|
const clauses = [];
|
|
2678
|
-
|
|
2920
|
+
// Sorted (canonical) order — MUST match fingerprintAliasWhere and collectAliasWhereParams.
|
|
2921
|
+
for (const key of sortedKeys(where)) {
|
|
2922
|
+
const value = where[key];
|
|
2679
2923
|
if (value === undefined)
|
|
2680
2924
|
continue;
|
|
2681
2925
|
if (key === 'OR' || key === 'AND') {
|
|
@@ -2717,7 +2961,9 @@ export class QueryInterface {
|
|
|
2717
2961
|
}
|
|
2718
2962
|
/** Mirrors {@link buildAliasWhere} param-push order for the cache-hit collect path. */
|
|
2719
2963
|
collectAliasWhereParams(targetTable, targetMeta, where, params) {
|
|
2720
|
-
|
|
2964
|
+
// Sorted (canonical) order — MUST match fingerprintAliasWhere and buildAliasWhere.
|
|
2965
|
+
for (const key of sortedKeys(where)) {
|
|
2966
|
+
const value = where[key];
|
|
2721
2967
|
if (value === undefined)
|
|
2722
2968
|
continue;
|
|
2723
2969
|
if (key === 'OR' || key === 'AND') {
|
|
@@ -2735,11 +2981,11 @@ export class QueryInterface {
|
|
|
2735
2981
|
}
|
|
2736
2982
|
if (value === null)
|
|
2737
2983
|
continue;
|
|
2984
|
+
const col = targetMeta.columnMap[key] ?? camelToSnake(key);
|
|
2738
2985
|
if (isWhereOperator(value)) {
|
|
2739
|
-
this.collectOperatorParams(value, params);
|
|
2986
|
+
this.collectOperatorParams(col, value, params);
|
|
2740
2987
|
continue;
|
|
2741
2988
|
}
|
|
2742
|
-
const col = targetMeta.columnMap[key] ?? camelToSnake(key);
|
|
2743
2989
|
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
|
|
2744
2990
|
params.push(value);
|
|
2745
2991
|
}
|
|
@@ -2773,11 +3019,7 @@ export class QueryInterface {
|
|
|
2773
3019
|
continue;
|
|
2774
3020
|
}
|
|
2775
3021
|
if (isWhereOperator(value)) {
|
|
2776
|
-
|
|
2777
|
-
.filter((k) => k !== 'mode')
|
|
2778
|
-
.sort();
|
|
2779
|
-
const mode = value.mode;
|
|
2780
|
-
parts.push(`${key}:op(${opKeys.join(',')}${mode === 'insensitive' ? ':i' : ''})`);
|
|
3022
|
+
parts.push(`${key}:${fingerprintOperatorShape(value)}`);
|
|
2781
3023
|
continue;
|
|
2782
3024
|
}
|
|
2783
3025
|
if (isUnmatchedPlainObject(value)) {
|
|
@@ -2796,6 +3038,16 @@ export class QueryInterface {
|
|
|
2796
3038
|
*/
|
|
2797
3039
|
buildOperatorClauses(column, op, params) {
|
|
2798
3040
|
const clauses = [];
|
|
3041
|
+
if (op.equals !== undefined) {
|
|
3042
|
+
if (op.equals === null) {
|
|
3043
|
+
clauses.push(`${column} IS NULL`);
|
|
3044
|
+
}
|
|
3045
|
+
else {
|
|
3046
|
+
assertBindableEqualsOperand(op.equals, column);
|
|
3047
|
+
params.push(op.equals);
|
|
3048
|
+
clauses.push(`${column} = ${this.p(params.length)}`);
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
2799
3051
|
if (op.gt !== undefined) {
|
|
2800
3052
|
params.push(op.gt);
|
|
2801
3053
|
clauses.push(`${column} > ${this.p(params.length)}`);
|
|
@@ -2822,12 +3074,12 @@ export class QueryInterface {
|
|
|
2822
3074
|
}
|
|
2823
3075
|
}
|
|
2824
3076
|
if (op.in !== undefined) {
|
|
2825
|
-
params.push(op.in);
|
|
2826
|
-
clauses.push(
|
|
3077
|
+
params.push(this.inParam(op.in));
|
|
3078
|
+
clauses.push(this.inClause(column, this.p(params.length), false));
|
|
2827
3079
|
}
|
|
2828
3080
|
if (op.notIn !== undefined) {
|
|
2829
|
-
params.push(op.notIn);
|
|
2830
|
-
clauses.push(
|
|
3081
|
+
params.push(this.inParam(op.notIn));
|
|
3082
|
+
clauses.push(this.inClause(column, this.p(params.length), true));
|
|
2831
3083
|
}
|
|
2832
3084
|
const buildLikeClause = (paramRef) => op.mode === 'insensitive' ? this.dialect.buildInsensitiveLike(column, paramRef) : `${column} LIKE ${paramRef}`;
|
|
2833
3085
|
if (op.contains !== undefined) {
|
|
@@ -2898,6 +3150,9 @@ export class QueryInterface {
|
|
|
2898
3150
|
* non-vector column — a user-supplied string can never become a SQL operator.
|
|
2899
3151
|
*/
|
|
2900
3152
|
vectorOperator(field, rawColumn, metric) {
|
|
3153
|
+
if (!this.dialect.supportsVector) {
|
|
3154
|
+
throw new UnsupportedFeatureError('pgvector distance operations', this.dialect.name, 'Vector search requires PostgreSQL with the pgvector extension.');
|
|
3155
|
+
}
|
|
2901
3156
|
const colType = this.getColumnPgType(rawColumn);
|
|
2902
3157
|
if (colType !== 'vector') {
|
|
2903
3158
|
throw new ValidationError(`[turbine] Column "${field}" on table "${this.table}" is not a vector column ` +
|
|
@@ -2918,6 +3173,9 @@ export class QueryInterface {
|
|
|
2918
3173
|
* placeholder string.
|
|
2919
3174
|
*/
|
|
2920
3175
|
pushVectorParam(field, _rawColumn, to, params) {
|
|
3176
|
+
if (!this.dialect.supportsVector) {
|
|
3177
|
+
throw new UnsupportedFeatureError('pgvector distance operations', this.dialect.name, 'Vector search requires PostgreSQL with the pgvector extension.');
|
|
3178
|
+
}
|
|
2921
3179
|
if (!Array.isArray(to) || to.length === 0) {
|
|
2922
3180
|
throw new ValidationError(`[turbine] Vector distance on "${field}" requires a non-empty array of numbers for "to".`);
|
|
2923
3181
|
}
|
|
@@ -3094,7 +3352,7 @@ export class QueryInterface {
|
|
|
3094
3352
|
const baseCols = cols.map((col) => `${qtbl}.${this.q(col)}`).join(', ');
|
|
3095
3353
|
const relationSelects = [];
|
|
3096
3354
|
const aliasCounter = { n: 0 };
|
|
3097
|
-
for (const [relName, relSpec] of
|
|
3355
|
+
for (const [relName, relSpec] of sortedEntries(withClause)) {
|
|
3098
3356
|
const relDef = meta.relations[relName];
|
|
3099
3357
|
if (!relDef) {
|
|
3100
3358
|
throw new RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
|
|
@@ -3228,6 +3486,32 @@ export class QueryInterface {
|
|
|
3228
3486
|
.map(([k]) => targetMeta.columnMap[k] ?? camelToSnake(k)));
|
|
3229
3487
|
targetColumns = targetMeta.allColumns.filter((col) => !omittedFields.has(col));
|
|
3230
3488
|
}
|
|
3489
|
+
// Engine override seam (additive): a dialect whose JSON-aggregation shape does
|
|
3490
|
+
// not map onto buildJsonObject/buildJsonArrayAgg (SQL Server FOR JSON PATH) owns
|
|
3491
|
+
// the WHOLE subquery. Absent for PG/MySQL/SQLite → the native path below runs
|
|
3492
|
+
// unchanged (byte-identical output, all their tests stay green). The override
|
|
3493
|
+
// pushes params per the documented RelationSubqueryContext ordering contract,
|
|
3494
|
+
// which mirrors collectRelationSubqueryParams so the SQL cache / pipeline stay
|
|
3495
|
+
// in sync.
|
|
3496
|
+
if (this.dialect.buildRelationSubquery) {
|
|
3497
|
+
return this.dialect.buildRelationSubquery({
|
|
3498
|
+
relDef,
|
|
3499
|
+
spec,
|
|
3500
|
+
params,
|
|
3501
|
+
parentRef,
|
|
3502
|
+
alias,
|
|
3503
|
+
targetTable,
|
|
3504
|
+
targetMeta,
|
|
3505
|
+
targetColumns,
|
|
3506
|
+
depth: currentDepth,
|
|
3507
|
+
path: currentPath,
|
|
3508
|
+
quote: (name) => this.q(name),
|
|
3509
|
+
buildWhere: (whereAlias) => (spec !== true && spec.where
|
|
3510
|
+
? this.buildAliasWhere(targetTable, targetMeta, whereAlias, spec.where, params)
|
|
3511
|
+
: '') ?? '',
|
|
3512
|
+
recurse: (nRelDef, nSpec, nParent, nDepth, nPath) => this.buildRelationSubquery(nRelDef, nSpec, params, nParent, aliasCounter, nDepth, nPath),
|
|
3513
|
+
});
|
|
3514
|
+
}
|
|
3231
3515
|
// Build JSON object pairs for resolved columns
|
|
3232
3516
|
const jsonPairs = targetColumns.map((col) => [
|
|
3233
3517
|
targetMeta.reverseColumnMap[col] ?? snakeToCamel(col),
|
|
@@ -3249,7 +3533,7 @@ export class QueryInterface {
|
|
|
3249
3533
|
}
|
|
3250
3534
|
// Nested relations — only in the non-wrapped path (wrapped path builds them separately)
|
|
3251
3535
|
if (!willWrap && spec !== true && spec.with) {
|
|
3252
|
-
for (const [nestedRelName, nestedSpec] of
|
|
3536
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
3253
3537
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3254
3538
|
if (!nestedRelDef) {
|
|
3255
3539
|
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
@@ -3259,7 +3543,7 @@ export class QueryInterface {
|
|
|
3259
3543
|
const nestedSubquery = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, alias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
|
|
3260
3544
|
// Use '[]'::json for hasMany (empty array), NULL for belongsTo/hasOne (no object)
|
|
3261
3545
|
const fallback = nestedRelDef.type === 'hasMany' ? this.dialect.emptyJsonArrayLiteral : this.dialect.nullJsonLiteral;
|
|
3262
|
-
jsonPairs.push([nestedRelName,
|
|
3546
|
+
jsonPairs.push([nestedRelName, this.dialect.wrapJsonSubresult(nestedSubquery, fallback)]);
|
|
3263
3547
|
}
|
|
3264
3548
|
}
|
|
3265
3549
|
const jsonObj = this.dialect.buildJsonObject(jsonPairs);
|
|
@@ -3307,8 +3591,7 @@ export class QueryInterface {
|
|
|
3307
3591
|
// `limit: 0` is honored (LIMIT 0 → empty array), so check !== undefined.
|
|
3308
3592
|
let limitClause = '';
|
|
3309
3593
|
if (relDef.type === 'hasMany' && spec !== true && spec.limit !== undefined) {
|
|
3310
|
-
|
|
3311
|
-
limitClause = ` LIMIT ${this.p(params.length)}`;
|
|
3594
|
+
limitClause = ` LIMIT ${this.paginationRef(spec.limit, params)}`;
|
|
3312
3595
|
}
|
|
3313
3596
|
if (relDef.type === 'hasMany') {
|
|
3314
3597
|
// When LIMIT or ORDER BY is used, wrap in a subquery so LIMIT applies to rows
|
|
@@ -3325,7 +3608,7 @@ export class QueryInterface {
|
|
|
3325
3608
|
]);
|
|
3326
3609
|
// Build nested relation subqueries referencing innerAlias
|
|
3327
3610
|
if (spec !== true && spec.with) {
|
|
3328
|
-
for (const [nestedRelName, nestedSpec] of
|
|
3611
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
3329
3612
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3330
3613
|
if (!nestedRelDef) {
|
|
3331
3614
|
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
@@ -3333,13 +3616,17 @@ export class QueryInterface {
|
|
|
3333
3616
|
}
|
|
3334
3617
|
const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, innerAlias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
|
|
3335
3618
|
const fallback = nestedRelDef.type === 'hasMany' ? this.dialect.emptyJsonArrayLiteral : this.dialect.nullJsonLiteral;
|
|
3336
|
-
innerJsonPairs.push([nestedRelName,
|
|
3619
|
+
innerJsonPairs.push([nestedRelName, this.dialect.wrapJsonSubresult(nestedSub, fallback)]);
|
|
3337
3620
|
}
|
|
3338
3621
|
}
|
|
3339
3622
|
const innerJsonObj = this.dialect.buildJsonObject(innerJsonPairs);
|
|
3340
3623
|
return `SELECT ${this.dialect.buildJsonArrayAgg(innerJsonObj)} FROM (${innerSql}) ${innerAlias}`;
|
|
3341
3624
|
}
|
|
3342
|
-
|
|
3625
|
+
// Inline ORDER BY only when the dialect's array-agg supports it (PG). For
|
|
3626
|
+
// hasMany this path is reached only when there is no orderClause, so the
|
|
3627
|
+
// argument is `undefined` either way — keeping PG output byte-identical.
|
|
3628
|
+
const inlineOrder = this.dialect.aggSupportsInlineOrderBy ? orderClause.trim() || undefined : undefined;
|
|
3629
|
+
return `SELECT ${this.dialect.buildJsonArrayAgg(jsonObj, inlineOrder)} FROM ${qTarget} ${alias} WHERE ${whereClause}`;
|
|
3343
3630
|
}
|
|
3344
3631
|
// belongsTo / hasOne — return single object
|
|
3345
3632
|
return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause} LIMIT 1`;
|
|
@@ -3427,8 +3714,7 @@ export class QueryInterface {
|
|
|
3427
3714
|
// LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
|
|
3428
3715
|
let limitClause = '';
|
|
3429
3716
|
if (spec !== true && spec.limit !== undefined) {
|
|
3430
|
-
|
|
3431
|
-
limitClause = ` LIMIT ${this.p(params.length)}`;
|
|
3717
|
+
limitClause = ` LIMIT ${this.paginationRef(spec.limit, params)}`;
|
|
3432
3718
|
}
|
|
3433
3719
|
const fromJoin = `FROM ${qTarget} ${talias} JOIN ${qJunction} ${jalias} ON ${joinOn}`;
|
|
3434
3720
|
// When LIMIT or ORDER BY is present, wrap the joined rows in an inner subquery
|
|
@@ -3443,7 +3729,7 @@ export class QueryInterface {
|
|
|
3443
3729
|
]);
|
|
3444
3730
|
// Nested relations reference the inner alias.
|
|
3445
3731
|
if (spec !== true && spec.with) {
|
|
3446
|
-
for (const [nestedRelName, nestedSpec] of
|
|
3732
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
3447
3733
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3448
3734
|
if (!nestedRelDef) {
|
|
3449
3735
|
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
@@ -3453,7 +3739,7 @@ export class QueryInterface {
|
|
|
3453
3739
|
const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
|
|
3454
3740
|
? this.dialect.nullJsonLiteral
|
|
3455
3741
|
: this.dialect.emptyJsonArrayLiteral;
|
|
3456
|
-
innerJsonPairs.push([nestedRelName,
|
|
3742
|
+
innerJsonPairs.push([nestedRelName, this.dialect.wrapJsonSubresult(nestedSub, fallback)]);
|
|
3457
3743
|
}
|
|
3458
3744
|
}
|
|
3459
3745
|
const innerJsonObj = this.dialect.buildJsonObject(innerJsonPairs);
|
|
@@ -3466,7 +3752,7 @@ export class QueryInterface {
|
|
|
3466
3752
|
`${talias}.${this.q(col)}`,
|
|
3467
3753
|
]);
|
|
3468
3754
|
if (spec !== true && spec.with) {
|
|
3469
|
-
for (const [nestedRelName, nestedSpec] of
|
|
3755
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
3470
3756
|
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3471
3757
|
if (!nestedRelDef) {
|
|
3472
3758
|
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
@@ -3476,7 +3762,7 @@ export class QueryInterface {
|
|
|
3476
3762
|
const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
|
|
3477
3763
|
? this.dialect.nullJsonLiteral
|
|
3478
3764
|
: this.dialect.emptyJsonArrayLiteral;
|
|
3479
|
-
jsonPairs.push([nestedRelName,
|
|
3765
|
+
jsonPairs.push([nestedRelName, this.dialect.wrapJsonSubresult(nestedSub, fallback)]);
|
|
3480
3766
|
}
|
|
3481
3767
|
}
|
|
3482
3768
|
const jsonObj = this.dialect.buildJsonObject(jsonPairs);
|