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.
@@ -2459,7 +2459,11 @@ class QueryInterface {
2459
2459
  }
2460
2460
  // Operator objects
2461
2461
  if ((0, filters_js_1.isWhereOperator)(value)) {
2462
- this.collectOperatorParams(rawColumn, value, params);
2462
+ this.collectOperatorParams(rawColumn, value, params, {
2463
+ meta: this.tableMeta,
2464
+ table: this.table,
2465
+ prefix: '',
2466
+ });
2463
2467
  continue;
2464
2468
  }
2465
2469
  // Plain equality — same strict validation as the build path, so a
@@ -2560,28 +2564,40 @@ class QueryInterface {
2560
2564
  }
2561
2565
  }
2562
2566
  if ((0, filters_js_1.isWhereOperator)(value)) {
2563
- this.collectOperatorParams(col, value, params);
2567
+ this.collectOperatorParams(col, value, params, { meta, table: targetTable, prefix: '' });
2564
2568
  continue;
2565
2569
  }
2566
2570
  this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
2567
2571
  params.push(value);
2568
2572
  }
2569
2573
  }
2570
- /** Collect params from operator clauses. Mirrors buildOperatorClauses. */
2571
- collectOperatorParams(column, op, params) {
2572
- 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)) {
2573
2589
  (0, filters_js_1.assertBindableEqualsOperand)(op.equals, `"${column}"`);
2574
2590
  params.push(op.equals);
2575
2591
  }
2576
- if (op.gt !== undefined)
2592
+ if (op.gt !== undefined && !skipRef(op.gt))
2577
2593
  params.push(op.gt);
2578
- if (op.gte !== undefined)
2594
+ if (op.gte !== undefined && !skipRef(op.gte))
2579
2595
  params.push(op.gte);
2580
- if (op.lt !== undefined)
2596
+ if (op.lt !== undefined && !skipRef(op.lt))
2581
2597
  params.push(op.lt);
2582
- if (op.lte !== undefined)
2598
+ if (op.lte !== undefined && !skipRef(op.lte))
2583
2599
  params.push(op.lte);
2584
- if (op.not !== undefined && op.not !== null)
2600
+ if (op.not !== undefined && op.not !== null && !skipRef(op.not))
2585
2601
  params.push(op.not);
2586
2602
  if (op.in !== undefined)
2587
2603
  params.push(this.inParam(op.in));
@@ -2637,10 +2653,10 @@ class QueryInterface {
2637
2653
  // isEmpty has no params (IS NULL / IS NOT NULL)
2638
2654
  }
2639
2655
  /**
2640
- * Collect params for an orderBy clause. Only vector KNN ordering pushes a
2641
- * param (the `$n::vector` query vector); plain direction ordering is
2642
- * parameterless. Mirrors buildOrderBy's push order exactly so the cached-SQL
2643
- * 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.
2644
2660
  */
2645
2661
  collectOrderByParams(orderBy, params) {
2646
2662
  for (const [key, dir] of Object.entries(orderBy)) {
@@ -2652,6 +2668,13 @@ class QueryInterface {
2652
2668
  this.pushVectorParam(key, rawColumn, dir.distance.to, params);
2653
2669
  continue;
2654
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
+ }
2655
2678
  // To-many relation orderBy (`{ posts: { _count } }`) uses the same count
2656
2679
  // subquery as `_count` — mirror its global-filter params. To-one relation
2657
2680
  // orderBy carries the target's global filter once per ordered column.
@@ -2798,9 +2821,20 @@ class QueryInterface {
2798
2821
  const targetMeta = this.schema.tables[targetTable];
2799
2822
  if (!targetMeta)
2800
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;
2801
2830
  // manyToMany param order mirrors buildManyToManySubquery:
2802
- // where params → limit param → nested-with params (always, both paths).
2831
+ // orderBy params → where params → limit param → nested-with params
2832
+ // (always, both paths).
2803
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
+ }
2804
2838
  if (spec.where) {
2805
2839
  this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
2806
2840
  }
@@ -2819,7 +2853,8 @@ class QueryInterface {
2819
2853
  return;
2820
2854
  }
2821
2855
  // Mirrors buildRelationSubquery's willWrap: `orderBy: {}` is treated as absent.
2822
- 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;
2823
2858
  const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
2824
2859
  // Non-wrapped path: nested relations BEFORE where/limit
2825
2860
  if (!willWrap && spec.with) {
@@ -2830,6 +2865,12 @@ class QueryInterface {
2830
2865
  this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
2831
2866
  }
2832
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
+ }
2833
2874
  // where params — mirrors buildAliasWhere push order
2834
2875
  if (spec.where) {
2835
2876
  this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
@@ -3180,7 +3221,11 @@ class QueryInterface {
3180
3221
  }
3181
3222
  // Handle operator objects
3182
3223
  if ((0, filters_js_1.isWhereOperator)(value)) {
3183
- 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
+ });
3184
3229
  andClauses.push(...opClauses);
3185
3230
  continue;
3186
3231
  }
@@ -3374,7 +3419,11 @@ class QueryInterface {
3374
3419
  }
3375
3420
  }
3376
3421
  if ((0, filters_js_1.isWhereOperator)(value)) {
3377
- const opClauses = this.buildOperatorClauses(qCol, value, params);
3422
+ const opClauses = this.buildOperatorClauses(qCol, value, params, {
3423
+ meta,
3424
+ table: targetTable,
3425
+ prefix: `${qt}.`,
3426
+ });
3378
3427
  conditions.push(...opClauses);
3379
3428
  continue;
3380
3429
  }
@@ -3541,7 +3590,11 @@ class QueryInterface {
3541
3590
  }
3542
3591
  }
3543
3592
  if ((0, filters_js_1.isWhereOperator)(value)) {
3544
- 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
+ }));
3545
3598
  continue;
3546
3599
  }
3547
3600
  this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
@@ -3600,7 +3653,7 @@ class QueryInterface {
3600
3653
  }
3601
3654
  }
3602
3655
  if ((0, filters_js_1.isWhereOperator)(value)) {
3603
- this.collectOperatorParams(col, value, params);
3656
+ this.collectOperatorParams(col, value, params, { meta: targetMeta, table: targetTable, prefix: '' });
3604
3657
  continue;
3605
3658
  }
3606
3659
  this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
@@ -3677,16 +3730,55 @@ class QueryInterface {
3677
3730
  }
3678
3731
  return parts.join('&');
3679
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
+ }
3680
3764
  /**
3681
3765
  * Build SQL clauses for a single operator object on a column.
3682
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.
3683
3772
  */
3684
- buildOperatorClauses(column, op, params) {
3773
+ buildOperatorClauses(column, op, params, refCtx) {
3685
3774
  const clauses = [];
3686
3775
  if (op.equals !== undefined) {
3687
3776
  if (op.equals === null) {
3688
3777
  clauses.push(`${column} IS NULL`);
3689
3778
  }
3779
+ else if ((0, filters_js_1.isColumnRef)(op.equals)) {
3780
+ clauses.push(`${column} = ${this.columnRefSql(op.equals, refCtx, op.mode)}`);
3781
+ }
3690
3782
  else {
3691
3783
  (0, filters_js_1.assertBindableEqualsOperand)(op.equals, column);
3692
3784
  params.push(op.equals);
@@ -3694,25 +3786,48 @@ class QueryInterface {
3694
3786
  }
3695
3787
  }
3696
3788
  if (op.gt !== undefined) {
3697
- params.push(op.gt);
3698
- 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
+ }
3699
3796
  }
3700
3797
  if (op.gte !== undefined) {
3701
- params.push(op.gte);
3702
- 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
+ }
3703
3805
  }
3704
3806
  if (op.lt !== undefined) {
3705
- params.push(op.lt);
3706
- 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
+ }
3707
3814
  }
3708
3815
  if (op.lte !== undefined) {
3709
- params.push(op.lte);
3710
- 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
+ }
3711
3823
  }
3712
3824
  if (op.not !== undefined) {
3713
3825
  if (op.not === null) {
3714
3826
  clauses.push(`${column} IS NOT NULL`);
3715
3827
  }
3828
+ else if ((0, filters_js_1.isColumnRef)(op.not)) {
3829
+ clauses.push(`${column} != ${this.columnRefSql(op.not, refCtx, op.mode)}`);
3830
+ }
3716
3831
  else {
3717
3832
  params.push(op.not);
3718
3833
  clauses.push(`${column} != ${this.p(params.length)}`);
@@ -3763,6 +3878,11 @@ class QueryInterface {
3763
3878
  if ((0, filters_js_1.isVectorOrderBy)(d)) {
3764
3879
  return `vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
3765
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
+ }
3766
3886
  if ((0, filters_js_1.isOrderBySpec)(d))
3767
3887
  return `spec(${d.sort},${d.nulls ?? ''})`;
3768
3888
  if (d && typeof d === 'object') {
@@ -3808,6 +3928,11 @@ class QueryInterface {
3808
3928
  const safeDir = value.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3809
3929
  return `${this.q(rawColumn)} ${operator} ${placeholder} ${safeDir}`;
3810
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
+ }
3811
3936
  // Relation ordering: an object value that is not a vector or OrderBySpec,
3812
3937
  // keyed by a relation name (`{ posts: { _count: 'desc' } }` / `{ author:
3813
3938
  // { name: 'asc' } }`).
@@ -3834,6 +3959,7 @@ class QueryInterface {
3834
3959
  value !== null &&
3835
3960
  !Array.isArray(value) &&
3836
3961
  !(0, filters_js_1.isVectorOrderBy)(value) &&
3962
+ !(0, filters_js_1.isJsonPathOrderBy)(value) &&
3837
3963
  !(0, filters_js_1.isOrderBySpec)(value));
3838
3964
  }
3839
3965
  /**
@@ -3850,6 +3976,63 @@ class QueryInterface {
3850
3976
  }
3851
3977
  return nulls === 'first' ? ' NULLS FIRST' : ' NULLS LAST';
3852
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
+ }
3853
4036
  /**
3854
4037
  * Compile a relation ordering term. For a to-many relation the only allowed
3855
4038
  * key is `_count`, which becomes a correlated `COUNT(*)` subquery. For a
@@ -3858,12 +4041,19 @@ class QueryInterface {
3858
4041
  *
3859
4042
  * Validation: relation must exist (E005); to-many only allows `_count`, and
3860
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`.
3861
4048
  */
3862
- buildRelationOrderBy(relName, value, alias, params) {
3863
- 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];
3864
4054
  if (!relDef) {
3865
- throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${this.table}". ` +
3866
- `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(', ')}`);
3867
4057
  }
3868
4058
  // To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
3869
4059
  if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
@@ -3873,14 +4063,14 @@ class QueryInterface {
3873
4063
  `(got: ${keys.join(', ') || '(empty)'}).`);
3874
4064
  }
3875
4065
  const { dir } = (0, filters_js_1.normalizeOrderBy)(value._count);
3876
- return `${this.buildRelationCountExpr(relDef, this.table, alias, params)} ${dir}`;
4066
+ return `${this.buildRelationCountExpr(relDef, parentRef, alias, params)} ${dir}`;
3877
4067
  }
3878
4068
  // To-one: each entry orders by a correlated scalar subquery on a target column.
3879
4069
  const targetMeta = this.schema.tables[relDef.to];
3880
4070
  if (!targetMeta)
3881
4071
  throw new errors_js_1.RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
3882
4072
  const qTarget = this.q(relDef.to);
3883
- const qParent = this.q(this.table);
4073
+ const qParent = this.q(parentRef);
3884
4074
  // belongsTo: alias.referenceKey = parent.foreignKey; hasOne: reversed.
3885
4075
  const correlation = relDef.type === 'belongsTo'
3886
4076
  ? this.dialect.buildCorrelation(alias, relDef.referenceKey, qParent, relDef.foreignKey)
@@ -3891,7 +4081,9 @@ class QueryInterface {
3891
4081
  }
3892
4082
  return entries
3893
4083
  .map(([col, dirValue]) => {
3894
- 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);
3895
4087
  if (!targetMeta.allColumns.includes(snakeCol)) {
3896
4088
  throw new errors_js_1.ValidationError(`[turbine] Unknown column "${col}" in orderBy on relation "${relName}" (table "${relDef.to}").`);
3897
4089
  }
@@ -3909,6 +4101,74 @@ class QueryInterface {
3909
4101
  })
3910
4102
  .join(', ');
3911
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
+ }
3912
4172
  /**
3913
4173
  * Build a correlated `(SELECT COUNT(*) …)` scalar subquery for a to-many
3914
4174
  * relation, correlated to `parentRef`. hasMany counts child rows via the FK;
@@ -4628,20 +4888,13 @@ class QueryInterface {
4628
4888
  // Quote parent ref — can be a table name or auto-generated alias
4629
4889
  const qParent = this.q(parentRef);
4630
4890
  const qTarget = this.q(targetTable);
4631
- // 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.
4632
4895
  let orderClause = '';
4633
4896
  if (relOrderEntries.length > 0) {
4634
- const orders = relOrderEntries
4635
- .map(([k, dirValue]) => {
4636
- const col = (0, schema_js_1.camelToSnake)(k);
4637
- if (!targetMeta.allColumns.includes(col)) {
4638
- throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
4639
- }
4640
- const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(dirValue);
4641
- return `${alias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
4642
- })
4643
- .join(', ');
4644
- orderClause = ` ORDER BY ${orders}`;
4897
+ orderClause = this.buildRelationOrderClause(targetTable, targetMeta, alias, relOrderEntries, params);
4645
4898
  }
4646
4899
  // Build WHERE — correlate to parent via parentRef (alias or table name).
4647
4900
  // For hasMany/hasOne: TARGET has the FK (RelationDef.foreignKey is always
@@ -4715,8 +4968,10 @@ class QueryInterface {
4715
4968
  const inlineOrder = this.dialect.aggSupportsInlineOrderBy ? orderClause.trim() || undefined : undefined;
4716
4969
  return `SELECT ${this.dialect.buildJsonArrayAgg(jsonObj, inlineOrder)} FROM ${qTarget} ${alias} WHERE ${whereClause}`;
4717
4970
  }
4718
- // belongsTo / hasOne return single object
4719
- 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`;
4720
4975
  }
4721
4976
  /**
4722
4977
  * Build the json_agg subquery for a `manyToMany` relation, JOINing the target
@@ -4774,22 +5029,15 @@ class QueryInterface {
4774
5029
  let whereClause = sourceKeys
4775
5030
  .map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
4776
5031
  .join(' AND ');
4777
- // ORDER BY on the target rows. `orderBy: {}` (no defined entries) is
4778
- // 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.
4779
5037
  const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
4780
5038
  let orderClause = '';
4781
5039
  if (relOrderEntries.length > 0) {
4782
- const orders = relOrderEntries
4783
- .map(([k, dirValue]) => {
4784
- const col = (0, schema_js_1.camelToSnake)(k);
4785
- if (!targetMeta.allColumns.includes(col)) {
4786
- throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
4787
- }
4788
- const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(dirValue);
4789
- return `${talias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
4790
- })
4791
- .join(', ');
4792
- orderClause = ` ORDER BY ${orders}`;
5040
+ orderClause = this.buildRelationOrderClause(targetTable, targetMeta, talias, relOrderEntries, params);
4793
5041
  }
4794
5042
  // Additional WHERE filters on the target — full scalar where surface,
4795
5043
  // properly parameterized against the target alias.