turbine-orm 0.50.0 → 0.51.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 +66 -66
- package/dist/adapters/cockroachdb.d.ts +5 -5
- package/dist/adapters/cockroachdb.js +10 -10
- package/dist/adapters/index.d.ts +5 -5
- package/dist/adapters/index.js +7 -7
- package/dist/adapters/yugabytedb.d.ts +7 -7
- package/dist/adapters/yugabytedb.js +10 -10
- package/dist/cjs/adapters/cockroachdb.d.ts +5 -5
- package/dist/cjs/adapters/cockroachdb.js +10 -10
- package/dist/cjs/adapters/index.d.ts +5 -5
- package/dist/cjs/adapters/index.js +7 -7
- package/dist/cjs/adapters/yugabytedb.d.ts +7 -7
- package/dist/cjs/adapters/yugabytedb.js +10 -10
- package/dist/cjs/cli/config.d.ts +13 -2
- package/dist/cjs/cli/config.js +3 -2
- package/dist/cjs/cli/destructive.d.ts +1 -1
- package/dist/cjs/cli/destructive.js +1 -1
- package/dist/cjs/cli/index.d.ts +10 -10
- package/dist/cjs/cli/index.js +49 -45
- package/dist/cjs/cli/loader.d.ts +7 -7
- package/dist/cjs/cli/loader.js +9 -9
- package/dist/cjs/cli/mcp.js +4 -4
- package/dist/cjs/cli/migrate.d.ts +5 -5
- package/dist/cjs/cli/migrate.js +11 -11
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/ui.d.ts +2 -2
- package/dist/cjs/cli/ui.js +2 -2
- package/dist/cjs/client.d.ts +49 -38
- package/dist/cjs/client.js +57 -56
- package/dist/cjs/dialect.d.ts +62 -18
- package/dist/cjs/dialect.js +40 -2
- package/dist/cjs/errors.d.ts +5 -5
- package/dist/cjs/errors.js +11 -11
- package/dist/cjs/generate.d.ts +6 -6
- package/dist/cjs/generate.js +31 -29
- package/dist/cjs/index-advisor.d.ts +5 -5
- package/dist/cjs/index-advisor.js +0 -0
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/index.js +7 -7
- package/dist/cjs/introspect.d.ts +35 -9
- package/dist/cjs/introspect.js +83 -32
- package/dist/cjs/mssql.d.ts +11 -11
- package/dist/cjs/mssql.js +64 -29
- package/dist/cjs/mysql.d.ts +8 -8
- package/dist/cjs/mysql.js +61 -23
- package/dist/cjs/nested-write.d.ts +21 -2
- package/dist/cjs/nested-write.js +51 -14
- package/dist/cjs/optional-peer-import.cjs +7 -7
- package/dist/cjs/optional-peer-import.d.cts +7 -7
- package/dist/cjs/pipeline-submittable.d.ts +2 -2
- package/dist/cjs/pipeline-submittable.js +6 -6
- package/dist/cjs/pipeline.d.ts +1 -1
- package/dist/cjs/pipeline.js +4 -4
- package/dist/cjs/powdb-introspect.d.ts +1 -1
- package/dist/cjs/powdb-introspect.js +1 -1
- package/dist/cjs/powdb.d.ts +28 -28
- package/dist/cjs/powdb.js +66 -66
- package/dist/cjs/powql.d.ts +27 -27
- package/dist/cjs/powql.js +73 -52
- package/dist/cjs/query/aggregates.d.ts +1 -1
- package/dist/cjs/query/aggregates.js +5 -5
- package/dist/cjs/query/batched-loader.d.ts +11 -11
- package/dist/cjs/query/batched-loader.js +24 -24
- package/dist/cjs/query/builder.d.ts +39 -21
- package/dist/cjs/query/builder.js +99 -57
- package/dist/cjs/query/compound-unique.d.ts +1 -1
- package/dist/cjs/query/compound-unique.js +0 -0
- package/dist/cjs/query/deferred.d.ts +12 -6
- package/dist/cjs/query/deferred.js +1 -1
- package/dist/cjs/query/filters.d.ts +31 -11
- package/dist/cjs/query/filters.js +67 -14
- package/dist/cjs/query/index.d.ts +1 -1
- package/dist/cjs/query/index.js +1 -1
- package/dist/cjs/query/relations.d.ts +9 -9
- package/dist/cjs/query/relations.js +164 -57
- package/dist/cjs/query/types.d.ts +86 -35
- package/dist/cjs/query/types.js +1 -1
- package/dist/cjs/query/utils.d.ts +27 -10
- package/dist/cjs/query/utils.js +86 -14
- package/dist/cjs/query/where.d.ts +47 -28
- package/dist/cjs/query/where.js +130 -31
- package/dist/cjs/query/writes.d.ts +24 -5
- package/dist/cjs/query/writes.js +102 -13
- package/dist/cjs/realtime.d.ts +7 -7
- package/dist/cjs/realtime.js +9 -9
- package/dist/cjs/schema-builder.d.ts +18 -7
- package/dist/cjs/schema-builder.js +17 -10
- package/dist/cjs/schema-metadata.d.ts +3 -3
- package/dist/cjs/schema-metadata.js +9 -9
- package/dist/cjs/schema-sql.d.ts +9 -9
- package/dist/cjs/schema-sql.js +20 -20
- package/dist/cjs/schema.d.ts +19 -9
- package/dist/cjs/schema.js +6 -6
- package/dist/cjs/serverless.d.ts +15 -15
- package/dist/cjs/serverless.js +16 -16
- package/dist/cjs/sqlite.d.ts +8 -8
- package/dist/cjs/sqlite.js +53 -22
- package/dist/cjs/typed-sql.d.ts +4 -4
- package/dist/cjs/typed-sql.js +5 -5
- package/dist/cli/config.d.ts +13 -2
- package/dist/cli/config.js +3 -2
- package/dist/cli/destructive.d.ts +1 -1
- package/dist/cli/destructive.js +1 -1
- package/dist/cli/index.d.ts +10 -10
- package/dist/cli/index.js +49 -45
- package/dist/cli/loader.d.ts +7 -7
- package/dist/cli/loader.js +9 -9
- package/dist/cli/mcp.js +4 -4
- package/dist/cli/migrate.d.ts +5 -5
- package/dist/cli/migrate.js +11 -11
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/ui.d.ts +2 -2
- package/dist/cli/ui.js +2 -2
- package/dist/client.d.ts +49 -38
- package/dist/client.js +57 -56
- package/dist/dialect.d.ts +62 -18
- package/dist/dialect.js +40 -2
- package/dist/errors.d.ts +5 -5
- package/dist/errors.js +11 -11
- package/dist/generate.d.ts +6 -6
- package/dist/generate.js +31 -29
- package/dist/index-advisor.d.ts +5 -5
- package/dist/index-advisor.js +0 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +7 -7
- package/dist/introspect.d.ts +35 -9
- package/dist/introspect.js +82 -32
- package/dist/mssql.d.ts +11 -11
- package/dist/mssql.js +64 -29
- package/dist/mysql.d.ts +8 -8
- package/dist/mysql.js +61 -23
- package/dist/nested-write.d.ts +21 -2
- package/dist/nested-write.js +51 -14
- package/dist/optional-peer-import.cjs +7 -7
- package/dist/optional-peer-import.d.cts +7 -7
- package/dist/pipeline-submittable.d.ts +2 -2
- package/dist/pipeline-submittable.js +6 -6
- package/dist/pipeline.d.ts +1 -1
- package/dist/pipeline.js +4 -4
- package/dist/powdb-introspect.d.ts +1 -1
- package/dist/powdb-introspect.js +1 -1
- package/dist/powdb.d.ts +28 -28
- package/dist/powdb.js +66 -66
- package/dist/powql.d.ts +27 -27
- package/dist/powql.js +73 -52
- package/dist/query/aggregates.d.ts +1 -1
- package/dist/query/aggregates.js +5 -5
- package/dist/query/batched-loader.d.ts +11 -11
- package/dist/query/batched-loader.js +24 -24
- package/dist/query/builder.d.ts +39 -21
- package/dist/query/builder.js +100 -58
- package/dist/query/compound-unique.d.ts +1 -1
- package/dist/query/compound-unique.js +0 -0
- package/dist/query/deferred.d.ts +12 -6
- package/dist/query/deferred.js +1 -1
- package/dist/query/filters.d.ts +31 -11
- package/dist/query/filters.js +66 -13
- package/dist/query/index.d.ts +1 -1
- package/dist/query/index.js +1 -1
- package/dist/query/relations.d.ts +9 -9
- package/dist/query/relations.js +165 -58
- package/dist/query/types.d.ts +86 -35
- package/dist/query/types.js +1 -1
- package/dist/query/utils.d.ts +27 -10
- package/dist/query/utils.js +84 -14
- package/dist/query/where.d.ts +47 -28
- package/dist/query/where.js +129 -32
- package/dist/query/writes.d.ts +24 -5
- package/dist/query/writes.js +101 -13
- package/dist/realtime.d.ts +7 -7
- package/dist/realtime.js +9 -9
- package/dist/schema-builder.d.ts +18 -7
- package/dist/schema-builder.js +17 -10
- package/dist/schema-metadata.d.ts +3 -3
- package/dist/schema-metadata.js +9 -9
- package/dist/schema-sql.d.ts +9 -9
- package/dist/schema-sql.js +20 -20
- package/dist/schema.d.ts +19 -9
- package/dist/schema.js +6 -6
- package/dist/serverless.d.ts +15 -15
- package/dist/serverless.js +16 -16
- package/dist/sqlite.d.ts +8 -8
- package/dist/sqlite.js +53 -22
- package/dist/typed-sql.d.ts +4 -4
- package/dist/typed-sql.js +5 -5
- package/package.json +2 -2
package/dist/query/relations.js
CHANGED
|
@@ -17,7 +17,7 @@ import { missingIndexForRelation } from '../index-advisor.js';
|
|
|
17
17
|
import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
|
|
18
18
|
import { resolveCountRelations } from './batched-loader.js';
|
|
19
19
|
import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, normalizeOrderBy, orderByEntries, sortedEntries, } from './filters.js';
|
|
20
|
-
import {
|
|
20
|
+
import { ownLookup } from './utils.js';
|
|
21
21
|
import { hasWarnedOnce, shouldWarnOnce, WARN_NS } from './warn-registry.js';
|
|
22
22
|
import * as whereMod from './where.js';
|
|
23
23
|
import * as writesMod from './writes.js';
|
|
@@ -29,7 +29,7 @@ export function resolveColumns(qi, select, omit, includePii) {
|
|
|
29
29
|
if (select) {
|
|
30
30
|
// An array here means a caller wrote `select: ['id', 'name']` (Drizzle/SQL
|
|
31
31
|
// style) instead of the object shape. Object.entries() would iterate the
|
|
32
|
-
// numeric indices and throw a cryptic `Unknown field "0"
|
|
32
|
+
// numeric indices and throw a cryptic `Unknown field "0"`, catch it early
|
|
33
33
|
// with an actionable message.
|
|
34
34
|
if (Array.isArray(select)) {
|
|
35
35
|
throw new ValidationError(`[turbine] "select" must be an object mapping field names to true ` +
|
|
@@ -79,7 +79,7 @@ export function withFingerprint(qi, withClause, table, depth = 0) {
|
|
|
79
79
|
const spec = withClause[relName];
|
|
80
80
|
if (!spec)
|
|
81
81
|
continue;
|
|
82
|
-
// Reserved `_count` key
|
|
82
|
+
// Reserved `_count` key, fingerprint by the selected relation set so
|
|
83
83
|
// `_count: true` and `_count: { posts: true }` never share a cache entry.
|
|
84
84
|
if (relName === '_count') {
|
|
85
85
|
const c = spec;
|
|
@@ -165,7 +165,7 @@ export function collectWithParams(qi, withClause, params, table, flattenPlan) {
|
|
|
165
165
|
}
|
|
166
166
|
collectRelationSubqueryParams(qi, relDef, relSpec, params, table ?? qi.table);
|
|
167
167
|
}
|
|
168
|
-
// `_count` global-filter params
|
|
168
|
+
// `_count` global-filter params, mirror buildSelectWithRelations, which
|
|
169
169
|
// appends the count subqueries (and any target-filter params) AFTER every
|
|
170
170
|
// relation subquery, in resolveCountRelations order.
|
|
171
171
|
const countSpec = withClause._count;
|
|
@@ -235,14 +235,14 @@ export function collectRelationSubqueryParams(qi, relDef, spec, params, _parentR
|
|
|
235
235
|
if (nativeOrderPath && hasOrder) {
|
|
236
236
|
collectRelationOrderParams(qi, targetTable, targetMeta, relOrderEntries, params);
|
|
237
237
|
}
|
|
238
|
-
// where params
|
|
238
|
+
// where params, mirrors buildAliasWhere push order
|
|
239
239
|
if (spec.where) {
|
|
240
240
|
whereMod.collectAliasWhereParams(qi, targetTable, targetMeta, spec.where, params);
|
|
241
241
|
}
|
|
242
|
-
// Global filter on the target
|
|
242
|
+
// Global filter on the target, mirrors targetGlobalFilterAlias in
|
|
243
243
|
// buildRelationSubquery (pushed after spec.where, before limit).
|
|
244
244
|
whereMod.collectTargetGlobalFilterAlias(qi, targetTable, params);
|
|
245
|
-
// limit param
|
|
245
|
+
// limit param, only hasMany parameterizes its limit (mirrors
|
|
246
246
|
// buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
|
|
247
247
|
// pushing one here would orphan a param and desync the collect path.
|
|
248
248
|
// `limit: 0` pushes (LIMIT 0 is honored), so check !== undefined.
|
|
@@ -263,7 +263,7 @@ export function collectRelationSubqueryParams(qi, relDef, spec, params, _parentR
|
|
|
263
263
|
* Value-shape fingerprint for a single orderBy entry, so two queries whose
|
|
264
264
|
* ORDER BY differs only in nulls placement, vector metric, or relation-count
|
|
265
265
|
* vs relation-column never collide on one cached SQL string. Captures the
|
|
266
|
-
* SQL-shaping bits (direction, nulls, metric, relation keys)
|
|
266
|
+
* SQL-shaping bits (direction, nulls, metric, relation keys), never values.
|
|
267
267
|
*/
|
|
268
268
|
export function orderByEntryFingerprint(qi, d, targetTable) {
|
|
269
269
|
// Vector KNN ordering changes the emitted operator by metric and adds a
|
|
@@ -309,7 +309,7 @@ export function orderByEntryFingerprint(qi, d, targetTable) {
|
|
|
309
309
|
// emits one ORDER BY term per entry in Object.entries order, so entry
|
|
310
310
|
// order is SQL-shaping precedence. A sorted fingerprint made
|
|
311
311
|
// `{ name: 'asc', email: 'desc' }` and the swapped literal share one
|
|
312
|
-
// cached SQL string
|
|
312
|
+
// cached SQL string, silently mis-ordered results on a warm cache.
|
|
313
313
|
return `rel(${Object.entries(d)
|
|
314
314
|
.map(([k, v]) => `${k}=${orderByEntryFingerprint(qi, v)}`)
|
|
315
315
|
.join(',')})`;
|
|
@@ -361,7 +361,7 @@ export function buildOrderBy(qi, orderBy, params, lateralSink) {
|
|
|
361
361
|
if (isRelationOrderByValue(qi, value)) {
|
|
362
362
|
return buildRelationOrderBy(qi, key, value, `ord${relOrdCounter++}`, params, undefined, lateralSink);
|
|
363
363
|
}
|
|
364
|
-
// Scalar column ordering
|
|
364
|
+
// Scalar column ordering, a plain direction or an OrderBySpec (nulls).
|
|
365
365
|
if (meta && !(key in meta.columnMap)) {
|
|
366
366
|
throw new ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${qi.table}". ` +
|
|
367
367
|
`Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
|
|
@@ -386,7 +386,7 @@ export function isRelationOrderByValue(_qi, value) {
|
|
|
386
386
|
}
|
|
387
387
|
/**
|
|
388
388
|
* Render the ` NULLS FIRST` / ` NULLS LAST` suffix for a column ordering.
|
|
389
|
-
* Only PostgreSQL and SQLite support the `NULLS FIRST/LAST` grammar
|
|
389
|
+
* Only PostgreSQL and SQLite support the `NULLS FIRST/LAST` grammar, on any
|
|
390
390
|
* other engine a caller asking for explicit nulls placement gets a clear
|
|
391
391
|
* {@link UnsupportedFeatureError} (E017) instead of broken SQL.
|
|
392
392
|
*/
|
|
@@ -466,6 +466,100 @@ export function buildJsonPathOrderEntry(qi, table, meta, field, spec, prefix, pa
|
|
|
466
466
|
: '';
|
|
467
467
|
return `${lhs} ${dir}${nullsSql}`;
|
|
468
468
|
}
|
|
469
|
+
/**
|
|
470
|
+
* Order by a column reached through TWO OR MORE to-one relation hops, e.g.
|
|
471
|
+
* `orderBy: { model: { category: { name: 'asc' } } }`.
|
|
472
|
+
*
|
|
473
|
+
* One hop already compiled to a correlated scalar subquery; each additional
|
|
474
|
+
* hop is added to that same subquery as an INNER JOIN, so the whole chain is
|
|
475
|
+
* one subquery with one `LIMIT 1` regardless of depth:
|
|
476
|
+
*
|
|
477
|
+
* (SELECT t1."name"
|
|
478
|
+
* FROM "models" t0
|
|
479
|
+
* JOIN "categories" t1 ON t1."id" = t0."category_id"
|
|
480
|
+
* WHERE t0."id" = "versions"."model_id"
|
|
481
|
+
* LIMIT 1) ASC
|
|
482
|
+
*
|
|
483
|
+
* Every hop must be to-one. A to-many hop has no single value to order by, so
|
|
484
|
+
* it is refused rather than silently picking an arbitrary row (`{ pick, by }`
|
|
485
|
+
* exists for that, deliberately, because it forces the caller to say WHICH
|
|
486
|
+
* row). Each hop's target global filter is applied to its join condition, so
|
|
487
|
+
* ordering never keys off a soft-deleted or other-tenant row.
|
|
488
|
+
*/
|
|
489
|
+
function buildChainedToOneOrderBy(qi, head, nextRelName, nextValue, params) {
|
|
490
|
+
const joins = [];
|
|
491
|
+
let currentMeta = qi.schema.tables[head.relDef.to];
|
|
492
|
+
let currentAlias = head.alias;
|
|
493
|
+
let hop = 0;
|
|
494
|
+
let relName = nextRelName;
|
|
495
|
+
let value = nextValue;
|
|
496
|
+
const path = [head.relName];
|
|
497
|
+
// Walk the chain, emitting one JOIN per hop, until the value stops being a
|
|
498
|
+
// relation object. Bounded by the same depth cap as nested `with`.
|
|
499
|
+
for (;;) {
|
|
500
|
+
if (!currentMeta)
|
|
501
|
+
throw new RelationError(`[turbine] Unknown relation target in orderBy chain "${path.join('.')}"`);
|
|
502
|
+
const relDef = ownLookup(currentMeta.relations, relName);
|
|
503
|
+
if (!relDef) {
|
|
504
|
+
throw new ValidationError(`[turbine] Unknown relation "${relName}" in orderBy on relation "${path.join('.')}" ` +
|
|
505
|
+
`(table "${currentMeta.name}"). Available: ${Object.keys(currentMeta.relations).join(', ') || '(none)'}.`);
|
|
506
|
+
}
|
|
507
|
+
if (relDef.type !== 'belongsTo' && relDef.type !== 'hasOne') {
|
|
508
|
+
throw new ValidationError(`[turbine] orderBy cannot traverse the to-many relation "${relName}" on "${currentMeta.name}" ` +
|
|
509
|
+
`(path "${path.concat(relName).join('.')}"): a to-many relation has no single value to order by. ` +
|
|
510
|
+
`Use a pick-row ordering ({ pick, by }) at the top level, or order by "_count".`);
|
|
511
|
+
}
|
|
512
|
+
if (++hop > MAX_ORDER_BY_RELATION_HOPS) {
|
|
513
|
+
throw new CircularRelationError(path.concat(relName));
|
|
514
|
+
}
|
|
515
|
+
const nextMeta = qi.schema.tables[relDef.to];
|
|
516
|
+
if (!nextMeta)
|
|
517
|
+
throw new RelationError(`[turbine] Unknown relation target "${relDef.to}" in orderBy`);
|
|
518
|
+
const nextAlias = `${head.alias}c${hop}`;
|
|
519
|
+
const on = relDef.type === 'belongsTo'
|
|
520
|
+
? qi.dialect.buildCorrelation(nextAlias, relDef.referenceKey, currentAlias, relDef.foreignKey)
|
|
521
|
+
: qi.dialect.buildCorrelation(nextAlias, relDef.foreignKey, currentAlias, relDef.referenceKey);
|
|
522
|
+
let onSql = on;
|
|
523
|
+
if (params) {
|
|
524
|
+
const gf = whereMod.targetGlobalFilterAlias(qi, relDef.to, nextAlias, params);
|
|
525
|
+
if (gf)
|
|
526
|
+
onSql += ` AND ${gf}`;
|
|
527
|
+
}
|
|
528
|
+
joins.push(`JOIN ${qi.q(relDef.to)} ${nextAlias} ON ${onSql}`);
|
|
529
|
+
path.push(relName);
|
|
530
|
+
currentMeta = nextMeta;
|
|
531
|
+
currentAlias = nextAlias;
|
|
532
|
+
// Exactly one key per level, as with the single-hop form.
|
|
533
|
+
const entries = Object.entries(value);
|
|
534
|
+
if (entries.length !== 1) {
|
|
535
|
+
throw new ValidationError(`[turbine] orderBy on relation "${path.join('.')}" needs exactly one key per level ` +
|
|
536
|
+
`(got: ${entries.map(([k]) => k).join(', ') || '(empty)'}).`);
|
|
537
|
+
}
|
|
538
|
+
const [key, entryValue] = entries[0];
|
|
539
|
+
if (ownLookup(currentMeta.relations, key) && isRelationOrderByValue(qi, entryValue)) {
|
|
540
|
+
relName = key;
|
|
541
|
+
value = entryValue;
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
// Terminal: a column on the last table in the chain.
|
|
545
|
+
const snakeCol = ownLookup(currentMeta.columnMap, key) ?? camelToSnake(key);
|
|
546
|
+
if (!currentMeta.allColumns.includes(snakeCol)) {
|
|
547
|
+
throw new ValidationError(`[turbine] Unknown column "${key}" in orderBy on relation "${path.join('.')}" (table "${currentMeta.name}").`);
|
|
548
|
+
}
|
|
549
|
+
const { dir, nulls } = normalizeOrderBy(entryValue);
|
|
550
|
+
let where = head.correlation;
|
|
551
|
+
if (params) {
|
|
552
|
+
const gf = whereMod.targetGlobalFilterAlias(qi, head.relDef.to, head.alias, params);
|
|
553
|
+
if (gf)
|
|
554
|
+
where += ` AND ${gf}`;
|
|
555
|
+
}
|
|
556
|
+
const from = `${qi.q(head.relDef.to)} ${head.alias} ${joins.join(' ')}`;
|
|
557
|
+
return (`(SELECT ${currentAlias}.${qi.q(snakeCol)} FROM ${from} WHERE ${where}${qi.limitOneClause()}) ` +
|
|
558
|
+
`${dir}${nullsSuffix(qi, nulls)}`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
/** Depth cap for a chained relation orderBy, mirroring the nested-`with` cap. */
|
|
562
|
+
const MAX_ORDER_BY_RELATION_HOPS = 10;
|
|
469
563
|
/**
|
|
470
564
|
* Compile a relation ordering term. For a to-many relation the only allowed
|
|
471
565
|
* key is `_count`, which becomes a correlated `COUNT(*)` subquery. For a
|
|
@@ -533,14 +627,26 @@ export function buildRelationOrderBy(qi, relName, value, alias, params, ctx, lat
|
|
|
533
627
|
}
|
|
534
628
|
return entries
|
|
535
629
|
.map(([col, dirValue]) => {
|
|
630
|
+
// A nested relation: `orderBy: { model: { category: { name: 'asc' } } }`.
|
|
631
|
+
// Each further to-one hop becomes a JOIN inside the SAME correlated
|
|
632
|
+
// subquery rather than another level of nesting, so an N-hop chain still
|
|
633
|
+
// costs one subquery.
|
|
634
|
+
const nestedRel = ownLookup(targetMeta.relations, col);
|
|
635
|
+
if (nestedRel && isRelationOrderByValue(qi, dirValue)) {
|
|
636
|
+
return buildChainedToOneOrderBy(qi, { relName, relDef, alias, correlation }, col, dirValue, params);
|
|
637
|
+
}
|
|
536
638
|
// columnMap-first resolution (camelToSnake fallback): mirrors the
|
|
537
639
|
// scalar orderBy path so camelCase-named DB columns resolve here too.
|
|
538
640
|
const snakeCol = ownLookup(targetMeta.columnMap, col) ?? camelToSnake(col);
|
|
539
641
|
if (!targetMeta.allColumns.includes(snakeCol)) {
|
|
540
|
-
|
|
642
|
+
const relationHint = ownLookup(targetMeta.relations, col)
|
|
643
|
+
? ` "${col}" is a relation on "${relDef.to}": order by one of ITS columns, e.g. ` +
|
|
644
|
+
`{ ${relName}: { ${col}: { <column>: 'asc' } } }.`
|
|
645
|
+
: '';
|
|
646
|
+
throw new ValidationError(`[turbine] Unknown column "${col}" in orderBy on relation "${relName}" (table "${relDef.to}").${relationHint}`);
|
|
541
647
|
}
|
|
542
648
|
const { dir, nulls } = normalizeOrderBy(dirValue);
|
|
543
|
-
// Target's global filter applies here too
|
|
649
|
+
// Target's global filter applies here too, otherwise ordering keys off
|
|
544
650
|
// a soft-deleted / other-tenant related row's value (matches the with
|
|
545
651
|
// subquery semantics for belongsTo/hasOne).
|
|
546
652
|
let where = correlation;
|
|
@@ -897,7 +1003,7 @@ export function collectManyToManyTargetGlobalFilter(qi, relDef, params) {
|
|
|
897
1003
|
/**
|
|
898
1004
|
* Param-collect mirror of {@link buildRelationCountExpr}'s global-filter
|
|
899
1005
|
* params (hasMany direct filter, or manyToMany EXISTS-on-target). Only pushes
|
|
900
|
-
* when a filter applies
|
|
1006
|
+
* when a filter applies, no-op otherwise.
|
|
901
1007
|
*/
|
|
902
1008
|
export function collectRelationCountParams(qi, relDef, params) {
|
|
903
1009
|
if (relDef.type === 'manyToMany') {
|
|
@@ -1051,7 +1157,7 @@ export function buildJsonRow(qi, jsonPairs) {
|
|
|
1051
1157
|
// `json_build_object` renders a handful of Postgres types as something other
|
|
1052
1158
|
// than the value the driver hands back for the same column, so the 'join'
|
|
1053
1159
|
// strategy used to disagree with a top-level read, with 'batched', and with
|
|
1054
|
-
// 'flatten'
|
|
1160
|
+
// 'flatten', losing precision outright on numeric and int8. The two halves of
|
|
1055
1161
|
// the fix live here and must stay in lockstep:
|
|
1056
1162
|
//
|
|
1057
1163
|
// SQL side jsonScalarPairs() emits `alias."col"::text` for a divergent
|
|
@@ -1064,9 +1170,17 @@ export function buildJsonRow(qi, jsonPairs) {
|
|
|
1064
1170
|
// decode (or vice versa). Postgres-only: other engines neither use
|
|
1065
1171
|
// json_build_object nor share this divergence set.
|
|
1066
1172
|
// ---------------------------------------------------------------------------
|
|
1067
|
-
/**
|
|
1068
|
-
|
|
1069
|
-
|
|
1173
|
+
/**
|
|
1174
|
+
* The JSON-wire rule for one column, or undefined when its JSON rendering
|
|
1175
|
+
* already matches the driver. Routed entirely through the dialect, so each
|
|
1176
|
+
* engine states its own divergence set (see {@link Dialect.jsonWireRule}); a
|
|
1177
|
+
* dialect that omits the hook keeps the pre-0.51 no-cast behavior.
|
|
1178
|
+
*/
|
|
1179
|
+
function jsonWireRuleFor(qi, meta, col) {
|
|
1180
|
+
const type = meta.pgTypes?.[col];
|
|
1181
|
+
if (!type)
|
|
1182
|
+
return undefined;
|
|
1183
|
+
return qi.dialect.jsonWireRule?.(type);
|
|
1070
1184
|
}
|
|
1071
1185
|
/**
|
|
1072
1186
|
* The JSON value expression for one scalar relation column: the plain column
|
|
@@ -1075,9 +1189,7 @@ function usesJsonWireCoercion(qi) {
|
|
|
1075
1189
|
*/
|
|
1076
1190
|
function jsonScalarExpr(qi, targetMeta, col, ref) {
|
|
1077
1191
|
const expr = `${ref}.${qi.q(col)}`;
|
|
1078
|
-
|
|
1079
|
-
return expr;
|
|
1080
|
-
return jsonWireCoercionOid(targetMeta.pgTypes?.[col]) === undefined ? expr : `${expr}::text`;
|
|
1192
|
+
return jsonWireRuleFor(qi, targetMeta, col)?.sql(expr) ?? expr;
|
|
1081
1193
|
}
|
|
1082
1194
|
/**
|
|
1083
1195
|
* The `[camelKey, valueExpr]` pairs for a relation's scalar columns. Single
|
|
@@ -1111,16 +1223,13 @@ function jsonWireFields(qi, table, meta) {
|
|
|
1111
1223
|
if (cached !== undefined)
|
|
1112
1224
|
return cached;
|
|
1113
1225
|
let fields = null;
|
|
1114
|
-
const
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
fields = new Map();
|
|
1122
|
-
fields.set(meta.reverseColumnMap[col] ?? snakeToCamel(col), oid);
|
|
1123
|
-
}
|
|
1226
|
+
for (const col of meta.allColumns) {
|
|
1227
|
+
const rule = jsonWireRuleFor(qi, meta, col);
|
|
1228
|
+
if (rule === undefined)
|
|
1229
|
+
continue;
|
|
1230
|
+
if (fields === null)
|
|
1231
|
+
fields = new Map();
|
|
1232
|
+
fields.set(meta.reverseColumnMap[col] ?? snakeToCamel(col), rule);
|
|
1124
1233
|
}
|
|
1125
1234
|
byTable.set(table, fields);
|
|
1126
1235
|
return fields;
|
|
@@ -1131,8 +1240,6 @@ function jsonWireFields(qi, table, meta) {
|
|
|
1131
1240
|
* so the ordinary relation pays nothing beyond the memo lookup.
|
|
1132
1241
|
*/
|
|
1133
1242
|
function decodeJsonWireRow(qi, row, table, meta) {
|
|
1134
|
-
if (!usesJsonWireCoercion(qi))
|
|
1135
|
-
return row;
|
|
1136
1243
|
const fields = jsonWireFields(qi, table, meta);
|
|
1137
1244
|
if (fields === null)
|
|
1138
1245
|
return row;
|
|
@@ -1141,13 +1248,13 @@ function decodeJsonWireRow(qi, row, table, meta) {
|
|
|
1141
1248
|
// be holding. Measured against an in-place variant on a 20K-child-row join the
|
|
1142
1249
|
// two were indistinguishable, so the copy is free insurance.
|
|
1143
1250
|
let decoded;
|
|
1144
|
-
for (const [field,
|
|
1251
|
+
for (const [field, rule] of fields) {
|
|
1145
1252
|
const raw = row[field];
|
|
1146
1253
|
if (typeof raw !== 'string')
|
|
1147
1254
|
continue;
|
|
1148
1255
|
if (decoded === undefined)
|
|
1149
1256
|
decoded = { ...row };
|
|
1150
|
-
decoded[field] =
|
|
1257
|
+
decoded[field] = rule.decode(raw);
|
|
1151
1258
|
}
|
|
1152
1259
|
return decoded ?? row;
|
|
1153
1260
|
}
|
|
@@ -1216,7 +1323,7 @@ export function makeNestedParser(qi, withClause, includePii, flattenPlan) {
|
|
|
1216
1323
|
/**
|
|
1217
1324
|
* Return a shallow copy of a top-level row with each relation column decoded
|
|
1218
1325
|
* from its positional array(s) into the object representation. Only relation
|
|
1219
|
-
* columns are positional
|
|
1326
|
+
* columns are positional, base scalar columns stay object-keyed, so the
|
|
1220
1327
|
* result is exactly what the object encoding would have handed parseNestedRow.
|
|
1221
1328
|
*/
|
|
1222
1329
|
export function decodePositionalRelations(qi, row, shapes) {
|
|
@@ -1266,7 +1373,7 @@ export function decodePositionalObject(qi, arr, shape) {
|
|
|
1266
1373
|
return obj;
|
|
1267
1374
|
}
|
|
1268
1375
|
// ---------------------------------------------------------------------------
|
|
1269
|
-
// relationLoadStrategy: 'flatten'
|
|
1376
|
+
// relationLoadStrategy: 'flatten', to-one relations compiled as LEFT JOINs
|
|
1270
1377
|
// ---------------------------------------------------------------------------
|
|
1271
1378
|
/**
|
|
1272
1379
|
* Alias prefix for a flattened relation's join. Deliberately distinct from the
|
|
@@ -1281,7 +1388,7 @@ const FLATTEN_ALIAS_PREFIX = 'f';
|
|
|
1281
1388
|
* The top-level `WHERE` and `ORDER BY` reference the parent's columns
|
|
1282
1389
|
* UNQUALIFIED (`WHERE "name" = $1`). A bare `LEFT JOIN "orgs" f0` puts a second
|
|
1283
1390
|
* table in scope, so any column name the two tables share ("id", "name",
|
|
1284
|
-
* "created_at"
|
|
1391
|
+
* "created_at", i.e. most of them) turns those references ambiguous and
|
|
1285
1392
|
* Postgres rejects the statement. Qualifying the parent's references is not an
|
|
1286
1393
|
* option here: they are compiled by the shared WHERE walk, which is scope-blind
|
|
1287
1394
|
* by design.
|
|
@@ -1384,13 +1491,13 @@ export function provableUniqueTargetKey(relDef, targetMeta) {
|
|
|
1384
1491
|
*
|
|
1385
1492
|
* A relation is ELIGIBLE when all of the following hold:
|
|
1386
1493
|
* 1. it is `belongsTo` or `hasOne` AND its target-side correlation columns are
|
|
1387
|
-
* provably unique ({@link provableUniqueTargetKey})
|
|
1494
|
+
* provably unique ({@link provableUniqueTargetKey}), the row-multiplication
|
|
1388
1495
|
* guard;
|
|
1389
1496
|
* 2. its spec declares no `limit` and no `orderBy` (both are no-ops over a
|
|
1390
1497
|
* single matching row, but refusing them keeps the emitted SQL and the
|
|
1391
1498
|
* param stream trivially equivalent);
|
|
1392
1499
|
* 3. its nested `with` names only real relations and no reserved `_count`
|
|
1393
|
-
* (nested `_count` is unsupported on every strategy
|
|
1500
|
+
* (nested `_count` is unsupported on every strategy, falling back lets the
|
|
1394
1501
|
* subquery path raise the same error);
|
|
1395
1502
|
* 4. the depth cap is not reached, so a too-deep chain still raises
|
|
1396
1503
|
* {@link CircularRelationError} from the subquery path instead of silently
|
|
@@ -1525,7 +1632,7 @@ function warnFlattenFallback(table, rejects) {
|
|
|
1525
1632
|
* it emits today, down to the cache key).
|
|
1526
1633
|
*
|
|
1527
1634
|
* The plan is a pure function of the schema, the `with` clause shape and
|
|
1528
|
-
* `includePii
|
|
1635
|
+
* `includePii`, never of any bound value, so the build path, the cache-hit
|
|
1529
1636
|
* param-collect path and the row assembler can each recompute it and agree.
|
|
1530
1637
|
*/
|
|
1531
1638
|
export function planFlattenWith(qi, table, withClause, includePii) {
|
|
@@ -1828,8 +1935,8 @@ export function buildSelectWithRelations(qi, table, withClause, params, columnsL
|
|
|
1828
1935
|
const meta = qi.schema.tables[table];
|
|
1829
1936
|
if (!meta)
|
|
1830
1937
|
throw new ValidationError(`[turbine] Unknown table "${table}"`);
|
|
1831
|
-
// Positional JSON encoding is Postgres-only in v1. Gate here
|
|
1832
|
-
// entry point for every `with` clause
|
|
1938
|
+
// Positional JSON encoding is Postgres-only in v1. Gate here, the single
|
|
1939
|
+
// entry point for every `with` clause, so no engine ever emits the
|
|
1833
1940
|
// json_build_array shape its dialect can't produce (and mssql's FOR JSON
|
|
1834
1941
|
// override path is never reached with positional active).
|
|
1835
1942
|
if (qi.jsonEncoding === 'positional' && qi.dialect.name !== 'postgresql') {
|
|
@@ -1971,7 +2078,7 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
|
|
|
1971
2078
|
const currentDepth = depth ?? 0;
|
|
1972
2079
|
const currentPath = path ?? [qi.table];
|
|
1973
2080
|
const targetTable = relDef.to;
|
|
1974
|
-
// Hard depth cap
|
|
2081
|
+
// Hard depth cap, the `with` clause is a finite JSON structure so users can't
|
|
1975
2082
|
// create true infinite recursion, but extremely deep nesting (10+ levels) produces
|
|
1976
2083
|
// unmanageably large SQL. Back-references (e.g. posts → user → posts) are allowed
|
|
1977
2084
|
// since they are legitimate queries (Prisma supports the same pattern).
|
|
@@ -1996,7 +2103,7 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
|
|
|
1996
2103
|
const miss = missingIndexForRelation(qi.schema, relDef);
|
|
1997
2104
|
if (miss && shouldWarnOnce(WARN_NS.unindexedRelation, warnKey)) {
|
|
1998
2105
|
console.warn(`[turbine] Relation "${relDef.name}" on "${relDef.from}" probes ` +
|
|
1999
|
-
`"${miss.table}"(${miss.columns.join(', ')}) which has no covering index
|
|
2106
|
+
`"${miss.table}"(${miss.columns.join(', ')}) which has no covering index, ` +
|
|
2000
2107
|
`each parent row scans the full table. Fix: ${miss.createSql}; ` +
|
|
2001
2108
|
'or run `npx turbine doctor` for a full report.');
|
|
2002
2109
|
}
|
|
@@ -2040,7 +2147,7 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
|
|
|
2040
2147
|
// Determine if this hasMany will take the wrapped subquery path (LIMIT or ORDER BY).
|
|
2041
2148
|
// When wrapping, nested relations are built in the wrapped path referencing innerAlias,
|
|
2042
2149
|
// so we must NOT build them here (they would push orphaned params).
|
|
2043
|
-
// An orderBy with no defined entries (`orderBy: {}`) is treated as absent
|
|
2150
|
+
// An orderBy with no defined entries (`orderBy: {}`) is treated as absent -
|
|
2044
2151
|
// it must neither trigger the wrap (dropping nested relations) nor render a
|
|
2045
2152
|
// dangling `ORDER BY `. `limit: 0` is meaningful (LIMIT 0) and DOES wrap.
|
|
2046
2153
|
const relOrderEntries = spec !== true && spec.orderBy ? orderByEntries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
|
|
@@ -2051,7 +2158,7 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
|
|
|
2051
2158
|
if (relDef.type === 'manyToMany') {
|
|
2052
2159
|
return buildManyToManySubquery(qi, relDef, spec, params, parentRef, aliasCounter, currentDepth, currentPath, alias, targetMeta, targetColumns, includePii);
|
|
2053
2160
|
}
|
|
2054
|
-
// Nested relations
|
|
2161
|
+
// Nested relations, only in the non-wrapped path (wrapped path builds them separately)
|
|
2055
2162
|
if (!willWrap && spec !== true && spec.with) {
|
|
2056
2163
|
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
2057
2164
|
const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
|
|
@@ -2067,7 +2174,7 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
|
|
|
2067
2174
|
}
|
|
2068
2175
|
}
|
|
2069
2176
|
const jsonObj = buildJsonRow(qi, jsonPairs);
|
|
2070
|
-
// Quote parent ref
|
|
2177
|
+
// Quote parent ref, can be a table name or auto-generated alias
|
|
2071
2178
|
const qParent = qi.q(parentRef);
|
|
2072
2179
|
const qTarget = qi.q(targetTable);
|
|
2073
2180
|
// Build ORDER BY for json_agg: unified with the top-level orderBy surface
|
|
@@ -2078,10 +2185,10 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
|
|
|
2078
2185
|
if (relOrderEntries.length > 0) {
|
|
2079
2186
|
orderClause = buildRelationOrderClause(qi, targetTable, targetMeta, alias, relOrderEntries, params);
|
|
2080
2187
|
}
|
|
2081
|
-
// Build WHERE
|
|
2188
|
+
// Build WHERE, correlate to parent via parentRef (alias or table name).
|
|
2082
2189
|
// For hasMany/hasOne: TARGET has the FK (RelationDef.foreignKey is always
|
|
2083
2190
|
// the child-side column), so alias.fk = parentRef.pk. hasOne is just
|
|
2084
|
-
// hasMany with a unique FK
|
|
2191
|
+
// hasMany with a unique FK, treating it like belongsTo here silently
|
|
2085
2192
|
// correlated the wrong columns (caught dogfooding: uuid = varchar).
|
|
2086
2193
|
// For belongsTo: SOURCE has the FK, so alias.pk = parentRef.fk (reversed).
|
|
2087
2194
|
// Supports composite foreign keys (string[]) via buildCorrelation.
|
|
@@ -2092,20 +2199,20 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
|
|
|
2092
2199
|
else {
|
|
2093
2200
|
whereClause = qi.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
|
|
2094
2201
|
}
|
|
2095
|
-
// Additional filters
|
|
2202
|
+
// Additional filters, full scalar where surface (equality, null, operator
|
|
2096
2203
|
// objects, OR/AND/NOT), properly parameterized against this alias.
|
|
2097
2204
|
if (spec !== true && spec.where) {
|
|
2098
2205
|
const extra = whereMod.buildAliasWhere(qi, targetTable, targetMeta, alias, spec.where, params);
|
|
2099
2206
|
if (extra)
|
|
2100
2207
|
whereClause += ` AND ${extra}`;
|
|
2101
2208
|
}
|
|
2102
|
-
// Global filter on the target table (soft-delete / tenancy)
|
|
2209
|
+
// Global filter on the target table (soft-delete / tenancy), AND-merged so
|
|
2103
2210
|
// a `with` never surfaces filtered-out child rows. Pushed AFTER spec.where,
|
|
2104
2211
|
// mirrored by collectRelationSubqueryParams.
|
|
2105
2212
|
const gfExtra = whereMod.targetGlobalFilterAlias(qi, targetTable, alias, params);
|
|
2106
2213
|
if (gfExtra)
|
|
2107
2214
|
whereClause += ` AND ${gfExtra}`;
|
|
2108
|
-
// LIMIT
|
|
2215
|
+
// LIMIT, only meaningful for hasMany. A belongsTo / hasOne subquery returns
|
|
2109
2216
|
// a single row (literal `LIMIT 1` below), so a `spec.limit` here must NOT push
|
|
2110
2217
|
// a parameter: doing so orphans an untyped `$N` that the SQL never references,
|
|
2111
2218
|
// which Postgres rejects with "could not determine data type of parameter $N"
|
|
@@ -2123,7 +2230,7 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
|
|
|
2123
2230
|
// Rewrite: SELECT json_agg(json_build_object(...)) FROM (SELECT * FROM table WHERE ... ORDER BY ... LIMIT N) AS alias
|
|
2124
2231
|
// Inner SELECT always needs all columns for WHERE/ORDER to work; json_build_object filters later
|
|
2125
2232
|
const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${alias}.${qi.q(c)}`).join(', ')} FROM ${qTarget} ${alias} WHERE ${whereClause}${orderClause}${limitClause}`;
|
|
2126
|
-
// For the json_build_object, reference the inner alias
|
|
2233
|
+
// For the json_build_object, reference the inner alias, only include resolved columns
|
|
2127
2234
|
const innerJsonPairs = jsonScalarPairs(qi, targetMeta, targetColumns, innerAlias);
|
|
2128
2235
|
// Build nested relation subqueries referencing innerAlias
|
|
2129
2236
|
if (spec !== true && spec.with) {
|
|
@@ -2143,7 +2250,7 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
|
|
|
2143
2250
|
}
|
|
2144
2251
|
// Inline ORDER BY only when the dialect's array-agg supports it (PG). For
|
|
2145
2252
|
// hasMany this path is reached only when there is no orderClause, so the
|
|
2146
|
-
// argument is `undefined` either way
|
|
2253
|
+
// argument is `undefined` either way, keeping PG output byte-identical.
|
|
2147
2254
|
const inlineOrder = qi.dialect.aggSupportsInlineOrderBy ? orderClause.trim() || undefined : undefined;
|
|
2148
2255
|
return `SELECT ${qi.dialect.buildJsonArrayAgg(jsonObj, inlineOrder)} FROM ${qTarget} ${alias} WHERE ${whereClause}`;
|
|
2149
2256
|
}
|
|
@@ -2184,7 +2291,7 @@ export function buildManyToManySubquery(qi, relDef, spec, params, parentRef, ali
|
|
|
2184
2291
|
// JOIN: junction.targetKey = target.<targetPK>. Composite keys pair positionally.
|
|
2185
2292
|
const targetKeys = normalizeKeyColumns(relDef.through.targetKey);
|
|
2186
2293
|
// The target PK is the column(s) the junction's targetKey references. An empty
|
|
2187
|
-
// introspected PK means we cannot know what to JOIN on
|
|
2294
|
+
// introspected PK means we cannot know what to JOIN on, fail loudly rather than
|
|
2188
2295
|
// silently guessing `id` and generating a wrong JOIN.
|
|
2189
2296
|
if (targetMeta.primaryKey.length === 0) {
|
|
2190
2297
|
throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}" targets table "${targetTable}" which has no primary key; ` +
|
|
@@ -2216,7 +2323,7 @@ export function buildManyToManySubquery(qi, relDef, spec, params, parentRef, ali
|
|
|
2216
2323
|
if (relOrderEntries.length > 0) {
|
|
2217
2324
|
orderClause = buildRelationOrderClause(qi, targetTable, targetMeta, talias, relOrderEntries, params);
|
|
2218
2325
|
}
|
|
2219
|
-
// Additional WHERE filters on the target
|
|
2326
|
+
// Additional WHERE filters on the target, full scalar where surface,
|
|
2220
2327
|
// properly parameterized against the target alias.
|
|
2221
2328
|
if (spec !== true && spec.where) {
|
|
2222
2329
|
const extra = whereMod.buildAliasWhere(qi, targetTable, targetMeta, talias, spec.where, params);
|
|
@@ -2228,7 +2335,7 @@ export function buildManyToManySubquery(qi, relDef, spec, params, parentRef, ali
|
|
|
2228
2335
|
const gfExtra = whereMod.targetGlobalFilterAlias(qi, targetTable, talias, params);
|
|
2229
2336
|
if (gfExtra)
|
|
2230
2337
|
whereClause += ` AND ${gfExtra}`;
|
|
2231
|
-
// LIMIT
|
|
2338
|
+
// LIMIT, `limit: 0` is honored (LIMIT 0 → empty array)
|
|
2232
2339
|
let limitClause = '';
|
|
2233
2340
|
if (spec !== true && spec.limit !== undefined) {
|
|
2234
2341
|
limitClause = ` LIMIT ${qi.paginationRef(spec.limit, params, 'relation limit')}`;
|