turbine-orm 0.25.0 → 0.27.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 +32 -1
- package/dist/cjs/cli/destructive.js +123 -0
- package/dist/cjs/cli/index.js +175 -7
- package/dist/cjs/cli/migrate.js +60 -0
- package/dist/cjs/client.js +14 -0
- package/dist/cjs/dialect.js +24 -0
- package/dist/cjs/index-advisor.js +0 -0
- package/dist/cjs/mssql.js +3 -1
- package/dist/cjs/query/batched-loader.js +392 -0
- package/dist/cjs/query/builder.js +548 -60
- package/dist/cjs/query/utils.js +36 -0
- package/dist/cli/destructive.d.ts +32 -0
- package/dist/cli/destructive.js +119 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +175 -7
- package/dist/cli/migrate.d.ts +3 -0
- package/dist/cli/migrate.js +60 -0
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +40 -1
- package/dist/client.js +14 -0
- package/dist/dialect.d.ts +11 -0
- package/dist/dialect.js +24 -0
- package/dist/index-advisor.d.ts +83 -0
- package/dist/index-advisor.js +0 -0
- package/dist/mssql.js +3 -1
- package/dist/query/batched-loader.d.ts +120 -0
- package/dist/query/batched-loader.js +386 -0
- package/dist/query/builder.d.ts +118 -1
- package/dist/query/builder.js +549 -61
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +37 -2
- package/dist/query/utils.d.ts +16 -0
- package/dist/query/utils.js +35 -0
- package/package.json +1 -1
package/dist/query/builder.js
CHANGED
|
@@ -12,9 +12,11 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { postgresDialect } from '../dialect.js';
|
|
14
14
|
import { CircularRelationError, NotFoundError, OptimisticLockError, RelationError, TimeoutError, UnsupportedFeatureError, ValidationError, wrapPgError, } from '../errors.js';
|
|
15
|
+
import { missingIndexForRelation } from '../index-advisor.js';
|
|
15
16
|
import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from '../nested-write.js';
|
|
16
17
|
import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
|
|
17
|
-
import {
|
|
18
|
+
import { includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, stripFields, } from './batched-loader.js';
|
|
19
|
+
import { escapeLike, LRUCache, OPERATOR_KEYS, parseDbDate, sqlToPreparedName } from './utils.js';
|
|
18
20
|
// ---------------------------------------------------------------------------
|
|
19
21
|
// Internal detection helpers — used by QueryInterface
|
|
20
22
|
// ---------------------------------------------------------------------------
|
|
@@ -92,6 +94,8 @@ function sortedEntries(obj) {
|
|
|
92
94
|
return Object.entries(obj).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
93
95
|
}
|
|
94
96
|
/** Known atomic-update operator keys — used to detect operator objects vs plain JSON values */
|
|
97
|
+
/** Relations already warned about missing FK indexes (once per process, dev only). */
|
|
98
|
+
const unindexedRelationWarned = new Set();
|
|
95
99
|
const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
|
|
96
100
|
/** Known JSONB operator keys */
|
|
97
101
|
const JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
|
|
@@ -231,9 +235,14 @@ export class QueryInterface {
|
|
|
231
235
|
middlewares;
|
|
232
236
|
defaultLimit;
|
|
233
237
|
warnOnUnlimited;
|
|
238
|
+
utcTimestamps;
|
|
234
239
|
preparedStatementsEnabled;
|
|
235
240
|
sqlCacheEnabled;
|
|
236
241
|
dialect;
|
|
242
|
+
/** Client-level default relation-loading strategy ('join' unless configured). */
|
|
243
|
+
relationLoadStrategy;
|
|
244
|
+
/** Nested-relation JSON encoding: 'object' (default) or 'positional'. */
|
|
245
|
+
jsonEncoding;
|
|
237
246
|
/**
|
|
238
247
|
* Tracks tables that have already triggered an unlimited-query warning so
|
|
239
248
|
* the user is not spammed once per row. Per-instance state — each
|
|
@@ -280,9 +289,12 @@ export class QueryInterface {
|
|
|
280
289
|
// than the (small) risk of noisy logs. Callers explicitly opt out with
|
|
281
290
|
// `warnOnUnlimited: false`.
|
|
282
291
|
this.warnOnUnlimited = options?.warnOnUnlimited !== false;
|
|
292
|
+
this.utcTimestamps = options?.utcTimestamps !== false;
|
|
283
293
|
this.preparedStatementsEnabled = options?.preparedStatements ?? true;
|
|
284
294
|
this.sqlCacheEnabled = options?.sqlCache !== false;
|
|
285
295
|
this.dialect = options?.dialect ?? postgresDialect;
|
|
296
|
+
this.relationLoadStrategy = options?.relationLoadStrategy ?? 'join';
|
|
297
|
+
this.jsonEncoding = options?.jsonEncoding ?? 'object';
|
|
286
298
|
this.txScoped = options?._txScoped ?? false;
|
|
287
299
|
this.options = options;
|
|
288
300
|
// Pre-compute column type lookup maps (TASK-26)
|
|
@@ -384,6 +396,77 @@ export class QueryInterface {
|
|
|
384
396
|
inParam(values) {
|
|
385
397
|
return this.dialect.inClauseParam ? this.dialect.inClauseParam(values) : values;
|
|
386
398
|
}
|
|
399
|
+
// -------------------------------------------------------------------------
|
|
400
|
+
// Batched relation loading (relationLoadStrategy: 'batched')
|
|
401
|
+
// -------------------------------------------------------------------------
|
|
402
|
+
/**
|
|
403
|
+
* Resolve the effective relation-loading strategy for a query: the per-query
|
|
404
|
+
* arg wins, then the client-level default, then `'join'`. Only meaningful when
|
|
405
|
+
* a `with` clause is present; the callers gate on that.
|
|
406
|
+
*/
|
|
407
|
+
resolveLoadStrategy(argStrategy) {
|
|
408
|
+
return argStrategy ?? this.relationLoadStrategy;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Build the {@link RelationLoadContext} the batched loader needs, closing over
|
|
412
|
+
* this interface's pool/dialect/executor. Child readers are constructed on the
|
|
413
|
+
* SAME pool (so they join an active transaction) with `defaultLimit` cleared
|
|
414
|
+
* and unlimited-warnings silenced — a relation load must fetch every matching
|
|
415
|
+
* child, and the per-relation `limit` is applied client-side by the loader.
|
|
416
|
+
*/
|
|
417
|
+
batchedContext(timeout) {
|
|
418
|
+
const childOptions = {
|
|
419
|
+
...this.options,
|
|
420
|
+
defaultLimit: undefined,
|
|
421
|
+
warnOnUnlimited: false,
|
|
422
|
+
};
|
|
423
|
+
return {
|
|
424
|
+
parentMeta: this.tableMeta,
|
|
425
|
+
schema: this.schema,
|
|
426
|
+
makeChild: (table) => new QueryInterface(this.pool, table, this.schema, [], childOptions),
|
|
427
|
+
exec: (sql, params, preparedName) => this.queryWithTimeout(sql, params, timeout, preparedName),
|
|
428
|
+
quote: (name) => this.q(name),
|
|
429
|
+
buildInClause: (expr, paramRef, negated) => this.inClause(expr, paramRef, negated),
|
|
430
|
+
inClauseParam: (values) => this.inParam(values),
|
|
431
|
+
paramPlaceholder: (index) => this.p(index),
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Run a findMany with the batched strategy: execute the base query WITHOUT
|
|
436
|
+
* relation subqueries (all other clauses intact), then load each relation via
|
|
437
|
+
* one flat follow-up query and stitch client-side. Parent stitch keys the
|
|
438
|
+
* caller's `select`/`omit` excluded are added for the base query and stripped
|
|
439
|
+
* from the returned rows, so the shape matches the join strategy exactly.
|
|
440
|
+
*/
|
|
441
|
+
async runFindManyBatched(args) {
|
|
442
|
+
const withClause = args.with;
|
|
443
|
+
const { baseArgs, strip } = this.prepareBatchedBase(args, withClause);
|
|
444
|
+
// baseArgs.with is always undefined here; the cast just bridges the R generic.
|
|
445
|
+
const deferred = this.buildFindMany(baseArgs);
|
|
446
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
447
|
+
const entities = deferred.transform(result);
|
|
448
|
+
if (entities.length > 0) {
|
|
449
|
+
await loadRelationsBatched(this.batchedContext(args.timeout), entities, withClause, args.timeout);
|
|
450
|
+
}
|
|
451
|
+
stripFields(entities, strip);
|
|
452
|
+
return entities;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Build the base findMany args for a batched run: drop `with`, and ensure every
|
|
456
|
+
* parent correlation key needed for stitching is projected (returning the list
|
|
457
|
+
* of keys that must be stripped from the output afterwards).
|
|
458
|
+
*/
|
|
459
|
+
prepareBatchedBase(args, withClause) {
|
|
460
|
+
const needed = neededParentKeyFields(this.tableMeta, withClause);
|
|
461
|
+
const proj = includeKeysForBatching(args.select, args.omit, needed);
|
|
462
|
+
const baseArgs = {
|
|
463
|
+
...args,
|
|
464
|
+
with: undefined,
|
|
465
|
+
select: proj.select,
|
|
466
|
+
omit: proj.omit,
|
|
467
|
+
};
|
|
468
|
+
return { baseArgs, strip: proj.strip };
|
|
469
|
+
}
|
|
387
470
|
/**
|
|
388
471
|
* Return cache hit/miss statistics for this QueryInterface instance.
|
|
389
472
|
* Useful for monitoring and benchmarking.
|
|
@@ -561,11 +644,34 @@ export class QueryInterface {
|
|
|
561
644
|
// -------------------------------------------------------------------------
|
|
562
645
|
async findUnique(args) {
|
|
563
646
|
return this.executeWithMiddleware('findUnique', args, async () => {
|
|
647
|
+
if (args.with && this.resolveLoadStrategy(args.relationLoadStrategy) === 'batched') {
|
|
648
|
+
return this.runFindUniqueBatched(args);
|
|
649
|
+
}
|
|
564
650
|
const deferred = this.buildFindUnique(args);
|
|
565
651
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
566
652
|
return deferred.transform(result);
|
|
567
653
|
});
|
|
568
654
|
}
|
|
655
|
+
/**
|
|
656
|
+
* Batched-strategy findUnique: fetch the single base row without relation
|
|
657
|
+
* subqueries (adding any parent stitch keys the projection excluded), then load
|
|
658
|
+
* its relations via one follow-up query each and stitch. Mirrors the join
|
|
659
|
+
* strategy's shape for the one row.
|
|
660
|
+
*/
|
|
661
|
+
async runFindUniqueBatched(args) {
|
|
662
|
+
const withClause = args.with;
|
|
663
|
+
const needed = neededParentKeyFields(this.tableMeta, withClause);
|
|
664
|
+
const proj = includeKeysForBatching(args.select, args.omit, needed);
|
|
665
|
+
const baseArgs = { ...args, with: undefined, select: proj.select, omit: proj.omit };
|
|
666
|
+
const deferred = this.buildFindUnique(baseArgs);
|
|
667
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
668
|
+
const entity = deferred.transform(result);
|
|
669
|
+
if (!entity)
|
|
670
|
+
return null;
|
|
671
|
+
await loadRelationsBatched(this.batchedContext(args.timeout), [entity], withClause, args.timeout);
|
|
672
|
+
stripFields([entity], proj.strip);
|
|
673
|
+
return entity;
|
|
674
|
+
}
|
|
569
675
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
570
676
|
buildFindUnique(args) {
|
|
571
677
|
const columnsList = this.resolveColumns(args.select, args.omit);
|
|
@@ -652,12 +758,13 @@ export class QueryInterface {
|
|
|
652
758
|
// Collect params in exact build order: where first, then with-clause relations
|
|
653
759
|
this.collectWhereParams(whereObj, params);
|
|
654
760
|
this.collectWithParams(args.with, params);
|
|
761
|
+
const parseWith = this.makeNestedParser(args.with);
|
|
655
762
|
return {
|
|
656
763
|
sql: entry.sql,
|
|
657
764
|
params,
|
|
658
765
|
transform: (result) => {
|
|
659
766
|
const row = result.rows[0];
|
|
660
|
-
return row ?
|
|
767
|
+
return row ? parseWith(row) : null;
|
|
661
768
|
},
|
|
662
769
|
tag: `${this.table}.findUnique`,
|
|
663
770
|
preparedName: entry.name,
|
|
@@ -680,6 +787,9 @@ export class QueryInterface {
|
|
|
680
787
|
}
|
|
681
788
|
}
|
|
682
789
|
return this.executeWithMiddleware('findMany', (args ?? {}), async () => {
|
|
790
|
+
if (args?.with && this.resolveLoadStrategy(args.relationLoadStrategy) === 'batched') {
|
|
791
|
+
return this.runFindManyBatched(args);
|
|
792
|
+
}
|
|
683
793
|
const deferred = this.buildFindMany(args);
|
|
684
794
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
|
|
685
795
|
return deferred.transform(result);
|
|
@@ -768,8 +878,9 @@ export class QueryInterface {
|
|
|
768
878
|
: { sql: '' };
|
|
769
879
|
const qt = this.q(this.table);
|
|
770
880
|
let distinctPrefix = '';
|
|
881
|
+
let distinctCols = [];
|
|
771
882
|
if (args?.distinct && args.distinct.length > 0) {
|
|
772
|
-
|
|
883
|
+
distinctCols = args.distinct.map((k) => this.toSqlColumn(k));
|
|
773
884
|
distinctPrefix = `DISTINCT ON (${distinctCols.join(', ')}) `;
|
|
774
885
|
}
|
|
775
886
|
let selectClause;
|
|
@@ -803,9 +914,24 @@ export class QueryInterface {
|
|
|
803
914
|
}
|
|
804
915
|
}
|
|
805
916
|
if (args?.orderBy) {
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
917
|
+
if (distinctPrefix) {
|
|
918
|
+
// Postgres requires DISTINCT ON expressions to lead the ORDER BY.
|
|
919
|
+
// Prisma semantics ("first row per combination, result in the user's
|
|
920
|
+
// order") need two levels: inner DISTINCT ON ordered by the distinct
|
|
921
|
+
// columns then the user's order (picks the right representative row),
|
|
922
|
+
// outer re-ordered by the user's order alone.
|
|
923
|
+
if (Object.values(args.orderBy).some((d) => isVectorOrderBy(d))) {
|
|
924
|
+
throw new ValidationError('[turbine] `distinct` cannot be combined with vector distance ordering.');
|
|
925
|
+
}
|
|
926
|
+
const userOrder = this.buildOrderBy(args.orderBy, freshParams);
|
|
927
|
+
sql += ` ORDER BY ${distinctCols.map((c) => `${c} ASC`).join(', ')}, ${userOrder}`;
|
|
928
|
+
sql = `SELECT * FROM (${sql}) AS ${this.q(`${this.table}_distinct`)} ORDER BY ${userOrder}`;
|
|
929
|
+
}
|
|
930
|
+
else {
|
|
931
|
+
// Pass freshParams so vector KNN ordering binds its `$n::vector` query
|
|
932
|
+
// vector at the correct position (after cursor params, before LIMIT).
|
|
933
|
+
sql += ` ORDER BY ${this.buildOrderBy(args.orderBy, freshParams)}`;
|
|
934
|
+
}
|
|
809
935
|
}
|
|
810
936
|
// Pagination — push params in the same order the collect path mirrors
|
|
811
937
|
// (limit before offset); the SQL TEXT shape is dialect-owned via
|
|
@@ -851,10 +977,12 @@ export class QueryInterface {
|
|
|
851
977
|
if (args?.offset !== undefined && !this.dialect.inlineLimitOffset) {
|
|
852
978
|
params.push(Number(args.offset));
|
|
853
979
|
}
|
|
980
|
+
// Build the row parser once (positional shapes are computed here, not per row).
|
|
981
|
+
const parseWith = args?.with ? this.makeNestedParser(args.with) : null;
|
|
854
982
|
return {
|
|
855
983
|
sql: entry.sql,
|
|
856
984
|
params,
|
|
857
|
-
transform: (result) => result.rows.map((row) =>
|
|
985
|
+
transform: (result) => result.rows.map((row) => (parseWith ? parseWith(row) : this.parseRow(row, this.table))),
|
|
858
986
|
tag: `${this.table}.findMany`,
|
|
859
987
|
preparedName: entry.name,
|
|
860
988
|
};
|
|
@@ -892,6 +1020,8 @@ export class QueryInterface {
|
|
|
892
1020
|
async *findManyStream(args) {
|
|
893
1021
|
const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 1000)));
|
|
894
1022
|
const hasRelations = !!args?.with;
|
|
1023
|
+
// Build the positional-aware relation parser once for the whole stream.
|
|
1024
|
+
const parseWith = hasRelations ? this.makeNestedParser(args.with) : null;
|
|
895
1025
|
// --- Speculative first fetch: try to satisfy the entire drain in one RTT ---
|
|
896
1026
|
const speculativeDeferred = this.buildFindMany({
|
|
897
1027
|
...args,
|
|
@@ -902,7 +1032,7 @@ export class QueryInterface {
|
|
|
902
1032
|
if (speculativeResult.rows.length <= batchSize) {
|
|
903
1033
|
// Small drain — yield all rows and return, no cursor needed
|
|
904
1034
|
for (const row of speculativeResult.rows) {
|
|
905
|
-
yield (
|
|
1035
|
+
yield (parseWith ? parseWith(row) : this.parseRow(row, this.table));
|
|
906
1036
|
}
|
|
907
1037
|
return;
|
|
908
1038
|
}
|
|
@@ -916,7 +1046,7 @@ export class QueryInterface {
|
|
|
916
1046
|
try {
|
|
917
1047
|
for await (const batch of this.dialect.openStream(client, deferred.sql, deferred.params, batchSize)) {
|
|
918
1048
|
for (const row of batch) {
|
|
919
|
-
yield (
|
|
1049
|
+
yield (parseWith ? parseWith(row) : this.parseRow(row, this.table));
|
|
920
1050
|
}
|
|
921
1051
|
}
|
|
922
1052
|
}
|
|
@@ -933,6 +1063,11 @@ export class QueryInterface {
|
|
|
933
1063
|
// -------------------------------------------------------------------------
|
|
934
1064
|
async findFirst(args) {
|
|
935
1065
|
return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
|
|
1066
|
+
if (args?.with && this.resolveLoadStrategy(args.relationLoadStrategy) === 'batched') {
|
|
1067
|
+
// findFirst is findMany + LIMIT 1: batch the single base row, then load.
|
|
1068
|
+
const rows = await this.runFindManyBatched({ ...args, limit: 1 });
|
|
1069
|
+
return (rows[0] ?? null);
|
|
1070
|
+
}
|
|
936
1071
|
const deferred = this.buildFindFirst(args);
|
|
937
1072
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
|
|
938
1073
|
return deferred.transform(result);
|
|
@@ -2100,7 +2235,7 @@ export class QueryInterface {
|
|
|
2100
2235
|
// Relation filters: { posts: { some: { published: true } } }
|
|
2101
2236
|
const relDef = this.tableMeta.relations[key];
|
|
2102
2237
|
if (relDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
2103
|
-
const filterObj = value;
|
|
2238
|
+
const filterObj = this.normalizeRelationFilter(relDef, value);
|
|
2104
2239
|
if ('some' in filterObj ||
|
|
2105
2240
|
'every' in filterObj ||
|
|
2106
2241
|
'none' in filterObj ||
|
|
@@ -2108,15 +2243,25 @@ export class QueryInterface {
|
|
|
2108
2243
|
'isNot' in filterObj) {
|
|
2109
2244
|
const relParts = [];
|
|
2110
2245
|
if (filterObj.some !== undefined)
|
|
2111
|
-
relParts.push(
|
|
2246
|
+
relParts.push(filterObj.some === null
|
|
2247
|
+
? 'some(null)'
|
|
2248
|
+
: `some(${this.fingerprintRelFilter(relDef.to, filterObj.some)})`);
|
|
2112
2249
|
if (filterObj.every !== undefined)
|
|
2113
|
-
relParts.push(
|
|
2250
|
+
relParts.push(filterObj.every === null
|
|
2251
|
+
? 'every(null)'
|
|
2252
|
+
: `every(${this.fingerprintRelFilter(relDef.to, filterObj.every)})`);
|
|
2114
2253
|
if (filterObj.none !== undefined)
|
|
2115
|
-
relParts.push(
|
|
2254
|
+
relParts.push(filterObj.none === null
|
|
2255
|
+
? 'none(null)'
|
|
2256
|
+
: `none(${this.fingerprintRelFilter(relDef.to, filterObj.none)})`);
|
|
2116
2257
|
if (filterObj.is !== undefined)
|
|
2117
|
-
relParts.push(
|
|
2258
|
+
relParts.push(filterObj.is === null
|
|
2259
|
+
? 'is(null)'
|
|
2260
|
+
: `is(${this.fingerprintRelFilter(relDef.to, filterObj.is)})`);
|
|
2118
2261
|
if (filterObj.isNot !== undefined)
|
|
2119
|
-
relParts.push(
|
|
2262
|
+
relParts.push(filterObj.isNot === null
|
|
2263
|
+
? 'isNot(null)'
|
|
2264
|
+
: `isNot(${this.fingerprintRelFilter(relDef.to, filterObj.isNot)})`);
|
|
2120
2265
|
parts.push(`${key}:{${relParts.join(',')}}`);
|
|
2121
2266
|
continue;
|
|
2122
2267
|
}
|
|
@@ -2186,7 +2331,8 @@ export class QueryInterface {
|
|
|
2186
2331
|
/**
|
|
2187
2332
|
* Fingerprint a relation filter sub-where for some/every/none.
|
|
2188
2333
|
*/
|
|
2189
|
-
fingerprintRelFilter(
|
|
2334
|
+
fingerprintRelFilter(targetTable, subWhere) {
|
|
2335
|
+
const meta = this.schema.tables[targetTable];
|
|
2190
2336
|
const keys = Object.keys(subWhere)
|
|
2191
2337
|
.filter((k) => subWhere[k] !== undefined)
|
|
2192
2338
|
.sort();
|
|
@@ -2197,6 +2343,33 @@ export class QueryInterface {
|
|
|
2197
2343
|
const value = subWhere[key];
|
|
2198
2344
|
if (value === undefined)
|
|
2199
2345
|
continue;
|
|
2346
|
+
if (key === 'OR' || key === 'AND') {
|
|
2347
|
+
const arr = Array.isArray(value) ? value : [];
|
|
2348
|
+
parts.push(`${key}[${arr.map((b) => `(${this.fingerprintRelFilter(targetTable, b)})`).join(',')}]`);
|
|
2349
|
+
continue;
|
|
2350
|
+
}
|
|
2351
|
+
if (key === 'NOT') {
|
|
2352
|
+
parts.push(`NOT(${this.fingerprintRelFilter(targetTable, value)})`);
|
|
2353
|
+
continue;
|
|
2354
|
+
}
|
|
2355
|
+
// Nested relation filter — must fingerprint the FULL inner shape, or two
|
|
2356
|
+
// different nested filters would collide on one cached SQL text.
|
|
2357
|
+
const nestedRel = meta?.relations?.[key];
|
|
2358
|
+
if (nestedRel && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
2359
|
+
const norm = this.normalizeRelationFilter(nestedRel, value);
|
|
2360
|
+
if ('some' in norm || 'every' in norm || 'none' in norm || 'is' in norm || 'isNot' in norm) {
|
|
2361
|
+
const inner = [];
|
|
2362
|
+
for (const op of ['some', 'none', 'every', 'is', 'isNot']) {
|
|
2363
|
+
if (norm[op] === undefined)
|
|
2364
|
+
continue;
|
|
2365
|
+
inner.push(norm[op] === null
|
|
2366
|
+
? `${op}(null)`
|
|
2367
|
+
: `${op}(${this.fingerprintRelFilter(nestedRel.to, norm[op])})`);
|
|
2368
|
+
}
|
|
2369
|
+
parts.push(`${key}:rel[${inner.join(',')}]`);
|
|
2370
|
+
continue;
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2200
2373
|
if (value === null) {
|
|
2201
2374
|
parts.push(`${key}:null`);
|
|
2202
2375
|
}
|
|
@@ -2254,21 +2427,21 @@ export class QueryInterface {
|
|
|
2254
2427
|
// Relation filters
|
|
2255
2428
|
const relationDef = this.tableMeta.relations[key];
|
|
2256
2429
|
if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
2257
|
-
const filterObj = value;
|
|
2430
|
+
const filterObj = this.normalizeRelationFilter(relationDef, value);
|
|
2258
2431
|
if ('some' in filterObj ||
|
|
2259
2432
|
'every' in filterObj ||
|
|
2260
2433
|
'none' in filterObj ||
|
|
2261
2434
|
'is' in filterObj ||
|
|
2262
2435
|
'isNot' in filterObj) {
|
|
2263
|
-
if (filterObj.some !== undefined)
|
|
2436
|
+
if (filterObj.some !== undefined && filterObj.some !== null)
|
|
2264
2437
|
this.collectRelFilterParams(relationDef.to, filterObj.some, params);
|
|
2265
|
-
if (filterObj.none !== undefined)
|
|
2438
|
+
if (filterObj.none !== undefined && filterObj.none !== null)
|
|
2266
2439
|
this.collectRelFilterParams(relationDef.to, filterObj.none, params);
|
|
2267
|
-
if (filterObj.every !== undefined)
|
|
2440
|
+
if (filterObj.every !== undefined && filterObj.every !== null)
|
|
2268
2441
|
this.collectRelFilterParams(relationDef.to, filterObj.every, params);
|
|
2269
|
-
if (filterObj.is !== undefined)
|
|
2442
|
+
if (filterObj.is !== undefined && filterObj.is !== null)
|
|
2270
2443
|
this.collectRelFilterParams(relationDef.to, filterObj.is, params);
|
|
2271
|
-
if (filterObj.isNot !== undefined)
|
|
2444
|
+
if (filterObj.isNot !== undefined && filterObj.isNot !== null)
|
|
2272
2445
|
this.collectRelFilterParams(relationDef.to, filterObj.isNot, params);
|
|
2273
2446
|
continue;
|
|
2274
2447
|
}
|
|
@@ -2329,6 +2502,36 @@ export class QueryInterface {
|
|
|
2329
2502
|
continue;
|
|
2330
2503
|
if (value === null)
|
|
2331
2504
|
continue;
|
|
2505
|
+
if (field === 'OR' || field === 'AND') {
|
|
2506
|
+
const arr = value;
|
|
2507
|
+
if (!Array.isArray(arr))
|
|
2508
|
+
continue;
|
|
2509
|
+
for (const branch of arr)
|
|
2510
|
+
this.collectRelFilterParams(targetTable, branch, params);
|
|
2511
|
+
continue;
|
|
2512
|
+
}
|
|
2513
|
+
if (field === 'NOT') {
|
|
2514
|
+
this.collectRelFilterParams(targetTable, value, params);
|
|
2515
|
+
continue;
|
|
2516
|
+
}
|
|
2517
|
+
const nestedRel = meta.relations?.[field];
|
|
2518
|
+
if (nestedRel && typeof value === 'object' && !Array.isArray(value)) {
|
|
2519
|
+
const norm = this.normalizeRelationFilter(nestedRel, value);
|
|
2520
|
+
if ('some' in norm || 'every' in norm || 'none' in norm || 'is' in norm || 'isNot' in norm) {
|
|
2521
|
+
// Same order as buildRelationFilter pushes params: some, none, every, is, isNot.
|
|
2522
|
+
if (norm.some != null)
|
|
2523
|
+
this.collectRelFilterParams(nestedRel.to, norm.some, params);
|
|
2524
|
+
if (norm.none != null)
|
|
2525
|
+
this.collectRelFilterParams(nestedRel.to, norm.none, params);
|
|
2526
|
+
if (norm.every != null)
|
|
2527
|
+
this.collectRelFilterParams(nestedRel.to, norm.every, params);
|
|
2528
|
+
if (norm.is != null)
|
|
2529
|
+
this.collectRelFilterParams(nestedRel.to, norm.is, params);
|
|
2530
|
+
if (norm.isNot != null)
|
|
2531
|
+
this.collectRelFilterParams(nestedRel.to, norm.isNot, params);
|
|
2532
|
+
continue;
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2332
2535
|
const col = meta.columnMap[field] ?? camelToSnake(field);
|
|
2333
2536
|
if (isWhereOperator(value)) {
|
|
2334
2537
|
this.collectOperatorParams(col, value, params);
|
|
@@ -2470,7 +2673,7 @@ export class QueryInterface {
|
|
|
2470
2673
|
// `{title: {contains: 'x'}}` emit different SQL so they must not share
|
|
2471
2674
|
// a fingerprint)
|
|
2472
2675
|
if (opts.where) {
|
|
2473
|
-
subParts.push(`w=${this.fingerprintAliasWhere(opts.where)}`);
|
|
2676
|
+
subParts.push(`w=${this.fingerprintAliasWhere(opts.where, meta.relations[relName]?.to)}`);
|
|
2474
2677
|
}
|
|
2475
2678
|
// orderBy shape
|
|
2476
2679
|
if (opts.orderBy) {
|
|
@@ -2690,7 +2893,7 @@ export class QueryInterface {
|
|
|
2690
2893
|
// Handle relation filters: { posts: { some: { published: true } } }
|
|
2691
2894
|
const relationDef = this.tableMeta.relations[key];
|
|
2692
2895
|
if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
2693
|
-
const filterObj = value;
|
|
2896
|
+
const filterObj = this.normalizeRelationFilter(relationDef, value);
|
|
2694
2897
|
// Check if this is a relation filter (has some/every/none keys)
|
|
2695
2898
|
if ('some' in filterObj ||
|
|
2696
2899
|
'every' in filterObj ||
|
|
@@ -2781,13 +2984,13 @@ export class QueryInterface {
|
|
|
2781
2984
|
* Build relation filter SQL: WHERE EXISTS / NOT EXISTS subquery
|
|
2782
2985
|
* Supports: some (EXISTS), every (NOT EXISTS ... NOT), none (NOT EXISTS)
|
|
2783
2986
|
*/
|
|
2784
|
-
buildRelationFilter(_relName, relDef, filterObj, params) {
|
|
2987
|
+
buildRelationFilter(_relName, relDef, filterObj, params, parentTable) {
|
|
2785
2988
|
const targetTable = relDef.to;
|
|
2786
2989
|
const targetMeta = this.schema.tables[targetTable];
|
|
2787
2990
|
if (!targetMeta)
|
|
2788
2991
|
return null;
|
|
2789
2992
|
const qt = this.q(targetTable);
|
|
2790
|
-
const qSelf = this.q(this.table);
|
|
2993
|
+
const qSelf = this.q(parentTable ?? this.table);
|
|
2791
2994
|
const clauses = [];
|
|
2792
2995
|
// Correlation: link child table to parent table (supports composite FKs)
|
|
2793
2996
|
let correlation;
|
|
@@ -2824,19 +3027,31 @@ export class QueryInterface {
|
|
|
2824
3027
|
// "every" with empty filter = true (all match trivially)
|
|
2825
3028
|
}
|
|
2826
3029
|
}
|
|
2827
|
-
// "is": EXISTS — for to-one relations (same SQL as "some")
|
|
3030
|
+
// "is": EXISTS — for to-one relations (same SQL as "some").
|
|
3031
|
+
// `is: null` = "no related row" (Prisma semantics) → NOT EXISTS.
|
|
2828
3032
|
if (filterObj.is !== undefined) {
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
3033
|
+
if (filterObj.is === null) {
|
|
3034
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation})`);
|
|
3035
|
+
}
|
|
3036
|
+
else {
|
|
3037
|
+
const subWhere = filterObj.is;
|
|
3038
|
+
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
3039
|
+
const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
|
|
3040
|
+
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
|
|
3041
|
+
}
|
|
2833
3042
|
}
|
|
2834
|
-
// "isNot": NOT EXISTS — for to-one relations (same SQL as "none")
|
|
3043
|
+
// "isNot": NOT EXISTS — for to-one relations (same SQL as "none").
|
|
3044
|
+
// `isNot: null` = "a related row exists" → EXISTS.
|
|
2835
3045
|
if (filterObj.isNot !== undefined) {
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
3046
|
+
if (filterObj.isNot === null) {
|
|
3047
|
+
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation})`);
|
|
3048
|
+
}
|
|
3049
|
+
else {
|
|
3050
|
+
const subWhere = filterObj.isNot;
|
|
3051
|
+
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
3052
|
+
const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
|
|
3053
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
|
|
3054
|
+
}
|
|
2840
3055
|
}
|
|
2841
3056
|
return clauses.length > 0 ? clauses.join(' AND ') : null;
|
|
2842
3057
|
}
|
|
@@ -2855,6 +3070,39 @@ export class QueryInterface {
|
|
|
2855
3070
|
const value = subWhere[field];
|
|
2856
3071
|
if (value === undefined)
|
|
2857
3072
|
continue;
|
|
3073
|
+
// OR / AND / NOT combinators inside a relation sub-where
|
|
3074
|
+
if (field === 'OR' || field === 'AND') {
|
|
3075
|
+
const arr = value;
|
|
3076
|
+
if (!Array.isArray(arr) || arr.length === 0)
|
|
3077
|
+
continue;
|
|
3078
|
+
const parts = [];
|
|
3079
|
+
for (const branch of arr) {
|
|
3080
|
+
const c = this.buildSubWhereForRelation(targetTable, branch, params);
|
|
3081
|
+
if (c)
|
|
3082
|
+
parts.push(`(${c})`);
|
|
3083
|
+
}
|
|
3084
|
+
if (parts.length)
|
|
3085
|
+
conditions.push(`(${parts.join(field === 'OR' ? ' OR ' : ' AND ')})`);
|
|
3086
|
+
continue;
|
|
3087
|
+
}
|
|
3088
|
+
if (field === 'NOT') {
|
|
3089
|
+
const c = this.buildSubWhereForRelation(targetTable, value, params);
|
|
3090
|
+
if (c)
|
|
3091
|
+
conditions.push(`NOT (${c})`);
|
|
3092
|
+
continue;
|
|
3093
|
+
}
|
|
3094
|
+
// Nested relation filter (relation of the relation target) — recurse
|
|
3095
|
+
// with the TARGET table as the correlation parent.
|
|
3096
|
+
const nestedRel = meta.relations?.[field];
|
|
3097
|
+
if (nestedRel && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
3098
|
+
const norm = this.normalizeRelationFilter(nestedRel, value);
|
|
3099
|
+
if ('some' in norm || 'every' in norm || 'none' in norm || 'is' in norm || 'isNot' in norm) {
|
|
3100
|
+
const c = this.buildRelationFilter(field, nestedRel, norm, params, targetTable);
|
|
3101
|
+
if (c)
|
|
3102
|
+
conditions.push(c);
|
|
3103
|
+
continue;
|
|
3104
|
+
}
|
|
3105
|
+
}
|
|
2858
3106
|
const col = meta.columnMap[field] ?? camelToSnake(field);
|
|
2859
3107
|
if (!meta.allColumns.includes(col)) {
|
|
2860
3108
|
throw new ValidationError(`[turbine] Unknown field "${field}" in relation filter for table "${targetTable}". ` +
|
|
@@ -2940,6 +3188,18 @@ export class QueryInterface {
|
|
|
2940
3188
|
clauses.push(`NOT (${sub})`);
|
|
2941
3189
|
continue;
|
|
2942
3190
|
}
|
|
3191
|
+
// Relation filter inside a with-clause where — EXISTS correlated to the
|
|
3192
|
+
// relation alias (some/every/none/is/isNot + bare to-one implicit `is`).
|
|
3193
|
+
const aliasRel = targetMeta.relations?.[key];
|
|
3194
|
+
if (aliasRel && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
3195
|
+
const norm = this.normalizeRelationFilter(aliasRel, value);
|
|
3196
|
+
if ('some' in norm || 'every' in norm || 'none' in norm || 'is' in norm || 'isNot' in norm) {
|
|
3197
|
+
const c = this.buildRelationFilter(key, aliasRel, norm, params, alias);
|
|
3198
|
+
if (c)
|
|
3199
|
+
clauses.push(c);
|
|
3200
|
+
continue;
|
|
3201
|
+
}
|
|
3202
|
+
}
|
|
2943
3203
|
const col = targetMeta.columnMap[key] ?? camelToSnake(key);
|
|
2944
3204
|
if (!targetMeta.allColumns.includes(col)) {
|
|
2945
3205
|
throw new ValidationError(`[turbine] Unknown column "${key}" in where for table "${targetTable}"`);
|
|
@@ -2981,6 +3241,24 @@ export class QueryInterface {
|
|
|
2981
3241
|
}
|
|
2982
3242
|
if (value === null)
|
|
2983
3243
|
continue;
|
|
3244
|
+
const aliasRel = targetMeta.relations?.[key];
|
|
3245
|
+
if (aliasRel && typeof value === 'object' && !Array.isArray(value)) {
|
|
3246
|
+
const norm = this.normalizeRelationFilter(aliasRel, value);
|
|
3247
|
+
if ('some' in norm || 'every' in norm || 'none' in norm || 'is' in norm || 'isNot' in norm) {
|
|
3248
|
+
// Same order as buildRelationFilter pushes params: some, none, every, is, isNot.
|
|
3249
|
+
if (norm.some != null)
|
|
3250
|
+
this.collectRelFilterParams(aliasRel.to, norm.some, params);
|
|
3251
|
+
if (norm.none != null)
|
|
3252
|
+
this.collectRelFilterParams(aliasRel.to, norm.none, params);
|
|
3253
|
+
if (norm.every != null)
|
|
3254
|
+
this.collectRelFilterParams(aliasRel.to, norm.every, params);
|
|
3255
|
+
if (norm.is != null)
|
|
3256
|
+
this.collectRelFilterParams(aliasRel.to, norm.is, params);
|
|
3257
|
+
if (norm.isNot != null)
|
|
3258
|
+
this.collectRelFilterParams(aliasRel.to, norm.isNot, params);
|
|
3259
|
+
continue;
|
|
3260
|
+
}
|
|
3261
|
+
}
|
|
2984
3262
|
const col = targetMeta.columnMap[key] ?? camelToSnake(key);
|
|
2985
3263
|
if (isWhereOperator(value)) {
|
|
2986
3264
|
this.collectOperatorParams(col, value, params);
|
|
@@ -2996,28 +3274,46 @@ export class QueryInterface {
|
|
|
2996
3274
|
* can emit — equality vs null vs operator sets vs combinators — or two
|
|
2997
3275
|
* differently-shaped wheres would share one cached SQL string.
|
|
2998
3276
|
*/
|
|
2999
|
-
fingerprintAliasWhere(where) {
|
|
3277
|
+
fingerprintAliasWhere(where, targetTable) {
|
|
3000
3278
|
const keys = Object.keys(where)
|
|
3001
3279
|
.filter((k) => where[k] !== undefined)
|
|
3002
3280
|
.sort();
|
|
3003
3281
|
const parts = [];
|
|
3282
|
+
const meta = targetTable ? this.schema.tables[targetTable] : undefined;
|
|
3004
3283
|
for (const key of keys) {
|
|
3005
3284
|
const value = where[key];
|
|
3006
3285
|
if (key === 'OR' || key === 'AND') {
|
|
3007
3286
|
const arr = value;
|
|
3008
3287
|
if (!Array.isArray(arr) || arr.length === 0)
|
|
3009
3288
|
continue;
|
|
3010
|
-
parts.push(`${key}[${arr.map((c) => this.fingerprintAliasWhere(c)).join(',')}]`);
|
|
3289
|
+
parts.push(`${key}[${arr.map((c) => this.fingerprintAliasWhere(c, targetTable)).join(',')}]`);
|
|
3011
3290
|
continue;
|
|
3012
3291
|
}
|
|
3013
3292
|
if (key === 'NOT') {
|
|
3014
|
-
parts.push(`NOT(${this.fingerprintAliasWhere(value)})`);
|
|
3293
|
+
parts.push(`NOT(${this.fingerprintAliasWhere(value, targetTable)})`);
|
|
3015
3294
|
continue;
|
|
3016
3295
|
}
|
|
3017
3296
|
if (value === null) {
|
|
3018
3297
|
parts.push(`${key}:null`);
|
|
3019
3298
|
continue;
|
|
3020
3299
|
}
|
|
3300
|
+
// Relation filter shapes must be fully fingerprinted (cache-key safety).
|
|
3301
|
+
const fpRel = meta?.relations?.[key];
|
|
3302
|
+
if (fpRel && typeof value === 'object' && !Array.isArray(value)) {
|
|
3303
|
+
const norm = this.normalizeRelationFilter(fpRel, value);
|
|
3304
|
+
if ('some' in norm || 'every' in norm || 'none' in norm || 'is' in norm || 'isNot' in norm) {
|
|
3305
|
+
const inner = [];
|
|
3306
|
+
for (const op of ['some', 'none', 'every', 'is', 'isNot']) {
|
|
3307
|
+
if (norm[op] === undefined)
|
|
3308
|
+
continue;
|
|
3309
|
+
inner.push(norm[op] === null
|
|
3310
|
+
? `${op}(null)`
|
|
3311
|
+
: `${op}(${this.fingerprintRelFilter(fpRel.to, norm[op])})`);
|
|
3312
|
+
}
|
|
3313
|
+
parts.push(`${key}:rel[${inner.join(',')}]`);
|
|
3314
|
+
continue;
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3021
3317
|
if (isWhereOperator(value)) {
|
|
3022
3318
|
parts.push(`${key}:${fingerprintOperatorShape(value)}`);
|
|
3023
3319
|
continue;
|
|
@@ -3198,6 +3494,25 @@ export class QueryInterface {
|
|
|
3198
3494
|
* memoized per table. Used so nested relation rows (camelCase keys) coerce
|
|
3199
3495
|
* dates the same way top-level rows do.
|
|
3200
3496
|
*/
|
|
3497
|
+
/**
|
|
3498
|
+
* Prisma-compat: a plain object on a to-one relation key —
|
|
3499
|
+
* `where: { vendor: { name: { contains: 'x' } } }` — is an implicit `is`
|
|
3500
|
+
* filter. Normalize it to `{ is: obj }` so all downstream handling (SQL,
|
|
3501
|
+
* params, fingerprint) sees one canonical shape. To-many relations still
|
|
3502
|
+
* require an explicit `some`/`every`/`none` (a bare object there is
|
|
3503
|
+
* ambiguous and was never valid in Prisma either).
|
|
3504
|
+
*/
|
|
3505
|
+
normalizeRelationFilter(relDef, filterObj) {
|
|
3506
|
+
if ((relDef.type === 'belongsTo' || relDef.type === 'hasOne') &&
|
|
3507
|
+
!('some' in filterObj) &&
|
|
3508
|
+
!('every' in filterObj) &&
|
|
3509
|
+
!('none' in filterObj) &&
|
|
3510
|
+
!('is' in filterObj) &&
|
|
3511
|
+
!('isNot' in filterObj)) {
|
|
3512
|
+
return { is: filterObj };
|
|
3513
|
+
}
|
|
3514
|
+
return filterObj;
|
|
3515
|
+
}
|
|
3201
3516
|
getCamelDateFields(table, meta) {
|
|
3202
3517
|
let camel = this.camelDateFieldCache.get(table);
|
|
3203
3518
|
if (!camel) {
|
|
@@ -3226,7 +3541,9 @@ export class QueryInterface {
|
|
|
3226
3541
|
const field = reverseMap[col] ?? col; // fall back to raw col name, not regex
|
|
3227
3542
|
// Top-level rows are snake_case (dateCols); nested rows are camelCase (camelDateFields).
|
|
3228
3543
|
if ((dateCols.has(col) || camelDateFields.has(field)) && value !== null && !(value instanceof Date)) {
|
|
3229
|
-
|
|
3544
|
+
// Offset-less strings (Postgres `timestamp`, json_agg output) are
|
|
3545
|
+
// pinned to UTC so results don't depend on the server's time zone.
|
|
3546
|
+
parsed[field] = this.utcTimestamps ? parseDbDate(String(value)) : new Date(value);
|
|
3230
3547
|
}
|
|
3231
3548
|
else {
|
|
3232
3549
|
parsed[field] = value;
|
|
@@ -3302,6 +3619,159 @@ export class QueryInterface {
|
|
|
3302
3619
|
}
|
|
3303
3620
|
return parsed;
|
|
3304
3621
|
}
|
|
3622
|
+
// -------------------------------------------------------------------------
|
|
3623
|
+
// Positional JSON encoding (jsonEncoding: 'positional')
|
|
3624
|
+
//
|
|
3625
|
+
// When active, relation subqueries emit `json_agg(json_build_array(v1, v2, …))`
|
|
3626
|
+
// instead of `json_build_object('k1', v1, …)`, dropping every repeated key
|
|
3627
|
+
// name. The builder knows the exact column order, so it records a recursive
|
|
3628
|
+
// RelationShape during SQL generation; the transform decodes each positional
|
|
3629
|
+
// array back into the object representation the object-encoding would have
|
|
3630
|
+
// produced, then hands it to parseNestedRow — so parsed output is byte-
|
|
3631
|
+
// identical to the object path (same dates, same snake→camel, same recursion).
|
|
3632
|
+
// -------------------------------------------------------------------------
|
|
3633
|
+
/**
|
|
3634
|
+
* Resolve the emitted column list for a relation, honoring `select` / `omit`.
|
|
3635
|
+
* Shared by {@link buildRelationSubquery} (json order) and
|
|
3636
|
+
* {@link buildRelationShape} (decode key order) so they can never diverge.
|
|
3637
|
+
*/
|
|
3638
|
+
resolveTargetColumns(spec, targetMeta) {
|
|
3639
|
+
if (spec !== true && spec.select) {
|
|
3640
|
+
const selectedFields = Object.entries(spec.select)
|
|
3641
|
+
.filter(([, v]) => v)
|
|
3642
|
+
.map(([k]) => targetMeta.columnMap[k] ?? camelToSnake(k));
|
|
3643
|
+
return selectedFields.filter((col) => targetMeta.allColumns.includes(col));
|
|
3644
|
+
}
|
|
3645
|
+
if (spec !== true && spec.omit) {
|
|
3646
|
+
const omittedFields = new Set(Object.entries(spec.omit)
|
|
3647
|
+
.filter(([, v]) => v)
|
|
3648
|
+
.map(([k]) => targetMeta.columnMap[k] ?? camelToSnake(k)));
|
|
3649
|
+
return targetMeta.allColumns.filter((col) => !omittedFields.has(col));
|
|
3650
|
+
}
|
|
3651
|
+
return targetMeta.allColumns;
|
|
3652
|
+
}
|
|
3653
|
+
/**
|
|
3654
|
+
* Render a single relation row's JSON: a keyed object (`'object'`) or a
|
|
3655
|
+
* positional array (`'positional'`). The array drops the keys but keeps the
|
|
3656
|
+
* exact expression order, so {@link RelationShape.keys} maps positions back.
|
|
3657
|
+
*/
|
|
3658
|
+
buildJsonRow(jsonPairs) {
|
|
3659
|
+
if (this.jsonEncoding === 'positional') {
|
|
3660
|
+
// buildJsonArray is defined on postgresDialect; positional is gated to PG
|
|
3661
|
+
// in buildSelectWithRelations, so the `?? buildJsonObject` never fires.
|
|
3662
|
+
return (this.dialect.buildJsonArray?.(jsonPairs.map(([, expr]) => expr)) ?? this.dialect.buildJsonObject(jsonPairs));
|
|
3663
|
+
}
|
|
3664
|
+
return this.dialect.buildJsonObject(jsonPairs);
|
|
3665
|
+
}
|
|
3666
|
+
/**
|
|
3667
|
+
* Build the top-level relation shapes for a `with` clause, mirroring
|
|
3668
|
+
* {@link buildSelectWithRelations}: same relation iteration order, same
|
|
3669
|
+
* per-relation column resolution, same nested recursion.
|
|
3670
|
+
*/
|
|
3671
|
+
buildRelationShapes(table, withClause) {
|
|
3672
|
+
const meta = this.schema.tables[table];
|
|
3673
|
+
if (!meta)
|
|
3674
|
+
return {};
|
|
3675
|
+
const shapes = {};
|
|
3676
|
+
for (const [relName, relSpec] of sortedEntries(withClause)) {
|
|
3677
|
+
const relDef = meta.relations[relName];
|
|
3678
|
+
if (!relDef)
|
|
3679
|
+
continue; // buildSelectWithRelations already threw for this
|
|
3680
|
+
shapes[relName] = this.buildRelationShape(relDef, relSpec, meta);
|
|
3681
|
+
}
|
|
3682
|
+
return shapes;
|
|
3683
|
+
}
|
|
3684
|
+
/**
|
|
3685
|
+
* Recursively describe one relation's positional layout: the camelCase key
|
|
3686
|
+
* order (scalar columns first, then nested relation slots in the same order
|
|
3687
|
+
* {@link buildRelationSubquery} appends them), the nested sub-shapes, and the
|
|
3688
|
+
* cardinality (single object for belongsTo/hasOne, array for the rest).
|
|
3689
|
+
*/
|
|
3690
|
+
buildRelationShape(relDef, spec, parentMeta) {
|
|
3691
|
+
void parentMeta;
|
|
3692
|
+
const targetMeta = this.schema.tables[relDef.to];
|
|
3693
|
+
if (!targetMeta)
|
|
3694
|
+
return { keys: [], nested: {}, cardinality: 'many' };
|
|
3695
|
+
const targetColumns = this.resolveTargetColumns(spec, targetMeta);
|
|
3696
|
+
const keys = targetColumns.map((col) => targetMeta.reverseColumnMap[col] ?? snakeToCamel(col));
|
|
3697
|
+
const nested = {};
|
|
3698
|
+
if (spec !== true && spec.with) {
|
|
3699
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
3700
|
+
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3701
|
+
if (!nestedRelDef)
|
|
3702
|
+
continue;
|
|
3703
|
+
keys.push(nestedRelName);
|
|
3704
|
+
nested[nestedRelName] = this.buildRelationShape(nestedRelDef, nestedSpec, targetMeta);
|
|
3705
|
+
}
|
|
3706
|
+
}
|
|
3707
|
+
const cardinality = relDef.type === 'belongsTo' || relDef.type === 'hasOne' ? 'one' : 'many';
|
|
3708
|
+
return { keys, nested, cardinality };
|
|
3709
|
+
}
|
|
3710
|
+
/**
|
|
3711
|
+
* Build the row parser for a `with` clause. In object mode this is just
|
|
3712
|
+
* {@link parseNestedRow}. In positional mode it decodes each relation's
|
|
3713
|
+
* positional arrays into the object form first (shapes built once, not per
|
|
3714
|
+
* row), then delegates to parseNestedRow for date/snake-camel coercion.
|
|
3715
|
+
*/
|
|
3716
|
+
makeNestedParser(withClause) {
|
|
3717
|
+
if (this.jsonEncoding !== 'positional') {
|
|
3718
|
+
return (row) => this.parseNestedRow(row, this.table);
|
|
3719
|
+
}
|
|
3720
|
+
const shapes = this.buildRelationShapes(this.table, withClause);
|
|
3721
|
+
return (row) => this.parseNestedRow(this.decodePositionalRelations(row, shapes), this.table);
|
|
3722
|
+
}
|
|
3723
|
+
/**
|
|
3724
|
+
* Return a shallow copy of a top-level row with each relation column decoded
|
|
3725
|
+
* from its positional array(s) into the object representation. Only relation
|
|
3726
|
+
* columns are positional — base scalar columns stay object-keyed — so the
|
|
3727
|
+
* result is exactly what the object encoding would have handed parseNestedRow.
|
|
3728
|
+
*/
|
|
3729
|
+
decodePositionalRelations(row, shapes) {
|
|
3730
|
+
const cloned = { ...row };
|
|
3731
|
+
for (const [relName, shape] of Object.entries(shapes)) {
|
|
3732
|
+
if (relName in cloned)
|
|
3733
|
+
cloned[relName] = this.decodePositionalValue(cloned[relName], shape);
|
|
3734
|
+
}
|
|
3735
|
+
return cloned;
|
|
3736
|
+
}
|
|
3737
|
+
/**
|
|
3738
|
+
* Decode one relation's positional JSON value. `json_agg` returns the value as
|
|
3739
|
+
* a JSON string at the top level (JSON.parse once); nested relation slots are
|
|
3740
|
+
* already-parsed arrays. A `'many'` value is an array of positional arrays; a
|
|
3741
|
+
* `'one'` value is a single positional array or null.
|
|
3742
|
+
*/
|
|
3743
|
+
decodePositionalValue(raw, shape) {
|
|
3744
|
+
let val = raw;
|
|
3745
|
+
if (typeof val === 'string') {
|
|
3746
|
+
try {
|
|
3747
|
+
val = JSON.parse(val);
|
|
3748
|
+
}
|
|
3749
|
+
catch {
|
|
3750
|
+
return raw; // parseNestedRow's warn path handles unparseable JSON
|
|
3751
|
+
}
|
|
3752
|
+
}
|
|
3753
|
+
if (val === null || val === undefined) {
|
|
3754
|
+
return shape.cardinality === 'many' ? [] : null;
|
|
3755
|
+
}
|
|
3756
|
+
if (shape.cardinality === 'many') {
|
|
3757
|
+
if (!Array.isArray(val))
|
|
3758
|
+
return val;
|
|
3759
|
+
return val.map((inner) => this.decodePositionalObject(inner, shape));
|
|
3760
|
+
}
|
|
3761
|
+
return this.decodePositionalObject(val, shape);
|
|
3762
|
+
}
|
|
3763
|
+
/** Map one positional array back to a keyed object using the shape's key order. */
|
|
3764
|
+
decodePositionalObject(arr, shape) {
|
|
3765
|
+
if (!Array.isArray(arr))
|
|
3766
|
+
return arr;
|
|
3767
|
+
const obj = {};
|
|
3768
|
+
for (let i = 0; i < shape.keys.length; i++) {
|
|
3769
|
+
const key = shape.keys[i];
|
|
3770
|
+
const nestedShape = shape.nested[key];
|
|
3771
|
+
obj[key] = nestedShape ? this.decodePositionalValue(arr[i], nestedShape) : arr[i];
|
|
3772
|
+
}
|
|
3773
|
+
return obj;
|
|
3774
|
+
}
|
|
3305
3775
|
/**
|
|
3306
3776
|
* Build a SELECT clause that includes both base columns and nested relation subqueries.
|
|
3307
3777
|
*
|
|
@@ -3347,6 +3817,13 @@ export class QueryInterface {
|
|
|
3347
3817
|
const meta = this.schema.tables[table];
|
|
3348
3818
|
if (!meta)
|
|
3349
3819
|
throw new ValidationError(`[turbine] Unknown table "${table}"`);
|
|
3820
|
+
// Positional JSON encoding is Postgres-only in v1. Gate here — the single
|
|
3821
|
+
// entry point for every `with` clause — so no engine ever emits the
|
|
3822
|
+
// json_build_array shape its dialect can't produce (and mssql's FOR JSON
|
|
3823
|
+
// override path is never reached with positional active).
|
|
3824
|
+
if (this.jsonEncoding === 'positional' && this.dialect.name !== 'postgresql') {
|
|
3825
|
+
throw new UnsupportedFeatureError("jsonEncoding: 'positional'", this.dialect.name, 'Positional relation encoding is only available on PostgreSQL in this version.');
|
|
3826
|
+
}
|
|
3350
3827
|
const cols = columnsList ?? meta.allColumns;
|
|
3351
3828
|
const qtbl = this.q(table);
|
|
3352
3829
|
const baseCols = cols.map((col) => `${qtbl}.${this.q(col)}`).join(', ');
|
|
@@ -3470,22 +3947,30 @@ export class QueryInterface {
|
|
|
3470
3947
|
const targetMeta = this.schema.tables[targetTable];
|
|
3471
3948
|
if (!targetMeta)
|
|
3472
3949
|
throw new RelationError(`[turbine] Unknown relation target "${targetTable}"`);
|
|
3950
|
+
// Dev-only: correlated relation loading probes the child table once per parent
|
|
3951
|
+
// row, so a missing FK index multiplies into per-parent full-table scans (a
|
|
3952
|
+
// batched-loader ORM pays the same missing index only once, which is why
|
|
3953
|
+
// schemas migrated from one often lack these). Name the exact index to create
|
|
3954
|
+
// instead of letting the slowness look like an ORM problem.
|
|
3955
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
3956
|
+
const warnKey = `${relDef.from}.${relDef.name}`;
|
|
3957
|
+
if (!unindexedRelationWarned.has(warnKey)) {
|
|
3958
|
+
const miss = missingIndexForRelation(this.schema, relDef);
|
|
3959
|
+
if (miss) {
|
|
3960
|
+
unindexedRelationWarned.add(warnKey);
|
|
3961
|
+
console.warn(`[turbine] Relation "${relDef.name}" on "${relDef.from}" probes ` +
|
|
3962
|
+
`"${miss.table}"(${miss.columns.join(', ')}) which has no covering index — ` +
|
|
3963
|
+
`each parent row scans the full table. Fix: ${miss.createSql}; ` +
|
|
3964
|
+
'or run `npx turbine doctor` for a full report.');
|
|
3965
|
+
}
|
|
3966
|
+
}
|
|
3967
|
+
}
|
|
3473
3968
|
// Generate a unique alias: t0, t1, t2, ...
|
|
3474
3969
|
const alias = `t${aliasCounter.n++}`;
|
|
3475
|
-
// Resolve which columns to include based on select/omit
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
.filter(([, v]) => v)
|
|
3480
|
-
.map(([k]) => targetMeta.columnMap[k] ?? camelToSnake(k));
|
|
3481
|
-
targetColumns = selectedFields.filter((col) => targetMeta.allColumns.includes(col));
|
|
3482
|
-
}
|
|
3483
|
-
else if (spec !== true && spec.omit) {
|
|
3484
|
-
const omittedFields = new Set(Object.entries(spec.omit)
|
|
3485
|
-
.filter(([, v]) => v)
|
|
3486
|
-
.map(([k]) => targetMeta.columnMap[k] ?? camelToSnake(k)));
|
|
3487
|
-
targetColumns = targetMeta.allColumns.filter((col) => !omittedFields.has(col));
|
|
3488
|
-
}
|
|
3970
|
+
// Resolve which columns to include based on select/omit. Shared with the
|
|
3971
|
+
// positional-shape builder so the emitted json_build_array column order and
|
|
3972
|
+
// the decode-side key order can never drift apart.
|
|
3973
|
+
const targetColumns = this.resolveTargetColumns(spec, targetMeta);
|
|
3489
3974
|
// Engine override seam (additive): a dialect whose JSON-aggregation shape does
|
|
3490
3975
|
// not map onto buildJsonObject/buildJsonArrayAgg (SQL Server FOR JSON PATH) owns
|
|
3491
3976
|
// the WHOLE subquery. Absent for PG/MySQL/SQLite → the native path below runs
|
|
@@ -3546,7 +4031,7 @@ export class QueryInterface {
|
|
|
3546
4031
|
jsonPairs.push([nestedRelName, this.dialect.wrapJsonSubresult(nestedSubquery, fallback)]);
|
|
3547
4032
|
}
|
|
3548
4033
|
}
|
|
3549
|
-
const jsonObj = this.
|
|
4034
|
+
const jsonObj = this.buildJsonRow(jsonPairs);
|
|
3550
4035
|
// Quote parent ref — can be a table name or auto-generated alias
|
|
3551
4036
|
const qParent = this.q(parentRef);
|
|
3552
4037
|
const qTarget = this.q(targetTable);
|
|
@@ -3566,11 +4051,14 @@ export class QueryInterface {
|
|
|
3566
4051
|
orderClause = ` ORDER BY ${orders}`;
|
|
3567
4052
|
}
|
|
3568
4053
|
// Build WHERE — correlate to parent via parentRef (alias or table name).
|
|
3569
|
-
// For hasMany:
|
|
3570
|
-
//
|
|
4054
|
+
// For hasMany/hasOne: TARGET has the FK (RelationDef.foreignKey is always
|
|
4055
|
+
// the child-side column), so alias.fk = parentRef.pk. hasOne is just
|
|
4056
|
+
// hasMany with a unique FK — treating it like belongsTo here silently
|
|
4057
|
+
// correlated the wrong columns (caught dogfooding: uuid = varchar).
|
|
4058
|
+
// For belongsTo: SOURCE has the FK, so alias.pk = parentRef.fk (reversed).
|
|
3571
4059
|
// Supports composite foreign keys (string[]) via buildCorrelation.
|
|
3572
4060
|
let whereClause;
|
|
3573
|
-
if (relDef.type === 'belongsTo'
|
|
4061
|
+
if (relDef.type === 'belongsTo') {
|
|
3574
4062
|
whereClause = this.dialect.buildCorrelation(alias, relDef.referenceKey, qParent, relDef.foreignKey);
|
|
3575
4063
|
}
|
|
3576
4064
|
else {
|
|
@@ -3619,7 +4107,7 @@ export class QueryInterface {
|
|
|
3619
4107
|
innerJsonPairs.push([nestedRelName, this.dialect.wrapJsonSubresult(nestedSub, fallback)]);
|
|
3620
4108
|
}
|
|
3621
4109
|
}
|
|
3622
|
-
const innerJsonObj = this.
|
|
4110
|
+
const innerJsonObj = this.buildJsonRow(innerJsonPairs);
|
|
3623
4111
|
return `SELECT ${this.dialect.buildJsonArrayAgg(innerJsonObj)} FROM (${innerSql}) ${innerAlias}`;
|
|
3624
4112
|
}
|
|
3625
4113
|
// Inline ORDER BY only when the dialect's array-agg supports it (PG). For
|
|
@@ -3742,7 +4230,7 @@ export class QueryInterface {
|
|
|
3742
4230
|
innerJsonPairs.push([nestedRelName, this.dialect.wrapJsonSubresult(nestedSub, fallback)]);
|
|
3743
4231
|
}
|
|
3744
4232
|
}
|
|
3745
|
-
const innerJsonObj = this.
|
|
4233
|
+
const innerJsonObj = this.buildJsonRow(innerJsonPairs);
|
|
3746
4234
|
return `SELECT ${this.dialect.buildJsonArrayAgg(innerJsonObj)} FROM (${innerSql}) ${innerAlias}`;
|
|
3747
4235
|
}
|
|
3748
4236
|
// Simple path: build the json object pairs directly off the target alias,
|
|
@@ -3765,7 +4253,7 @@ export class QueryInterface {
|
|
|
3765
4253
|
jsonPairs.push([nestedRelName, this.dialect.wrapJsonSubresult(nestedSub, fallback)]);
|
|
3766
4254
|
}
|
|
3767
4255
|
}
|
|
3768
|
-
const jsonObj = this.
|
|
4256
|
+
const jsonObj = this.buildJsonRow(jsonPairs);
|
|
3769
4257
|
return `SELECT ${this.dialect.buildJsonArrayAgg(jsonObj)} ${fromJoin} WHERE ${whereClause}`;
|
|
3770
4258
|
}
|
|
3771
4259
|
/**
|