turbine-orm 0.30.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.
@@ -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, fingerprintJsonFilterShape, fingerprintOperatorShape, isArrayFilter, isJsonFilter, 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';
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();
@@ -2423,7 +2423,11 @@ export class QueryInterface {
2423
2423
  }
2424
2424
  // Operator objects
2425
2425
  if (isWhereOperator(value)) {
2426
- this.collectOperatorParams(rawColumn, value, params);
2426
+ this.collectOperatorParams(rawColumn, value, params, {
2427
+ meta: this.tableMeta,
2428
+ table: this.table,
2429
+ prefix: '',
2430
+ });
2427
2431
  continue;
2428
2432
  }
2429
2433
  // Plain equality — same strict validation as the build path, so a
@@ -2524,28 +2528,40 @@ export class QueryInterface {
2524
2528
  }
2525
2529
  }
2526
2530
  if (isWhereOperator(value)) {
2527
- this.collectOperatorParams(col, value, params);
2531
+ this.collectOperatorParams(col, value, params, { meta, table: targetTable, prefix: '' });
2528
2532
  continue;
2529
2533
  }
2530
2534
  this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
2531
2535
  params.push(value);
2532
2536
  }
2533
2537
  }
2534
- /** Collect params from operator clauses. Mirrors buildOperatorClauses. */
2535
- collectOperatorParams(column, op, params) {
2536
- 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)) {
2537
2553
  assertBindableEqualsOperand(op.equals, `"${column}"`);
2538
2554
  params.push(op.equals);
2539
2555
  }
2540
- if (op.gt !== undefined)
2556
+ if (op.gt !== undefined && !skipRef(op.gt))
2541
2557
  params.push(op.gt);
2542
- if (op.gte !== undefined)
2558
+ if (op.gte !== undefined && !skipRef(op.gte))
2543
2559
  params.push(op.gte);
2544
- if (op.lt !== undefined)
2560
+ if (op.lt !== undefined && !skipRef(op.lt))
2545
2561
  params.push(op.lt);
2546
- if (op.lte !== undefined)
2562
+ if (op.lte !== undefined && !skipRef(op.lte))
2547
2563
  params.push(op.lte);
2548
- if (op.not !== undefined && op.not !== null)
2564
+ if (op.not !== undefined && op.not !== null && !skipRef(op.not))
2549
2565
  params.push(op.not);
2550
2566
  if (op.in !== undefined)
2551
2567
  params.push(this.inParam(op.in));
@@ -2601,10 +2617,10 @@ export class QueryInterface {
2601
2617
  // isEmpty has no params (IS NULL / IS NOT NULL)
2602
2618
  }
2603
2619
  /**
2604
- * Collect params for an orderBy clause. Only vector KNN ordering pushes a
2605
- * param (the `$n::vector` query vector); plain direction ordering is
2606
- * parameterless. Mirrors buildOrderBy's push order exactly so the cached-SQL
2607
- * 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.
2608
2624
  */
2609
2625
  collectOrderByParams(orderBy, params) {
2610
2626
  for (const [key, dir] of Object.entries(orderBy)) {
@@ -2616,6 +2632,13 @@ export class QueryInterface {
2616
2632
  this.pushVectorParam(key, rawColumn, dir.distance.to, params);
2617
2633
  continue;
2618
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
+ }
2619
2642
  // To-many relation orderBy (`{ posts: { _count } }`) uses the same count
2620
2643
  // subquery as `_count` — mirror its global-filter params. To-one relation
2621
2644
  // orderBy carries the target's global filter once per ordered column.
@@ -2762,9 +2785,20 @@ export class QueryInterface {
2762
2785
  const targetMeta = this.schema.tables[targetTable];
2763
2786
  if (!targetMeta)
2764
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;
2765
2794
  // manyToMany param order mirrors buildManyToManySubquery:
2766
- // where params → limit param → nested-with params (always, both paths).
2795
+ // orderBy params → where params → limit param → nested-with params
2796
+ // (always, both paths).
2767
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
+ }
2768
2802
  if (spec.where) {
2769
2803
  this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
2770
2804
  }
@@ -2783,7 +2817,8 @@ export class QueryInterface {
2783
2817
  return;
2784
2818
  }
2785
2819
  // Mirrors buildRelationSubquery's willWrap: `orderBy: {}` is treated as absent.
2786
- 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;
2787
2822
  const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
2788
2823
  // Non-wrapped path: nested relations BEFORE where/limit
2789
2824
  if (!willWrap && spec.with) {
@@ -2794,6 +2829,12 @@ export class QueryInterface {
2794
2829
  this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
2795
2830
  }
2796
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
+ }
2797
2838
  // where params — mirrors buildAliasWhere push order
2798
2839
  if (spec.where) {
2799
2840
  this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
@@ -3144,7 +3185,11 @@ export class QueryInterface {
3144
3185
  }
3145
3186
  // Handle operator objects
3146
3187
  if (isWhereOperator(value)) {
3147
- 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
+ });
3148
3193
  andClauses.push(...opClauses);
3149
3194
  continue;
3150
3195
  }
@@ -3338,7 +3383,11 @@ export class QueryInterface {
3338
3383
  }
3339
3384
  }
3340
3385
  if (isWhereOperator(value)) {
3341
- const opClauses = this.buildOperatorClauses(qCol, value, params);
3386
+ const opClauses = this.buildOperatorClauses(qCol, value, params, {
3387
+ meta,
3388
+ table: targetTable,
3389
+ prefix: `${qt}.`,
3390
+ });
3342
3391
  conditions.push(...opClauses);
3343
3392
  continue;
3344
3393
  }
@@ -3505,7 +3554,11 @@ export class QueryInterface {
3505
3554
  }
3506
3555
  }
3507
3556
  if (isWhereOperator(value)) {
3508
- 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
+ }));
3509
3562
  continue;
3510
3563
  }
3511
3564
  this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
@@ -3564,7 +3617,7 @@ export class QueryInterface {
3564
3617
  }
3565
3618
  }
3566
3619
  if (isWhereOperator(value)) {
3567
- this.collectOperatorParams(col, value, params);
3620
+ this.collectOperatorParams(col, value, params, { meta: targetMeta, table: targetTable, prefix: '' });
3568
3621
  continue;
3569
3622
  }
3570
3623
  this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
@@ -3641,16 +3694,55 @@ export class QueryInterface {
3641
3694
  }
3642
3695
  return parts.join('&');
3643
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
+ }
3644
3728
  /**
3645
3729
  * Build SQL clauses for a single operator object on a column.
3646
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.
3647
3736
  */
3648
- buildOperatorClauses(column, op, params) {
3737
+ buildOperatorClauses(column, op, params, refCtx) {
3649
3738
  const clauses = [];
3650
3739
  if (op.equals !== undefined) {
3651
3740
  if (op.equals === null) {
3652
3741
  clauses.push(`${column} IS NULL`);
3653
3742
  }
3743
+ else if (isColumnRef(op.equals)) {
3744
+ clauses.push(`${column} = ${this.columnRefSql(op.equals, refCtx, op.mode)}`);
3745
+ }
3654
3746
  else {
3655
3747
  assertBindableEqualsOperand(op.equals, column);
3656
3748
  params.push(op.equals);
@@ -3658,25 +3750,48 @@ export class QueryInterface {
3658
3750
  }
3659
3751
  }
3660
3752
  if (op.gt !== undefined) {
3661
- params.push(op.gt);
3662
- 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
+ }
3663
3760
  }
3664
3761
  if (op.gte !== undefined) {
3665
- params.push(op.gte);
3666
- 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
+ }
3667
3769
  }
3668
3770
  if (op.lt !== undefined) {
3669
- params.push(op.lt);
3670
- 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
+ }
3671
3778
  }
3672
3779
  if (op.lte !== undefined) {
3673
- params.push(op.lte);
3674
- 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
+ }
3675
3787
  }
3676
3788
  if (op.not !== undefined) {
3677
3789
  if (op.not === null) {
3678
3790
  clauses.push(`${column} IS NOT NULL`);
3679
3791
  }
3792
+ else if (isColumnRef(op.not)) {
3793
+ clauses.push(`${column} != ${this.columnRefSql(op.not, refCtx, op.mode)}`);
3794
+ }
3680
3795
  else {
3681
3796
  params.push(op.not);
3682
3797
  clauses.push(`${column} != ${this.p(params.length)}`);
@@ -3727,6 +3842,11 @@ export class QueryInterface {
3727
3842
  if (isVectorOrderBy(d)) {
3728
3843
  return `vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
3729
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
+ }
3730
3850
  if (isOrderBySpec(d))
3731
3851
  return `spec(${d.sort},${d.nulls ?? ''})`;
3732
3852
  if (d && typeof d === 'object') {
@@ -3772,6 +3892,11 @@ export class QueryInterface {
3772
3892
  const safeDir = value.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3773
3893
  return `${this.q(rawColumn)} ${operator} ${placeholder} ${safeDir}`;
3774
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
+ }
3775
3900
  // Relation ordering: an object value that is not a vector or OrderBySpec,
3776
3901
  // keyed by a relation name (`{ posts: { _count: 'desc' } }` / `{ author:
3777
3902
  // { name: 'asc' } }`).
@@ -3798,6 +3923,7 @@ export class QueryInterface {
3798
3923
  value !== null &&
3799
3924
  !Array.isArray(value) &&
3800
3925
  !isVectorOrderBy(value) &&
3926
+ !isJsonPathOrderBy(value) &&
3801
3927
  !isOrderBySpec(value));
3802
3928
  }
3803
3929
  /**
@@ -3814,6 +3940,63 @@ export class QueryInterface {
3814
3940
  }
3815
3941
  return nulls === 'first' ? ' NULLS FIRST' : ' NULLS LAST';
3816
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
+ }
3817
4000
  /**
3818
4001
  * Compile a relation ordering term. For a to-many relation the only allowed
3819
4002
  * key is `_count`, which becomes a correlated `COUNT(*)` subquery. For a
@@ -3822,12 +4005,19 @@ export class QueryInterface {
3822
4005
  *
3823
4006
  * Validation: relation must exist (E005); to-many only allows `_count`, and
3824
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`.
3825
4012
  */
3826
- buildRelationOrderBy(relName, value, alias, params) {
3827
- 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];
3828
4018
  if (!relDef) {
3829
- throw new RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${this.table}". ` +
3830
- `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(', ')}`);
3831
4021
  }
3832
4022
  // To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
3833
4023
  if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
@@ -3837,14 +4027,14 @@ export class QueryInterface {
3837
4027
  `(got: ${keys.join(', ') || '(empty)'}).`);
3838
4028
  }
3839
4029
  const { dir } = normalizeOrderBy(value._count);
3840
- return `${this.buildRelationCountExpr(relDef, this.table, alias, params)} ${dir}`;
4030
+ return `${this.buildRelationCountExpr(relDef, parentRef, alias, params)} ${dir}`;
3841
4031
  }
3842
4032
  // To-one: each entry orders by a correlated scalar subquery on a target column.
3843
4033
  const targetMeta = this.schema.tables[relDef.to];
3844
4034
  if (!targetMeta)
3845
4035
  throw new RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
3846
4036
  const qTarget = this.q(relDef.to);
3847
- const qParent = this.q(this.table);
4037
+ const qParent = this.q(parentRef);
3848
4038
  // belongsTo: alias.referenceKey = parent.foreignKey; hasOne: reversed.
3849
4039
  const correlation = relDef.type === 'belongsTo'
3850
4040
  ? this.dialect.buildCorrelation(alias, relDef.referenceKey, qParent, relDef.foreignKey)
@@ -3855,7 +4045,9 @@ export class QueryInterface {
3855
4045
  }
3856
4046
  return entries
3857
4047
  .map(([col, dirValue]) => {
3858
- 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);
3859
4051
  if (!targetMeta.allColumns.includes(snakeCol)) {
3860
4052
  throw new ValidationError(`[turbine] Unknown column "${col}" in orderBy on relation "${relName}" (table "${relDef.to}").`);
3861
4053
  }
@@ -3873,6 +4065,74 @@ export class QueryInterface {
3873
4065
  })
3874
4066
  .join(', ');
3875
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
+ }
3876
4136
  /**
3877
4137
  * Build a correlated `(SELECT COUNT(*) …)` scalar subquery for a to-many
3878
4138
  * relation, correlated to `parentRef`. hasMany counts child rows via the FK;
@@ -4592,20 +4852,13 @@ export class QueryInterface {
4592
4852
  // Quote parent ref — can be a table name or auto-generated alias
4593
4853
  const qParent = this.q(parentRef);
4594
4854
  const qTarget = this.q(targetTable);
4595
- // 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.
4596
4859
  let orderClause = '';
4597
4860
  if (relOrderEntries.length > 0) {
4598
- const orders = relOrderEntries
4599
- .map(([k, dirValue]) => {
4600
- const col = camelToSnake(k);
4601
- if (!targetMeta.allColumns.includes(col)) {
4602
- throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
4603
- }
4604
- const { dir, nulls } = normalizeOrderBy(dirValue);
4605
- return `${alias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
4606
- })
4607
- .join(', ');
4608
- orderClause = ` ORDER BY ${orders}`;
4861
+ orderClause = this.buildRelationOrderClause(targetTable, targetMeta, alias, relOrderEntries, params);
4609
4862
  }
4610
4863
  // Build WHERE — correlate to parent via parentRef (alias or table name).
4611
4864
  // For hasMany/hasOne: TARGET has the FK (RelationDef.foreignKey is always
@@ -4679,8 +4932,10 @@ export class QueryInterface {
4679
4932
  const inlineOrder = this.dialect.aggSupportsInlineOrderBy ? orderClause.trim() || undefined : undefined;
4680
4933
  return `SELECT ${this.dialect.buildJsonArrayAgg(jsonObj, inlineOrder)} FROM ${qTarget} ${alias} WHERE ${whereClause}`;
4681
4934
  }
4682
- // belongsTo / hasOne return single object
4683
- 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`;
4684
4939
  }
4685
4940
  /**
4686
4941
  * Build the json_agg subquery for a `manyToMany` relation, JOINing the target
@@ -4738,22 +4993,15 @@ export class QueryInterface {
4738
4993
  let whereClause = sourceKeys
4739
4994
  .map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
4740
4995
  .join(' AND ');
4741
- // ORDER BY on the target rows. `orderBy: {}` (no defined entries) is
4742
- // 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.
4743
5001
  const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
4744
5002
  let orderClause = '';
4745
5003
  if (relOrderEntries.length > 0) {
4746
- const orders = relOrderEntries
4747
- .map(([k, dirValue]) => {
4748
- const col = camelToSnake(k);
4749
- if (!targetMeta.allColumns.includes(col)) {
4750
- throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
4751
- }
4752
- const { dir, nulls } = normalizeOrderBy(dirValue);
4753
- return `${talias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
4754
- })
4755
- .join(', ');
4756
- orderClause = ` ORDER BY ${orders}`;
5004
+ orderClause = this.buildRelationOrderClause(targetTable, targetMeta, talias, relOrderEntries, params);
4757
5005
  }
4758
5006
  // Additional WHERE filters on the target — full scalar where surface,
4759
5007
  // properly parameterized against the target alias.
@@ -5,7 +5,7 @@
5
5
  * compiler. Kept out of builder.ts so the class file stays about SQL assembly
6
6
  * and execution rather than filter-shape bookkeeping.
7
7
  */
8
- import type { ArrayFilter, JsonFilter, OrderBySpec, OrderDirection, TextSearchFilter, VectorFilter, VectorOrderBy, WhereOperator } from './types.js';
8
+ import type { ArrayFilter, ColumnRef, JsonFilter, JsonPathOrderBy, OrderBySpec, OrderDirection, TextSearchFilter, VectorFilter, VectorOrderBy, WhereOperator } from './types.js';
9
9
  /** Check if a value is a where operator object (has at least one known operator key) */
10
10
  export declare function isWhereOperator(value: unknown): value is WhereOperator;
11
11
  /**
@@ -15,11 +15,30 @@ export declare function isWhereOperator(value: unknown): value is WhereOperator;
15
15
  * bind values and return false, as do arrays and Dates.
16
16
  */
17
17
  export declare function isUnmatchedPlainObject(value: unknown): boolean;
18
+ /**
19
+ * Operator keys that accept a {@link ColumnRef} (`{ col: 'otherField' }`)
20
+ * value for column-to-column comparison. `in`/`notIn` and the LIKE operators
21
+ * take values only.
22
+ */
23
+ export declare const COLUMN_REF_OPERATORS: Set<string>;
24
+ /**
25
+ * Check if an operator value is a column reference: a plain object whose ONLY
26
+ * key is `col` with a string value. Anything else (extra keys, non-string
27
+ * `col`) is treated as a plain value so JSON payloads that merely contain a
28
+ * `col` property keep their equality meaning.
29
+ */
30
+ export declare function isColumnRef(value: unknown): value is ColumnRef;
18
31
  /**
19
32
  * Fingerprint the SHAPE of a where-operator object. Null-valued `equals` /
20
33
  * `not` compile to parameterless `IS NULL` / `IS NOT NULL` (different SQL, no
21
34
  * param pushed), so null-ness is part of the shape — without it a cache entry
22
35
  * warmed by `{ not: 5 }` would serve `{ not: null }` with a desynced param list.
36
+ *
37
+ * Column references ({@link ColumnRef}) compile the referenced column into the
38
+ * SQL TEXT (no param bound), so the referenced field name is part of the shape
39
+ *: `{ equals: { col: 'a' } }` and `{ equals: { col: 'b' } }` must never share
40
+ * a cache entry. The name is JSON-encoded so exotic field names cannot collide
41
+ * with other fingerprint tokens.
23
42
  */
24
43
  export declare function fingerprintOperatorShape(value: WhereOperator): string;
25
44
  /**
@@ -127,6 +146,14 @@ export declare function isVectorFilter(value: unknown): value is VectorFilter;
127
146
  export declare function isVectorOrderBy(value: unknown): value is VectorOrderBy;
128
147
  /** Check if an orderBy value is an explicit `{ sort, nulls? }` spec. */
129
148
  export declare function isOrderBySpec(value: unknown): value is OrderBySpec;
149
+ /**
150
+ * Check if an orderBy value is a JSON-path ordering: `{ path: [...] }` with an
151
+ * ARRAY path. The array requirement disambiguates from relation orderBy values
152
+ * (whose entries are directions/specs keyed by target column: a target column
153
+ * literally named `path` maps to a string direction, never an array), and the
154
+ * `distance`/`sort` exclusions keep vector and spec shapes out.
155
+ */
156
+ export declare function isJsonPathOrderBy(value: unknown): value is JsonPathOrderBy;
130
157
  /**
131
158
  * Normalize an orderBy value into `{ direction, nulls }`. Accepts a plain
132
159
  * direction string or an {@link OrderBySpec}. Used by every ORDER BY compile