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
@@ -97,6 +97,12 @@ class QueryInterface {
97
97
  /** Pre-computed column type lookups (avoids linear scans per query) */
98
98
  columnPgTypeMap;
99
99
  columnArrayTypeMap;
100
+ /**
101
+ * Columns whose type lives in a DIFFERENT schema than the introspected one
102
+ * (ColumnMetadata.pgTypeSchema is recorded only in that case) — such columns
103
+ * must never receive this schema's `::"enum"` cast (see enumTypeForColumn).
104
+ */
105
+ crossSchemaTypeColumns;
100
106
  /** Tracks tables that have already triggered a deep-with warning (one-time) */
101
107
  deepWithWarned = new Set();
102
108
  /**
@@ -134,9 +140,19 @@ class QueryInterface {
134
140
  this.middlewares = middlewares ?? [];
135
141
  this.defaultLimit = options?.defaultLimit;
136
142
  // Default to ON: surfacing accidental full-table scans is more valuable
137
- // than the (small) risk of noisy logs. Callers explicitly opt out with
138
- // `warnOnUnlimited: false`.
139
- this.warnOnUnlimited = options?.warnOnUnlimited !== false;
143
+ // than the (small) risk of noisy logs. Callers opt out globally with
144
+ // `warnOnUnlimited: false`, per table with `warnOnUnlimited: { users:
145
+ // false }` (unlisted tables keep the default), or per call via
146
+ // `findMany({ warnOnUnlimited: false })`.
147
+ // Per-table maps accept BOTH key forms — the snake_case table name
148
+ // (`user_profiles`) and the camelCase accessor (`userProfiles`) — since
149
+ // users naturally key by the accessor they type everywhere else. The
150
+ // snake_case entry wins when both are present.
151
+ const warnOpt = options?.warnOnUnlimited;
152
+ this.warnOnUnlimited =
153
+ typeof warnOpt === 'object' && warnOpt !== null
154
+ ? (warnOpt[table] ?? warnOpt[(0, schema_js_1.snakeToCamel)(table)]) !== false
155
+ : warnOpt !== false;
140
156
  this.utcTimestamps = options?.utcTimestamps !== false;
141
157
  this.preparedStatementsEnabled = options?.preparedStatements ?? true;
142
158
  this.sqlCacheEnabled = options?.sqlCache !== false;
@@ -152,9 +168,12 @@ class QueryInterface {
152
168
  // Pre-compute column type lookup maps (TASK-26)
153
169
  this.columnPgTypeMap = new Map();
154
170
  this.columnArrayTypeMap = new Map();
171
+ this.crossSchemaTypeColumns = new Set();
155
172
  for (const col of this.tableMeta.columns) {
156
173
  this.columnPgTypeMap.set(col.name, col.dialectType ?? col.pgType);
157
174
  this.columnArrayTypeMap.set(col.name, col.arrayType ?? col.pgArrayType);
175
+ if (col.pgTypeSchema !== undefined)
176
+ this.crossSchemaTypeColumns.add(col.name);
158
177
  }
159
178
  }
160
179
  /** Quote an identifier through the active SQL dialect. */
@@ -680,10 +699,16 @@ class QueryInterface {
680
699
  * loop calling `db.users.findMany()` thousands of times only logs once.
681
700
  * Suppressed when `defaultLimit` is configured (the caller has already
682
701
  * opted in to a bounded query) and when the user passed an explicit
683
- * `limit`, `take`, or `cursor`.
702
+ * `limit`, `take`, or `cursor`. A per-call `warnOnUnlimited` overrides the
703
+ * config-level setting in either direction (`false` silences a call that
704
+ * intentionally reads the full set; `true` forces the warning even when
705
+ * disabled in config).
684
706
  */
685
707
  maybeWarnUnlimited(args) {
686
- if (!this.warnOnUnlimited)
708
+ const perCall = args?.warnOnUnlimited;
709
+ if (perCall === false)
710
+ return;
711
+ if (perCall === undefined && !this.warnOnUnlimited)
687
712
  return;
688
713
  if (this.defaultLimit !== undefined)
689
714
  return;
@@ -694,7 +719,7 @@ class QueryInterface {
694
719
  return;
695
720
  this.warnedTables.add(this.table);
696
721
  console.warn(`[turbine] warning: findMany on "${this.table}" has no limit — this will fetch every row. ` +
697
- 'Pass `limit` or set `warnOnUnlimited: false` in config to silence.');
722
+ 'Pass `limit`, or silence with `warnOnUnlimited: false` (per call, per table, or in config).');
698
723
  }
699
724
  /**
700
725
  * Recursively measure the maximum depth of a `with` clause tree.
@@ -1043,7 +1068,8 @@ class QueryInterface {
1043
1068
  const entries = Object.entries(args.data).filter(([, v]) => v !== undefined);
1044
1069
  const columns = entries.map(([k]) => this.toSqlColumn(k));
1045
1070
  const params = entries.map(([, v]) => v);
1046
- const placeholders = entries.map((_, i) => `${this.p(i + 1)}`);
1071
+ // Enum columns get an explicit `::"EnumName"` cast (see enumTypeForColumn).
1072
+ const placeholders = entries.map(([k], i) => `${this.p(i + 1)}${this.enumCastSuffix(this.toColumn(k))}`);
1047
1073
  const sql = this.dialect.buildInsertStatement({
1048
1074
  table: this.q(this.table),
1049
1075
  columns,
@@ -1125,7 +1151,13 @@ class QueryInterface {
1125
1151
  return keys.map((key) => record[key]);
1126
1152
  });
1127
1153
  // Use actual Postgres types for array casts in the default PostgreSQL dialect.
1128
- const typeCasts = columns.map((col) => this.getColumnArrayType(col));
1154
+ // Enum columns cast to `"EnumName"[]` — the generic text[] fallback would
1155
+ // type the UNNEST output as text, which Postgres refuses to coerce to the
1156
+ // enum ("column X is of type Y but expression is of type text").
1157
+ const typeCasts = columns.map((col) => {
1158
+ const enumType = this.enumTypeForColumn(col);
1159
+ return enumType ? `${this.q(enumType)}[]` : this.getColumnArrayType(col);
1160
+ });
1129
1161
  const quotedColumns = columns.map((c) => this.q(c));
1130
1162
  const built = this.dialect.buildBulkInsertStatement({
1131
1163
  table: qt,
@@ -1399,7 +1431,8 @@ class QueryInterface {
1399
1431
  const createEntries = Object.entries(args.create).filter(([, v]) => v !== undefined);
1400
1432
  const columns = createEntries.map(([k]) => this.toSqlColumn(k));
1401
1433
  const createParams = createEntries.map(([, v]) => v);
1402
- const placeholders = createEntries.map((_, i) => `${this.p(i + 1)}`);
1434
+ // Enum columns get an explicit `::"EnumName"` cast (see enumTypeForColumn).
1435
+ const placeholders = createEntries.map(([k], i) => `${this.p(i + 1)}${this.enumCastSuffix(this.toColumn(k))}`);
1403
1436
  // The conflict target comes from `where` keys — must be unique/PK columns
1404
1437
  const conflictKeys = Object.keys(args.where).filter((k) => args.where[k] !== undefined);
1405
1438
  const conflictColumns = conflictKeys.map((k) => this.toSqlColumn(k));
@@ -1407,7 +1440,7 @@ class QueryInterface {
1407
1440
  const updateEntries = Object.entries(args.update).filter(([, v]) => v !== undefined);
1408
1441
  let paramIdx = createParams.length + 1;
1409
1442
  const setClauses = updateEntries.map(([k]) => {
1410
- const clause = `${this.toSqlColumn(k)} = ${this.p(paramIdx)}`;
1443
+ const clause = `${this.toSqlColumn(k)} = ${this.p(paramIdx)}${this.enumCastSuffix(this.toColumn(k))}`;
1411
1444
  paramIdx++;
1412
1445
  return clause;
1413
1446
  });
@@ -2087,6 +2120,10 @@ class QueryInterface {
2087
2120
  */
2088
2121
  buildSetClause(key, value, params) {
2089
2122
  const col = this.toSqlColumn(key);
2123
+ // Enum columns get an explicit `::"EnumName"` cast on their value bind
2124
+ // (see enumTypeForColumn); `''` everywhere else. Value-invariant, so the
2125
+ // SQL cache and collectSetParams are unaffected.
2126
+ const cast = this.enumCastSuffix(this.toColumn(key));
2090
2127
  // Detect atomic-operator object: plain object (not null, not array, not
2091
2128
  // Date, not Buffer) with EXACTLY one key matching an operator name.
2092
2129
  if (value !== null &&
@@ -2101,7 +2138,7 @@ class QueryInterface {
2101
2138
  const opValue = v[op];
2102
2139
  if (op === 'set') {
2103
2140
  params.push(opValue);
2104
- return `${col} = ${this.p(params.length)}`;
2141
+ return `${col} = ${this.p(params.length)}${cast}`;
2105
2142
  }
2106
2143
  // Arithmetic operators: must be finite numbers
2107
2144
  if (typeof opValue !== 'number' || !Number.isFinite(opValue)) {
@@ -2129,7 +2166,7 @@ class QueryInterface {
2129
2166
  }
2130
2167
  // Plain value (including null, Date, Buffer, arrays, JSON objects)
2131
2168
  params.push(value);
2132
- return `${col} = ${this.p(params.length)}`;
2169
+ return `${col} = ${this.p(params.length)}${cast}`;
2133
2170
  }
2134
2171
  // =========================================================================
2135
2172
  // Fingerprinting — value-invariant shape keys for SQL cache lookup
@@ -2228,10 +2265,10 @@ class QueryInterface {
2228
2265
  parts.push(`${key}:vec(${dist.metric},${cmps})`);
2229
2266
  continue;
2230
2267
  }
2231
- // JSON filter
2268
+ // JSON filter — range ops carry a numeric/string annotation because the
2269
+ // numeric compile emits a cast (different SQL shape).
2232
2270
  if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
2233
- const jKeys = Object.keys(value).sort();
2234
- parts.push(`${key}:json(${jKeys.join(',')})`);
2271
+ parts.push(`${key}:${(0, filters_js_1.fingerprintJsonFilterShape)(value)}`);
2235
2272
  continue;
2236
2273
  }
2237
2274
  // Array filter
@@ -2317,6 +2354,15 @@ class QueryInterface {
2317
2354
  else if ((0, filters_js_1.isWhereOperator)(value)) {
2318
2355
  parts.push(`${key}:${(0, filters_js_1.fingerprintOperatorShape)(value)}`);
2319
2356
  }
2357
+ else if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
2358
+ // Mirrors fingerprintWhere: JSON filters inside relation sub-wheres
2359
+ // build real JSON clauses (buildSubWhereForRelation), so their shape
2360
+ // must be cache-distinct from plain equality.
2361
+ parts.push(`${key}:${(0, filters_js_1.fingerprintJsonFilterShape)(value)}`);
2362
+ }
2363
+ else if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
2364
+ parts.push(`${key}:arr(${this.fingerprintArrayFilter(value)})`);
2365
+ }
2320
2366
  else if ((0, filters_js_1.isUnmatchedPlainObject)(value)) {
2321
2367
  parts.push(`${key}:obj(${Object.keys(value)
2322
2368
  .sort()
@@ -2394,7 +2440,7 @@ class QueryInterface {
2394
2440
  if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
2395
2441
  const colType = this.getColumnPgType(rawColumn);
2396
2442
  if (colType === 'json' || colType === 'jsonb') {
2397
- this.collectJsonFilterParams(value, params);
2443
+ this.collectJsonFilterParams(value, params, this.q(rawColumn));
2398
2444
  continue;
2399
2445
  }
2400
2446
  }
@@ -2413,7 +2459,11 @@ class QueryInterface {
2413
2459
  }
2414
2460
  // Operator objects
2415
2461
  if ((0, filters_js_1.isWhereOperator)(value)) {
2416
- this.collectOperatorParams(rawColumn, value, params);
2462
+ this.collectOperatorParams(rawColumn, value, params, {
2463
+ meta: this.tableMeta,
2464
+ table: this.table,
2465
+ prefix: '',
2466
+ });
2417
2467
  continue;
2418
2468
  }
2419
2469
  // Plain equality — same strict validation as the build path, so a
@@ -2495,29 +2545,59 @@ class QueryInterface {
2495
2545
  }
2496
2546
  }
2497
2547
  const col = meta.columnMap[field] ?? (0, schema_js_1.camelToSnake)(field);
2548
+ // JSONB filter — mirrors buildSubWhereForRelation (which mirrors the
2549
+ // top-level buildWhereClause): route to the JSON param collector when
2550
+ // the target column is json/jsonb.
2551
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
2552
+ const colType = this.pgTypeForColumn(meta, col);
2553
+ if (colType === 'json' || colType === 'jsonb') {
2554
+ this.collectJsonFilterParams(value, params, `${this.q(targetTable)}.${this.q(col)}`);
2555
+ continue;
2556
+ }
2557
+ }
2558
+ // Array filter — mirrors buildSubWhereForRelation.
2559
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
2560
+ const colType = this.pgTypeForColumn(meta, col);
2561
+ if (colType.startsWith('_')) {
2562
+ this.collectArrayFilterParams(value, params);
2563
+ continue;
2564
+ }
2565
+ }
2498
2566
  if ((0, filters_js_1.isWhereOperator)(value)) {
2499
- this.collectOperatorParams(col, value, params);
2567
+ this.collectOperatorParams(col, value, params, { meta, table: targetTable, prefix: '' });
2500
2568
  continue;
2501
2569
  }
2502
2570
  this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
2503
2571
  params.push(value);
2504
2572
  }
2505
2573
  }
2506
- /** Collect params from operator clauses. Mirrors buildOperatorClauses. */
2507
- collectOperatorParams(column, op, params) {
2508
- if (op.equals !== undefined && op.equals !== null) {
2574
+ /**
2575
+ * Collect params from operator clauses. Mirrors buildOperatorClauses:
2576
+ * {@link ColumnRef} values compile into the SQL text, so they push NOTHING -
2577
+ * but they re-run the same validation (unknown ref / insensitive mode) so a
2578
+ * warmed cache can never skip a check the build path enforces.
2579
+ */
2580
+ collectOperatorParams(column, op, params, refCtx) {
2581
+ const skipRef = (v) => {
2582
+ if (!(0, filters_js_1.isColumnRef)(v))
2583
+ return false;
2584
+ if (refCtx)
2585
+ this.resolveColumnRef(v, refCtx, op.mode);
2586
+ return true;
2587
+ };
2588
+ if (op.equals !== undefined && op.equals !== null && !skipRef(op.equals)) {
2509
2589
  (0, filters_js_1.assertBindableEqualsOperand)(op.equals, `"${column}"`);
2510
2590
  params.push(op.equals);
2511
2591
  }
2512
- if (op.gt !== undefined)
2592
+ if (op.gt !== undefined && !skipRef(op.gt))
2513
2593
  params.push(op.gt);
2514
- if (op.gte !== undefined)
2594
+ if (op.gte !== undefined && !skipRef(op.gte))
2515
2595
  params.push(op.gte);
2516
- if (op.lt !== undefined)
2596
+ if (op.lt !== undefined && !skipRef(op.lt))
2517
2597
  params.push(op.lt);
2518
- if (op.lte !== undefined)
2598
+ if (op.lte !== undefined && !skipRef(op.lte))
2519
2599
  params.push(op.lte);
2520
- if (op.not !== undefined && op.not !== null)
2600
+ if (op.not !== undefined && op.not !== null && !skipRef(op.not))
2521
2601
  params.push(op.not);
2522
2602
  if (op.in !== undefined)
2523
2603
  params.push(this.inParam(op.in));
@@ -2530,10 +2610,22 @@ class QueryInterface {
2530
2610
  if (op.endsWith !== undefined)
2531
2611
  params.push(`%${(0, utils_js_1.escapeLike)(op.endsWith)}`);
2532
2612
  }
2533
- /** Collect params from JSON filter. Mirrors buildJsonFilterClauses. */
2534
- collectJsonFilterParams(filter, params) {
2613
+ /**
2614
+ * Collect params from JSON filter. Mirrors buildJsonFilterClauses exactly:
2615
+ * the `path` is bound at most once (its placeholder is shared by every
2616
+ * extraction clause), then equals/contains/hasKey values, then the range
2617
+ * comparison values in {@link JSON_RANGE_OPERATORS} order.
2618
+ */
2619
+ collectJsonFilterParams(filter, params, column) {
2620
+ let pathPushed = false;
2621
+ const pushPathOnce = () => {
2622
+ if (!pathPushed) {
2623
+ params.push(filter.path);
2624
+ pathPushed = true;
2625
+ }
2626
+ };
2535
2627
  if (filter.path !== undefined && filter.equals !== undefined) {
2536
- params.push(filter.path);
2628
+ pushPathOnce();
2537
2629
  params.push(String(filter.equals));
2538
2630
  }
2539
2631
  else if (filter.equals !== undefined) {
@@ -2545,6 +2637,10 @@ class QueryInterface {
2545
2637
  if (filter.hasKey !== undefined) {
2546
2638
  params.push(filter.hasKey);
2547
2639
  }
2640
+ for (const { value } of this.jsonRangeEntries(filter, column)) {
2641
+ pushPathOnce();
2642
+ params.push(value);
2643
+ }
2548
2644
  }
2549
2645
  /** Collect params from array filter. Mirrors buildArrayFilterClauses. */
2550
2646
  collectArrayFilterParams(filter, params) {
@@ -2557,10 +2653,10 @@ class QueryInterface {
2557
2653
  // isEmpty has no params (IS NULL / IS NOT NULL)
2558
2654
  }
2559
2655
  /**
2560
- * Collect params for an orderBy clause. Only vector KNN ordering pushes a
2561
- * param (the `$n::vector` query vector); plain direction ordering is
2562
- * parameterless. Mirrors buildOrderBy's push order exactly so the cached-SQL
2563
- * param re-collection stays in lockstep.
2656
+ * Collect params for an orderBy clause. Vector KNN ordering pushes the
2657
+ * `$n::vector` query vector and JSON-path ordering pushes its text[] path;
2658
+ * plain direction ordering is parameterless. Mirrors buildOrderBy's push
2659
+ * order exactly so the cached-SQL param re-collection stays in lockstep.
2564
2660
  */
2565
2661
  collectOrderByParams(orderBy, params) {
2566
2662
  for (const [key, dir] of Object.entries(orderBy)) {
@@ -2572,6 +2668,13 @@ class QueryInterface {
2572
2668
  this.pushVectorParam(key, rawColumn, dir.distance.to, params);
2573
2669
  continue;
2574
2670
  }
2671
+ // JSON-path ordering: mirrors buildJsonPathOrderEntry: same validation,
2672
+ // then the path bound as one text[] param.
2673
+ if ((0, filters_js_1.isJsonPathOrderBy)(dir)) {
2674
+ this.validateJsonPathOrderBy(this.table, this.tableMeta, key, dir);
2675
+ params.push(dir.path.map(String));
2676
+ continue;
2677
+ }
2575
2678
  // To-many relation orderBy (`{ posts: { _count } }`) uses the same count
2576
2679
  // subquery as `_count` — mirror its global-filter params. To-one relation
2577
2680
  // orderBy carries the target's global filter once per ordered column.
@@ -2718,9 +2821,20 @@ class QueryInterface {
2718
2821
  const targetMeta = this.schema.tables[targetTable];
2719
2822
  if (!targetMeta)
2720
2823
  return;
2824
+ // A dialect that owns the whole subquery (buildRelationSubquery override,
2825
+ // SQL Server FOR JSON) compiles orderBy through its OWN paging clause -
2826
+ // plain directions only, no order params: so the native order-param
2827
+ // mirrors below must stay off for it (its documented contract remains
2828
+ // where → limit → nested).
2829
+ const nativeOrderPath = !this.dialect.buildRelationSubquery;
2721
2830
  // manyToMany param order mirrors buildManyToManySubquery:
2722
- // where params → limit param → nested-with params (always, both paths).
2831
+ // orderBy params → where params → limit param → nested-with params
2832
+ // (always, both paths).
2723
2833
  if (relDef.type === 'manyToMany') {
2834
+ const m2mOrderEntries = spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
2835
+ if (nativeOrderPath && m2mOrderEntries.length > 0) {
2836
+ this.collectRelationOrderParams(targetTable, targetMeta, m2mOrderEntries, params);
2837
+ }
2724
2838
  if (spec.where) {
2725
2839
  this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
2726
2840
  }
@@ -2739,7 +2853,8 @@ class QueryInterface {
2739
2853
  return;
2740
2854
  }
2741
2855
  // Mirrors buildRelationSubquery's willWrap: `orderBy: {}` is treated as absent.
2742
- const hasOrder = spec.orderBy ? Object.values(spec.orderBy).some((dir) => dir !== undefined) : false;
2856
+ const relOrderEntries = spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
2857
+ const hasOrder = relOrderEntries.length > 0;
2743
2858
  const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
2744
2859
  // Non-wrapped path: nested relations BEFORE where/limit
2745
2860
  if (!willWrap && spec.with) {
@@ -2750,6 +2865,12 @@ class QueryInterface {
2750
2865
  this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
2751
2866
  }
2752
2867
  }
2868
+ // orderBy params (JSON paths / relation-order global filters): mirrors
2869
+ // buildRelationSubquery, which builds its ORDER BY terms BEFORE compiling
2870
+ // spec.where (both wrapped and non-wrapped paths).
2871
+ if (nativeOrderPath && hasOrder) {
2872
+ this.collectRelationOrderParams(targetTable, targetMeta, relOrderEntries, params);
2873
+ }
2753
2874
  // where params — mirrors buildAliasWhere push order
2754
2875
  if (spec.where) {
2755
2876
  this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
@@ -3100,7 +3221,11 @@ class QueryInterface {
3100
3221
  }
3101
3222
  // Handle operator objects
3102
3223
  if ((0, filters_js_1.isWhereOperator)(value)) {
3103
- const opClauses = this.buildOperatorClauses(column, value, params);
3224
+ const opClauses = this.buildOperatorClauses(column, value, params, {
3225
+ meta: this.tableMeta,
3226
+ table: this.table,
3227
+ prefix: '',
3228
+ });
3104
3229
  andClauses.push(...opClauses);
3105
3230
  continue;
3106
3231
  }
@@ -3264,8 +3389,41 @@ class QueryInterface {
3264
3389
  conditions.push(`${qCol} IS NULL`);
3265
3390
  continue;
3266
3391
  }
3392
+ // JSONB filter on a json/jsonb column of the relation target — mirrors
3393
+ // the top-level WHERE path (buildWhereClause). Without this branch the
3394
+ // filter object used to fall through to plain equality and bind as a
3395
+ // jsonb value, silently matching nothing.
3396
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
3397
+ const colType = this.pgTypeForColumn(meta, col);
3398
+ if (colType === 'json' || colType === 'jsonb') {
3399
+ conditions.push(...this.buildJsonFilterClauses(qCol, value, params));
3400
+ continue;
3401
+ }
3402
+ const jsonKey = (0, filters_js_1.findJsonUniqueKey)(value);
3403
+ if (jsonKey) {
3404
+ throw new errors_js_1.ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not a JSON column ` +
3405
+ `(actual type: ${colType}); cannot apply JSON operator '${jsonKey}'.`);
3406
+ }
3407
+ }
3408
+ // Array filter on an array column of the relation target — same mirror.
3409
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
3410
+ const colType = this.pgTypeForColumn(meta, col);
3411
+ if (colType.startsWith('_')) {
3412
+ conditions.push(...this.buildArrayFilterClauses(qCol, value, params, colType));
3413
+ continue;
3414
+ }
3415
+ const arrayKey = (0, filters_js_1.findArrayUniqueKey)(value);
3416
+ if (arrayKey) {
3417
+ throw new errors_js_1.ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not an array column ` +
3418
+ `(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
3419
+ }
3420
+ }
3267
3421
  if ((0, filters_js_1.isWhereOperator)(value)) {
3268
- const opClauses = this.buildOperatorClauses(qCol, value, params);
3422
+ const opClauses = this.buildOperatorClauses(qCol, value, params, {
3423
+ meta,
3424
+ table: targetTable,
3425
+ prefix: `${qt}.`,
3426
+ });
3269
3427
  conditions.push(...opClauses);
3270
3428
  continue;
3271
3429
  }
@@ -3282,6 +3440,49 @@ class QueryInterface {
3282
3440
  pgTypeForColumn(meta, column) {
3283
3441
  return meta.dialectTypes?.[column] ?? meta.pgTypes?.[column] ?? 'text';
3284
3442
  }
3443
+ /**
3444
+ * The Postgres enum type name for a column, when the schema knows one.
3445
+ *
3446
+ * Introspection stores each column's `udt_name` in `pgTypes` and every
3447
+ * database enum in `schema.enums` (typname → labels); a column whose type
3448
+ * matches an enum key needs an explicit `::"EnumName"` cast on its write
3449
+ * binds — bulk-insert forms like `UNNEST($1::text[])` otherwise type the
3450
+ * value as text and Postgres refuses the implicit text→enum coercion
3451
+ * ("column X is of type Y but expression is of type text").
3452
+ *
3453
+ * Postgres-only by construction: gated on the active dialect being
3454
+ * `postgresql` AND on `schema.enums` having entries (only PG introspection
3455
+ * produces them — `defineSchema` and the other engines leave it empty), so
3456
+ * SQLite/MySQL/MSSQL/PowDB output is byte-identical.
3457
+ */
3458
+ enumTypeForColumn(column) {
3459
+ if (this.dialect.name !== 'postgresql')
3460
+ return null;
3461
+ const enums = this.schema.enums;
3462
+ if (!enums)
3463
+ return null;
3464
+ // Cross-schema guard (N-5): introspection records pgTypeSchema ONLY when
3465
+ // the column's type lives OUTSIDE the introspected schema. A same-named
3466
+ // enum in another schema must not get this schema's cast — search_path
3467
+ // would resolve `::"status"` to the wrong type. Skipping the cast restores
3468
+ // the pre-cast behavior for such columns. Columns without pgTypeSchema
3469
+ // (same-schema types, defineSchema/legacy metadata) keep the cast.
3470
+ if (this.crossSchemaTypeColumns.has(column))
3471
+ return null;
3472
+ const pgType = this.columnPgTypeMap.get(column) ?? this.tableMeta.pgTypes?.[column];
3473
+ if (!pgType || pgType.startsWith('_'))
3474
+ return null;
3475
+ return Object.hasOwn(enums, pgType) ? pgType : null;
3476
+ }
3477
+ /**
3478
+ * `::"EnumName"` cast suffix for a write-bind placeholder on an enum
3479
+ * column; `''` for every other column, so non-enum SQL stays byte-identical.
3480
+ * The type name is an introspected identifier and is quoted via the dialect.
3481
+ */
3482
+ enumCastSuffix(column) {
3483
+ const enumType = this.enumTypeForColumn(column);
3484
+ return enumType ? `::${this.q(enumType)}` : '';
3485
+ }
3285
3486
  /**
3286
3487
  * Equality-fallthrough guard shared by every SQL-build path AND every
3287
3488
  * cache-hit param-collect path. A plain object literal that matched no known
@@ -3360,8 +3561,40 @@ class QueryInterface {
3360
3561
  clauses.push(`${qCol} IS NULL`);
3361
3562
  continue;
3362
3563
  }
3564
+ // JSONB filter on a json/jsonb column — mirrors the top-level WHERE path
3565
+ // (buildWhereClause) so a `with.where` JSON filter is never silently
3566
+ // bound as a plain equality value.
3567
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
3568
+ const colType = this.pgTypeForColumn(targetMeta, col);
3569
+ if (colType === 'json' || colType === 'jsonb') {
3570
+ clauses.push(...this.buildJsonFilterClauses(qCol, value, params));
3571
+ continue;
3572
+ }
3573
+ const jsonKey = (0, filters_js_1.findJsonUniqueKey)(value);
3574
+ if (jsonKey) {
3575
+ throw new errors_js_1.ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not a JSON column ` +
3576
+ `(actual type: ${colType}); cannot apply JSON operator '${jsonKey}'.`);
3577
+ }
3578
+ }
3579
+ // Array filter on an array column — same mirror.
3580
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
3581
+ const colType = this.pgTypeForColumn(targetMeta, col);
3582
+ if (colType.startsWith('_')) {
3583
+ clauses.push(...this.buildArrayFilterClauses(qCol, value, params, colType));
3584
+ continue;
3585
+ }
3586
+ const arrayKey = (0, filters_js_1.findArrayUniqueKey)(value);
3587
+ if (arrayKey) {
3588
+ throw new errors_js_1.ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not an array column ` +
3589
+ `(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
3590
+ }
3591
+ }
3363
3592
  if ((0, filters_js_1.isWhereOperator)(value)) {
3364
- clauses.push(...this.buildOperatorClauses(qCol, value, params));
3593
+ clauses.push(...this.buildOperatorClauses(qCol, value, params, {
3594
+ meta: targetMeta,
3595
+ table: targetTable,
3596
+ prefix: `${alias}.`,
3597
+ }));
3365
3598
  continue;
3366
3599
  }
3367
3600
  this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
@@ -3403,8 +3636,24 @@ class QueryInterface {
3403
3636
  }
3404
3637
  }
3405
3638
  const col = targetMeta.columnMap[key] ?? (0, schema_js_1.camelToSnake)(key);
3639
+ // JSONB filter — mirrors buildAliasWhere.
3640
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
3641
+ const colType = this.pgTypeForColumn(targetMeta, col);
3642
+ if (colType === 'json' || colType === 'jsonb') {
3643
+ this.collectJsonFilterParams(value, params, this.q(col));
3644
+ continue;
3645
+ }
3646
+ }
3647
+ // Array filter — mirrors buildAliasWhere.
3648
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
3649
+ const colType = this.pgTypeForColumn(targetMeta, col);
3650
+ if (colType.startsWith('_')) {
3651
+ this.collectArrayFilterParams(value, params);
3652
+ continue;
3653
+ }
3654
+ }
3406
3655
  if ((0, filters_js_1.isWhereOperator)(value)) {
3407
- this.collectOperatorParams(col, value, params);
3656
+ this.collectOperatorParams(col, value, params, { meta: targetMeta, table: targetTable, prefix: '' });
3408
3657
  continue;
3409
3658
  }
3410
3659
  this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
@@ -3461,6 +3710,16 @@ class QueryInterface {
3461
3710
  parts.push(`${key}:${(0, filters_js_1.fingerprintOperatorShape)(value)}`);
3462
3711
  continue;
3463
3712
  }
3713
+ // JSON / array filters build real clauses in buildAliasWhere, so their
3714
+ // shape must be cache-distinct from plain equality (mirrors fingerprintWhere).
3715
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
3716
+ parts.push(`${key}:${(0, filters_js_1.fingerprintJsonFilterShape)(value)}`);
3717
+ continue;
3718
+ }
3719
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
3720
+ parts.push(`${key}:arr(${this.fingerprintArrayFilter(value)})`);
3721
+ continue;
3722
+ }
3464
3723
  if ((0, filters_js_1.isUnmatchedPlainObject)(value)) {
3465
3724
  parts.push(`${key}:obj(${Object.keys(value)
3466
3725
  .sort()
@@ -3471,16 +3730,55 @@ class QueryInterface {
3471
3730
  }
3472
3731
  return parts.join('&');
3473
3732
  }
3733
+ /**
3734
+ * Validate a `{ col }` column reference against its table and return the
3735
+ * resolved snake_case column name. Shared by the SQL-build path
3736
+ * ({@link buildOperatorClauses}) and the cache-hit param-collect path
3737
+ * (`collectOperatorParams`) so both always throw identically: a warmed
3738
+ * cache can never skip the check.
3739
+ */
3740
+ resolveColumnRef(ref, ctx, mode) {
3741
+ if (mode === 'insensitive') {
3742
+ throw new errors_js_1.ValidationError(`[turbine] mode: 'insensitive' cannot be combined with a column reference ({ col: "${ref.col}" }). ` +
3743
+ `Case-insensitive column-to-column comparison is not supported: use client.sql\`...\` ` +
3744
+ `for lower(a) = lower(b).`);
3745
+ }
3746
+ const col = ctx.meta.columnMap[ref.col] ?? (0, schema_js_1.camelToSnake)(ref.col);
3747
+ if (!ctx.meta.allColumns.includes(col)) {
3748
+ throw new errors_js_1.ValidationError(`[turbine] Unknown field "${ref.col}" referenced by { col } in where on table "${ctx.table}". ` +
3749
+ `Known fields: ${Object.keys(ctx.meta.columnMap).join(', ') || '(none)'}.`);
3750
+ }
3751
+ return col;
3752
+ }
3753
+ /**
3754
+ * Compile a `{ col }` reference to its quoted, prefix-matched SQL identifier.
3755
+ * NO param is bound: the referenced column is part of the SQL text (and of
3756
+ * the where fingerprint, see {@link fingerprintOperatorShape}).
3757
+ */
3758
+ columnRefSql(ref, ctx, mode) {
3759
+ if (!ctx) {
3760
+ throw new errors_js_1.ValidationError(`[turbine] Column reference { col: "${ref.col}" } is not supported in this filter context.`);
3761
+ }
3762
+ return `${ctx.prefix}${this.q(this.resolveColumnRef(ref, ctx, mode))}`;
3763
+ }
3474
3764
  /**
3475
3765
  * Build SQL clauses for a single operator object on a column.
3476
3766
  * Each operator key becomes its own clause, all ANDed together.
3767
+ *
3768
+ * `equals`/`not`/`gt`/`gte`/`lt`/`lte` also accept a {@link ColumnRef}
3769
+ * (`{ col: 'otherField' }`) which compiles to a column-to-column comparison
3770
+ * against `refCtx`: no param bound, so `collectOperatorParams` mirrors by
3771
+ * pushing nothing and the referenced name lives in the fingerprint.
3477
3772
  */
3478
- buildOperatorClauses(column, op, params) {
3773
+ buildOperatorClauses(column, op, params, refCtx) {
3479
3774
  const clauses = [];
3480
3775
  if (op.equals !== undefined) {
3481
3776
  if (op.equals === null) {
3482
3777
  clauses.push(`${column} IS NULL`);
3483
3778
  }
3779
+ else if ((0, filters_js_1.isColumnRef)(op.equals)) {
3780
+ clauses.push(`${column} = ${this.columnRefSql(op.equals, refCtx, op.mode)}`);
3781
+ }
3484
3782
  else {
3485
3783
  (0, filters_js_1.assertBindableEqualsOperand)(op.equals, column);
3486
3784
  params.push(op.equals);
@@ -3488,25 +3786,48 @@ class QueryInterface {
3488
3786
  }
3489
3787
  }
3490
3788
  if (op.gt !== undefined) {
3491
- params.push(op.gt);
3492
- clauses.push(`${column} > ${this.p(params.length)}`);
3789
+ if ((0, filters_js_1.isColumnRef)(op.gt)) {
3790
+ clauses.push(`${column} > ${this.columnRefSql(op.gt, refCtx, op.mode)}`);
3791
+ }
3792
+ else {
3793
+ params.push(op.gt);
3794
+ clauses.push(`${column} > ${this.p(params.length)}`);
3795
+ }
3493
3796
  }
3494
3797
  if (op.gte !== undefined) {
3495
- params.push(op.gte);
3496
- clauses.push(`${column} >= ${this.p(params.length)}`);
3798
+ if ((0, filters_js_1.isColumnRef)(op.gte)) {
3799
+ clauses.push(`${column} >= ${this.columnRefSql(op.gte, refCtx, op.mode)}`);
3800
+ }
3801
+ else {
3802
+ params.push(op.gte);
3803
+ clauses.push(`${column} >= ${this.p(params.length)}`);
3804
+ }
3497
3805
  }
3498
3806
  if (op.lt !== undefined) {
3499
- params.push(op.lt);
3500
- clauses.push(`${column} < ${this.p(params.length)}`);
3807
+ if ((0, filters_js_1.isColumnRef)(op.lt)) {
3808
+ clauses.push(`${column} < ${this.columnRefSql(op.lt, refCtx, op.mode)}`);
3809
+ }
3810
+ else {
3811
+ params.push(op.lt);
3812
+ clauses.push(`${column} < ${this.p(params.length)}`);
3813
+ }
3501
3814
  }
3502
3815
  if (op.lte !== undefined) {
3503
- params.push(op.lte);
3504
- clauses.push(`${column} <= ${this.p(params.length)}`);
3816
+ if ((0, filters_js_1.isColumnRef)(op.lte)) {
3817
+ clauses.push(`${column} <= ${this.columnRefSql(op.lte, refCtx, op.mode)}`);
3818
+ }
3819
+ else {
3820
+ params.push(op.lte);
3821
+ clauses.push(`${column} <= ${this.p(params.length)}`);
3822
+ }
3505
3823
  }
3506
3824
  if (op.not !== undefined) {
3507
3825
  if (op.not === null) {
3508
3826
  clauses.push(`${column} IS NOT NULL`);
3509
3827
  }
3828
+ else if ((0, filters_js_1.isColumnRef)(op.not)) {
3829
+ clauses.push(`${column} != ${this.columnRefSql(op.not, refCtx, op.mode)}`);
3830
+ }
3510
3831
  else {
3511
3832
  params.push(op.not);
3512
3833
  clauses.push(`${column} != ${this.p(params.length)}`);
@@ -3557,6 +3878,11 @@ class QueryInterface {
3557
3878
  if ((0, filters_js_1.isVectorOrderBy)(d)) {
3558
3879
  return `vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
3559
3880
  }
3881
+ // JSON-path ordering: direction, cast kind, and nulls placement change the
3882
+ // SQL text; the path itself is a bound param and stays OUT of the key.
3883
+ if ((0, filters_js_1.isJsonPathOrderBy)(d)) {
3884
+ return `jp(${d.direction ?? 'asc'},${d.type === 'numeric' ? 'num' : 'text'},${d.nulls ?? ''})`;
3885
+ }
3560
3886
  if ((0, filters_js_1.isOrderBySpec)(d))
3561
3887
  return `spec(${d.sort},${d.nulls ?? ''})`;
3562
3888
  if (d && typeof d === 'object') {
@@ -3602,6 +3928,11 @@ class QueryInterface {
3602
3928
  const safeDir = value.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3603
3929
  return `${this.q(rawColumn)} ${operator} ${placeholder} ${safeDir}`;
3604
3930
  }
3931
+ // JSON-path ordering: { path: [...], direction?, type?, nulls? } on a
3932
+ // json/jsonb column of THIS table. Path is bound as one text[] param.
3933
+ if ((0, filters_js_1.isJsonPathOrderBy)(value)) {
3934
+ return this.buildJsonPathOrderEntry(this.table, this.tableMeta, key, value, '', params);
3935
+ }
3605
3936
  // Relation ordering: an object value that is not a vector or OrderBySpec,
3606
3937
  // keyed by a relation name (`{ posts: { _count: 'desc' } }` / `{ author:
3607
3938
  // { name: 'asc' } }`).
@@ -3628,6 +3959,7 @@ class QueryInterface {
3628
3959
  value !== null &&
3629
3960
  !Array.isArray(value) &&
3630
3961
  !(0, filters_js_1.isVectorOrderBy)(value) &&
3962
+ !(0, filters_js_1.isJsonPathOrderBy)(value) &&
3631
3963
  !(0, filters_js_1.isOrderBySpec)(value));
3632
3964
  }
3633
3965
  /**
@@ -3644,6 +3976,63 @@ class QueryInterface {
3644
3976
  }
3645
3977
  return nulls === 'first' ? ' NULLS FIRST' : ' NULLS LAST';
3646
3978
  }
3979
+ /**
3980
+ * Resolve an orderBy key to its snake_case column via the table's columnMap
3981
+ * (camelToSnake fallback), throwing the SAME unknown-field E003 the top-level
3982
+ * where path uses. Shared by top-level JSON-path ordering and every nested
3983
+ * relation orderBy path so nested orderBy accepts exactly what top-level
3984
+ * accepts (the 0.30.x bug: nested orderBy skipped the columnMap and rejected
3985
+ * camelCase-named DB columns like "sortOrder").
3986
+ */
3987
+ resolveOrderByColumn(table, meta, key) {
3988
+ const col = meta.columnMap[key] ?? (0, schema_js_1.camelToSnake)(key);
3989
+ if (!meta.allColumns.includes(col)) {
3990
+ throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${table}". ` +
3991
+ `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
3992
+ }
3993
+ return col;
3994
+ }
3995
+ /**
3996
+ * Validate a {@link JsonPathOrderBy} entry: column must exist AND be
3997
+ * json/jsonb, path must be a non-empty array of keys/indexes: and return
3998
+ * the resolved column. Shared by the SQL-build path
3999
+ * ({@link buildJsonPathOrderEntry}) and the cache-hit param-collect mirrors
4000
+ * so both always throw identically.
4001
+ */
4002
+ validateJsonPathOrderBy(table, meta, field, spec) {
4003
+ const col = this.resolveOrderByColumn(table, meta, field);
4004
+ if (spec.path.length === 0 ||
4005
+ spec.path.some((el) => typeof el !== 'string' && !(typeof el === 'number' && Number.isFinite(el)))) {
4006
+ throw new errors_js_1.ValidationError(`[turbine] JSON-path orderBy on "${field}" (table "${table}") requires a non-empty \`path\` array ` +
4007
+ `of keys/indexes (e.g. { path: ['weight'], direction: 'asc' }).`);
4008
+ }
4009
+ const colType = this.pgTypeForColumn(meta, col);
4010
+ if (colType !== 'json' && colType !== 'jsonb') {
4011
+ throw new errors_js_1.ValidationError(`[turbine] JSON-path orderBy on "${field}": column "${col}" on table "${table}" is not a JSON column ` +
4012
+ `(actual type: ${colType}).`);
4013
+ }
4014
+ return col;
4015
+ }
4016
+ /**
4017
+ * Compile one {@link JsonPathOrderBy} entry:
4018
+ * `("col" #>> $n::text[])::numeric ASC`: the numeric cast only with
4019
+ * `type: 'numeric'` (default is text comparison), the extraction routed
4020
+ * through the dialect's JSON hook exactly like the JSON where-filters, the
4021
+ * path bound as ONE text[] param (mirrored by the order-param collectors).
4022
+ * `prefix` scopes the column (`''` top-level, `t0.` inside a relation
4023
+ * subquery).
4024
+ */
4025
+ buildJsonPathOrderEntry(table, meta, field, spec, prefix, params) {
4026
+ const col = this.validateJsonPathOrderBy(table, meta, field, spec);
4027
+ if (!params) {
4028
+ throw new errors_js_1.ValidationError(`[turbine] JSON-path ordering on "${field}" is not supported in this orderBy context.`);
4029
+ }
4030
+ params.push(spec.path.map(String));
4031
+ const extract = this.dialect.buildJsonPathExtract(`${prefix}${this.q(col)}`, this.p(params.length));
4032
+ const lhs = spec.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
4033
+ const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
4034
+ return `${lhs} ${dir}${this.nullsSuffix(spec.nulls)}`;
4035
+ }
3647
4036
  /**
3648
4037
  * Compile a relation ordering term. For a to-many relation the only allowed
3649
4038
  * key is `_count`, which becomes a correlated `COUNT(*)` subquery. For a
@@ -3652,12 +4041,19 @@ class QueryInterface {
3652
4041
  *
3653
4042
  * Validation: relation must exist (E005); to-many only allows `_count`, and
3654
4043
  * to-one only allows real target columns (E003).
4044
+ *
4045
+ * `ctx` generalizes the term beyond the root table: inside a relation
4046
+ * subquery's orderBy the relations live on the TARGET table's metadata and
4047
+ * the correlation parent is the relation's alias, not `this.table`.
3655
4048
  */
3656
- buildRelationOrderBy(relName, value, alias, params) {
3657
- const relDef = this.tableMeta.relations[relName];
4049
+ buildRelationOrderBy(relName, value, alias, params, ctx) {
4050
+ const ownerMeta = ctx?.meta ?? this.tableMeta;
4051
+ const ownerTable = ctx?.table ?? this.table;
4052
+ const parentRef = ctx?.parentRef ?? this.table;
4053
+ const relDef = ownerMeta.relations[relName];
3658
4054
  if (!relDef) {
3659
- throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${this.table}". ` +
3660
- `Available: ${Object.keys(this.tableMeta.relations).join(', ')}`);
4055
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${ownerTable}". ` +
4056
+ `Available: ${Object.keys(ownerMeta.relations).join(', ')}`);
3661
4057
  }
3662
4058
  // To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
3663
4059
  if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
@@ -3667,14 +4063,14 @@ class QueryInterface {
3667
4063
  `(got: ${keys.join(', ') || '(empty)'}).`);
3668
4064
  }
3669
4065
  const { dir } = (0, filters_js_1.normalizeOrderBy)(value._count);
3670
- return `${this.buildRelationCountExpr(relDef, this.table, alias, params)} ${dir}`;
4066
+ return `${this.buildRelationCountExpr(relDef, parentRef, alias, params)} ${dir}`;
3671
4067
  }
3672
4068
  // To-one: each entry orders by a correlated scalar subquery on a target column.
3673
4069
  const targetMeta = this.schema.tables[relDef.to];
3674
4070
  if (!targetMeta)
3675
4071
  throw new errors_js_1.RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
3676
4072
  const qTarget = this.q(relDef.to);
3677
- const qParent = this.q(this.table);
4073
+ const qParent = this.q(parentRef);
3678
4074
  // belongsTo: alias.referenceKey = parent.foreignKey; hasOne: reversed.
3679
4075
  const correlation = relDef.type === 'belongsTo'
3680
4076
  ? this.dialect.buildCorrelation(alias, relDef.referenceKey, qParent, relDef.foreignKey)
@@ -3685,7 +4081,9 @@ class QueryInterface {
3685
4081
  }
3686
4082
  return entries
3687
4083
  .map(([col, dirValue]) => {
3688
- const snakeCol = (0, schema_js_1.camelToSnake)(col);
4084
+ // columnMap-first resolution (camelToSnake fallback): mirrors the
4085
+ // scalar orderBy path so camelCase-named DB columns resolve here too.
4086
+ const snakeCol = targetMeta.columnMap[col] ?? (0, schema_js_1.camelToSnake)(col);
3689
4087
  if (!targetMeta.allColumns.includes(snakeCol)) {
3690
4088
  throw new errors_js_1.ValidationError(`[turbine] Unknown column "${col}" in orderBy on relation "${relName}" (table "${relDef.to}").`);
3691
4089
  }
@@ -3703,6 +4101,74 @@ class QueryInterface {
3703
4101
  })
3704
4102
  .join(', ');
3705
4103
  }
4104
+ /**
4105
+ * Compile the ORDER BY terms of a relation `with` clause against the
4106
+ * relation's table alias. One unified path for every relation shape
4107
+ * (hasMany / manyToMany / belongsTo / hasOne) supporting exactly what the
4108
+ * top-level orderBy accepts at this level:
4109
+ *
4110
+ * - scalar columns via columnMap resolution (camelToSnake fallback) with
4111
+ * {@link OrderBySpec} nulls placement,
4112
+ * - {@link JsonPathOrderBy} entries (path bound as one text[] param),
4113
+ * - relation ordering on the TARGET's relations (`_count` for to-many, a
4114
+ * target column for to-one), correlated to the relation alias,
4115
+ * - vector KNN ordering stays top-level-only (E003, same as before).
4116
+ *
4117
+ * Param pushes (JSON paths, relation-order global filters) MUST be mirrored,
4118
+ * in the same order, by {@link collectRelationOrderParams}.
4119
+ */
4120
+ buildRelationOrderClause(targetTable, targetMeta, alias, orderEntries, params) {
4121
+ let relOrdCounter = 0;
4122
+ const orders = orderEntries
4123
+ .map(([key, dirValue]) => {
4124
+ if ((0, filters_js_1.isVectorOrderBy)(dirValue)) {
4125
+ throw new errors_js_1.ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
4126
+ }
4127
+ if ((0, filters_js_1.isJsonPathOrderBy)(dirValue)) {
4128
+ return this.buildJsonPathOrderEntry(targetTable, targetMeta, key, dirValue, `${alias}.`, params);
4129
+ }
4130
+ if (this.isRelationOrderByValue(dirValue)) {
4131
+ return this.buildRelationOrderBy(key, dirValue, `${alias}ord${relOrdCounter++}`, params, { meta: targetMeta, table: targetTable, parentRef: alias });
4132
+ }
4133
+ const col = this.resolveOrderByColumn(targetTable, targetMeta, key);
4134
+ const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(dirValue);
4135
+ return `${alias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
4136
+ })
4137
+ .join(', ');
4138
+ return ` ORDER BY ${orders}`;
4139
+ }
4140
+ /**
4141
+ * Param-collect mirror of {@link buildRelationOrderClause}: JSON-path
4142
+ * entries push their path (one text[] param each); relation-order entries
4143
+ * mirror {@link collectOrderByParams}' relation branch (count / to-one
4144
+ * global-filter params); scalar entries push nothing but re-run the same
4145
+ * column validation so a warmed cache can never skip it.
4146
+ */
4147
+ collectRelationOrderParams(targetTable, targetMeta, orderEntries, params) {
4148
+ for (const [key, dirValue] of orderEntries) {
4149
+ if ((0, filters_js_1.isVectorOrderBy)(dirValue)) {
4150
+ throw new errors_js_1.ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
4151
+ }
4152
+ if ((0, filters_js_1.isJsonPathOrderBy)(dirValue)) {
4153
+ this.validateJsonPathOrderBy(targetTable, targetMeta, key, dirValue);
4154
+ params.push(dirValue.path.map(String));
4155
+ continue;
4156
+ }
4157
+ if (this.isRelationOrderByValue(dirValue)) {
4158
+ const relDef = targetMeta.relations[key];
4159
+ if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
4160
+ this.collectRelationCountParams(relDef, params);
4161
+ }
4162
+ else if (relDef) {
4163
+ for (const _col of Object.keys(dirValue)) {
4164
+ this.collectTargetGlobalFilterAlias(relDef.to, params);
4165
+ }
4166
+ }
4167
+ continue;
4168
+ }
4169
+ this.resolveOrderByColumn(targetTable, targetMeta, key);
4170
+ }
4171
+ }
3706
4172
  /**
3707
4173
  * Build a correlated `(SELECT COUNT(*) …)` scalar subquery for a to-many
3708
4174
  * relation, correlated to `parentRef`. hasMany counts child rows via the FK;
@@ -4422,20 +4888,13 @@ class QueryInterface {
4422
4888
  // Quote parent ref — can be a table name or auto-generated alias
4423
4889
  const qParent = this.q(parentRef);
4424
4890
  const qTarget = this.q(targetTable);
4425
- // Build ORDER BY for json_agg
4891
+ // Build ORDER BY for json_agg: unified with the top-level orderBy surface
4892
+ // (columnMap resolution, OrderBySpec nulls, JSON-path, relation ordering).
4893
+ // Param pushes here land BEFORE the spec.where params, mirrored by
4894
+ // collectRelationSubqueryParams.
4426
4895
  let orderClause = '';
4427
4896
  if (relOrderEntries.length > 0) {
4428
- const orders = relOrderEntries
4429
- .map(([k, dirValue]) => {
4430
- const col = (0, schema_js_1.camelToSnake)(k);
4431
- if (!targetMeta.allColumns.includes(col)) {
4432
- throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
4433
- }
4434
- const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(dirValue);
4435
- return `${alias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
4436
- })
4437
- .join(', ');
4438
- orderClause = ` ORDER BY ${orders}`;
4897
+ orderClause = this.buildRelationOrderClause(targetTable, targetMeta, alias, relOrderEntries, params);
4439
4898
  }
4440
4899
  // Build WHERE — correlate to parent via parentRef (alias or table name).
4441
4900
  // For hasMany/hasOne: TARGET has the FK (RelationDef.foreignKey is always
@@ -4509,8 +4968,10 @@ class QueryInterface {
4509
4968
  const inlineOrder = this.dialect.aggSupportsInlineOrderBy ? orderClause.trim() || undefined : undefined;
4510
4969
  return `SELECT ${this.dialect.buildJsonArrayAgg(jsonObj, inlineOrder)} FROM ${qTarget} ${alias} WHERE ${whereClause}`;
4511
4970
  }
4512
- // belongsTo / hasOne return single object
4513
- return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause} LIMIT 1`;
4971
+ // belongsTo / hasOne: return single object. An orderBy picks WHICH row
4972
+ // the LIMIT 1 keeps (deterministic hasOne over a non-unique FK): matching
4973
+ // the batched strategy, which orders its flat follow-up and takes bucket[0].
4974
+ return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause}${orderClause} LIMIT 1`;
4514
4975
  }
4515
4976
  /**
4516
4977
  * Build the json_agg subquery for a `manyToMany` relation, JOINing the target
@@ -4568,22 +5029,15 @@ class QueryInterface {
4568
5029
  let whereClause = sourceKeys
4569
5030
  .map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
4570
5031
  .join(' AND ');
4571
- // ORDER BY on the target rows. `orderBy: {}` (no defined entries) is
4572
- // treated as absent it must not render a dangling `ORDER BY `.
5032
+ // ORDER BY on the target rows: unified with the top-level orderBy surface
5033
+ // (columnMap resolution, OrderBySpec nulls, JSON-path, relation ordering).
5034
+ // `orderBy: {}` (no defined entries) is treated as absent: it must not
5035
+ // render a dangling `ORDER BY `. Param pushes here land BEFORE the
5036
+ // spec.where params, mirrored by collectRelationSubqueryParams' m2m branch.
4573
5037
  const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
4574
5038
  let orderClause = '';
4575
5039
  if (relOrderEntries.length > 0) {
4576
- const orders = relOrderEntries
4577
- .map(([k, dirValue]) => {
4578
- const col = (0, schema_js_1.camelToSnake)(k);
4579
- if (!targetMeta.allColumns.includes(col)) {
4580
- throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
4581
- }
4582
- const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(dirValue);
4583
- return `${talias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
4584
- })
4585
- .join(', ');
4586
- orderClause = ` ORDER BY ${orders}`;
5040
+ orderClause = this.buildRelationOrderClause(targetTable, targetMeta, talias, relOrderEntries, params);
4587
5041
  }
4588
5042
  // Additional WHERE filters on the target — full scalar where surface,
4589
5043
  // properly parameterized against the target alias.
@@ -4685,18 +5139,59 @@ class QueryInterface {
4685
5139
  };
4686
5140
  return typeMap[baseType] ?? 'text';
4687
5141
  }
5142
+ /**
5143
+ * Validate and enumerate the range comparisons (`gt`/`gte`/`lt`/`lte`) on a
5144
+ * JSON filter, in the fixed {@link JSON_RANGE_OPERATORS} order. Shared by
5145
+ * the SQL-build path ({@link buildJsonFilterClauses}) and the cache-hit
5146
+ * param-collect path ({@link collectJsonFilterParams}) so both always agree
5147
+ * on which params are pushed — and both throw identically for invalid
5148
+ * shapes, so a warmed cache can never skip validation.
5149
+ */
5150
+ jsonRangeEntries(filter, column) {
5151
+ const entries = [];
5152
+ for (const [op, sqlOp] of Object.entries(filters_js_1.JSON_RANGE_OPERATORS)) {
5153
+ const value = filter[op];
5154
+ if (value === undefined)
5155
+ continue;
5156
+ if (filter.path === undefined) {
5157
+ throw new errors_js_1.ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a \`path\` ` +
5158
+ `(e.g. { path: ['meta', 'score'], ${op}: ${JSON.stringify(value)} }).`);
5159
+ }
5160
+ if (typeof value !== 'number' && typeof value !== 'string') {
5161
+ throw new errors_js_1.ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a number or string, ` +
5162
+ `got ${JSON.stringify(value)}.`);
5163
+ }
5164
+ if (typeof value === 'number' && !Number.isFinite(value)) {
5165
+ throw new errors_js_1.ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a finite number.`);
5166
+ }
5167
+ entries.push({ sqlOp, value });
5168
+ }
5169
+ return entries;
5170
+ }
4688
5171
  /**
4689
5172
  * Build SQL clauses for JSONB filter operators on a column.
4690
- * Supports: path, equals, contains, hasKey.
5173
+ * Supports: path, equals, contains, hasKey, gt, gte, lt, lte.
5174
+ *
5175
+ * The `path` param is bound at most once and its placeholder is shared by
5176
+ * every clause that extracts it (equals + range ops), so the param list
5177
+ * stays byte-identical to {@link collectJsonFilterParams}.
4691
5178
  */
4692
5179
  buildJsonFilterClauses(column, filter, params) {
4693
5180
  const clauses = [];
5181
+ // Lazily bind the path once; reuse the same $N in every extraction clause.
5182
+ let pathParamIdx = null;
5183
+ const pathExtract = () => {
5184
+ if (pathParamIdx === null) {
5185
+ params.push(filter.path);
5186
+ pathParamIdx = params.length;
5187
+ }
5188
+ return this.dialect.buildJsonPathExtract(column, this.p(pathParamIdx));
5189
+ };
4694
5190
  if (filter.path !== undefined && filter.equals !== undefined) {
4695
5191
  // Path access + equals: column #>> $N::text[] = $M
4696
- params.push(filter.path);
4697
- const pathParam = params.length;
5192
+ const extract = pathExtract();
4698
5193
  params.push(String(filter.equals));
4699
- clauses.push(`${this.dialect.buildJsonPathExtract(column, this.p(pathParam))} = ${this.p(params.length)}`);
5194
+ clauses.push(`${extract} = ${this.p(params.length)}`);
4700
5195
  }
4701
5196
  else if (filter.equals !== undefined) {
4702
5197
  // Containment equality: column @> $N::jsonb
@@ -4713,8 +5208,28 @@ class QueryInterface {
4713
5208
  params.push(filter.hasKey);
4714
5209
  clauses.push(`${column} ? ${this.p(params.length)}`);
4715
5210
  }
5211
+ // Range comparisons on the extracted path: numbers compare numerically
5212
+ // (cast through the dialect), strings compare as text.
5213
+ for (const { sqlOp, value } of this.jsonRangeEntries(filter, column)) {
5214
+ const extract = pathExtract();
5215
+ params.push(value);
5216
+ const lhs = typeof value === 'number' ? this.castJsonNumeric(extract) : extract;
5217
+ clauses.push(`${lhs} ${sqlOp} ${this.p(params.length)}`);
5218
+ }
4716
5219
  return clauses;
4717
5220
  }
5221
+ /**
5222
+ * Cast an extracted JSON path text value to a numeric type for range
5223
+ * comparison. PostgreSQL uses `(expr)::numeric` (exact — the right way to
5224
+ * compare JSON numbers, and `::float` would lose precision on big ints);
5225
+ * other dialects route through {@link Dialect.castAggregate} (SQLite/MySQL/
5226
+ * SQL Server have no `::` operator) as a float cast.
5227
+ */
5228
+ castJsonNumeric(extract) {
5229
+ if (this.dialect.name === 'postgresql')
5230
+ return `(${extract})::numeric`;
5231
+ return this.dialect.castAggregate ? this.dialect.castAggregate(`(${extract})`, 'float') : `(${extract})::numeric`;
5232
+ }
4718
5233
  /**
4719
5234
  * Build SQL clauses for Array filter operators on a column.
4720
5235
  * Supports: has, hasEvery, hasSome, isEmpty.