turbine-orm 0.25.0 → 0.27.0

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