turbine-orm 0.29.0 → 0.31.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.
Files changed (48) hide show
  1. package/README.md +1 -1
  2. package/dist/cjs/cli/index.js +5 -0
  3. package/dist/cjs/cli/mcp.js +22 -92
  4. package/dist/cjs/client.js +47 -6
  5. package/dist/cjs/generate.js +71 -25
  6. package/dist/cjs/index.js +4 -1
  7. package/dist/cjs/introspect.js +350 -120
  8. package/dist/cjs/mssql.js +42 -136
  9. package/dist/cjs/mysql.js +16 -129
  10. package/dist/cjs/optional-peer-import.cjs +122 -0
  11. package/dist/cjs/powdb.js +579 -89
  12. package/dist/cjs/powql.js +56 -26
  13. package/dist/cjs/query/builder.js +601 -86
  14. package/dist/cjs/query/filters.js +80 -2
  15. package/dist/cjs/schema-metadata.js +316 -0
  16. package/dist/cjs/sqlite.js +8 -89
  17. package/dist/cli/index.d.ts +2 -0
  18. package/dist/cli/index.js +5 -0
  19. package/dist/cli/mcp.d.ts +18 -0
  20. package/dist/cli/mcp.js +22 -93
  21. package/dist/client.d.ts +19 -2
  22. package/dist/client.js +47 -6
  23. package/dist/generate.d.ts +16 -4
  24. package/dist/generate.js +71 -25
  25. package/dist/index.d.ts +2 -1
  26. package/dist/index.js +2 -0
  27. package/dist/introspect.d.ts +94 -1
  28. package/dist/introspect.js +345 -120
  29. package/dist/mssql.js +40 -104
  30. package/dist/mysql.js +14 -97
  31. package/dist/optional-peer-import.cjs +89 -0
  32. package/dist/optional-peer-import.d.cts +53 -0
  33. package/dist/powdb.d.ts +118 -23
  34. package/dist/powdb.js +574 -88
  35. package/dist/powql.d.ts +6 -0
  36. package/dist/powql.js +58 -28
  37. package/dist/query/builder.d.ts +145 -8
  38. package/dist/query/builder.js +602 -87
  39. package/dist/query/deferred.d.ts +7 -2
  40. package/dist/query/filters.d.ts +46 -1
  41. package/dist/query/filters.js +76 -1
  42. package/dist/query/index.d.ts +1 -1
  43. package/dist/query/types.d.ts +85 -11
  44. package/dist/schema-metadata.d.ts +77 -0
  45. package/dist/schema-metadata.js +313 -0
  46. package/dist/schema.d.ts +10 -0
  47. package/dist/sqlite.js +9 -90
  48. package/package.json +3 -3
@@ -16,7 +16,7 @@ import { missingIndexForRelation } from '../index-advisor.js';
16
16
  import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from '../nested-write.js';
17
17
  import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
18
18
  import { includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, resolveCountRelations, stripFields, } from './batched-loader.js';
19
- import { assertBindableEqualsOperand, findArrayUniqueKey, findJsonUniqueKey, fingerprintOperatorShape, isArrayFilter, isJsonFilter, isOrderBySpec, isTextSearchFilter, isUnmatchedPlainObject, isVectorFilter, isVectorOrderBy, isWhereOperator, normalizeOrderBy, sortedEntries, sortedKeys, UPDATE_OPERATOR_KEYS, VECTOR_DISTANCE_COMPARATORS, VECTOR_METRIC_OPERATORS, validateTextSearchConfig, } from './filters.js';
19
+ import { assertBindableEqualsOperand, findArrayUniqueKey, findJsonUniqueKey, fingerprintJsonFilterShape, fingerprintOperatorShape, isArrayFilter, isColumnRef, isJsonFilter, isJsonPathOrderBy, isOrderBySpec, isTextSearchFilter, isUnmatchedPlainObject, isVectorFilter, isVectorOrderBy, isWhereOperator, JSON_RANGE_OPERATORS, normalizeOrderBy, sortedEntries, sortedKeys, UPDATE_OPERATOR_KEYS, VECTOR_DISTANCE_COMPARATORS, VECTOR_METRIC_OPERATORS, validateTextSearchConfig, } from './filters.js';
20
20
  import { escapeLike, LRUCache, OPERATOR_KEYS, parseDbDate, sqlToPreparedName } from './utils.js';
21
21
  /** Relations already warned about missing FK indexes (once per process, dev only). */
22
22
  const unindexedRelationWarned = new Set();
@@ -61,6 +61,12 @@ export class QueryInterface {
61
61
  /** Pre-computed column type lookups (avoids linear scans per query) */
62
62
  columnPgTypeMap;
63
63
  columnArrayTypeMap;
64
+ /**
65
+ * Columns whose type lives in a DIFFERENT schema than the introspected one
66
+ * (ColumnMetadata.pgTypeSchema is recorded only in that case) — such columns
67
+ * must never receive this schema's `::"enum"` cast (see enumTypeForColumn).
68
+ */
69
+ crossSchemaTypeColumns;
64
70
  /** Tracks tables that have already triggered a deep-with warning (one-time) */
65
71
  deepWithWarned = new Set();
66
72
  /**
@@ -98,9 +104,19 @@ export class QueryInterface {
98
104
  this.middlewares = middlewares ?? [];
99
105
  this.defaultLimit = options?.defaultLimit;
100
106
  // Default to ON: surfacing accidental full-table scans is more valuable
101
- // than the (small) risk of noisy logs. Callers explicitly opt out with
102
- // `warnOnUnlimited: false`.
103
- this.warnOnUnlimited = options?.warnOnUnlimited !== false;
107
+ // than the (small) risk of noisy logs. Callers opt out globally with
108
+ // `warnOnUnlimited: false`, per table with `warnOnUnlimited: { users:
109
+ // false }` (unlisted tables keep the default), or per call via
110
+ // `findMany({ warnOnUnlimited: false })`.
111
+ // Per-table maps accept BOTH key forms — the snake_case table name
112
+ // (`user_profiles`) and the camelCase accessor (`userProfiles`) — since
113
+ // users naturally key by the accessor they type everywhere else. The
114
+ // snake_case entry wins when both are present.
115
+ const warnOpt = options?.warnOnUnlimited;
116
+ this.warnOnUnlimited =
117
+ typeof warnOpt === 'object' && warnOpt !== null
118
+ ? (warnOpt[table] ?? warnOpt[snakeToCamel(table)]) !== false
119
+ : warnOpt !== false;
104
120
  this.utcTimestamps = options?.utcTimestamps !== false;
105
121
  this.preparedStatementsEnabled = options?.preparedStatements ?? true;
106
122
  this.sqlCacheEnabled = options?.sqlCache !== false;
@@ -116,9 +132,12 @@ export class QueryInterface {
116
132
  // Pre-compute column type lookup maps (TASK-26)
117
133
  this.columnPgTypeMap = new Map();
118
134
  this.columnArrayTypeMap = new Map();
135
+ this.crossSchemaTypeColumns = new Set();
119
136
  for (const col of this.tableMeta.columns) {
120
137
  this.columnPgTypeMap.set(col.name, col.dialectType ?? col.pgType);
121
138
  this.columnArrayTypeMap.set(col.name, col.arrayType ?? col.pgArrayType);
139
+ if (col.pgTypeSchema !== undefined)
140
+ this.crossSchemaTypeColumns.add(col.name);
122
141
  }
123
142
  }
124
143
  /** Quote an identifier through the active SQL dialect. */
@@ -644,10 +663,16 @@ export class QueryInterface {
644
663
  * loop calling `db.users.findMany()` thousands of times only logs once.
645
664
  * Suppressed when `defaultLimit` is configured (the caller has already
646
665
  * opted in to a bounded query) and when the user passed an explicit
647
- * `limit`, `take`, or `cursor`.
666
+ * `limit`, `take`, or `cursor`. A per-call `warnOnUnlimited` overrides the
667
+ * config-level setting in either direction (`false` silences a call that
668
+ * intentionally reads the full set; `true` forces the warning even when
669
+ * disabled in config).
648
670
  */
649
671
  maybeWarnUnlimited(args) {
650
- if (!this.warnOnUnlimited)
672
+ const perCall = args?.warnOnUnlimited;
673
+ if (perCall === false)
674
+ return;
675
+ if (perCall === undefined && !this.warnOnUnlimited)
651
676
  return;
652
677
  if (this.defaultLimit !== undefined)
653
678
  return;
@@ -658,7 +683,7 @@ export class QueryInterface {
658
683
  return;
659
684
  this.warnedTables.add(this.table);
660
685
  console.warn(`[turbine] warning: findMany on "${this.table}" has no limit — this will fetch every row. ` +
661
- 'Pass `limit` or set `warnOnUnlimited: false` in config to silence.');
686
+ 'Pass `limit`, or silence with `warnOnUnlimited: false` (per call, per table, or in config).');
662
687
  }
663
688
  /**
664
689
  * Recursively measure the maximum depth of a `with` clause tree.
@@ -1007,7 +1032,8 @@ export class QueryInterface {
1007
1032
  const entries = Object.entries(args.data).filter(([, v]) => v !== undefined);
1008
1033
  const columns = entries.map(([k]) => this.toSqlColumn(k));
1009
1034
  const params = entries.map(([, v]) => v);
1010
- const placeholders = entries.map((_, i) => `${this.p(i + 1)}`);
1035
+ // Enum columns get an explicit `::"EnumName"` cast (see enumTypeForColumn).
1036
+ const placeholders = entries.map(([k], i) => `${this.p(i + 1)}${this.enumCastSuffix(this.toColumn(k))}`);
1011
1037
  const sql = this.dialect.buildInsertStatement({
1012
1038
  table: this.q(this.table),
1013
1039
  columns,
@@ -1089,7 +1115,13 @@ export class QueryInterface {
1089
1115
  return keys.map((key) => record[key]);
1090
1116
  });
1091
1117
  // Use actual Postgres types for array casts in the default PostgreSQL dialect.
1092
- const typeCasts = columns.map((col) => this.getColumnArrayType(col));
1118
+ // Enum columns cast to `"EnumName"[]` — the generic text[] fallback would
1119
+ // type the UNNEST output as text, which Postgres refuses to coerce to the
1120
+ // enum ("column X is of type Y but expression is of type text").
1121
+ const typeCasts = columns.map((col) => {
1122
+ const enumType = this.enumTypeForColumn(col);
1123
+ return enumType ? `${this.q(enumType)}[]` : this.getColumnArrayType(col);
1124
+ });
1093
1125
  const quotedColumns = columns.map((c) => this.q(c));
1094
1126
  const built = this.dialect.buildBulkInsertStatement({
1095
1127
  table: qt,
@@ -1363,7 +1395,8 @@ export class QueryInterface {
1363
1395
  const createEntries = Object.entries(args.create).filter(([, v]) => v !== undefined);
1364
1396
  const columns = createEntries.map(([k]) => this.toSqlColumn(k));
1365
1397
  const createParams = createEntries.map(([, v]) => v);
1366
- const placeholders = createEntries.map((_, i) => `${this.p(i + 1)}`);
1398
+ // Enum columns get an explicit `::"EnumName"` cast (see enumTypeForColumn).
1399
+ const placeholders = createEntries.map(([k], i) => `${this.p(i + 1)}${this.enumCastSuffix(this.toColumn(k))}`);
1367
1400
  // The conflict target comes from `where` keys — must be unique/PK columns
1368
1401
  const conflictKeys = Object.keys(args.where).filter((k) => args.where[k] !== undefined);
1369
1402
  const conflictColumns = conflictKeys.map((k) => this.toSqlColumn(k));
@@ -1371,7 +1404,7 @@ export class QueryInterface {
1371
1404
  const updateEntries = Object.entries(args.update).filter(([, v]) => v !== undefined);
1372
1405
  let paramIdx = createParams.length + 1;
1373
1406
  const setClauses = updateEntries.map(([k]) => {
1374
- const clause = `${this.toSqlColumn(k)} = ${this.p(paramIdx)}`;
1407
+ const clause = `${this.toSqlColumn(k)} = ${this.p(paramIdx)}${this.enumCastSuffix(this.toColumn(k))}`;
1375
1408
  paramIdx++;
1376
1409
  return clause;
1377
1410
  });
@@ -2051,6 +2084,10 @@ export class QueryInterface {
2051
2084
  */
2052
2085
  buildSetClause(key, value, params) {
2053
2086
  const col = this.toSqlColumn(key);
2087
+ // Enum columns get an explicit `::"EnumName"` cast on their value bind
2088
+ // (see enumTypeForColumn); `''` everywhere else. Value-invariant, so the
2089
+ // SQL cache and collectSetParams are unaffected.
2090
+ const cast = this.enumCastSuffix(this.toColumn(key));
2054
2091
  // Detect atomic-operator object: plain object (not null, not array, not
2055
2092
  // Date, not Buffer) with EXACTLY one key matching an operator name.
2056
2093
  if (value !== null &&
@@ -2065,7 +2102,7 @@ export class QueryInterface {
2065
2102
  const opValue = v[op];
2066
2103
  if (op === 'set') {
2067
2104
  params.push(opValue);
2068
- return `${col} = ${this.p(params.length)}`;
2105
+ return `${col} = ${this.p(params.length)}${cast}`;
2069
2106
  }
2070
2107
  // Arithmetic operators: must be finite numbers
2071
2108
  if (typeof opValue !== 'number' || !Number.isFinite(opValue)) {
@@ -2093,7 +2130,7 @@ export class QueryInterface {
2093
2130
  }
2094
2131
  // Plain value (including null, Date, Buffer, arrays, JSON objects)
2095
2132
  params.push(value);
2096
- return `${col} = ${this.p(params.length)}`;
2133
+ return `${col} = ${this.p(params.length)}${cast}`;
2097
2134
  }
2098
2135
  // =========================================================================
2099
2136
  // Fingerprinting — value-invariant shape keys for SQL cache lookup
@@ -2192,10 +2229,10 @@ export class QueryInterface {
2192
2229
  parts.push(`${key}:vec(${dist.metric},${cmps})`);
2193
2230
  continue;
2194
2231
  }
2195
- // JSON filter
2232
+ // JSON filter — range ops carry a numeric/string annotation because the
2233
+ // numeric compile emits a cast (different SQL shape).
2196
2234
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
2197
- const jKeys = Object.keys(value).sort();
2198
- parts.push(`${key}:json(${jKeys.join(',')})`);
2235
+ parts.push(`${key}:${fingerprintJsonFilterShape(value)}`);
2199
2236
  continue;
2200
2237
  }
2201
2238
  // Array filter
@@ -2281,6 +2318,15 @@ export class QueryInterface {
2281
2318
  else if (isWhereOperator(value)) {
2282
2319
  parts.push(`${key}:${fingerprintOperatorShape(value)}`);
2283
2320
  }
2321
+ else if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
2322
+ // Mirrors fingerprintWhere: JSON filters inside relation sub-wheres
2323
+ // build real JSON clauses (buildSubWhereForRelation), so their shape
2324
+ // must be cache-distinct from plain equality.
2325
+ parts.push(`${key}:${fingerprintJsonFilterShape(value)}`);
2326
+ }
2327
+ else if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
2328
+ parts.push(`${key}:arr(${this.fingerprintArrayFilter(value)})`);
2329
+ }
2284
2330
  else if (isUnmatchedPlainObject(value)) {
2285
2331
  parts.push(`${key}:obj(${Object.keys(value)
2286
2332
  .sort()
@@ -2358,7 +2404,7 @@ export class QueryInterface {
2358
2404
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
2359
2405
  const colType = this.getColumnPgType(rawColumn);
2360
2406
  if (colType === 'json' || colType === 'jsonb') {
2361
- this.collectJsonFilterParams(value, params);
2407
+ this.collectJsonFilterParams(value, params, this.q(rawColumn));
2362
2408
  continue;
2363
2409
  }
2364
2410
  }
@@ -2377,7 +2423,11 @@ export class QueryInterface {
2377
2423
  }
2378
2424
  // Operator objects
2379
2425
  if (isWhereOperator(value)) {
2380
- this.collectOperatorParams(rawColumn, value, params);
2426
+ this.collectOperatorParams(rawColumn, value, params, {
2427
+ meta: this.tableMeta,
2428
+ table: this.table,
2429
+ prefix: '',
2430
+ });
2381
2431
  continue;
2382
2432
  }
2383
2433
  // Plain equality — same strict validation as the build path, so a
@@ -2459,29 +2509,59 @@ export class QueryInterface {
2459
2509
  }
2460
2510
  }
2461
2511
  const col = meta.columnMap[field] ?? camelToSnake(field);
2512
+ // JSONB filter — mirrors buildSubWhereForRelation (which mirrors the
2513
+ // top-level buildWhereClause): route to the JSON param collector when
2514
+ // the target column is json/jsonb.
2515
+ if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
2516
+ const colType = this.pgTypeForColumn(meta, col);
2517
+ if (colType === 'json' || colType === 'jsonb') {
2518
+ this.collectJsonFilterParams(value, params, `${this.q(targetTable)}.${this.q(col)}`);
2519
+ continue;
2520
+ }
2521
+ }
2522
+ // Array filter — mirrors buildSubWhereForRelation.
2523
+ if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
2524
+ const colType = this.pgTypeForColumn(meta, col);
2525
+ if (colType.startsWith('_')) {
2526
+ this.collectArrayFilterParams(value, params);
2527
+ continue;
2528
+ }
2529
+ }
2462
2530
  if (isWhereOperator(value)) {
2463
- this.collectOperatorParams(col, value, params);
2531
+ this.collectOperatorParams(col, value, params, { meta, table: targetTable, prefix: '' });
2464
2532
  continue;
2465
2533
  }
2466
2534
  this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
2467
2535
  params.push(value);
2468
2536
  }
2469
2537
  }
2470
- /** Collect params from operator clauses. Mirrors buildOperatorClauses. */
2471
- collectOperatorParams(column, op, params) {
2472
- if (op.equals !== undefined && op.equals !== null) {
2538
+ /**
2539
+ * Collect params from operator clauses. Mirrors buildOperatorClauses:
2540
+ * {@link ColumnRef} values compile into the SQL text, so they push NOTHING -
2541
+ * but they re-run the same validation (unknown ref / insensitive mode) so a
2542
+ * warmed cache can never skip a check the build path enforces.
2543
+ */
2544
+ collectOperatorParams(column, op, params, refCtx) {
2545
+ const skipRef = (v) => {
2546
+ if (!isColumnRef(v))
2547
+ return false;
2548
+ if (refCtx)
2549
+ this.resolveColumnRef(v, refCtx, op.mode);
2550
+ return true;
2551
+ };
2552
+ if (op.equals !== undefined && op.equals !== null && !skipRef(op.equals)) {
2473
2553
  assertBindableEqualsOperand(op.equals, `"${column}"`);
2474
2554
  params.push(op.equals);
2475
2555
  }
2476
- if (op.gt !== undefined)
2556
+ if (op.gt !== undefined && !skipRef(op.gt))
2477
2557
  params.push(op.gt);
2478
- if (op.gte !== undefined)
2558
+ if (op.gte !== undefined && !skipRef(op.gte))
2479
2559
  params.push(op.gte);
2480
- if (op.lt !== undefined)
2560
+ if (op.lt !== undefined && !skipRef(op.lt))
2481
2561
  params.push(op.lt);
2482
- if (op.lte !== undefined)
2562
+ if (op.lte !== undefined && !skipRef(op.lte))
2483
2563
  params.push(op.lte);
2484
- if (op.not !== undefined && op.not !== null)
2564
+ if (op.not !== undefined && op.not !== null && !skipRef(op.not))
2485
2565
  params.push(op.not);
2486
2566
  if (op.in !== undefined)
2487
2567
  params.push(this.inParam(op.in));
@@ -2494,10 +2574,22 @@ export class QueryInterface {
2494
2574
  if (op.endsWith !== undefined)
2495
2575
  params.push(`%${escapeLike(op.endsWith)}`);
2496
2576
  }
2497
- /** Collect params from JSON filter. Mirrors buildJsonFilterClauses. */
2498
- collectJsonFilterParams(filter, params) {
2577
+ /**
2578
+ * Collect params from JSON filter. Mirrors buildJsonFilterClauses exactly:
2579
+ * the `path` is bound at most once (its placeholder is shared by every
2580
+ * extraction clause), then equals/contains/hasKey values, then the range
2581
+ * comparison values in {@link JSON_RANGE_OPERATORS} order.
2582
+ */
2583
+ collectJsonFilterParams(filter, params, column) {
2584
+ let pathPushed = false;
2585
+ const pushPathOnce = () => {
2586
+ if (!pathPushed) {
2587
+ params.push(filter.path);
2588
+ pathPushed = true;
2589
+ }
2590
+ };
2499
2591
  if (filter.path !== undefined && filter.equals !== undefined) {
2500
- params.push(filter.path);
2592
+ pushPathOnce();
2501
2593
  params.push(String(filter.equals));
2502
2594
  }
2503
2595
  else if (filter.equals !== undefined) {
@@ -2509,6 +2601,10 @@ export class QueryInterface {
2509
2601
  if (filter.hasKey !== undefined) {
2510
2602
  params.push(filter.hasKey);
2511
2603
  }
2604
+ for (const { value } of this.jsonRangeEntries(filter, column)) {
2605
+ pushPathOnce();
2606
+ params.push(value);
2607
+ }
2512
2608
  }
2513
2609
  /** Collect params from array filter. Mirrors buildArrayFilterClauses. */
2514
2610
  collectArrayFilterParams(filter, params) {
@@ -2521,10 +2617,10 @@ export class QueryInterface {
2521
2617
  // isEmpty has no params (IS NULL / IS NOT NULL)
2522
2618
  }
2523
2619
  /**
2524
- * Collect params for an orderBy clause. Only vector KNN ordering pushes a
2525
- * param (the `$n::vector` query vector); plain direction ordering is
2526
- * parameterless. Mirrors buildOrderBy's push order exactly so the cached-SQL
2527
- * param re-collection stays in lockstep.
2620
+ * Collect params for an orderBy clause. Vector KNN ordering pushes the
2621
+ * `$n::vector` query vector and JSON-path ordering pushes its text[] path;
2622
+ * plain direction ordering is parameterless. Mirrors buildOrderBy's push
2623
+ * order exactly so the cached-SQL param re-collection stays in lockstep.
2528
2624
  */
2529
2625
  collectOrderByParams(orderBy, params) {
2530
2626
  for (const [key, dir] of Object.entries(orderBy)) {
@@ -2536,6 +2632,13 @@ export class QueryInterface {
2536
2632
  this.pushVectorParam(key, rawColumn, dir.distance.to, params);
2537
2633
  continue;
2538
2634
  }
2635
+ // JSON-path ordering: mirrors buildJsonPathOrderEntry: same validation,
2636
+ // then the path bound as one text[] param.
2637
+ if (isJsonPathOrderBy(dir)) {
2638
+ this.validateJsonPathOrderBy(this.table, this.tableMeta, key, dir);
2639
+ params.push(dir.path.map(String));
2640
+ continue;
2641
+ }
2539
2642
  // To-many relation orderBy (`{ posts: { _count } }`) uses the same count
2540
2643
  // subquery as `_count` — mirror its global-filter params. To-one relation
2541
2644
  // orderBy carries the target's global filter once per ordered column.
@@ -2682,9 +2785,20 @@ export class QueryInterface {
2682
2785
  const targetMeta = this.schema.tables[targetTable];
2683
2786
  if (!targetMeta)
2684
2787
  return;
2788
+ // A dialect that owns the whole subquery (buildRelationSubquery override,
2789
+ // SQL Server FOR JSON) compiles orderBy through its OWN paging clause -
2790
+ // plain directions only, no order params: so the native order-param
2791
+ // mirrors below must stay off for it (its documented contract remains
2792
+ // where → limit → nested).
2793
+ const nativeOrderPath = !this.dialect.buildRelationSubquery;
2685
2794
  // manyToMany param order mirrors buildManyToManySubquery:
2686
- // where params → limit param → nested-with params (always, both paths).
2795
+ // orderBy params → where params → limit param → nested-with params
2796
+ // (always, both paths).
2687
2797
  if (relDef.type === 'manyToMany') {
2798
+ const m2mOrderEntries = spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
2799
+ if (nativeOrderPath && m2mOrderEntries.length > 0) {
2800
+ this.collectRelationOrderParams(targetTable, targetMeta, m2mOrderEntries, params);
2801
+ }
2688
2802
  if (spec.where) {
2689
2803
  this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
2690
2804
  }
@@ -2703,7 +2817,8 @@ export class QueryInterface {
2703
2817
  return;
2704
2818
  }
2705
2819
  // Mirrors buildRelationSubquery's willWrap: `orderBy: {}` is treated as absent.
2706
- const hasOrder = spec.orderBy ? Object.values(spec.orderBy).some((dir) => dir !== undefined) : false;
2820
+ const relOrderEntries = spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
2821
+ const hasOrder = relOrderEntries.length > 0;
2707
2822
  const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
2708
2823
  // Non-wrapped path: nested relations BEFORE where/limit
2709
2824
  if (!willWrap && spec.with) {
@@ -2714,6 +2829,12 @@ export class QueryInterface {
2714
2829
  this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
2715
2830
  }
2716
2831
  }
2832
+ // orderBy params (JSON paths / relation-order global filters): mirrors
2833
+ // buildRelationSubquery, which builds its ORDER BY terms BEFORE compiling
2834
+ // spec.where (both wrapped and non-wrapped paths).
2835
+ if (nativeOrderPath && hasOrder) {
2836
+ this.collectRelationOrderParams(targetTable, targetMeta, relOrderEntries, params);
2837
+ }
2717
2838
  // where params — mirrors buildAliasWhere push order
2718
2839
  if (spec.where) {
2719
2840
  this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
@@ -3064,7 +3185,11 @@ export class QueryInterface {
3064
3185
  }
3065
3186
  // Handle operator objects
3066
3187
  if (isWhereOperator(value)) {
3067
- const opClauses = this.buildOperatorClauses(column, value, params);
3188
+ const opClauses = this.buildOperatorClauses(column, value, params, {
3189
+ meta: this.tableMeta,
3190
+ table: this.table,
3191
+ prefix: '',
3192
+ });
3068
3193
  andClauses.push(...opClauses);
3069
3194
  continue;
3070
3195
  }
@@ -3228,8 +3353,41 @@ export class QueryInterface {
3228
3353
  conditions.push(`${qCol} IS NULL`);
3229
3354
  continue;
3230
3355
  }
3356
+ // JSONB filter on a json/jsonb column of the relation target — mirrors
3357
+ // the top-level WHERE path (buildWhereClause). Without this branch the
3358
+ // filter object used to fall through to plain equality and bind as a
3359
+ // jsonb value, silently matching nothing.
3360
+ if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
3361
+ const colType = this.pgTypeForColumn(meta, col);
3362
+ if (colType === 'json' || colType === 'jsonb') {
3363
+ conditions.push(...this.buildJsonFilterClauses(qCol, value, params));
3364
+ continue;
3365
+ }
3366
+ const jsonKey = findJsonUniqueKey(value);
3367
+ if (jsonKey) {
3368
+ throw new ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not a JSON column ` +
3369
+ `(actual type: ${colType}); cannot apply JSON operator '${jsonKey}'.`);
3370
+ }
3371
+ }
3372
+ // Array filter on an array column of the relation target — same mirror.
3373
+ if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
3374
+ const colType = this.pgTypeForColumn(meta, col);
3375
+ if (colType.startsWith('_')) {
3376
+ conditions.push(...this.buildArrayFilterClauses(qCol, value, params, colType));
3377
+ continue;
3378
+ }
3379
+ const arrayKey = findArrayUniqueKey(value);
3380
+ if (arrayKey) {
3381
+ throw new ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not an array column ` +
3382
+ `(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
3383
+ }
3384
+ }
3231
3385
  if (isWhereOperator(value)) {
3232
- const opClauses = this.buildOperatorClauses(qCol, value, params);
3386
+ const opClauses = this.buildOperatorClauses(qCol, value, params, {
3387
+ meta,
3388
+ table: targetTable,
3389
+ prefix: `${qt}.`,
3390
+ });
3233
3391
  conditions.push(...opClauses);
3234
3392
  continue;
3235
3393
  }
@@ -3246,6 +3404,49 @@ export class QueryInterface {
3246
3404
  pgTypeForColumn(meta, column) {
3247
3405
  return meta.dialectTypes?.[column] ?? meta.pgTypes?.[column] ?? 'text';
3248
3406
  }
3407
+ /**
3408
+ * The Postgres enum type name for a column, when the schema knows one.
3409
+ *
3410
+ * Introspection stores each column's `udt_name` in `pgTypes` and every
3411
+ * database enum in `schema.enums` (typname → labels); a column whose type
3412
+ * matches an enum key needs an explicit `::"EnumName"` cast on its write
3413
+ * binds — bulk-insert forms like `UNNEST($1::text[])` otherwise type the
3414
+ * value as text and Postgres refuses the implicit text→enum coercion
3415
+ * ("column X is of type Y but expression is of type text").
3416
+ *
3417
+ * Postgres-only by construction: gated on the active dialect being
3418
+ * `postgresql` AND on `schema.enums` having entries (only PG introspection
3419
+ * produces them — `defineSchema` and the other engines leave it empty), so
3420
+ * SQLite/MySQL/MSSQL/PowDB output is byte-identical.
3421
+ */
3422
+ enumTypeForColumn(column) {
3423
+ if (this.dialect.name !== 'postgresql')
3424
+ return null;
3425
+ const enums = this.schema.enums;
3426
+ if (!enums)
3427
+ return null;
3428
+ // Cross-schema guard (N-5): introspection records pgTypeSchema ONLY when
3429
+ // the column's type lives OUTSIDE the introspected schema. A same-named
3430
+ // enum in another schema must not get this schema's cast — search_path
3431
+ // would resolve `::"status"` to the wrong type. Skipping the cast restores
3432
+ // the pre-cast behavior for such columns. Columns without pgTypeSchema
3433
+ // (same-schema types, defineSchema/legacy metadata) keep the cast.
3434
+ if (this.crossSchemaTypeColumns.has(column))
3435
+ return null;
3436
+ const pgType = this.columnPgTypeMap.get(column) ?? this.tableMeta.pgTypes?.[column];
3437
+ if (!pgType || pgType.startsWith('_'))
3438
+ return null;
3439
+ return Object.hasOwn(enums, pgType) ? pgType : null;
3440
+ }
3441
+ /**
3442
+ * `::"EnumName"` cast suffix for a write-bind placeholder on an enum
3443
+ * column; `''` for every other column, so non-enum SQL stays byte-identical.
3444
+ * The type name is an introspected identifier and is quoted via the dialect.
3445
+ */
3446
+ enumCastSuffix(column) {
3447
+ const enumType = this.enumTypeForColumn(column);
3448
+ return enumType ? `::${this.q(enumType)}` : '';
3449
+ }
3249
3450
  /**
3250
3451
  * Equality-fallthrough guard shared by every SQL-build path AND every
3251
3452
  * cache-hit param-collect path. A plain object literal that matched no known
@@ -3324,8 +3525,40 @@ export class QueryInterface {
3324
3525
  clauses.push(`${qCol} IS NULL`);
3325
3526
  continue;
3326
3527
  }
3528
+ // JSONB filter on a json/jsonb column — mirrors the top-level WHERE path
3529
+ // (buildWhereClause) so a `with.where` JSON filter is never silently
3530
+ // bound as a plain equality value.
3531
+ if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
3532
+ const colType = this.pgTypeForColumn(targetMeta, col);
3533
+ if (colType === 'json' || colType === 'jsonb') {
3534
+ clauses.push(...this.buildJsonFilterClauses(qCol, value, params));
3535
+ continue;
3536
+ }
3537
+ const jsonKey = findJsonUniqueKey(value);
3538
+ if (jsonKey) {
3539
+ throw new ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not a JSON column ` +
3540
+ `(actual type: ${colType}); cannot apply JSON operator '${jsonKey}'.`);
3541
+ }
3542
+ }
3543
+ // Array filter on an array column — same mirror.
3544
+ if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
3545
+ const colType = this.pgTypeForColumn(targetMeta, col);
3546
+ if (colType.startsWith('_')) {
3547
+ clauses.push(...this.buildArrayFilterClauses(qCol, value, params, colType));
3548
+ continue;
3549
+ }
3550
+ const arrayKey = findArrayUniqueKey(value);
3551
+ if (arrayKey) {
3552
+ throw new ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not an array column ` +
3553
+ `(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
3554
+ }
3555
+ }
3327
3556
  if (isWhereOperator(value)) {
3328
- clauses.push(...this.buildOperatorClauses(qCol, value, params));
3557
+ clauses.push(...this.buildOperatorClauses(qCol, value, params, {
3558
+ meta: targetMeta,
3559
+ table: targetTable,
3560
+ prefix: `${alias}.`,
3561
+ }));
3329
3562
  continue;
3330
3563
  }
3331
3564
  this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
@@ -3367,8 +3600,24 @@ export class QueryInterface {
3367
3600
  }
3368
3601
  }
3369
3602
  const col = targetMeta.columnMap[key] ?? camelToSnake(key);
3603
+ // JSONB filter — mirrors buildAliasWhere.
3604
+ if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
3605
+ const colType = this.pgTypeForColumn(targetMeta, col);
3606
+ if (colType === 'json' || colType === 'jsonb') {
3607
+ this.collectJsonFilterParams(value, params, this.q(col));
3608
+ continue;
3609
+ }
3610
+ }
3611
+ // Array filter — mirrors buildAliasWhere.
3612
+ if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
3613
+ const colType = this.pgTypeForColumn(targetMeta, col);
3614
+ if (colType.startsWith('_')) {
3615
+ this.collectArrayFilterParams(value, params);
3616
+ continue;
3617
+ }
3618
+ }
3370
3619
  if (isWhereOperator(value)) {
3371
- this.collectOperatorParams(col, value, params);
3620
+ this.collectOperatorParams(col, value, params, { meta: targetMeta, table: targetTable, prefix: '' });
3372
3621
  continue;
3373
3622
  }
3374
3623
  this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
@@ -3425,6 +3674,16 @@ export class QueryInterface {
3425
3674
  parts.push(`${key}:${fingerprintOperatorShape(value)}`);
3426
3675
  continue;
3427
3676
  }
3677
+ // JSON / array filters build real clauses in buildAliasWhere, so their
3678
+ // shape must be cache-distinct from plain equality (mirrors fingerprintWhere).
3679
+ if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
3680
+ parts.push(`${key}:${fingerprintJsonFilterShape(value)}`);
3681
+ continue;
3682
+ }
3683
+ if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
3684
+ parts.push(`${key}:arr(${this.fingerprintArrayFilter(value)})`);
3685
+ continue;
3686
+ }
3428
3687
  if (isUnmatchedPlainObject(value)) {
3429
3688
  parts.push(`${key}:obj(${Object.keys(value)
3430
3689
  .sort()
@@ -3435,16 +3694,55 @@ export class QueryInterface {
3435
3694
  }
3436
3695
  return parts.join('&');
3437
3696
  }
3697
+ /**
3698
+ * Validate a `{ col }` column reference against its table and return the
3699
+ * resolved snake_case column name. Shared by the SQL-build path
3700
+ * ({@link buildOperatorClauses}) and the cache-hit param-collect path
3701
+ * (`collectOperatorParams`) so both always throw identically: a warmed
3702
+ * cache can never skip the check.
3703
+ */
3704
+ resolveColumnRef(ref, ctx, mode) {
3705
+ if (mode === 'insensitive') {
3706
+ throw new ValidationError(`[turbine] mode: 'insensitive' cannot be combined with a column reference ({ col: "${ref.col}" }). ` +
3707
+ `Case-insensitive column-to-column comparison is not supported: use client.sql\`...\` ` +
3708
+ `for lower(a) = lower(b).`);
3709
+ }
3710
+ const col = ctx.meta.columnMap[ref.col] ?? camelToSnake(ref.col);
3711
+ if (!ctx.meta.allColumns.includes(col)) {
3712
+ throw new ValidationError(`[turbine] Unknown field "${ref.col}" referenced by { col } in where on table "${ctx.table}". ` +
3713
+ `Known fields: ${Object.keys(ctx.meta.columnMap).join(', ') || '(none)'}.`);
3714
+ }
3715
+ return col;
3716
+ }
3717
+ /**
3718
+ * Compile a `{ col }` reference to its quoted, prefix-matched SQL identifier.
3719
+ * NO param is bound: the referenced column is part of the SQL text (and of
3720
+ * the where fingerprint, see {@link fingerprintOperatorShape}).
3721
+ */
3722
+ columnRefSql(ref, ctx, mode) {
3723
+ if (!ctx) {
3724
+ throw new ValidationError(`[turbine] Column reference { col: "${ref.col}" } is not supported in this filter context.`);
3725
+ }
3726
+ return `${ctx.prefix}${this.q(this.resolveColumnRef(ref, ctx, mode))}`;
3727
+ }
3438
3728
  /**
3439
3729
  * Build SQL clauses for a single operator object on a column.
3440
3730
  * Each operator key becomes its own clause, all ANDed together.
3731
+ *
3732
+ * `equals`/`not`/`gt`/`gte`/`lt`/`lte` also accept a {@link ColumnRef}
3733
+ * (`{ col: 'otherField' }`) which compiles to a column-to-column comparison
3734
+ * against `refCtx`: no param bound, so `collectOperatorParams` mirrors by
3735
+ * pushing nothing and the referenced name lives in the fingerprint.
3441
3736
  */
3442
- buildOperatorClauses(column, op, params) {
3737
+ buildOperatorClauses(column, op, params, refCtx) {
3443
3738
  const clauses = [];
3444
3739
  if (op.equals !== undefined) {
3445
3740
  if (op.equals === null) {
3446
3741
  clauses.push(`${column} IS NULL`);
3447
3742
  }
3743
+ else if (isColumnRef(op.equals)) {
3744
+ clauses.push(`${column} = ${this.columnRefSql(op.equals, refCtx, op.mode)}`);
3745
+ }
3448
3746
  else {
3449
3747
  assertBindableEqualsOperand(op.equals, column);
3450
3748
  params.push(op.equals);
@@ -3452,25 +3750,48 @@ export class QueryInterface {
3452
3750
  }
3453
3751
  }
3454
3752
  if (op.gt !== undefined) {
3455
- params.push(op.gt);
3456
- clauses.push(`${column} > ${this.p(params.length)}`);
3753
+ if (isColumnRef(op.gt)) {
3754
+ clauses.push(`${column} > ${this.columnRefSql(op.gt, refCtx, op.mode)}`);
3755
+ }
3756
+ else {
3757
+ params.push(op.gt);
3758
+ clauses.push(`${column} > ${this.p(params.length)}`);
3759
+ }
3457
3760
  }
3458
3761
  if (op.gte !== undefined) {
3459
- params.push(op.gte);
3460
- clauses.push(`${column} >= ${this.p(params.length)}`);
3762
+ if (isColumnRef(op.gte)) {
3763
+ clauses.push(`${column} >= ${this.columnRefSql(op.gte, refCtx, op.mode)}`);
3764
+ }
3765
+ else {
3766
+ params.push(op.gte);
3767
+ clauses.push(`${column} >= ${this.p(params.length)}`);
3768
+ }
3461
3769
  }
3462
3770
  if (op.lt !== undefined) {
3463
- params.push(op.lt);
3464
- clauses.push(`${column} < ${this.p(params.length)}`);
3771
+ if (isColumnRef(op.lt)) {
3772
+ clauses.push(`${column} < ${this.columnRefSql(op.lt, refCtx, op.mode)}`);
3773
+ }
3774
+ else {
3775
+ params.push(op.lt);
3776
+ clauses.push(`${column} < ${this.p(params.length)}`);
3777
+ }
3465
3778
  }
3466
3779
  if (op.lte !== undefined) {
3467
- params.push(op.lte);
3468
- clauses.push(`${column} <= ${this.p(params.length)}`);
3780
+ if (isColumnRef(op.lte)) {
3781
+ clauses.push(`${column} <= ${this.columnRefSql(op.lte, refCtx, op.mode)}`);
3782
+ }
3783
+ else {
3784
+ params.push(op.lte);
3785
+ clauses.push(`${column} <= ${this.p(params.length)}`);
3786
+ }
3469
3787
  }
3470
3788
  if (op.not !== undefined) {
3471
3789
  if (op.not === null) {
3472
3790
  clauses.push(`${column} IS NOT NULL`);
3473
3791
  }
3792
+ else if (isColumnRef(op.not)) {
3793
+ clauses.push(`${column} != ${this.columnRefSql(op.not, refCtx, op.mode)}`);
3794
+ }
3474
3795
  else {
3475
3796
  params.push(op.not);
3476
3797
  clauses.push(`${column} != ${this.p(params.length)}`);
@@ -3521,6 +3842,11 @@ export class QueryInterface {
3521
3842
  if (isVectorOrderBy(d)) {
3522
3843
  return `vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
3523
3844
  }
3845
+ // JSON-path ordering: direction, cast kind, and nulls placement change the
3846
+ // SQL text; the path itself is a bound param and stays OUT of the key.
3847
+ if (isJsonPathOrderBy(d)) {
3848
+ return `jp(${d.direction ?? 'asc'},${d.type === 'numeric' ? 'num' : 'text'},${d.nulls ?? ''})`;
3849
+ }
3524
3850
  if (isOrderBySpec(d))
3525
3851
  return `spec(${d.sort},${d.nulls ?? ''})`;
3526
3852
  if (d && typeof d === 'object') {
@@ -3566,6 +3892,11 @@ export class QueryInterface {
3566
3892
  const safeDir = value.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3567
3893
  return `${this.q(rawColumn)} ${operator} ${placeholder} ${safeDir}`;
3568
3894
  }
3895
+ // JSON-path ordering: { path: [...], direction?, type?, nulls? } on a
3896
+ // json/jsonb column of THIS table. Path is bound as one text[] param.
3897
+ if (isJsonPathOrderBy(value)) {
3898
+ return this.buildJsonPathOrderEntry(this.table, this.tableMeta, key, value, '', params);
3899
+ }
3569
3900
  // Relation ordering: an object value that is not a vector or OrderBySpec,
3570
3901
  // keyed by a relation name (`{ posts: { _count: 'desc' } }` / `{ author:
3571
3902
  // { name: 'asc' } }`).
@@ -3592,6 +3923,7 @@ export class QueryInterface {
3592
3923
  value !== null &&
3593
3924
  !Array.isArray(value) &&
3594
3925
  !isVectorOrderBy(value) &&
3926
+ !isJsonPathOrderBy(value) &&
3595
3927
  !isOrderBySpec(value));
3596
3928
  }
3597
3929
  /**
@@ -3608,6 +3940,63 @@ export class QueryInterface {
3608
3940
  }
3609
3941
  return nulls === 'first' ? ' NULLS FIRST' : ' NULLS LAST';
3610
3942
  }
3943
+ /**
3944
+ * Resolve an orderBy key to its snake_case column via the table's columnMap
3945
+ * (camelToSnake fallback), throwing the SAME unknown-field E003 the top-level
3946
+ * where path uses. Shared by top-level JSON-path ordering and every nested
3947
+ * relation orderBy path so nested orderBy accepts exactly what top-level
3948
+ * accepts (the 0.30.x bug: nested orderBy skipped the columnMap and rejected
3949
+ * camelCase-named DB columns like "sortOrder").
3950
+ */
3951
+ resolveOrderByColumn(table, meta, key) {
3952
+ const col = meta.columnMap[key] ?? camelToSnake(key);
3953
+ if (!meta.allColumns.includes(col)) {
3954
+ throw new ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${table}". ` +
3955
+ `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
3956
+ }
3957
+ return col;
3958
+ }
3959
+ /**
3960
+ * Validate a {@link JsonPathOrderBy} entry: column must exist AND be
3961
+ * json/jsonb, path must be a non-empty array of keys/indexes: and return
3962
+ * the resolved column. Shared by the SQL-build path
3963
+ * ({@link buildJsonPathOrderEntry}) and the cache-hit param-collect mirrors
3964
+ * so both always throw identically.
3965
+ */
3966
+ validateJsonPathOrderBy(table, meta, field, spec) {
3967
+ const col = this.resolveOrderByColumn(table, meta, field);
3968
+ if (spec.path.length === 0 ||
3969
+ spec.path.some((el) => typeof el !== 'string' && !(typeof el === 'number' && Number.isFinite(el)))) {
3970
+ throw new ValidationError(`[turbine] JSON-path orderBy on "${field}" (table "${table}") requires a non-empty \`path\` array ` +
3971
+ `of keys/indexes (e.g. { path: ['weight'], direction: 'asc' }).`);
3972
+ }
3973
+ const colType = this.pgTypeForColumn(meta, col);
3974
+ if (colType !== 'json' && colType !== 'jsonb') {
3975
+ throw new ValidationError(`[turbine] JSON-path orderBy on "${field}": column "${col}" on table "${table}" is not a JSON column ` +
3976
+ `(actual type: ${colType}).`);
3977
+ }
3978
+ return col;
3979
+ }
3980
+ /**
3981
+ * Compile one {@link JsonPathOrderBy} entry:
3982
+ * `("col" #>> $n::text[])::numeric ASC`: the numeric cast only with
3983
+ * `type: 'numeric'` (default is text comparison), the extraction routed
3984
+ * through the dialect's JSON hook exactly like the JSON where-filters, the
3985
+ * path bound as ONE text[] param (mirrored by the order-param collectors).
3986
+ * `prefix` scopes the column (`''` top-level, `t0.` inside a relation
3987
+ * subquery).
3988
+ */
3989
+ buildJsonPathOrderEntry(table, meta, field, spec, prefix, params) {
3990
+ const col = this.validateJsonPathOrderBy(table, meta, field, spec);
3991
+ if (!params) {
3992
+ throw new ValidationError(`[turbine] JSON-path ordering on "${field}" is not supported in this orderBy context.`);
3993
+ }
3994
+ params.push(spec.path.map(String));
3995
+ const extract = this.dialect.buildJsonPathExtract(`${prefix}${this.q(col)}`, this.p(params.length));
3996
+ const lhs = spec.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
3997
+ const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3998
+ return `${lhs} ${dir}${this.nullsSuffix(spec.nulls)}`;
3999
+ }
3611
4000
  /**
3612
4001
  * Compile a relation ordering term. For a to-many relation the only allowed
3613
4002
  * key is `_count`, which becomes a correlated `COUNT(*)` subquery. For a
@@ -3616,12 +4005,19 @@ export class QueryInterface {
3616
4005
  *
3617
4006
  * Validation: relation must exist (E005); to-many only allows `_count`, and
3618
4007
  * to-one only allows real target columns (E003).
4008
+ *
4009
+ * `ctx` generalizes the term beyond the root table: inside a relation
4010
+ * subquery's orderBy the relations live on the TARGET table's metadata and
4011
+ * the correlation parent is the relation's alias, not `this.table`.
3619
4012
  */
3620
- buildRelationOrderBy(relName, value, alias, params) {
3621
- const relDef = this.tableMeta.relations[relName];
4013
+ buildRelationOrderBy(relName, value, alias, params, ctx) {
4014
+ const ownerMeta = ctx?.meta ?? this.tableMeta;
4015
+ const ownerTable = ctx?.table ?? this.table;
4016
+ const parentRef = ctx?.parentRef ?? this.table;
4017
+ const relDef = ownerMeta.relations[relName];
3622
4018
  if (!relDef) {
3623
- throw new RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${this.table}". ` +
3624
- `Available: ${Object.keys(this.tableMeta.relations).join(', ')}`);
4019
+ throw new RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${ownerTable}". ` +
4020
+ `Available: ${Object.keys(ownerMeta.relations).join(', ')}`);
3625
4021
  }
3626
4022
  // To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
3627
4023
  if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
@@ -3631,14 +4027,14 @@ export class QueryInterface {
3631
4027
  `(got: ${keys.join(', ') || '(empty)'}).`);
3632
4028
  }
3633
4029
  const { dir } = normalizeOrderBy(value._count);
3634
- return `${this.buildRelationCountExpr(relDef, this.table, alias, params)} ${dir}`;
4030
+ return `${this.buildRelationCountExpr(relDef, parentRef, alias, params)} ${dir}`;
3635
4031
  }
3636
4032
  // To-one: each entry orders by a correlated scalar subquery on a target column.
3637
4033
  const targetMeta = this.schema.tables[relDef.to];
3638
4034
  if (!targetMeta)
3639
4035
  throw new RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
3640
4036
  const qTarget = this.q(relDef.to);
3641
- const qParent = this.q(this.table);
4037
+ const qParent = this.q(parentRef);
3642
4038
  // belongsTo: alias.referenceKey = parent.foreignKey; hasOne: reversed.
3643
4039
  const correlation = relDef.type === 'belongsTo'
3644
4040
  ? this.dialect.buildCorrelation(alias, relDef.referenceKey, qParent, relDef.foreignKey)
@@ -3649,7 +4045,9 @@ export class QueryInterface {
3649
4045
  }
3650
4046
  return entries
3651
4047
  .map(([col, dirValue]) => {
3652
- const snakeCol = camelToSnake(col);
4048
+ // columnMap-first resolution (camelToSnake fallback): mirrors the
4049
+ // scalar orderBy path so camelCase-named DB columns resolve here too.
4050
+ const snakeCol = targetMeta.columnMap[col] ?? camelToSnake(col);
3653
4051
  if (!targetMeta.allColumns.includes(snakeCol)) {
3654
4052
  throw new ValidationError(`[turbine] Unknown column "${col}" in orderBy on relation "${relName}" (table "${relDef.to}").`);
3655
4053
  }
@@ -3667,6 +4065,74 @@ export class QueryInterface {
3667
4065
  })
3668
4066
  .join(', ');
3669
4067
  }
4068
+ /**
4069
+ * Compile the ORDER BY terms of a relation `with` clause against the
4070
+ * relation's table alias. One unified path for every relation shape
4071
+ * (hasMany / manyToMany / belongsTo / hasOne) supporting exactly what the
4072
+ * top-level orderBy accepts at this level:
4073
+ *
4074
+ * - scalar columns via columnMap resolution (camelToSnake fallback) with
4075
+ * {@link OrderBySpec} nulls placement,
4076
+ * - {@link JsonPathOrderBy} entries (path bound as one text[] param),
4077
+ * - relation ordering on the TARGET's relations (`_count` for to-many, a
4078
+ * target column for to-one), correlated to the relation alias,
4079
+ * - vector KNN ordering stays top-level-only (E003, same as before).
4080
+ *
4081
+ * Param pushes (JSON paths, relation-order global filters) MUST be mirrored,
4082
+ * in the same order, by {@link collectRelationOrderParams}.
4083
+ */
4084
+ buildRelationOrderClause(targetTable, targetMeta, alias, orderEntries, params) {
4085
+ let relOrdCounter = 0;
4086
+ const orders = orderEntries
4087
+ .map(([key, dirValue]) => {
4088
+ if (isVectorOrderBy(dirValue)) {
4089
+ throw new ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
4090
+ }
4091
+ if (isJsonPathOrderBy(dirValue)) {
4092
+ return this.buildJsonPathOrderEntry(targetTable, targetMeta, key, dirValue, `${alias}.`, params);
4093
+ }
4094
+ if (this.isRelationOrderByValue(dirValue)) {
4095
+ return this.buildRelationOrderBy(key, dirValue, `${alias}ord${relOrdCounter++}`, params, { meta: targetMeta, table: targetTable, parentRef: alias });
4096
+ }
4097
+ const col = this.resolveOrderByColumn(targetTable, targetMeta, key);
4098
+ const { dir, nulls } = normalizeOrderBy(dirValue);
4099
+ return `${alias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
4100
+ })
4101
+ .join(', ');
4102
+ return ` ORDER BY ${orders}`;
4103
+ }
4104
+ /**
4105
+ * Param-collect mirror of {@link buildRelationOrderClause}: JSON-path
4106
+ * entries push their path (one text[] param each); relation-order entries
4107
+ * mirror {@link collectOrderByParams}' relation branch (count / to-one
4108
+ * global-filter params); scalar entries push nothing but re-run the same
4109
+ * column validation so a warmed cache can never skip it.
4110
+ */
4111
+ collectRelationOrderParams(targetTable, targetMeta, orderEntries, params) {
4112
+ for (const [key, dirValue] of orderEntries) {
4113
+ if (isVectorOrderBy(dirValue)) {
4114
+ throw new ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
4115
+ }
4116
+ if (isJsonPathOrderBy(dirValue)) {
4117
+ this.validateJsonPathOrderBy(targetTable, targetMeta, key, dirValue);
4118
+ params.push(dirValue.path.map(String));
4119
+ continue;
4120
+ }
4121
+ if (this.isRelationOrderByValue(dirValue)) {
4122
+ const relDef = targetMeta.relations[key];
4123
+ if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
4124
+ this.collectRelationCountParams(relDef, params);
4125
+ }
4126
+ else if (relDef) {
4127
+ for (const _col of Object.keys(dirValue)) {
4128
+ this.collectTargetGlobalFilterAlias(relDef.to, params);
4129
+ }
4130
+ }
4131
+ continue;
4132
+ }
4133
+ this.resolveOrderByColumn(targetTable, targetMeta, key);
4134
+ }
4135
+ }
3670
4136
  /**
3671
4137
  * Build a correlated `(SELECT COUNT(*) …)` scalar subquery for a to-many
3672
4138
  * relation, correlated to `parentRef`. hasMany counts child rows via the FK;
@@ -4386,20 +4852,13 @@ export class QueryInterface {
4386
4852
  // Quote parent ref — can be a table name or auto-generated alias
4387
4853
  const qParent = this.q(parentRef);
4388
4854
  const qTarget = this.q(targetTable);
4389
- // Build ORDER BY for json_agg
4855
+ // Build ORDER BY for json_agg: unified with the top-level orderBy surface
4856
+ // (columnMap resolution, OrderBySpec nulls, JSON-path, relation ordering).
4857
+ // Param pushes here land BEFORE the spec.where params, mirrored by
4858
+ // collectRelationSubqueryParams.
4390
4859
  let orderClause = '';
4391
4860
  if (relOrderEntries.length > 0) {
4392
- const orders = relOrderEntries
4393
- .map(([k, dirValue]) => {
4394
- const col = camelToSnake(k);
4395
- if (!targetMeta.allColumns.includes(col)) {
4396
- throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
4397
- }
4398
- const { dir, nulls } = normalizeOrderBy(dirValue);
4399
- return `${alias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
4400
- })
4401
- .join(', ');
4402
- orderClause = ` ORDER BY ${orders}`;
4861
+ orderClause = this.buildRelationOrderClause(targetTable, targetMeta, alias, relOrderEntries, params);
4403
4862
  }
4404
4863
  // Build WHERE — correlate to parent via parentRef (alias or table name).
4405
4864
  // For hasMany/hasOne: TARGET has the FK (RelationDef.foreignKey is always
@@ -4473,8 +4932,10 @@ export class QueryInterface {
4473
4932
  const inlineOrder = this.dialect.aggSupportsInlineOrderBy ? orderClause.trim() || undefined : undefined;
4474
4933
  return `SELECT ${this.dialect.buildJsonArrayAgg(jsonObj, inlineOrder)} FROM ${qTarget} ${alias} WHERE ${whereClause}`;
4475
4934
  }
4476
- // belongsTo / hasOne — return single object
4477
- return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause} LIMIT 1`;
4935
+ // belongsTo / hasOne: return single object. An orderBy picks WHICH row
4936
+ // the LIMIT 1 keeps (deterministic hasOne over a non-unique FK): matching
4937
+ // the batched strategy, which orders its flat follow-up and takes bucket[0].
4938
+ return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause}${orderClause} LIMIT 1`;
4478
4939
  }
4479
4940
  /**
4480
4941
  * Build the json_agg subquery for a `manyToMany` relation, JOINing the target
@@ -4532,22 +4993,15 @@ export class QueryInterface {
4532
4993
  let whereClause = sourceKeys
4533
4994
  .map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
4534
4995
  .join(' AND ');
4535
- // ORDER BY on the target rows. `orderBy: {}` (no defined entries) is
4536
- // treated as absent — it must not render a dangling `ORDER BY `.
4996
+ // ORDER BY on the target rows: unified with the top-level orderBy surface
4997
+ // (columnMap resolution, OrderBySpec nulls, JSON-path, relation ordering).
4998
+ // `orderBy: {}` (no defined entries) is treated as absent: it must not
4999
+ // render a dangling `ORDER BY `. Param pushes here land BEFORE the
5000
+ // spec.where params, mirrored by collectRelationSubqueryParams' m2m branch.
4537
5001
  const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
4538
5002
  let orderClause = '';
4539
5003
  if (relOrderEntries.length > 0) {
4540
- const orders = relOrderEntries
4541
- .map(([k, dirValue]) => {
4542
- const col = camelToSnake(k);
4543
- if (!targetMeta.allColumns.includes(col)) {
4544
- throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
4545
- }
4546
- const { dir, nulls } = normalizeOrderBy(dirValue);
4547
- return `${talias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
4548
- })
4549
- .join(', ');
4550
- orderClause = ` ORDER BY ${orders}`;
5004
+ orderClause = this.buildRelationOrderClause(targetTable, targetMeta, talias, relOrderEntries, params);
4551
5005
  }
4552
5006
  // Additional WHERE filters on the target — full scalar where surface,
4553
5007
  // properly parameterized against the target alias.
@@ -4649,18 +5103,59 @@ export class QueryInterface {
4649
5103
  };
4650
5104
  return typeMap[baseType] ?? 'text';
4651
5105
  }
5106
+ /**
5107
+ * Validate and enumerate the range comparisons (`gt`/`gte`/`lt`/`lte`) on a
5108
+ * JSON filter, in the fixed {@link JSON_RANGE_OPERATORS} order. Shared by
5109
+ * the SQL-build path ({@link buildJsonFilterClauses}) and the cache-hit
5110
+ * param-collect path ({@link collectJsonFilterParams}) so both always agree
5111
+ * on which params are pushed — and both throw identically for invalid
5112
+ * shapes, so a warmed cache can never skip validation.
5113
+ */
5114
+ jsonRangeEntries(filter, column) {
5115
+ const entries = [];
5116
+ for (const [op, sqlOp] of Object.entries(JSON_RANGE_OPERATORS)) {
5117
+ const value = filter[op];
5118
+ if (value === undefined)
5119
+ continue;
5120
+ if (filter.path === undefined) {
5121
+ throw new ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a \`path\` ` +
5122
+ `(e.g. { path: ['meta', 'score'], ${op}: ${JSON.stringify(value)} }).`);
5123
+ }
5124
+ if (typeof value !== 'number' && typeof value !== 'string') {
5125
+ throw new ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a number or string, ` +
5126
+ `got ${JSON.stringify(value)}.`);
5127
+ }
5128
+ if (typeof value === 'number' && !Number.isFinite(value)) {
5129
+ throw new ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a finite number.`);
5130
+ }
5131
+ entries.push({ sqlOp, value });
5132
+ }
5133
+ return entries;
5134
+ }
4652
5135
  /**
4653
5136
  * Build SQL clauses for JSONB filter operators on a column.
4654
- * Supports: path, equals, contains, hasKey.
5137
+ * Supports: path, equals, contains, hasKey, gt, gte, lt, lte.
5138
+ *
5139
+ * The `path` param is bound at most once and its placeholder is shared by
5140
+ * every clause that extracts it (equals + range ops), so the param list
5141
+ * stays byte-identical to {@link collectJsonFilterParams}.
4655
5142
  */
4656
5143
  buildJsonFilterClauses(column, filter, params) {
4657
5144
  const clauses = [];
5145
+ // Lazily bind the path once; reuse the same $N in every extraction clause.
5146
+ let pathParamIdx = null;
5147
+ const pathExtract = () => {
5148
+ if (pathParamIdx === null) {
5149
+ params.push(filter.path);
5150
+ pathParamIdx = params.length;
5151
+ }
5152
+ return this.dialect.buildJsonPathExtract(column, this.p(pathParamIdx));
5153
+ };
4658
5154
  if (filter.path !== undefined && filter.equals !== undefined) {
4659
5155
  // Path access + equals: column #>> $N::text[] = $M
4660
- params.push(filter.path);
4661
- const pathParam = params.length;
5156
+ const extract = pathExtract();
4662
5157
  params.push(String(filter.equals));
4663
- clauses.push(`${this.dialect.buildJsonPathExtract(column, this.p(pathParam))} = ${this.p(params.length)}`);
5158
+ clauses.push(`${extract} = ${this.p(params.length)}`);
4664
5159
  }
4665
5160
  else if (filter.equals !== undefined) {
4666
5161
  // Containment equality: column @> $N::jsonb
@@ -4677,8 +5172,28 @@ export class QueryInterface {
4677
5172
  params.push(filter.hasKey);
4678
5173
  clauses.push(`${column} ? ${this.p(params.length)}`);
4679
5174
  }
5175
+ // Range comparisons on the extracted path: numbers compare numerically
5176
+ // (cast through the dialect), strings compare as text.
5177
+ for (const { sqlOp, value } of this.jsonRangeEntries(filter, column)) {
5178
+ const extract = pathExtract();
5179
+ params.push(value);
5180
+ const lhs = typeof value === 'number' ? this.castJsonNumeric(extract) : extract;
5181
+ clauses.push(`${lhs} ${sqlOp} ${this.p(params.length)}`);
5182
+ }
4680
5183
  return clauses;
4681
5184
  }
5185
+ /**
5186
+ * Cast an extracted JSON path text value to a numeric type for range
5187
+ * comparison. PostgreSQL uses `(expr)::numeric` (exact — the right way to
5188
+ * compare JSON numbers, and `::float` would lose precision on big ints);
5189
+ * other dialects route through {@link Dialect.castAggregate} (SQLite/MySQL/
5190
+ * SQL Server have no `::` operator) as a float cast.
5191
+ */
5192
+ castJsonNumeric(extract) {
5193
+ if (this.dialect.name === 'postgresql')
5194
+ return `(${extract})::numeric`;
5195
+ return this.dialect.castAggregate ? this.dialect.castAggregate(`(${extract})`, 'float') : `(${extract})::numeric`;
5196
+ }
4682
5197
  /**
4683
5198
  * Build SQL clauses for Array filter operators on a column.
4684
5199
  * Supports: has, hasEvery, hasSome, isEmpty.