turbine-orm 0.24.0 → 0.26.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 +26 -1
- package/dist/cjs/cli/index.js +83 -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/cjs/serverless.js +35 -3
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +83 -0
- 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/dist/serverless.d.ts +32 -4
- package/dist/serverless.js +35 -3
- package/package.json +1 -1
|
@@ -48,8 +48,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
48
48
|
exports.QueryInterface = void 0;
|
|
49
49
|
const dialect_js_1 = require("../dialect.js");
|
|
50
50
|
const errors_js_1 = require("../errors.js");
|
|
51
|
+
const index_advisor_js_1 = require("../index-advisor.js");
|
|
51
52
|
const nested_write_js_1 = require("../nested-write.js");
|
|
52
53
|
const schema_js_1 = require("../schema.js");
|
|
54
|
+
const batched_loader_js_1 = require("./batched-loader.js");
|
|
53
55
|
const utils_js_1 = require("./utils.js");
|
|
54
56
|
// ---------------------------------------------------------------------------
|
|
55
57
|
// Internal detection helpers — used by QueryInterface
|
|
@@ -128,6 +130,8 @@ function sortedEntries(obj) {
|
|
|
128
130
|
return Object.entries(obj).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
129
131
|
}
|
|
130
132
|
/** Known atomic-update operator keys — used to detect operator objects vs plain JSON values */
|
|
133
|
+
/** Relations already warned about missing FK indexes (once per process, dev only). */
|
|
134
|
+
const unindexedRelationWarned = new Set();
|
|
131
135
|
const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
|
|
132
136
|
/** Known JSONB operator keys */
|
|
133
137
|
const JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
|
|
@@ -267,9 +271,14 @@ class QueryInterface {
|
|
|
267
271
|
middlewares;
|
|
268
272
|
defaultLimit;
|
|
269
273
|
warnOnUnlimited;
|
|
274
|
+
utcTimestamps;
|
|
270
275
|
preparedStatementsEnabled;
|
|
271
276
|
sqlCacheEnabled;
|
|
272
277
|
dialect;
|
|
278
|
+
/** Client-level default relation-loading strategy ('join' unless configured). */
|
|
279
|
+
relationLoadStrategy;
|
|
280
|
+
/** Nested-relation JSON encoding: 'object' (default) or 'positional'. */
|
|
281
|
+
jsonEncoding;
|
|
273
282
|
/**
|
|
274
283
|
* Tracks tables that have already triggered an unlimited-query warning so
|
|
275
284
|
* the user is not spammed once per row. Per-instance state — each
|
|
@@ -316,9 +325,12 @@ class QueryInterface {
|
|
|
316
325
|
// than the (small) risk of noisy logs. Callers explicitly opt out with
|
|
317
326
|
// `warnOnUnlimited: false`.
|
|
318
327
|
this.warnOnUnlimited = options?.warnOnUnlimited !== false;
|
|
328
|
+
this.utcTimestamps = options?.utcTimestamps !== false;
|
|
319
329
|
this.preparedStatementsEnabled = options?.preparedStatements ?? true;
|
|
320
330
|
this.sqlCacheEnabled = options?.sqlCache !== false;
|
|
321
331
|
this.dialect = options?.dialect ?? dialect_js_1.postgresDialect;
|
|
332
|
+
this.relationLoadStrategy = options?.relationLoadStrategy ?? 'join';
|
|
333
|
+
this.jsonEncoding = options?.jsonEncoding ?? 'object';
|
|
322
334
|
this.txScoped = options?._txScoped ?? false;
|
|
323
335
|
this.options = options;
|
|
324
336
|
// Pre-compute column type lookup maps (TASK-26)
|
|
@@ -420,6 +432,77 @@ class QueryInterface {
|
|
|
420
432
|
inParam(values) {
|
|
421
433
|
return this.dialect.inClauseParam ? this.dialect.inClauseParam(values) : values;
|
|
422
434
|
}
|
|
435
|
+
// -------------------------------------------------------------------------
|
|
436
|
+
// Batched relation loading (relationLoadStrategy: 'batched')
|
|
437
|
+
// -------------------------------------------------------------------------
|
|
438
|
+
/**
|
|
439
|
+
* Resolve the effective relation-loading strategy for a query: the per-query
|
|
440
|
+
* arg wins, then the client-level default, then `'join'`. Only meaningful when
|
|
441
|
+
* a `with` clause is present; the callers gate on that.
|
|
442
|
+
*/
|
|
443
|
+
resolveLoadStrategy(argStrategy) {
|
|
444
|
+
return argStrategy ?? this.relationLoadStrategy;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Build the {@link RelationLoadContext} the batched loader needs, closing over
|
|
448
|
+
* this interface's pool/dialect/executor. Child readers are constructed on the
|
|
449
|
+
* SAME pool (so they join an active transaction) with `defaultLimit` cleared
|
|
450
|
+
* and unlimited-warnings silenced — a relation load must fetch every matching
|
|
451
|
+
* child, and the per-relation `limit` is applied client-side by the loader.
|
|
452
|
+
*/
|
|
453
|
+
batchedContext(timeout) {
|
|
454
|
+
const childOptions = {
|
|
455
|
+
...this.options,
|
|
456
|
+
defaultLimit: undefined,
|
|
457
|
+
warnOnUnlimited: false,
|
|
458
|
+
};
|
|
459
|
+
return {
|
|
460
|
+
parentMeta: this.tableMeta,
|
|
461
|
+
schema: this.schema,
|
|
462
|
+
makeChild: (table) => new QueryInterface(this.pool, table, this.schema, [], childOptions),
|
|
463
|
+
exec: (sql, params, preparedName) => this.queryWithTimeout(sql, params, timeout, preparedName),
|
|
464
|
+
quote: (name) => this.q(name),
|
|
465
|
+
buildInClause: (expr, paramRef, negated) => this.inClause(expr, paramRef, negated),
|
|
466
|
+
inClauseParam: (values) => this.inParam(values),
|
|
467
|
+
paramPlaceholder: (index) => this.p(index),
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Run a findMany with the batched strategy: execute the base query WITHOUT
|
|
472
|
+
* relation subqueries (all other clauses intact), then load each relation via
|
|
473
|
+
* one flat follow-up query and stitch client-side. Parent stitch keys the
|
|
474
|
+
* caller's `select`/`omit` excluded are added for the base query and stripped
|
|
475
|
+
* from the returned rows, so the shape matches the join strategy exactly.
|
|
476
|
+
*/
|
|
477
|
+
async runFindManyBatched(args) {
|
|
478
|
+
const withClause = args.with;
|
|
479
|
+
const { baseArgs, strip } = this.prepareBatchedBase(args, withClause);
|
|
480
|
+
// baseArgs.with is always undefined here; the cast just bridges the R generic.
|
|
481
|
+
const deferred = this.buildFindMany(baseArgs);
|
|
482
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
483
|
+
const entities = deferred.transform(result);
|
|
484
|
+
if (entities.length > 0) {
|
|
485
|
+
await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout), entities, withClause, args.timeout);
|
|
486
|
+
}
|
|
487
|
+
(0, batched_loader_js_1.stripFields)(entities, strip);
|
|
488
|
+
return entities;
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Build the base findMany args for a batched run: drop `with`, and ensure every
|
|
492
|
+
* parent correlation key needed for stitching is projected (returning the list
|
|
493
|
+
* of keys that must be stripped from the output afterwards).
|
|
494
|
+
*/
|
|
495
|
+
prepareBatchedBase(args, withClause) {
|
|
496
|
+
const needed = (0, batched_loader_js_1.neededParentKeyFields)(this.tableMeta, withClause);
|
|
497
|
+
const proj = (0, batched_loader_js_1.includeKeysForBatching)(args.select, args.omit, needed);
|
|
498
|
+
const baseArgs = {
|
|
499
|
+
...args,
|
|
500
|
+
with: undefined,
|
|
501
|
+
select: proj.select,
|
|
502
|
+
omit: proj.omit,
|
|
503
|
+
};
|
|
504
|
+
return { baseArgs, strip: proj.strip };
|
|
505
|
+
}
|
|
423
506
|
/**
|
|
424
507
|
* Return cache hit/miss statistics for this QueryInterface instance.
|
|
425
508
|
* Useful for monitoring and benchmarking.
|
|
@@ -597,11 +680,34 @@ class QueryInterface {
|
|
|
597
680
|
// -------------------------------------------------------------------------
|
|
598
681
|
async findUnique(args) {
|
|
599
682
|
return this.executeWithMiddleware('findUnique', args, async () => {
|
|
683
|
+
if (args.with && this.resolveLoadStrategy(args.relationLoadStrategy) === 'batched') {
|
|
684
|
+
return this.runFindUniqueBatched(args);
|
|
685
|
+
}
|
|
600
686
|
const deferred = this.buildFindUnique(args);
|
|
601
687
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
602
688
|
return deferred.transform(result);
|
|
603
689
|
});
|
|
604
690
|
}
|
|
691
|
+
/**
|
|
692
|
+
* Batched-strategy findUnique: fetch the single base row without relation
|
|
693
|
+
* subqueries (adding any parent stitch keys the projection excluded), then load
|
|
694
|
+
* its relations via one follow-up query each and stitch. Mirrors the join
|
|
695
|
+
* strategy's shape for the one row.
|
|
696
|
+
*/
|
|
697
|
+
async runFindUniqueBatched(args) {
|
|
698
|
+
const withClause = args.with;
|
|
699
|
+
const needed = (0, batched_loader_js_1.neededParentKeyFields)(this.tableMeta, withClause);
|
|
700
|
+
const proj = (0, batched_loader_js_1.includeKeysForBatching)(args.select, args.omit, needed);
|
|
701
|
+
const baseArgs = { ...args, with: undefined, select: proj.select, omit: proj.omit };
|
|
702
|
+
const deferred = this.buildFindUnique(baseArgs);
|
|
703
|
+
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
704
|
+
const entity = deferred.transform(result);
|
|
705
|
+
if (!entity)
|
|
706
|
+
return null;
|
|
707
|
+
await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout), [entity], withClause, args.timeout);
|
|
708
|
+
(0, batched_loader_js_1.stripFields)([entity], proj.strip);
|
|
709
|
+
return entity;
|
|
710
|
+
}
|
|
605
711
|
// biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
|
|
606
712
|
buildFindUnique(args) {
|
|
607
713
|
const columnsList = this.resolveColumns(args.select, args.omit);
|
|
@@ -688,12 +794,13 @@ class QueryInterface {
|
|
|
688
794
|
// Collect params in exact build order: where first, then with-clause relations
|
|
689
795
|
this.collectWhereParams(whereObj, params);
|
|
690
796
|
this.collectWithParams(args.with, params);
|
|
797
|
+
const parseWith = this.makeNestedParser(args.with);
|
|
691
798
|
return {
|
|
692
799
|
sql: entry.sql,
|
|
693
800
|
params,
|
|
694
801
|
transform: (result) => {
|
|
695
802
|
const row = result.rows[0];
|
|
696
|
-
return row ?
|
|
803
|
+
return row ? parseWith(row) : null;
|
|
697
804
|
},
|
|
698
805
|
tag: `${this.table}.findUnique`,
|
|
699
806
|
preparedName: entry.name,
|
|
@@ -716,6 +823,9 @@ class QueryInterface {
|
|
|
716
823
|
}
|
|
717
824
|
}
|
|
718
825
|
return this.executeWithMiddleware('findMany', (args ?? {}), async () => {
|
|
826
|
+
if (args?.with && this.resolveLoadStrategy(args.relationLoadStrategy) === 'batched') {
|
|
827
|
+
return this.runFindManyBatched(args);
|
|
828
|
+
}
|
|
719
829
|
const deferred = this.buildFindMany(args);
|
|
720
830
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
|
|
721
831
|
return deferred.transform(result);
|
|
@@ -804,8 +914,9 @@ class QueryInterface {
|
|
|
804
914
|
: { sql: '' };
|
|
805
915
|
const qt = this.q(this.table);
|
|
806
916
|
let distinctPrefix = '';
|
|
917
|
+
let distinctCols = [];
|
|
807
918
|
if (args?.distinct && args.distinct.length > 0) {
|
|
808
|
-
|
|
919
|
+
distinctCols = args.distinct.map((k) => this.toSqlColumn(k));
|
|
809
920
|
distinctPrefix = `DISTINCT ON (${distinctCols.join(', ')}) `;
|
|
810
921
|
}
|
|
811
922
|
let selectClause;
|
|
@@ -839,9 +950,24 @@ class QueryInterface {
|
|
|
839
950
|
}
|
|
840
951
|
}
|
|
841
952
|
if (args?.orderBy) {
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
953
|
+
if (distinctPrefix) {
|
|
954
|
+
// Postgres requires DISTINCT ON expressions to lead the ORDER BY.
|
|
955
|
+
// Prisma semantics ("first row per combination, result in the user's
|
|
956
|
+
// order") need two levels: inner DISTINCT ON ordered by the distinct
|
|
957
|
+
// columns then the user's order (picks the right representative row),
|
|
958
|
+
// outer re-ordered by the user's order alone.
|
|
959
|
+
if (Object.values(args.orderBy).some((d) => isVectorOrderBy(d))) {
|
|
960
|
+
throw new errors_js_1.ValidationError('[turbine] `distinct` cannot be combined with vector distance ordering.');
|
|
961
|
+
}
|
|
962
|
+
const userOrder = this.buildOrderBy(args.orderBy, freshParams);
|
|
963
|
+
sql += ` ORDER BY ${distinctCols.map((c) => `${c} ASC`).join(', ')}, ${userOrder}`;
|
|
964
|
+
sql = `SELECT * FROM (${sql}) AS ${this.q(`${this.table}_distinct`)} ORDER BY ${userOrder}`;
|
|
965
|
+
}
|
|
966
|
+
else {
|
|
967
|
+
// Pass freshParams so vector KNN ordering binds its `$n::vector` query
|
|
968
|
+
// vector at the correct position (after cursor params, before LIMIT).
|
|
969
|
+
sql += ` ORDER BY ${this.buildOrderBy(args.orderBy, freshParams)}`;
|
|
970
|
+
}
|
|
845
971
|
}
|
|
846
972
|
// Pagination — push params in the same order the collect path mirrors
|
|
847
973
|
// (limit before offset); the SQL TEXT shape is dialect-owned via
|
|
@@ -887,10 +1013,12 @@ class QueryInterface {
|
|
|
887
1013
|
if (args?.offset !== undefined && !this.dialect.inlineLimitOffset) {
|
|
888
1014
|
params.push(Number(args.offset));
|
|
889
1015
|
}
|
|
1016
|
+
// Build the row parser once (positional shapes are computed here, not per row).
|
|
1017
|
+
const parseWith = args?.with ? this.makeNestedParser(args.with) : null;
|
|
890
1018
|
return {
|
|
891
1019
|
sql: entry.sql,
|
|
892
1020
|
params,
|
|
893
|
-
transform: (result) => result.rows.map((row) =>
|
|
1021
|
+
transform: (result) => result.rows.map((row) => (parseWith ? parseWith(row) : this.parseRow(row, this.table))),
|
|
894
1022
|
tag: `${this.table}.findMany`,
|
|
895
1023
|
preparedName: entry.name,
|
|
896
1024
|
};
|
|
@@ -928,6 +1056,8 @@ class QueryInterface {
|
|
|
928
1056
|
async *findManyStream(args) {
|
|
929
1057
|
const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 1000)));
|
|
930
1058
|
const hasRelations = !!args?.with;
|
|
1059
|
+
// Build the positional-aware relation parser once for the whole stream.
|
|
1060
|
+
const parseWith = hasRelations ? this.makeNestedParser(args.with) : null;
|
|
931
1061
|
// --- Speculative first fetch: try to satisfy the entire drain in one RTT ---
|
|
932
1062
|
const speculativeDeferred = this.buildFindMany({
|
|
933
1063
|
...args,
|
|
@@ -938,7 +1068,7 @@ class QueryInterface {
|
|
|
938
1068
|
if (speculativeResult.rows.length <= batchSize) {
|
|
939
1069
|
// Small drain — yield all rows and return, no cursor needed
|
|
940
1070
|
for (const row of speculativeResult.rows) {
|
|
941
|
-
yield (
|
|
1071
|
+
yield (parseWith ? parseWith(row) : this.parseRow(row, this.table));
|
|
942
1072
|
}
|
|
943
1073
|
return;
|
|
944
1074
|
}
|
|
@@ -952,7 +1082,7 @@ class QueryInterface {
|
|
|
952
1082
|
try {
|
|
953
1083
|
for await (const batch of this.dialect.openStream(client, deferred.sql, deferred.params, batchSize)) {
|
|
954
1084
|
for (const row of batch) {
|
|
955
|
-
yield (
|
|
1085
|
+
yield (parseWith ? parseWith(row) : this.parseRow(row, this.table));
|
|
956
1086
|
}
|
|
957
1087
|
}
|
|
958
1088
|
}
|
|
@@ -969,6 +1099,11 @@ class QueryInterface {
|
|
|
969
1099
|
// -------------------------------------------------------------------------
|
|
970
1100
|
async findFirst(args) {
|
|
971
1101
|
return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
|
|
1102
|
+
if (args?.with && this.resolveLoadStrategy(args.relationLoadStrategy) === 'batched') {
|
|
1103
|
+
// findFirst is findMany + LIMIT 1: batch the single base row, then load.
|
|
1104
|
+
const rows = await this.runFindManyBatched({ ...args, limit: 1 });
|
|
1105
|
+
return (rows[0] ?? null);
|
|
1106
|
+
}
|
|
972
1107
|
const deferred = this.buildFindFirst(args);
|
|
973
1108
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout, deferred.preparedName);
|
|
974
1109
|
return deferred.transform(result);
|
|
@@ -2136,7 +2271,7 @@ class QueryInterface {
|
|
|
2136
2271
|
// Relation filters: { posts: { some: { published: true } } }
|
|
2137
2272
|
const relDef = this.tableMeta.relations[key];
|
|
2138
2273
|
if (relDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
2139
|
-
const filterObj = value;
|
|
2274
|
+
const filterObj = this.normalizeRelationFilter(relDef, value);
|
|
2140
2275
|
if ('some' in filterObj ||
|
|
2141
2276
|
'every' in filterObj ||
|
|
2142
2277
|
'none' in filterObj ||
|
|
@@ -2144,15 +2279,25 @@ class QueryInterface {
|
|
|
2144
2279
|
'isNot' in filterObj) {
|
|
2145
2280
|
const relParts = [];
|
|
2146
2281
|
if (filterObj.some !== undefined)
|
|
2147
|
-
relParts.push(
|
|
2282
|
+
relParts.push(filterObj.some === null
|
|
2283
|
+
? 'some(null)'
|
|
2284
|
+
: `some(${this.fingerprintRelFilter(relDef.to, filterObj.some)})`);
|
|
2148
2285
|
if (filterObj.every !== undefined)
|
|
2149
|
-
relParts.push(
|
|
2286
|
+
relParts.push(filterObj.every === null
|
|
2287
|
+
? 'every(null)'
|
|
2288
|
+
: `every(${this.fingerprintRelFilter(relDef.to, filterObj.every)})`);
|
|
2150
2289
|
if (filterObj.none !== undefined)
|
|
2151
|
-
relParts.push(
|
|
2290
|
+
relParts.push(filterObj.none === null
|
|
2291
|
+
? 'none(null)'
|
|
2292
|
+
: `none(${this.fingerprintRelFilter(relDef.to, filterObj.none)})`);
|
|
2152
2293
|
if (filterObj.is !== undefined)
|
|
2153
|
-
relParts.push(
|
|
2294
|
+
relParts.push(filterObj.is === null
|
|
2295
|
+
? 'is(null)'
|
|
2296
|
+
: `is(${this.fingerprintRelFilter(relDef.to, filterObj.is)})`);
|
|
2154
2297
|
if (filterObj.isNot !== undefined)
|
|
2155
|
-
relParts.push(
|
|
2298
|
+
relParts.push(filterObj.isNot === null
|
|
2299
|
+
? 'isNot(null)'
|
|
2300
|
+
: `isNot(${this.fingerprintRelFilter(relDef.to, filterObj.isNot)})`);
|
|
2156
2301
|
parts.push(`${key}:{${relParts.join(',')}}`);
|
|
2157
2302
|
continue;
|
|
2158
2303
|
}
|
|
@@ -2222,7 +2367,8 @@ class QueryInterface {
|
|
|
2222
2367
|
/**
|
|
2223
2368
|
* Fingerprint a relation filter sub-where for some/every/none.
|
|
2224
2369
|
*/
|
|
2225
|
-
fingerprintRelFilter(
|
|
2370
|
+
fingerprintRelFilter(targetTable, subWhere) {
|
|
2371
|
+
const meta = this.schema.tables[targetTable];
|
|
2226
2372
|
const keys = Object.keys(subWhere)
|
|
2227
2373
|
.filter((k) => subWhere[k] !== undefined)
|
|
2228
2374
|
.sort();
|
|
@@ -2233,6 +2379,33 @@ class QueryInterface {
|
|
|
2233
2379
|
const value = subWhere[key];
|
|
2234
2380
|
if (value === undefined)
|
|
2235
2381
|
continue;
|
|
2382
|
+
if (key === 'OR' || key === 'AND') {
|
|
2383
|
+
const arr = Array.isArray(value) ? value : [];
|
|
2384
|
+
parts.push(`${key}[${arr.map((b) => `(${this.fingerprintRelFilter(targetTable, b)})`).join(',')}]`);
|
|
2385
|
+
continue;
|
|
2386
|
+
}
|
|
2387
|
+
if (key === 'NOT') {
|
|
2388
|
+
parts.push(`NOT(${this.fingerprintRelFilter(targetTable, value)})`);
|
|
2389
|
+
continue;
|
|
2390
|
+
}
|
|
2391
|
+
// Nested relation filter — must fingerprint the FULL inner shape, or two
|
|
2392
|
+
// different nested filters would collide on one cached SQL text.
|
|
2393
|
+
const nestedRel = meta?.relations?.[key];
|
|
2394
|
+
if (nestedRel && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
2395
|
+
const norm = this.normalizeRelationFilter(nestedRel, value);
|
|
2396
|
+
if ('some' in norm || 'every' in norm || 'none' in norm || 'is' in norm || 'isNot' in norm) {
|
|
2397
|
+
const inner = [];
|
|
2398
|
+
for (const op of ['some', 'none', 'every', 'is', 'isNot']) {
|
|
2399
|
+
if (norm[op] === undefined)
|
|
2400
|
+
continue;
|
|
2401
|
+
inner.push(norm[op] === null
|
|
2402
|
+
? `${op}(null)`
|
|
2403
|
+
: `${op}(${this.fingerprintRelFilter(nestedRel.to, norm[op])})`);
|
|
2404
|
+
}
|
|
2405
|
+
parts.push(`${key}:rel[${inner.join(',')}]`);
|
|
2406
|
+
continue;
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2236
2409
|
if (value === null) {
|
|
2237
2410
|
parts.push(`${key}:null`);
|
|
2238
2411
|
}
|
|
@@ -2290,21 +2463,21 @@ class QueryInterface {
|
|
|
2290
2463
|
// Relation filters
|
|
2291
2464
|
const relationDef = this.tableMeta.relations[key];
|
|
2292
2465
|
if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
2293
|
-
const filterObj = value;
|
|
2466
|
+
const filterObj = this.normalizeRelationFilter(relationDef, value);
|
|
2294
2467
|
if ('some' in filterObj ||
|
|
2295
2468
|
'every' in filterObj ||
|
|
2296
2469
|
'none' in filterObj ||
|
|
2297
2470
|
'is' in filterObj ||
|
|
2298
2471
|
'isNot' in filterObj) {
|
|
2299
|
-
if (filterObj.some !== undefined)
|
|
2472
|
+
if (filterObj.some !== undefined && filterObj.some !== null)
|
|
2300
2473
|
this.collectRelFilterParams(relationDef.to, filterObj.some, params);
|
|
2301
|
-
if (filterObj.none !== undefined)
|
|
2474
|
+
if (filterObj.none !== undefined && filterObj.none !== null)
|
|
2302
2475
|
this.collectRelFilterParams(relationDef.to, filterObj.none, params);
|
|
2303
|
-
if (filterObj.every !== undefined)
|
|
2476
|
+
if (filterObj.every !== undefined && filterObj.every !== null)
|
|
2304
2477
|
this.collectRelFilterParams(relationDef.to, filterObj.every, params);
|
|
2305
|
-
if (filterObj.is !== undefined)
|
|
2478
|
+
if (filterObj.is !== undefined && filterObj.is !== null)
|
|
2306
2479
|
this.collectRelFilterParams(relationDef.to, filterObj.is, params);
|
|
2307
|
-
if (filterObj.isNot !== undefined)
|
|
2480
|
+
if (filterObj.isNot !== undefined && filterObj.isNot !== null)
|
|
2308
2481
|
this.collectRelFilterParams(relationDef.to, filterObj.isNot, params);
|
|
2309
2482
|
continue;
|
|
2310
2483
|
}
|
|
@@ -2365,6 +2538,36 @@ class QueryInterface {
|
|
|
2365
2538
|
continue;
|
|
2366
2539
|
if (value === null)
|
|
2367
2540
|
continue;
|
|
2541
|
+
if (field === 'OR' || field === 'AND') {
|
|
2542
|
+
const arr = value;
|
|
2543
|
+
if (!Array.isArray(arr))
|
|
2544
|
+
continue;
|
|
2545
|
+
for (const branch of arr)
|
|
2546
|
+
this.collectRelFilterParams(targetTable, branch, params);
|
|
2547
|
+
continue;
|
|
2548
|
+
}
|
|
2549
|
+
if (field === 'NOT') {
|
|
2550
|
+
this.collectRelFilterParams(targetTable, value, params);
|
|
2551
|
+
continue;
|
|
2552
|
+
}
|
|
2553
|
+
const nestedRel = meta.relations?.[field];
|
|
2554
|
+
if (nestedRel && typeof value === 'object' && !Array.isArray(value)) {
|
|
2555
|
+
const norm = this.normalizeRelationFilter(nestedRel, value);
|
|
2556
|
+
if ('some' in norm || 'every' in norm || 'none' in norm || 'is' in norm || 'isNot' in norm) {
|
|
2557
|
+
// Same order as buildRelationFilter pushes params: some, none, every, is, isNot.
|
|
2558
|
+
if (norm.some != null)
|
|
2559
|
+
this.collectRelFilterParams(nestedRel.to, norm.some, params);
|
|
2560
|
+
if (norm.none != null)
|
|
2561
|
+
this.collectRelFilterParams(nestedRel.to, norm.none, params);
|
|
2562
|
+
if (norm.every != null)
|
|
2563
|
+
this.collectRelFilterParams(nestedRel.to, norm.every, params);
|
|
2564
|
+
if (norm.is != null)
|
|
2565
|
+
this.collectRelFilterParams(nestedRel.to, norm.is, params);
|
|
2566
|
+
if (norm.isNot != null)
|
|
2567
|
+
this.collectRelFilterParams(nestedRel.to, norm.isNot, params);
|
|
2568
|
+
continue;
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2368
2571
|
const col = meta.columnMap[field] ?? (0, schema_js_1.camelToSnake)(field);
|
|
2369
2572
|
if (isWhereOperator(value)) {
|
|
2370
2573
|
this.collectOperatorParams(col, value, params);
|
|
@@ -2506,7 +2709,7 @@ class QueryInterface {
|
|
|
2506
2709
|
// `{title: {contains: 'x'}}` emit different SQL so they must not share
|
|
2507
2710
|
// a fingerprint)
|
|
2508
2711
|
if (opts.where) {
|
|
2509
|
-
subParts.push(`w=${this.fingerprintAliasWhere(opts.where)}`);
|
|
2712
|
+
subParts.push(`w=${this.fingerprintAliasWhere(opts.where, meta.relations[relName]?.to)}`);
|
|
2510
2713
|
}
|
|
2511
2714
|
// orderBy shape
|
|
2512
2715
|
if (opts.orderBy) {
|
|
@@ -2726,7 +2929,7 @@ class QueryInterface {
|
|
|
2726
2929
|
// Handle relation filters: { posts: { some: { published: true } } }
|
|
2727
2930
|
const relationDef = this.tableMeta.relations[key];
|
|
2728
2931
|
if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
2729
|
-
const filterObj = value;
|
|
2932
|
+
const filterObj = this.normalizeRelationFilter(relationDef, value);
|
|
2730
2933
|
// Check if this is a relation filter (has some/every/none keys)
|
|
2731
2934
|
if ('some' in filterObj ||
|
|
2732
2935
|
'every' in filterObj ||
|
|
@@ -2817,13 +3020,13 @@ class QueryInterface {
|
|
|
2817
3020
|
* Build relation filter SQL: WHERE EXISTS / NOT EXISTS subquery
|
|
2818
3021
|
* Supports: some (EXISTS), every (NOT EXISTS ... NOT), none (NOT EXISTS)
|
|
2819
3022
|
*/
|
|
2820
|
-
buildRelationFilter(_relName, relDef, filterObj, params) {
|
|
3023
|
+
buildRelationFilter(_relName, relDef, filterObj, params, parentTable) {
|
|
2821
3024
|
const targetTable = relDef.to;
|
|
2822
3025
|
const targetMeta = this.schema.tables[targetTable];
|
|
2823
3026
|
if (!targetMeta)
|
|
2824
3027
|
return null;
|
|
2825
3028
|
const qt = this.q(targetTable);
|
|
2826
|
-
const qSelf = this.q(this.table);
|
|
3029
|
+
const qSelf = this.q(parentTable ?? this.table);
|
|
2827
3030
|
const clauses = [];
|
|
2828
3031
|
// Correlation: link child table to parent table (supports composite FKs)
|
|
2829
3032
|
let correlation;
|
|
@@ -2860,19 +3063,31 @@ class QueryInterface {
|
|
|
2860
3063
|
// "every" with empty filter = true (all match trivially)
|
|
2861
3064
|
}
|
|
2862
3065
|
}
|
|
2863
|
-
// "is": EXISTS — for to-one relations (same SQL as "some")
|
|
3066
|
+
// "is": EXISTS — for to-one relations (same SQL as "some").
|
|
3067
|
+
// `is: null` = "no related row" (Prisma semantics) → NOT EXISTS.
|
|
2864
3068
|
if (filterObj.is !== undefined) {
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
3069
|
+
if (filterObj.is === null) {
|
|
3070
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation})`);
|
|
3071
|
+
}
|
|
3072
|
+
else {
|
|
3073
|
+
const subWhere = filterObj.is;
|
|
3074
|
+
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
3075
|
+
const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
|
|
3076
|
+
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
|
|
3077
|
+
}
|
|
2869
3078
|
}
|
|
2870
|
-
// "isNot": NOT EXISTS — for to-one relations (same SQL as "none")
|
|
3079
|
+
// "isNot": NOT EXISTS — for to-one relations (same SQL as "none").
|
|
3080
|
+
// `isNot: null` = "a related row exists" → EXISTS.
|
|
2871
3081
|
if (filterObj.isNot !== undefined) {
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
3082
|
+
if (filterObj.isNot === null) {
|
|
3083
|
+
clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation})`);
|
|
3084
|
+
}
|
|
3085
|
+
else {
|
|
3086
|
+
const subWhere = filterObj.isNot;
|
|
3087
|
+
const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
|
|
3088
|
+
const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
|
|
3089
|
+
clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
|
|
3090
|
+
}
|
|
2876
3091
|
}
|
|
2877
3092
|
return clauses.length > 0 ? clauses.join(' AND ') : null;
|
|
2878
3093
|
}
|
|
@@ -2891,6 +3106,39 @@ class QueryInterface {
|
|
|
2891
3106
|
const value = subWhere[field];
|
|
2892
3107
|
if (value === undefined)
|
|
2893
3108
|
continue;
|
|
3109
|
+
// OR / AND / NOT combinators inside a relation sub-where
|
|
3110
|
+
if (field === 'OR' || field === 'AND') {
|
|
3111
|
+
const arr = value;
|
|
3112
|
+
if (!Array.isArray(arr) || arr.length === 0)
|
|
3113
|
+
continue;
|
|
3114
|
+
const parts = [];
|
|
3115
|
+
for (const branch of arr) {
|
|
3116
|
+
const c = this.buildSubWhereForRelation(targetTable, branch, params);
|
|
3117
|
+
if (c)
|
|
3118
|
+
parts.push(`(${c})`);
|
|
3119
|
+
}
|
|
3120
|
+
if (parts.length)
|
|
3121
|
+
conditions.push(`(${parts.join(field === 'OR' ? ' OR ' : ' AND ')})`);
|
|
3122
|
+
continue;
|
|
3123
|
+
}
|
|
3124
|
+
if (field === 'NOT') {
|
|
3125
|
+
const c = this.buildSubWhereForRelation(targetTable, value, params);
|
|
3126
|
+
if (c)
|
|
3127
|
+
conditions.push(`NOT (${c})`);
|
|
3128
|
+
continue;
|
|
3129
|
+
}
|
|
3130
|
+
// Nested relation filter (relation of the relation target) — recurse
|
|
3131
|
+
// with the TARGET table as the correlation parent.
|
|
3132
|
+
const nestedRel = meta.relations?.[field];
|
|
3133
|
+
if (nestedRel && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
3134
|
+
const norm = this.normalizeRelationFilter(nestedRel, value);
|
|
3135
|
+
if ('some' in norm || 'every' in norm || 'none' in norm || 'is' in norm || 'isNot' in norm) {
|
|
3136
|
+
const c = this.buildRelationFilter(field, nestedRel, norm, params, targetTable);
|
|
3137
|
+
if (c)
|
|
3138
|
+
conditions.push(c);
|
|
3139
|
+
continue;
|
|
3140
|
+
}
|
|
3141
|
+
}
|
|
2894
3142
|
const col = meta.columnMap[field] ?? (0, schema_js_1.camelToSnake)(field);
|
|
2895
3143
|
if (!meta.allColumns.includes(col)) {
|
|
2896
3144
|
throw new errors_js_1.ValidationError(`[turbine] Unknown field "${field}" in relation filter for table "${targetTable}". ` +
|
|
@@ -2976,6 +3224,18 @@ class QueryInterface {
|
|
|
2976
3224
|
clauses.push(`NOT (${sub})`);
|
|
2977
3225
|
continue;
|
|
2978
3226
|
}
|
|
3227
|
+
// Relation filter inside a with-clause where — EXISTS correlated to the
|
|
3228
|
+
// relation alias (some/every/none/is/isNot + bare to-one implicit `is`).
|
|
3229
|
+
const aliasRel = targetMeta.relations?.[key];
|
|
3230
|
+
if (aliasRel && typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
3231
|
+
const norm = this.normalizeRelationFilter(aliasRel, value);
|
|
3232
|
+
if ('some' in norm || 'every' in norm || 'none' in norm || 'is' in norm || 'isNot' in norm) {
|
|
3233
|
+
const c = this.buildRelationFilter(key, aliasRel, norm, params, alias);
|
|
3234
|
+
if (c)
|
|
3235
|
+
clauses.push(c);
|
|
3236
|
+
continue;
|
|
3237
|
+
}
|
|
3238
|
+
}
|
|
2979
3239
|
const col = targetMeta.columnMap[key] ?? (0, schema_js_1.camelToSnake)(key);
|
|
2980
3240
|
if (!targetMeta.allColumns.includes(col)) {
|
|
2981
3241
|
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${key}" in where for table "${targetTable}"`);
|
|
@@ -3017,6 +3277,24 @@ class QueryInterface {
|
|
|
3017
3277
|
}
|
|
3018
3278
|
if (value === null)
|
|
3019
3279
|
continue;
|
|
3280
|
+
const aliasRel = targetMeta.relations?.[key];
|
|
3281
|
+
if (aliasRel && typeof value === 'object' && !Array.isArray(value)) {
|
|
3282
|
+
const norm = this.normalizeRelationFilter(aliasRel, value);
|
|
3283
|
+
if ('some' in norm || 'every' in norm || 'none' in norm || 'is' in norm || 'isNot' in norm) {
|
|
3284
|
+
// Same order as buildRelationFilter pushes params: some, none, every, is, isNot.
|
|
3285
|
+
if (norm.some != null)
|
|
3286
|
+
this.collectRelFilterParams(aliasRel.to, norm.some, params);
|
|
3287
|
+
if (norm.none != null)
|
|
3288
|
+
this.collectRelFilterParams(aliasRel.to, norm.none, params);
|
|
3289
|
+
if (norm.every != null)
|
|
3290
|
+
this.collectRelFilterParams(aliasRel.to, norm.every, params);
|
|
3291
|
+
if (norm.is != null)
|
|
3292
|
+
this.collectRelFilterParams(aliasRel.to, norm.is, params);
|
|
3293
|
+
if (norm.isNot != null)
|
|
3294
|
+
this.collectRelFilterParams(aliasRel.to, norm.isNot, params);
|
|
3295
|
+
continue;
|
|
3296
|
+
}
|
|
3297
|
+
}
|
|
3020
3298
|
const col = targetMeta.columnMap[key] ?? (0, schema_js_1.camelToSnake)(key);
|
|
3021
3299
|
if (isWhereOperator(value)) {
|
|
3022
3300
|
this.collectOperatorParams(col, value, params);
|
|
@@ -3032,28 +3310,46 @@ class QueryInterface {
|
|
|
3032
3310
|
* can emit — equality vs null vs operator sets vs combinators — or two
|
|
3033
3311
|
* differently-shaped wheres would share one cached SQL string.
|
|
3034
3312
|
*/
|
|
3035
|
-
fingerprintAliasWhere(where) {
|
|
3313
|
+
fingerprintAliasWhere(where, targetTable) {
|
|
3036
3314
|
const keys = Object.keys(where)
|
|
3037
3315
|
.filter((k) => where[k] !== undefined)
|
|
3038
3316
|
.sort();
|
|
3039
3317
|
const parts = [];
|
|
3318
|
+
const meta = targetTable ? this.schema.tables[targetTable] : undefined;
|
|
3040
3319
|
for (const key of keys) {
|
|
3041
3320
|
const value = where[key];
|
|
3042
3321
|
if (key === 'OR' || key === 'AND') {
|
|
3043
3322
|
const arr = value;
|
|
3044
3323
|
if (!Array.isArray(arr) || arr.length === 0)
|
|
3045
3324
|
continue;
|
|
3046
|
-
parts.push(`${key}[${arr.map((c) => this.fingerprintAliasWhere(c)).join(',')}]`);
|
|
3325
|
+
parts.push(`${key}[${arr.map((c) => this.fingerprintAliasWhere(c, targetTable)).join(',')}]`);
|
|
3047
3326
|
continue;
|
|
3048
3327
|
}
|
|
3049
3328
|
if (key === 'NOT') {
|
|
3050
|
-
parts.push(`NOT(${this.fingerprintAliasWhere(value)})`);
|
|
3329
|
+
parts.push(`NOT(${this.fingerprintAliasWhere(value, targetTable)})`);
|
|
3051
3330
|
continue;
|
|
3052
3331
|
}
|
|
3053
3332
|
if (value === null) {
|
|
3054
3333
|
parts.push(`${key}:null`);
|
|
3055
3334
|
continue;
|
|
3056
3335
|
}
|
|
3336
|
+
// Relation filter shapes must be fully fingerprinted (cache-key safety).
|
|
3337
|
+
const fpRel = meta?.relations?.[key];
|
|
3338
|
+
if (fpRel && typeof value === 'object' && !Array.isArray(value)) {
|
|
3339
|
+
const norm = this.normalizeRelationFilter(fpRel, value);
|
|
3340
|
+
if ('some' in norm || 'every' in norm || 'none' in norm || 'is' in norm || 'isNot' in norm) {
|
|
3341
|
+
const inner = [];
|
|
3342
|
+
for (const op of ['some', 'none', 'every', 'is', 'isNot']) {
|
|
3343
|
+
if (norm[op] === undefined)
|
|
3344
|
+
continue;
|
|
3345
|
+
inner.push(norm[op] === null
|
|
3346
|
+
? `${op}(null)`
|
|
3347
|
+
: `${op}(${this.fingerprintRelFilter(fpRel.to, norm[op])})`);
|
|
3348
|
+
}
|
|
3349
|
+
parts.push(`${key}:rel[${inner.join(',')}]`);
|
|
3350
|
+
continue;
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3057
3353
|
if (isWhereOperator(value)) {
|
|
3058
3354
|
parts.push(`${key}:${fingerprintOperatorShape(value)}`);
|
|
3059
3355
|
continue;
|
|
@@ -3234,6 +3530,25 @@ class QueryInterface {
|
|
|
3234
3530
|
* memoized per table. Used so nested relation rows (camelCase keys) coerce
|
|
3235
3531
|
* dates the same way top-level rows do.
|
|
3236
3532
|
*/
|
|
3533
|
+
/**
|
|
3534
|
+
* Prisma-compat: a plain object on a to-one relation key —
|
|
3535
|
+
* `where: { vendor: { name: { contains: 'x' } } }` — is an implicit `is`
|
|
3536
|
+
* filter. Normalize it to `{ is: obj }` so all downstream handling (SQL,
|
|
3537
|
+
* params, fingerprint) sees one canonical shape. To-many relations still
|
|
3538
|
+
* require an explicit `some`/`every`/`none` (a bare object there is
|
|
3539
|
+
* ambiguous and was never valid in Prisma either).
|
|
3540
|
+
*/
|
|
3541
|
+
normalizeRelationFilter(relDef, filterObj) {
|
|
3542
|
+
if ((relDef.type === 'belongsTo' || relDef.type === 'hasOne') &&
|
|
3543
|
+
!('some' in filterObj) &&
|
|
3544
|
+
!('every' in filterObj) &&
|
|
3545
|
+
!('none' in filterObj) &&
|
|
3546
|
+
!('is' in filterObj) &&
|
|
3547
|
+
!('isNot' in filterObj)) {
|
|
3548
|
+
return { is: filterObj };
|
|
3549
|
+
}
|
|
3550
|
+
return filterObj;
|
|
3551
|
+
}
|
|
3237
3552
|
getCamelDateFields(table, meta) {
|
|
3238
3553
|
let camel = this.camelDateFieldCache.get(table);
|
|
3239
3554
|
if (!camel) {
|
|
@@ -3262,7 +3577,9 @@ class QueryInterface {
|
|
|
3262
3577
|
const field = reverseMap[col] ?? col; // fall back to raw col name, not regex
|
|
3263
3578
|
// Top-level rows are snake_case (dateCols); nested rows are camelCase (camelDateFields).
|
|
3264
3579
|
if ((dateCols.has(col) || camelDateFields.has(field)) && value !== null && !(value instanceof Date)) {
|
|
3265
|
-
|
|
3580
|
+
// Offset-less strings (Postgres `timestamp`, json_agg output) are
|
|
3581
|
+
// pinned to UTC so results don't depend on the server's time zone.
|
|
3582
|
+
parsed[field] = this.utcTimestamps ? (0, utils_js_1.parseDbDate)(String(value)) : new Date(value);
|
|
3266
3583
|
}
|
|
3267
3584
|
else {
|
|
3268
3585
|
parsed[field] = value;
|
|
@@ -3338,6 +3655,159 @@ class QueryInterface {
|
|
|
3338
3655
|
}
|
|
3339
3656
|
return parsed;
|
|
3340
3657
|
}
|
|
3658
|
+
// -------------------------------------------------------------------------
|
|
3659
|
+
// Positional JSON encoding (jsonEncoding: 'positional')
|
|
3660
|
+
//
|
|
3661
|
+
// When active, relation subqueries emit `json_agg(json_build_array(v1, v2, …))`
|
|
3662
|
+
// instead of `json_build_object('k1', v1, …)`, dropping every repeated key
|
|
3663
|
+
// name. The builder knows the exact column order, so it records a recursive
|
|
3664
|
+
// RelationShape during SQL generation; the transform decodes each positional
|
|
3665
|
+
// array back into the object representation the object-encoding would have
|
|
3666
|
+
// produced, then hands it to parseNestedRow — so parsed output is byte-
|
|
3667
|
+
// identical to the object path (same dates, same snake→camel, same recursion).
|
|
3668
|
+
// -------------------------------------------------------------------------
|
|
3669
|
+
/**
|
|
3670
|
+
* Resolve the emitted column list for a relation, honoring `select` / `omit`.
|
|
3671
|
+
* Shared by {@link buildRelationSubquery} (json order) and
|
|
3672
|
+
* {@link buildRelationShape} (decode key order) so they can never diverge.
|
|
3673
|
+
*/
|
|
3674
|
+
resolveTargetColumns(spec, targetMeta) {
|
|
3675
|
+
if (spec !== true && spec.select) {
|
|
3676
|
+
const selectedFields = Object.entries(spec.select)
|
|
3677
|
+
.filter(([, v]) => v)
|
|
3678
|
+
.map(([k]) => targetMeta.columnMap[k] ?? (0, schema_js_1.camelToSnake)(k));
|
|
3679
|
+
return selectedFields.filter((col) => targetMeta.allColumns.includes(col));
|
|
3680
|
+
}
|
|
3681
|
+
if (spec !== true && spec.omit) {
|
|
3682
|
+
const omittedFields = new Set(Object.entries(spec.omit)
|
|
3683
|
+
.filter(([, v]) => v)
|
|
3684
|
+
.map(([k]) => targetMeta.columnMap[k] ?? (0, schema_js_1.camelToSnake)(k)));
|
|
3685
|
+
return targetMeta.allColumns.filter((col) => !omittedFields.has(col));
|
|
3686
|
+
}
|
|
3687
|
+
return targetMeta.allColumns;
|
|
3688
|
+
}
|
|
3689
|
+
/**
|
|
3690
|
+
* Render a single relation row's JSON: a keyed object (`'object'`) or a
|
|
3691
|
+
* positional array (`'positional'`). The array drops the keys but keeps the
|
|
3692
|
+
* exact expression order, so {@link RelationShape.keys} maps positions back.
|
|
3693
|
+
*/
|
|
3694
|
+
buildJsonRow(jsonPairs) {
|
|
3695
|
+
if (this.jsonEncoding === 'positional') {
|
|
3696
|
+
// buildJsonArray is defined on postgresDialect; positional is gated to PG
|
|
3697
|
+
// in buildSelectWithRelations, so the `?? buildJsonObject` never fires.
|
|
3698
|
+
return (this.dialect.buildJsonArray?.(jsonPairs.map(([, expr]) => expr)) ?? this.dialect.buildJsonObject(jsonPairs));
|
|
3699
|
+
}
|
|
3700
|
+
return this.dialect.buildJsonObject(jsonPairs);
|
|
3701
|
+
}
|
|
3702
|
+
/**
|
|
3703
|
+
* Build the top-level relation shapes for a `with` clause, mirroring
|
|
3704
|
+
* {@link buildSelectWithRelations}: same relation iteration order, same
|
|
3705
|
+
* per-relation column resolution, same nested recursion.
|
|
3706
|
+
*/
|
|
3707
|
+
buildRelationShapes(table, withClause) {
|
|
3708
|
+
const meta = this.schema.tables[table];
|
|
3709
|
+
if (!meta)
|
|
3710
|
+
return {};
|
|
3711
|
+
const shapes = {};
|
|
3712
|
+
for (const [relName, relSpec] of sortedEntries(withClause)) {
|
|
3713
|
+
const relDef = meta.relations[relName];
|
|
3714
|
+
if (!relDef)
|
|
3715
|
+
continue; // buildSelectWithRelations already threw for this
|
|
3716
|
+
shapes[relName] = this.buildRelationShape(relDef, relSpec, meta);
|
|
3717
|
+
}
|
|
3718
|
+
return shapes;
|
|
3719
|
+
}
|
|
3720
|
+
/**
|
|
3721
|
+
* Recursively describe one relation's positional layout: the camelCase key
|
|
3722
|
+
* order (scalar columns first, then nested relation slots in the same order
|
|
3723
|
+
* {@link buildRelationSubquery} appends them), the nested sub-shapes, and the
|
|
3724
|
+
* cardinality (single object for belongsTo/hasOne, array for the rest).
|
|
3725
|
+
*/
|
|
3726
|
+
buildRelationShape(relDef, spec, parentMeta) {
|
|
3727
|
+
void parentMeta;
|
|
3728
|
+
const targetMeta = this.schema.tables[relDef.to];
|
|
3729
|
+
if (!targetMeta)
|
|
3730
|
+
return { keys: [], nested: {}, cardinality: 'many' };
|
|
3731
|
+
const targetColumns = this.resolveTargetColumns(spec, targetMeta);
|
|
3732
|
+
const keys = targetColumns.map((col) => targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col));
|
|
3733
|
+
const nested = {};
|
|
3734
|
+
if (spec !== true && spec.with) {
|
|
3735
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
|
|
3736
|
+
const nestedRelDef = targetMeta.relations[nestedRelName];
|
|
3737
|
+
if (!nestedRelDef)
|
|
3738
|
+
continue;
|
|
3739
|
+
keys.push(nestedRelName);
|
|
3740
|
+
nested[nestedRelName] = this.buildRelationShape(nestedRelDef, nestedSpec, targetMeta);
|
|
3741
|
+
}
|
|
3742
|
+
}
|
|
3743
|
+
const cardinality = relDef.type === 'belongsTo' || relDef.type === 'hasOne' ? 'one' : 'many';
|
|
3744
|
+
return { keys, nested, cardinality };
|
|
3745
|
+
}
|
|
3746
|
+
/**
|
|
3747
|
+
* Build the row parser for a `with` clause. In object mode this is just
|
|
3748
|
+
* {@link parseNestedRow}. In positional mode it decodes each relation's
|
|
3749
|
+
* positional arrays into the object form first (shapes built once, not per
|
|
3750
|
+
* row), then delegates to parseNestedRow for date/snake-camel coercion.
|
|
3751
|
+
*/
|
|
3752
|
+
makeNestedParser(withClause) {
|
|
3753
|
+
if (this.jsonEncoding !== 'positional') {
|
|
3754
|
+
return (row) => this.parseNestedRow(row, this.table);
|
|
3755
|
+
}
|
|
3756
|
+
const shapes = this.buildRelationShapes(this.table, withClause);
|
|
3757
|
+
return (row) => this.parseNestedRow(this.decodePositionalRelations(row, shapes), this.table);
|
|
3758
|
+
}
|
|
3759
|
+
/**
|
|
3760
|
+
* Return a shallow copy of a top-level row with each relation column decoded
|
|
3761
|
+
* from its positional array(s) into the object representation. Only relation
|
|
3762
|
+
* columns are positional — base scalar columns stay object-keyed — so the
|
|
3763
|
+
* result is exactly what the object encoding would have handed parseNestedRow.
|
|
3764
|
+
*/
|
|
3765
|
+
decodePositionalRelations(row, shapes) {
|
|
3766
|
+
const cloned = { ...row };
|
|
3767
|
+
for (const [relName, shape] of Object.entries(shapes)) {
|
|
3768
|
+
if (relName in cloned)
|
|
3769
|
+
cloned[relName] = this.decodePositionalValue(cloned[relName], shape);
|
|
3770
|
+
}
|
|
3771
|
+
return cloned;
|
|
3772
|
+
}
|
|
3773
|
+
/**
|
|
3774
|
+
* Decode one relation's positional JSON value. `json_agg` returns the value as
|
|
3775
|
+
* a JSON string at the top level (JSON.parse once); nested relation slots are
|
|
3776
|
+
* already-parsed arrays. A `'many'` value is an array of positional arrays; a
|
|
3777
|
+
* `'one'` value is a single positional array or null.
|
|
3778
|
+
*/
|
|
3779
|
+
decodePositionalValue(raw, shape) {
|
|
3780
|
+
let val = raw;
|
|
3781
|
+
if (typeof val === 'string') {
|
|
3782
|
+
try {
|
|
3783
|
+
val = JSON.parse(val);
|
|
3784
|
+
}
|
|
3785
|
+
catch {
|
|
3786
|
+
return raw; // parseNestedRow's warn path handles unparseable JSON
|
|
3787
|
+
}
|
|
3788
|
+
}
|
|
3789
|
+
if (val === null || val === undefined) {
|
|
3790
|
+
return shape.cardinality === 'many' ? [] : null;
|
|
3791
|
+
}
|
|
3792
|
+
if (shape.cardinality === 'many') {
|
|
3793
|
+
if (!Array.isArray(val))
|
|
3794
|
+
return val;
|
|
3795
|
+
return val.map((inner) => this.decodePositionalObject(inner, shape));
|
|
3796
|
+
}
|
|
3797
|
+
return this.decodePositionalObject(val, shape);
|
|
3798
|
+
}
|
|
3799
|
+
/** Map one positional array back to a keyed object using the shape's key order. */
|
|
3800
|
+
decodePositionalObject(arr, shape) {
|
|
3801
|
+
if (!Array.isArray(arr))
|
|
3802
|
+
return arr;
|
|
3803
|
+
const obj = {};
|
|
3804
|
+
for (let i = 0; i < shape.keys.length; i++) {
|
|
3805
|
+
const key = shape.keys[i];
|
|
3806
|
+
const nestedShape = shape.nested[key];
|
|
3807
|
+
obj[key] = nestedShape ? this.decodePositionalValue(arr[i], nestedShape) : arr[i];
|
|
3808
|
+
}
|
|
3809
|
+
return obj;
|
|
3810
|
+
}
|
|
3341
3811
|
/**
|
|
3342
3812
|
* Build a SELECT clause that includes both base columns and nested relation subqueries.
|
|
3343
3813
|
*
|
|
@@ -3383,6 +3853,13 @@ class QueryInterface {
|
|
|
3383
3853
|
const meta = this.schema.tables[table];
|
|
3384
3854
|
if (!meta)
|
|
3385
3855
|
throw new errors_js_1.ValidationError(`[turbine] Unknown table "${table}"`);
|
|
3856
|
+
// Positional JSON encoding is Postgres-only in v1. Gate here — the single
|
|
3857
|
+
// entry point for every `with` clause — so no engine ever emits the
|
|
3858
|
+
// json_build_array shape its dialect can't produce (and mssql's FOR JSON
|
|
3859
|
+
// override path is never reached with positional active).
|
|
3860
|
+
if (this.jsonEncoding === 'positional' && this.dialect.name !== 'postgresql') {
|
|
3861
|
+
throw new errors_js_1.UnsupportedFeatureError("jsonEncoding: 'positional'", this.dialect.name, 'Positional relation encoding is only available on PostgreSQL in this version.');
|
|
3862
|
+
}
|
|
3386
3863
|
const cols = columnsList ?? meta.allColumns;
|
|
3387
3864
|
const qtbl = this.q(table);
|
|
3388
3865
|
const baseCols = cols.map((col) => `${qtbl}.${this.q(col)}`).join(', ');
|
|
@@ -3506,22 +3983,30 @@ class QueryInterface {
|
|
|
3506
3983
|
const targetMeta = this.schema.tables[targetTable];
|
|
3507
3984
|
if (!targetMeta)
|
|
3508
3985
|
throw new errors_js_1.RelationError(`[turbine] Unknown relation target "${targetTable}"`);
|
|
3986
|
+
// Dev-only: correlated relation loading probes the child table once per parent
|
|
3987
|
+
// row, so a missing FK index multiplies into per-parent full-table scans (a
|
|
3988
|
+
// batched-loader ORM pays the same missing index only once, which is why
|
|
3989
|
+
// schemas migrated from one often lack these). Name the exact index to create
|
|
3990
|
+
// instead of letting the slowness look like an ORM problem.
|
|
3991
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
3992
|
+
const warnKey = `${relDef.from}.${relDef.name}`;
|
|
3993
|
+
if (!unindexedRelationWarned.has(warnKey)) {
|
|
3994
|
+
const miss = (0, index_advisor_js_1.missingIndexForRelation)(this.schema, relDef);
|
|
3995
|
+
if (miss) {
|
|
3996
|
+
unindexedRelationWarned.add(warnKey);
|
|
3997
|
+
console.warn(`[turbine] Relation "${relDef.name}" on "${relDef.from}" probes ` +
|
|
3998
|
+
`"${miss.table}"(${miss.columns.join(', ')}) which has no covering index — ` +
|
|
3999
|
+
`each parent row scans the full table. Fix: ${miss.createSql}; ` +
|
|
4000
|
+
'or run `npx turbine doctor` for a full report.');
|
|
4001
|
+
}
|
|
4002
|
+
}
|
|
4003
|
+
}
|
|
3509
4004
|
// Generate a unique alias: t0, t1, t2, ...
|
|
3510
4005
|
const alias = `t${aliasCounter.n++}`;
|
|
3511
|
-
// Resolve which columns to include based on select/omit
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
.filter(([, v]) => v)
|
|
3516
|
-
.map(([k]) => targetMeta.columnMap[k] ?? (0, schema_js_1.camelToSnake)(k));
|
|
3517
|
-
targetColumns = selectedFields.filter((col) => targetMeta.allColumns.includes(col));
|
|
3518
|
-
}
|
|
3519
|
-
else if (spec !== true && spec.omit) {
|
|
3520
|
-
const omittedFields = new Set(Object.entries(spec.omit)
|
|
3521
|
-
.filter(([, v]) => v)
|
|
3522
|
-
.map(([k]) => targetMeta.columnMap[k] ?? (0, schema_js_1.camelToSnake)(k)));
|
|
3523
|
-
targetColumns = targetMeta.allColumns.filter((col) => !omittedFields.has(col));
|
|
3524
|
-
}
|
|
4006
|
+
// Resolve which columns to include based on select/omit. Shared with the
|
|
4007
|
+
// positional-shape builder so the emitted json_build_array column order and
|
|
4008
|
+
// the decode-side key order can never drift apart.
|
|
4009
|
+
const targetColumns = this.resolveTargetColumns(spec, targetMeta);
|
|
3525
4010
|
// Engine override seam (additive): a dialect whose JSON-aggregation shape does
|
|
3526
4011
|
// not map onto buildJsonObject/buildJsonArrayAgg (SQL Server FOR JSON PATH) owns
|
|
3527
4012
|
// the WHOLE subquery. Absent for PG/MySQL/SQLite → the native path below runs
|
|
@@ -3582,7 +4067,7 @@ class QueryInterface {
|
|
|
3582
4067
|
jsonPairs.push([nestedRelName, this.dialect.wrapJsonSubresult(nestedSubquery, fallback)]);
|
|
3583
4068
|
}
|
|
3584
4069
|
}
|
|
3585
|
-
const jsonObj = this.
|
|
4070
|
+
const jsonObj = this.buildJsonRow(jsonPairs);
|
|
3586
4071
|
// Quote parent ref — can be a table name or auto-generated alias
|
|
3587
4072
|
const qParent = this.q(parentRef);
|
|
3588
4073
|
const qTarget = this.q(targetTable);
|
|
@@ -3602,11 +4087,14 @@ class QueryInterface {
|
|
|
3602
4087
|
orderClause = ` ORDER BY ${orders}`;
|
|
3603
4088
|
}
|
|
3604
4089
|
// Build WHERE — correlate to parent via parentRef (alias or table name).
|
|
3605
|
-
// For hasMany:
|
|
3606
|
-
//
|
|
4090
|
+
// For hasMany/hasOne: TARGET has the FK (RelationDef.foreignKey is always
|
|
4091
|
+
// the child-side column), so alias.fk = parentRef.pk. hasOne is just
|
|
4092
|
+
// hasMany with a unique FK — treating it like belongsTo here silently
|
|
4093
|
+
// correlated the wrong columns (caught dogfooding: uuid = varchar).
|
|
4094
|
+
// For belongsTo: SOURCE has the FK, so alias.pk = parentRef.fk (reversed).
|
|
3607
4095
|
// Supports composite foreign keys (string[]) via buildCorrelation.
|
|
3608
4096
|
let whereClause;
|
|
3609
|
-
if (relDef.type === 'belongsTo'
|
|
4097
|
+
if (relDef.type === 'belongsTo') {
|
|
3610
4098
|
whereClause = this.dialect.buildCorrelation(alias, relDef.referenceKey, qParent, relDef.foreignKey);
|
|
3611
4099
|
}
|
|
3612
4100
|
else {
|
|
@@ -3655,7 +4143,7 @@ class QueryInterface {
|
|
|
3655
4143
|
innerJsonPairs.push([nestedRelName, this.dialect.wrapJsonSubresult(nestedSub, fallback)]);
|
|
3656
4144
|
}
|
|
3657
4145
|
}
|
|
3658
|
-
const innerJsonObj = this.
|
|
4146
|
+
const innerJsonObj = this.buildJsonRow(innerJsonPairs);
|
|
3659
4147
|
return `SELECT ${this.dialect.buildJsonArrayAgg(innerJsonObj)} FROM (${innerSql}) ${innerAlias}`;
|
|
3660
4148
|
}
|
|
3661
4149
|
// Inline ORDER BY only when the dialect's array-agg supports it (PG). For
|
|
@@ -3778,7 +4266,7 @@ class QueryInterface {
|
|
|
3778
4266
|
innerJsonPairs.push([nestedRelName, this.dialect.wrapJsonSubresult(nestedSub, fallback)]);
|
|
3779
4267
|
}
|
|
3780
4268
|
}
|
|
3781
|
-
const innerJsonObj = this.
|
|
4269
|
+
const innerJsonObj = this.buildJsonRow(innerJsonPairs);
|
|
3782
4270
|
return `SELECT ${this.dialect.buildJsonArrayAgg(innerJsonObj)} FROM (${innerSql}) ${innerAlias}`;
|
|
3783
4271
|
}
|
|
3784
4272
|
// Simple path: build the json object pairs directly off the target alias,
|
|
@@ -3801,7 +4289,7 @@ class QueryInterface {
|
|
|
3801
4289
|
jsonPairs.push([nestedRelName, this.dialect.wrapJsonSubresult(nestedSub, fallback)]);
|
|
3802
4290
|
}
|
|
3803
4291
|
}
|
|
3804
|
-
const jsonObj = this.
|
|
4292
|
+
const jsonObj = this.buildJsonRow(jsonPairs);
|
|
3805
4293
|
return `SELECT ${this.dialect.buildJsonArrayAgg(jsonObj)} ${fromJoin} WHERE ${whereClause}`;
|
|
3806
4294
|
}
|
|
3807
4295
|
/**
|