turbine-orm 0.25.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.
@@ -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 ? this.parseNestedRow(row, this.table) : null;
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
- const distinctCols = args.distinct.map((k) => this.toSqlColumn(k));
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
- // Pass freshParams so vector KNN ordering binds its `$n::vector` query
843
- // vector at the correct position (after cursor params, before LIMIT).
844
- sql += ` ORDER BY ${this.buildOrderBy(args.orderBy, freshParams)}`;
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) => args?.with ? this.parseNestedRow(row, this.table) : this.parseRow(row, this.table)),
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 (hasRelations ? this.parseNestedRow(row, this.table) : this.parseRow(row, this.table));
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 (hasRelations ? this.parseNestedRow(row, this.table) : this.parseRow(row, this.table));
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(`some(${this.fingerprintRelFilter(relDef.to, filterObj.some)})`);
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(`every(${this.fingerprintRelFilter(relDef.to, filterObj.every)})`);
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(`none(${this.fingerprintRelFilter(relDef.to, filterObj.none)})`);
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(`is(${this.fingerprintRelFilter(relDef.to, filterObj.is)})`);
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(`isNot(${this.fingerprintRelFilter(relDef.to, filterObj.isNot)})`);
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(_targetTable, subWhere) {
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
- const subWhere = filterObj.is;
2866
- const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
2867
- const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
2868
- clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
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
- const subWhere = filterObj.isNot;
2873
- const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
2874
- const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
2875
- clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
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
- parsed[field] = new Date(value);
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
- let targetColumns = targetMeta.allColumns;
3513
- if (spec !== true && spec.select) {
3514
- const selectedFields = Object.entries(spec.select)
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.dialect.buildJsonObject(jsonPairs);
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: target has FK, so alias.fk = parentRef.pk
3606
- // For belongsTo: source has FK, so alias.pk = parentRef.fk (reversed)
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' || relDef.type === 'hasOne') {
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.dialect.buildJsonObject(innerJsonPairs);
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.dialect.buildJsonObject(innerJsonPairs);
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.dialect.buildJsonObject(jsonPairs);
4292
+ const jsonObj = this.buildJsonRow(jsonPairs);
3805
4293
  return `SELECT ${this.dialect.buildJsonArrayAgg(jsonObj)} ${fromJoin} WHERE ${whereClause}`;
3806
4294
  }
3807
4295
  /**