turbine-orm 0.30.0 → 0.32.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.
@@ -15,8 +15,8 @@ import { CircularRelationError, NotFoundError, OptimisticLockError, RelationErro
15
15
  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
- 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';
18
+ import { includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, rejectNestedPickOrder, resolveCountRelations, stripFields, } from './batched-loader.js';
19
+ import { assertBindableEqualsOperand, findArrayUniqueKey, findJsonUniqueKey, fingerprintJsonFilterShape, fingerprintOperatorShape, isArrayFilter, isColumnRef, isJsonFilter, isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, 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();
@@ -291,6 +291,10 @@ export class QueryInterface {
291
291
  */
292
292
  async runFindManyBatched(args) {
293
293
  const withClause = args.with;
294
+ // Scope-rule parity with the join strategy (which throws at SQL build):
295
+ // reject nested pick-row ordering BEFORE the base query so acceptance
296
+ // never depends on how many rows come back.
297
+ rejectNestedPickOrder(withClause);
294
298
  // Capture the opt-out from the ARGS before any await: this.currentSkip is
295
299
  // instance state on a cached accessor, so a concurrent build during the
296
300
  // base-query await would overwrite it (tenant query loading relations with
@@ -516,6 +520,8 @@ export class QueryInterface {
516
520
  */
517
521
  async runFindUniqueBatched(args) {
518
522
  const withClause = args.with;
523
+ // Same scope-rule parity as runFindManyBatched: reject before querying.
524
+ rejectNestedPickOrder(withClause);
519
525
  const needed = neededParentKeyFields(this.tableMeta, withClause);
520
526
  const proj = includeKeysForBatching(args.select, args.omit, needed);
521
527
  const baseArgs = { ...args, with: undefined, select: proj.select, omit: proj.omit };
@@ -702,6 +708,21 @@ export class QueryInterface {
702
708
  // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
703
709
  buildFindMany(args) {
704
710
  this.currentSkip = args?.skipGlobalFilters;
711
+ // `distinct` + relation orderBy is refused up front (E003): the distinct
712
+ // path re-orders in an outer wrapper (`... AS "<table>_distinct" ORDER BY
713
+ // <userOrder>`) where a correlated relation subquery (pick-row, `_count`,
714
+ // to-one relation ordering) would reference the parent table name out of
715
+ // scope — a guaranteed "missing FROM-clause entry" crash on Postgres.
716
+ // Checked BEFORE the SQL cache so build and warm-cache paths throw
717
+ // identically (same rule as the vector guard inside the distinct branch).
718
+ if (args?.distinct && args.distinct.length > 0 && args.orderBy) {
719
+ for (const d of Object.values(args.orderBy)) {
720
+ if (this.isRelationOrderByValue(d)) {
721
+ throw new ValidationError('[turbine] `distinct` cannot be combined with relation orderBy (pick-row, `_count`, or ' +
722
+ 'to-one relation ordering): the outer re-order cannot reference the parent table.');
723
+ }
724
+ }
725
+ }
705
726
  const columnsList = this.resolveColumns(args?.select, args?.omit);
706
727
  const colKey = columnsList ? columnsList.join(',') : '*';
707
728
  // AND-merge this table's global filter into the user where; `hasWhere` gates
@@ -715,7 +736,7 @@ export class QueryInterface {
715
736
  const withFp = args?.with ? this.withFingerprint(args.with) : '';
716
737
  const orderFp = args?.orderBy
717
738
  ? Object.entries(args.orderBy)
718
- .map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d)}`)
739
+ .map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, this.tableMeta.relations[k]?.to)}`)
719
740
  .join(',')
720
741
  : '';
721
742
  const cursorFp = args?.cursor
@@ -1576,67 +1597,124 @@ export class QueryInterface {
1576
1597
  const meta = this.schema.tables[this.table];
1577
1598
  if (meta) {
1578
1599
  for (const key of args.by) {
1579
- if (!(key in meta.columnMap)) {
1600
+ if (typeof key === 'string' && !(key in meta.columnMap)) {
1580
1601
  throw new ValidationError(`Unknown column "${key}" in groupBy for table "${this.table}"`);
1581
1602
  }
1582
1603
  }
1583
1604
  }
1584
1605
  this.currentSkip = args.skipGlobalFilters;
1585
- const groupColsRaw = args.by.map((k) => this.toColumn(k));
1586
- const groupCols = groupColsRaw.map((c) => this.q(c));
1587
1606
  const gbWhere = this.mergeGlobalFilter(args.where);
1588
1607
  const { sql: whereSql, params } = gbWhere
1589
1608
  ? this.buildWhere(gbWhere)
1590
1609
  : { sql: '', params: [] };
1591
- // Build SELECT expressions: group-by columns + aggregate functions
1592
- const selectExprs = [...groupCols];
1610
+ // Row source. Plain: `"table"<WHERE>`. With `distinctOn` (PostgreSQL
1611
+ // only), the groupBy runs over one representative row per column
1612
+ // combination: the wrapper carries args.where INSIDE it (filter before
1613
+ // picking) and is aliased as the table name so every outer expression is
1614
+ // byte-identical either way.
1615
+ const fromSql = args.distinctOn
1616
+ ? this.buildDistinctOnSource(args.distinctOn, whereSql, params)
1617
+ : `${this.q(this.table)}${whereSql}`;
1618
+ // Group keys: plain columns and/or JSON-path keys. Output-name collisions
1619
+ // are rejected up front — and the check runs over the EMITTED SQL output
1620
+ // column names (snake_case column / JSON alias / `_agg_key` aggregate
1621
+ // alias), not just the given arg keys: the driver keeps only the LAST
1622
+ // duplicate field per row object, so a JSON alias equal to another key's
1623
+ // snake_case column (or an aggregate output alias) would silently clobber
1624
+ // that value in the results.
1625
+ const groupExprs = [];
1626
+ const selectExprs = [];
1627
+ /** by entries in order: how to read each group key off the result row. */
1628
+ const byReaders = [];
1629
+ const usedResultKeys = new Set();
1630
+ const claimResultKey = (key, what) => {
1631
+ if (key === '_count' || usedResultKeys.has(key)) {
1632
+ throw new ValidationError(`[turbine] groupBy output name "${key}" (${what}) collides with another output column on table ` +
1633
+ `"${this.table}": set an explicit \`alias\` (or rename the aggregate key) to disambiguate.`);
1634
+ }
1635
+ usedResultKeys.add(key);
1636
+ };
1637
+ for (const entry of args.by) {
1638
+ if (typeof entry === 'string') {
1639
+ const col = this.toColumn(entry);
1640
+ claimResultKey(entry, `column "${col}"`);
1641
+ // The emitted output column is the snake_case name; claim it too (when
1642
+ // it differs from the result key) so a JSON alias like 'created_at'
1643
+ // cannot silently shadow the 'createdAt' group key on the wire.
1644
+ if (col !== entry)
1645
+ claimResultKey(col, `column "${col}"`);
1646
+ groupExprs.push(this.q(col));
1647
+ selectExprs.push(this.q(col));
1648
+ byReaders.push({ resultKey: entry, rowKey: col, raw: false });
1649
+ }
1650
+ else {
1651
+ const col = this.resolveJsonPathTarget('group key', entry.field, entry.path);
1652
+ params.push(this.jsonPathParam(entry.path));
1653
+ const extract = this.dialect.buildJsonPathExtract(this.q(col), this.p(params.length));
1654
+ const alias = entry.alias ?? String(entry.path[entry.path.length - 1]);
1655
+ claimResultKey(alias, `JSON path on "${entry.field}"`);
1656
+ // Same expression (and the same $n placeholder) in SELECT and GROUP BY.
1657
+ selectExprs.push(`(${extract}) AS ${this.q(alias)}`);
1658
+ groupExprs.push(extract);
1659
+ byReaders.push({ resultKey: alias, rowKey: alias, raw: true });
1660
+ }
1661
+ }
1593
1662
  // _count
1594
1663
  if (args._count === true || args._count === undefined) {
1595
1664
  // default: always include count
1596
1665
  selectExprs.push(`${this.castAgg('COUNT(*)', 'int')} AS _count`);
1597
1666
  }
1598
- // _sum
1599
- if (args._sum) {
1600
- for (const [field, enabled] of Object.entries(args._sum)) {
1601
- if (enabled) {
1602
- const col = this.toColumn(field);
1603
- selectExprs.push(`SUM(${this.q(col)}) AS ${this.q(`_sum_${col}`)}`);
1604
- }
1605
- }
1606
- }
1607
- // _avg
1608
- if (args._avg) {
1609
- for (const [field, enabled] of Object.entries(args._avg)) {
1610
- if (enabled) {
1611
- const col = this.toColumn(field);
1612
- selectExprs.push(`${this.castAgg(`AVG(${this.q(col)})`, 'float')} AS ${this.q(`_avg_${col}`)}`);
1613
- }
1614
- }
1615
- }
1616
- // _min
1617
- if (args._min) {
1618
- for (const [field, enabled] of Object.entries(args._min)) {
1619
- if (enabled) {
1620
- const col = this.toColumn(field);
1621
- selectExprs.push(`MIN(${this.q(col)}) AS ${this.q(`_min_${col}`)}`);
1667
+ // _sum / _avg / _min / _max: `true` keeps the plain-column behavior; a
1668
+ // {@link JsonPathAggregateTarget} aggregates a JSON path under the arg key
1669
+ // as alias. `jsonAggFields` routes each JSON-aggregate row key back to its
1670
+ // alias (and coercion kind) in the transform; `jsonAggExprs` lets HAVING
1671
+ // reuse the exact aggregate expression (same placeholders) by alias.
1672
+ const jsonAggFields = new Map();
1673
+ const jsonAggExprs = new Map();
1674
+ const buildAggregates = (aggKey, sqlFn, spec) => {
1675
+ if (!spec)
1676
+ return;
1677
+ for (const [key, target] of Object.entries(spec)) {
1678
+ if (!target)
1679
+ continue;
1680
+ if (target === true) {
1681
+ const col = this.toColumn(key);
1682
+ // Aggregate output aliases share the same output-name namespace as
1683
+ // the group keys: `_sum: { totalPrice: true, total_price: {json} }`
1684
+ // would emit two "_sum_total_price" columns and silently drop one.
1685
+ claimResultKey(`${aggKey}_${col}`, `${aggKey} of column "${col}"`);
1686
+ const inner = `${sqlFn}(${this.q(col)})`;
1687
+ const expr = aggKey === '_avg' ? this.castAgg(inner, 'float') : inner;
1688
+ selectExprs.push(`${expr} AS ${this.q(`${aggKey}_${col}`)}`);
1689
+ continue;
1622
1690
  }
1623
- }
1624
- }
1625
- // _max
1626
- if (args._max) {
1627
- for (const [field, enabled] of Object.entries(args._max)) {
1628
- if (enabled) {
1629
- const col = this.toColumn(field);
1630
- selectExprs.push(`MAX(${this.q(col)}) AS ${this.q(`_max_${col}`)}`);
1691
+ const col = this.resolveJsonPathTarget(`${aggKey} target "${key}"`, target.field, target.path);
1692
+ const alwaysNumeric = aggKey === '_sum' || aggKey === '_avg';
1693
+ if (alwaysNumeric && target.type === 'text') {
1694
+ throw new ValidationError(`[turbine] groupBy ${aggKey} target "${key}" on table "${this.table}": ` +
1695
+ `${aggKey} over a JSON path is always numeric: remove \`type: 'text'\`.`);
1631
1696
  }
1697
+ const numeric = alwaysNumeric || target.type === 'numeric';
1698
+ claimResultKey(`${aggKey}_${key}`, `${aggKey} JSON target "${key}"`);
1699
+ params.push(this.jsonPathParam(target.path));
1700
+ const extract = this.dialect.buildJsonPathExtract(this.q(col), this.p(params.length));
1701
+ const inner = `${sqlFn}(${numeric ? this.castJsonNumeric(extract) : extract})`;
1702
+ const expr = aggKey === '_avg' ? this.castAgg(inner, 'float') : inner;
1703
+ selectExprs.push(`${expr} AS ${this.q(`${aggKey}_${key}`)}`);
1704
+ jsonAggFields.set(`${aggKey}_${key}`, { field: key, numeric });
1705
+ jsonAggExprs.set(`${key}:${aggKey}`, expr);
1632
1706
  }
1633
- }
1634
- let sql = `SELECT ${selectExprs.join(', ')} FROM ${this.q(this.table)}${whereSql} GROUP BY ${groupCols.join(', ')}`;
1707
+ };
1708
+ buildAggregates('_sum', 'SUM', args._sum);
1709
+ buildAggregates('_avg', 'AVG', args._avg);
1710
+ buildAggregates('_min', 'MIN', args._min);
1711
+ buildAggregates('_max', 'MAX', args._max);
1712
+ let sql = `SELECT ${selectExprs.join(', ')} FROM ${fromSql} GROUP BY ${groupExprs.join(', ')}`;
1635
1713
  // HAVING — filter whole groups by their aggregate values.
1636
1714
  // Appends to the same `params` array, so placeholders continue from the
1637
1715
  // WHERE clause's parameter positions (this.p(params.length) below).
1638
1716
  if (args.having) {
1639
- const havingClauses = this.buildHavingClauses(args.having, params);
1717
+ const havingClauses = this.buildHavingClauses(args.having, params, jsonAggExprs);
1640
1718
  if (havingClauses.length > 0) {
1641
1719
  sql += ` HAVING ${havingClauses.join(' AND ')}`;
1642
1720
  }
@@ -1652,9 +1730,11 @@ export class QueryInterface {
1652
1730
  const parsed = this.parseRow(row, this.table);
1653
1731
  // Restructure aggregate results into nested objects (Prisma-style)
1654
1732
  const restructured = {};
1655
- // Copy group-by fields
1656
- for (const field of args.by) {
1657
- restructured[field] = parsed[field];
1733
+ // Copy group-by fields. JSON-path keys read their alias off the raw
1734
+ // row (the alias is not a table column, so parseRow's snake→camel
1735
+ // mapping must not touch it).
1736
+ for (const reader of byReaders) {
1737
+ restructured[reader.resultKey] = reader.raw ? row[reader.rowKey] : parsed[reader.resultKey];
1658
1738
  }
1659
1739
  // _count
1660
1740
  if ('_count' in row) {
@@ -1669,29 +1749,27 @@ export class QueryInterface {
1669
1749
  const minObj = {};
1670
1750
  const maxObj = {};
1671
1751
  let hasSums = false, hasAvgs = false, hasMins = false, hasMaxs = false;
1752
+ // JSON-path aggregates keep their arg key verbatim; plain-column
1753
+ // aggregates keep the snake→camel field mapping.
1754
+ const jsonAgg = (rawKey) => jsonAggFields.get(rawKey);
1755
+ const fieldFor = (rawKey, col) => jsonAgg(rawKey)?.field ?? this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
1672
1756
  for (const [rawKey, rawValue] of Object.entries(row)) {
1673
1757
  if (rawKey.startsWith('_sum_')) {
1674
- const col = rawKey.slice(5);
1675
- const field = this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
1676
- sumObj[field] = rawValue !== null ? Number(rawValue) : null;
1758
+ sumObj[fieldFor(rawKey, rawKey.slice(5))] = rawValue !== null ? Number(rawValue) : null;
1677
1759
  hasSums = true;
1678
1760
  }
1679
1761
  else if (rawKey.startsWith('_avg_')) {
1680
- const col = rawKey.slice(5);
1681
- const field = this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
1682
- avgObj[field] = rawValue !== null ? Number(rawValue) : null;
1762
+ avgObj[fieldFor(rawKey, rawKey.slice(5))] = rawValue !== null ? Number(rawValue) : null;
1683
1763
  hasAvgs = true;
1684
1764
  }
1685
1765
  else if (rawKey.startsWith('_min_')) {
1686
- const col = rawKey.slice(5);
1687
- const field = this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
1688
- minObj[field] = rawValue;
1766
+ const j = jsonAgg(rawKey);
1767
+ minObj[fieldFor(rawKey, rawKey.slice(5))] = j?.numeric && rawValue !== null ? Number(rawValue) : rawValue;
1689
1768
  hasMins = true;
1690
1769
  }
1691
1770
  else if (rawKey.startsWith('_max_')) {
1692
- const col = rawKey.slice(5);
1693
- const field = this.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
1694
- maxObj[field] = rawValue;
1771
+ const j = jsonAgg(rawKey);
1772
+ maxObj[fieldFor(rawKey, rawKey.slice(5))] = j?.numeric && rawValue !== null ? Number(rawValue) : rawValue;
1695
1773
  hasMaxs = true;
1696
1774
  }
1697
1775
  }
@@ -1708,6 +1786,75 @@ export class QueryInterface {
1708
1786
  tag: `${this.table}.groupBy`,
1709
1787
  };
1710
1788
  }
1789
+ /**
1790
+ * Validate a JSON-path target (group key or aggregate target) in groupBy:
1791
+ * the field must resolve to a real json/jsonb column and the path must be a
1792
+ * non-empty array of keys/indexes. Returns the resolved snake_case column.
1793
+ */
1794
+ resolveJsonPathTarget(context, field, path) {
1795
+ if (typeof field !== 'string') {
1796
+ throw new ValidationError(`[turbine] groupBy ${context} on table "${this.table}" requires a string \`field\`.`);
1797
+ }
1798
+ const col = this.toColumn(field);
1799
+ if (!Array.isArray(path) ||
1800
+ path.length === 0 ||
1801
+ path.some((el) => typeof el !== 'string' && !(typeof el === 'number' && Number.isFinite(el)))) {
1802
+ throw new ValidationError(`[turbine] groupBy ${context} on "${field}" (table "${this.table}") requires a non-empty \`path\` ` +
1803
+ `array of keys/indexes (e.g. { field: '${field}', path: ['category'] }).`);
1804
+ }
1805
+ const colType = this.pgTypeForColumn(this.tableMeta, col);
1806
+ if (!this.isJsonColumnType(colType)) {
1807
+ throw new ValidationError(`[turbine] groupBy ${context} on "${field}": column "${col}" on table "${this.table}" is not a JSON ` +
1808
+ `column (actual type: ${colType}).`);
1809
+ }
1810
+ return col;
1811
+ }
1812
+ /**
1813
+ * Build the `distinctOn` row source for groupBy (PostgreSQL only: other
1814
+ * engines throw {@link UnsupportedFeatureError} E017):
1815
+ *
1816
+ * ```sql
1817
+ * (SELECT DISTINCT ON ("c1") * FROM "table"<WHERE> ORDER BY "c1", <orderBy>) AS "table"
1818
+ * ```
1819
+ *
1820
+ * The wrapper is aliased as the table name so every outer expression (group
1821
+ * keys, aggregates, HAVING, ORDER BY) is byte-identical to the plain path.
1822
+ * `distinctOn.orderBy` is required (it decides which row survives) and
1823
+ * supports plain columns, {@link OrderBySpec} nulls, and JSON-path specs;
1824
+ * JSON paths push their text[] param here, after the WHERE params.
1825
+ */
1826
+ buildDistinctOnSource(distinctOn, whereSql, params) {
1827
+ if (this.dialect.name !== 'postgresql') {
1828
+ throw new UnsupportedFeatureError('DISTINCT ON row source (groupBy distinctOn)', this.dialect.name, 'groupBy({ distinctOn }) requires PostgreSQL: SELECT DISTINCT ON is not portable.');
1829
+ }
1830
+ if (!Array.isArray(distinctOn.columns) || distinctOn.columns.length === 0) {
1831
+ throw new ValidationError(`[turbine] groupBy distinctOn on table "${this.table}" requires a non-empty \`columns\` array.`);
1832
+ }
1833
+ const orderEntries = Object.entries(distinctOn.orderBy ?? {});
1834
+ if (orderEntries.length === 0) {
1835
+ throw new ValidationError(`[turbine] groupBy distinctOn on table "${this.table}" requires \`orderBy\` to pick ONE row per ` +
1836
+ "column combination deterministically (e.g. orderBy: { createdAt: 'desc' }).");
1837
+ }
1838
+ const distinctCols = distinctOn.columns.map((c) => this.q(this.toColumn(c)));
1839
+ // DISTINCT ON expressions must lead the ORDER BY; the user's orderBy then
1840
+ // decides which row survives per combination.
1841
+ const orderParts = [...distinctCols];
1842
+ for (const [key, value] of orderEntries) {
1843
+ if (isJsonPathOrderBy(value)) {
1844
+ orderParts.push(this.buildJsonPathOrderEntry(this.table, this.tableMeta, key, value, '', params));
1845
+ continue;
1846
+ }
1847
+ if (isVectorOrderBy(value) || this.isRelationOrderByValue(value)) {
1848
+ throw new ValidationError(`[turbine] groupBy distinctOn.orderBy on "${key}" (table "${this.table}") supports plain columns, ` +
1849
+ 'sort specs, and JSON-path orderings only.');
1850
+ }
1851
+ const col = this.resolveOrderByColumn(this.table, this.tableMeta, key);
1852
+ const { dir, nulls } = normalizeOrderBy(value);
1853
+ orderParts.push(`${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`);
1854
+ }
1855
+ return (`(SELECT DISTINCT ON (${distinctCols.join(', ')}) * FROM ${this.q(this.table)}${whereSql} ` +
1856
+ `ORDER BY ${orderParts.join(', ')}) AS ${this.q(this.table)}`);
1857
+ }
1711
1858
  /**
1712
1859
  * Build the SQL fragments for a {@link HavingClause}.
1713
1860
  *
@@ -1718,8 +1865,14 @@ export class QueryInterface {
1718
1865
  * comparison value is pushed onto the shared `params` array and referenced by
1719
1866
  * a `$N` placeholder via {@link buildHavingNumericClauses} — there is no string
1720
1867
  * interpolation of user values.
1868
+ *
1869
+ * `jsonAggExprs` (from {@link buildGroupBy}) maps `alias:aggKey` to the
1870
+ * exact aggregate expression a JSON-path aggregate emitted in SELECT
1871
+ * (including its already-bound path placeholder), so HAVING on a JSON-path
1872
+ * aggregate alias reuses the same expression instead of resolving the alias
1873
+ * as a column.
1721
1874
  */
1722
- buildHavingClauses(having, params) {
1875
+ buildHavingClauses(having, params, jsonAggExprs) {
1723
1876
  const clauses = [];
1724
1877
  // Maps the per-field aggregate key to its SQL function name. The set of
1725
1878
  // allowed keys is fixed here — any other key on a field's filter object is
@@ -1746,8 +1899,14 @@ export class QueryInterface {
1746
1899
  }
1747
1900
  // toColumn validates the field against schema metadata (throws
1748
1901
  // ValidationError on unknown columns) and q() quotes the identifier — no
1749
- // unvalidated identifier ever reaches the SQL string.
1750
- const quotedCol = this.q(this.toColumn(key));
1902
+ // unvalidated identifier ever reaches the SQL string. Resolution is lazy:
1903
+ // a JSON-path aggregate alias is not a column, so it must not hit
1904
+ // toColumn when every aggregate under it resolves via `jsonAggExprs`.
1905
+ let quotedCol = null;
1906
+ const columnExpr = () => {
1907
+ quotedCol ??= this.q(this.toColumn(key));
1908
+ return quotedCol;
1909
+ };
1751
1910
  for (const [aggKey, filter] of Object.entries(value)) {
1752
1911
  if (filter === undefined)
1753
1912
  continue;
@@ -1756,7 +1915,7 @@ export class QueryInterface {
1756
1915
  throw new ValidationError(`[turbine] Unknown aggregate "${aggKey}" in having for field "${key}" on table "${this.table}". ` +
1757
1916
  `Supported: ${Object.keys(aggFnByKey).join(', ')}.`);
1758
1917
  }
1759
- const expr = `${fn}(${quotedCol})`;
1918
+ const expr = jsonAggExprs?.get(`${key}:${aggKey}`) ?? `${fn}(${columnExpr()})`;
1760
1919
  clauses.push(...this.buildHavingNumericClauses(expr, filter, params));
1761
1920
  }
1762
1921
  }
@@ -2403,7 +2562,7 @@ export class QueryInterface {
2403
2562
  // JSONB filter
2404
2563
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
2405
2564
  const colType = this.getColumnPgType(rawColumn);
2406
- if (colType === 'json' || colType === 'jsonb') {
2565
+ if (this.isJsonColumnType(colType)) {
2407
2566
  this.collectJsonFilterParams(value, params, this.q(rawColumn));
2408
2567
  continue;
2409
2568
  }
@@ -2423,7 +2582,11 @@ export class QueryInterface {
2423
2582
  }
2424
2583
  // Operator objects
2425
2584
  if (isWhereOperator(value)) {
2426
- this.collectOperatorParams(rawColumn, value, params);
2585
+ this.collectOperatorParams(rawColumn, value, params, {
2586
+ meta: this.tableMeta,
2587
+ table: this.table,
2588
+ prefix: '',
2589
+ });
2427
2590
  continue;
2428
2591
  }
2429
2592
  // Plain equality — same strict validation as the build path, so a
@@ -2510,7 +2673,7 @@ export class QueryInterface {
2510
2673
  // the target column is json/jsonb.
2511
2674
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
2512
2675
  const colType = this.pgTypeForColumn(meta, col);
2513
- if (colType === 'json' || colType === 'jsonb') {
2676
+ if (this.isJsonColumnType(colType)) {
2514
2677
  this.collectJsonFilterParams(value, params, `${this.q(targetTable)}.${this.q(col)}`);
2515
2678
  continue;
2516
2679
  }
@@ -2524,28 +2687,40 @@ export class QueryInterface {
2524
2687
  }
2525
2688
  }
2526
2689
  if (isWhereOperator(value)) {
2527
- this.collectOperatorParams(col, value, params);
2690
+ this.collectOperatorParams(col, value, params, { meta, table: targetTable, prefix: '' });
2528
2691
  continue;
2529
2692
  }
2530
2693
  this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
2531
2694
  params.push(value);
2532
2695
  }
2533
2696
  }
2534
- /** Collect params from operator clauses. Mirrors buildOperatorClauses. */
2535
- collectOperatorParams(column, op, params) {
2536
- if (op.equals !== undefined && op.equals !== null) {
2697
+ /**
2698
+ * Collect params from operator clauses. Mirrors buildOperatorClauses:
2699
+ * {@link ColumnRef} values compile into the SQL text, so they push NOTHING -
2700
+ * but they re-run the same validation (unknown ref / insensitive mode) so a
2701
+ * warmed cache can never skip a check the build path enforces.
2702
+ */
2703
+ collectOperatorParams(column, op, params, refCtx) {
2704
+ const skipRef = (v) => {
2705
+ if (!isColumnRef(v))
2706
+ return false;
2707
+ if (refCtx)
2708
+ this.resolveColumnRef(v, refCtx, op.mode);
2709
+ return true;
2710
+ };
2711
+ if (op.equals !== undefined && op.equals !== null && !skipRef(op.equals)) {
2537
2712
  assertBindableEqualsOperand(op.equals, `"${column}"`);
2538
2713
  params.push(op.equals);
2539
2714
  }
2540
- if (op.gt !== undefined)
2715
+ if (op.gt !== undefined && !skipRef(op.gt))
2541
2716
  params.push(op.gt);
2542
- if (op.gte !== undefined)
2717
+ if (op.gte !== undefined && !skipRef(op.gte))
2543
2718
  params.push(op.gte);
2544
- if (op.lt !== undefined)
2719
+ if (op.lt !== undefined && !skipRef(op.lt))
2545
2720
  params.push(op.lt);
2546
- if (op.lte !== undefined)
2721
+ if (op.lte !== undefined && !skipRef(op.lte))
2547
2722
  params.push(op.lte);
2548
- if (op.not !== undefined && op.not !== null)
2723
+ if (op.not !== undefined && op.not !== null && !skipRef(op.not))
2549
2724
  params.push(op.not);
2550
2725
  if (op.in !== undefined)
2551
2726
  params.push(this.inParam(op.in));
@@ -2568,7 +2743,8 @@ export class QueryInterface {
2568
2743
  let pathPushed = false;
2569
2744
  const pushPathOnce = () => {
2570
2745
  if (!pathPushed) {
2571
- params.push(filter.path);
2746
+ // Only reached when a path-requiring clause validated filter.path.
2747
+ params.push(this.jsonPathParam(filter.path, filter.path));
2572
2748
  pathPushed = true;
2573
2749
  }
2574
2750
  };
@@ -2601,10 +2777,10 @@ export class QueryInterface {
2601
2777
  // isEmpty has no params (IS NULL / IS NOT NULL)
2602
2778
  }
2603
2779
  /**
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.
2780
+ * Collect params for an orderBy clause. Vector KNN ordering pushes the
2781
+ * `$n::vector` query vector and JSON-path ordering pushes its text[] path;
2782
+ * plain direction ordering is parameterless. Mirrors buildOrderBy's push
2783
+ * order exactly so the cached-SQL param re-collection stays in lockstep.
2608
2784
  */
2609
2785
  collectOrderByParams(orderBy, params) {
2610
2786
  for (const [key, dir] of Object.entries(orderBy)) {
@@ -2616,12 +2792,24 @@ export class QueryInterface {
2616
2792
  this.pushVectorParam(key, rawColumn, dir.distance.to, params);
2617
2793
  continue;
2618
2794
  }
2795
+ // JSON-path ordering: mirrors buildJsonPathOrderEntry: same validation,
2796
+ // then the path bound as one text[] param.
2797
+ if (isJsonPathOrderBy(dir)) {
2798
+ this.validateJsonPathOrderBy(this.table, this.tableMeta, key, dir);
2799
+ params.push(this.jsonPathParam(dir.path));
2800
+ continue;
2801
+ }
2619
2802
  // To-many relation orderBy (`{ posts: { _count } }`) uses the same count
2620
- // subquery as `_count` mirror its global-filter params. To-one relation
2621
- // orderBy carries the target's global filter once per ordered column.
2803
+ // subquery as `_count`: mirror its global-filter params. Pick-row
2804
+ // ordering mirrors its full param chain (by-path / global filter /
2805
+ // pick.where / pick.orderBy paths). To-one relation orderBy carries the
2806
+ // target's global filter once per ordered column.
2622
2807
  if (this.isRelationOrderByValue(dir)) {
2623
2808
  const relDef = this.tableMeta.relations[key];
2624
- if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
2809
+ if (relDef && isRelationPickOrderBy(dir)) {
2810
+ this.collectRelationPickOrderParams(key, relDef, dir, params);
2811
+ }
2812
+ else if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
2625
2813
  this.collectRelationCountParams(relDef, params);
2626
2814
  }
2627
2815
  else if (relDef) {
@@ -2711,7 +2899,8 @@ export class QueryInterface {
2711
2899
  }
2712
2900
  // orderBy shape (OrderBySpec nulls placement changes the SQL, so fingerprint it)
2713
2901
  if (opts.orderBy) {
2714
- const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d)}`);
2902
+ const targetRels = this.schema.tables[relDef.to]?.relations;
2903
+ const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, targetRels?.[k]?.to)}`);
2715
2904
  subParts.push(`o=${oEntries.join(',')}`);
2716
2905
  }
2717
2906
  // limit presence
@@ -2762,9 +2951,20 @@ export class QueryInterface {
2762
2951
  const targetMeta = this.schema.tables[targetTable];
2763
2952
  if (!targetMeta)
2764
2953
  return;
2954
+ // A dialect that owns the whole subquery (buildRelationSubquery override,
2955
+ // SQL Server FOR JSON) compiles orderBy through its OWN paging clause -
2956
+ // plain directions only, no order params: so the native order-param
2957
+ // mirrors below must stay off for it (its documented contract remains
2958
+ // where → limit → nested).
2959
+ const nativeOrderPath = !this.dialect.buildRelationSubquery;
2765
2960
  // manyToMany param order mirrors buildManyToManySubquery:
2766
- // where params → limit param → nested-with params (always, both paths).
2961
+ // orderBy params → where params → limit param → nested-with params
2962
+ // (always, both paths).
2767
2963
  if (relDef.type === 'manyToMany') {
2964
+ const m2mOrderEntries = spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
2965
+ if (nativeOrderPath && m2mOrderEntries.length > 0) {
2966
+ this.collectRelationOrderParams(targetTable, targetMeta, m2mOrderEntries, params);
2967
+ }
2768
2968
  if (spec.where) {
2769
2969
  this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
2770
2970
  }
@@ -2783,7 +2983,8 @@ export class QueryInterface {
2783
2983
  return;
2784
2984
  }
2785
2985
  // Mirrors buildRelationSubquery's willWrap: `orderBy: {}` is treated as absent.
2786
- const hasOrder = spec.orderBy ? Object.values(spec.orderBy).some((dir) => dir !== undefined) : false;
2986
+ const relOrderEntries = spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
2987
+ const hasOrder = relOrderEntries.length > 0;
2787
2988
  const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
2788
2989
  // Non-wrapped path: nested relations BEFORE where/limit
2789
2990
  if (!willWrap && spec.with) {
@@ -2794,6 +2995,12 @@ export class QueryInterface {
2794
2995
  this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
2795
2996
  }
2796
2997
  }
2998
+ // orderBy params (JSON paths / relation-order global filters): mirrors
2999
+ // buildRelationSubquery, which builds its ORDER BY terms BEFORE compiling
3000
+ // spec.where (both wrapped and non-wrapped paths).
3001
+ if (nativeOrderPath && hasOrder) {
3002
+ this.collectRelationOrderParams(targetTable, targetMeta, relOrderEntries, params);
3003
+ }
2797
3004
  // where params — mirrors buildAliasWhere push order
2798
3005
  if (spec.where) {
2799
3006
  this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
@@ -3103,7 +3310,7 @@ export class QueryInterface {
3103
3310
  // Handle JSONB filter operators (for json/jsonb columns)
3104
3311
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
3105
3312
  const colType = this.getColumnPgType(rawColumn);
3106
- if (colType === 'json' || colType === 'jsonb') {
3313
+ if (this.isJsonColumnType(colType)) {
3107
3314
  const jsonClauses = this.buildJsonFilterClauses(column, value, params);
3108
3315
  andClauses.push(...jsonClauses);
3109
3316
  continue;
@@ -3144,7 +3351,11 @@ export class QueryInterface {
3144
3351
  }
3145
3352
  // Handle operator objects
3146
3353
  if (isWhereOperator(value)) {
3147
- const opClauses = this.buildOperatorClauses(column, value, params);
3354
+ const opClauses = this.buildOperatorClauses(column, value, params, {
3355
+ meta: this.tableMeta,
3356
+ table: this.table,
3357
+ prefix: '',
3358
+ });
3148
3359
  andClauses.push(...opClauses);
3149
3360
  continue;
3150
3361
  }
@@ -3314,7 +3525,7 @@ export class QueryInterface {
3314
3525
  // jsonb value, silently matching nothing.
3315
3526
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
3316
3527
  const colType = this.pgTypeForColumn(meta, col);
3317
- if (colType === 'json' || colType === 'jsonb') {
3528
+ if (this.isJsonColumnType(colType)) {
3318
3529
  conditions.push(...this.buildJsonFilterClauses(qCol, value, params));
3319
3530
  continue;
3320
3531
  }
@@ -3338,7 +3549,11 @@ export class QueryInterface {
3338
3549
  }
3339
3550
  }
3340
3551
  if (isWhereOperator(value)) {
3341
- const opClauses = this.buildOperatorClauses(qCol, value, params);
3552
+ const opClauses = this.buildOperatorClauses(qCol, value, params, {
3553
+ meta,
3554
+ table: targetTable,
3555
+ prefix: `${qt}.`,
3556
+ });
3342
3557
  conditions.push(...opClauses);
3343
3558
  continue;
3344
3559
  }
@@ -3410,7 +3625,7 @@ export class QueryInterface {
3410
3625
  assertBindableEqualityValue(rawColumn, value, columnPgType, table) {
3411
3626
  if (!isUnmatchedPlainObject(value))
3412
3627
  return;
3413
- if (columnPgType === 'json' || columnPgType === 'jsonb')
3628
+ if (this.isJsonColumnType(columnPgType))
3414
3629
  return;
3415
3630
  const badKeys = Object.keys(value);
3416
3631
  throw new ValidationError(badKeys.length === 0
@@ -3481,7 +3696,7 @@ export class QueryInterface {
3481
3696
  // bound as a plain equality value.
3482
3697
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
3483
3698
  const colType = this.pgTypeForColumn(targetMeta, col);
3484
- if (colType === 'json' || colType === 'jsonb') {
3699
+ if (this.isJsonColumnType(colType)) {
3485
3700
  clauses.push(...this.buildJsonFilterClauses(qCol, value, params));
3486
3701
  continue;
3487
3702
  }
@@ -3505,7 +3720,11 @@ export class QueryInterface {
3505
3720
  }
3506
3721
  }
3507
3722
  if (isWhereOperator(value)) {
3508
- clauses.push(...this.buildOperatorClauses(qCol, value, params));
3723
+ clauses.push(...this.buildOperatorClauses(qCol, value, params, {
3724
+ meta: targetMeta,
3725
+ table: targetTable,
3726
+ prefix: `${alias}.`,
3727
+ }));
3509
3728
  continue;
3510
3729
  }
3511
3730
  this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
@@ -3550,7 +3769,7 @@ export class QueryInterface {
3550
3769
  // JSONB filter — mirrors buildAliasWhere.
3551
3770
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
3552
3771
  const colType = this.pgTypeForColumn(targetMeta, col);
3553
- if (colType === 'json' || colType === 'jsonb') {
3772
+ if (this.isJsonColumnType(colType)) {
3554
3773
  this.collectJsonFilterParams(value, params, this.q(col));
3555
3774
  continue;
3556
3775
  }
@@ -3564,7 +3783,7 @@ export class QueryInterface {
3564
3783
  }
3565
3784
  }
3566
3785
  if (isWhereOperator(value)) {
3567
- this.collectOperatorParams(col, value, params);
3786
+ this.collectOperatorParams(col, value, params, { meta: targetMeta, table: targetTable, prefix: '' });
3568
3787
  continue;
3569
3788
  }
3570
3789
  this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
@@ -3641,16 +3860,55 @@ export class QueryInterface {
3641
3860
  }
3642
3861
  return parts.join('&');
3643
3862
  }
3863
+ /**
3864
+ * Validate a `{ col }` column reference against its table and return the
3865
+ * resolved snake_case column name. Shared by the SQL-build path
3866
+ * ({@link buildOperatorClauses}) and the cache-hit param-collect path
3867
+ * (`collectOperatorParams`) so both always throw identically: a warmed
3868
+ * cache can never skip the check.
3869
+ */
3870
+ resolveColumnRef(ref, ctx, mode) {
3871
+ if (mode === 'insensitive') {
3872
+ throw new ValidationError(`[turbine] mode: 'insensitive' cannot be combined with a column reference ({ col: "${ref.col}" }). ` +
3873
+ `Case-insensitive column-to-column comparison is not supported: use client.sql\`...\` ` +
3874
+ `for lower(a) = lower(b).`);
3875
+ }
3876
+ const col = ctx.meta.columnMap[ref.col] ?? camelToSnake(ref.col);
3877
+ if (!ctx.meta.allColumns.includes(col)) {
3878
+ throw new ValidationError(`[turbine] Unknown field "${ref.col}" referenced by { col } in where on table "${ctx.table}". ` +
3879
+ `Known fields: ${Object.keys(ctx.meta.columnMap).join(', ') || '(none)'}.`);
3880
+ }
3881
+ return col;
3882
+ }
3883
+ /**
3884
+ * Compile a `{ col }` reference to its quoted, prefix-matched SQL identifier.
3885
+ * NO param is bound: the referenced column is part of the SQL text (and of
3886
+ * the where fingerprint, see {@link fingerprintOperatorShape}).
3887
+ */
3888
+ columnRefSql(ref, ctx, mode) {
3889
+ if (!ctx) {
3890
+ throw new ValidationError(`[turbine] Column reference { col: "${ref.col}" } is not supported in this filter context.`);
3891
+ }
3892
+ return `${ctx.prefix}${this.q(this.resolveColumnRef(ref, ctx, mode))}`;
3893
+ }
3644
3894
  /**
3645
3895
  * Build SQL clauses for a single operator object on a column.
3646
3896
  * Each operator key becomes its own clause, all ANDed together.
3897
+ *
3898
+ * `equals`/`not`/`gt`/`gte`/`lt`/`lte` also accept a {@link ColumnRef}
3899
+ * (`{ col: 'otherField' }`) which compiles to a column-to-column comparison
3900
+ * against `refCtx`: no param bound, so `collectOperatorParams` mirrors by
3901
+ * pushing nothing and the referenced name lives in the fingerprint.
3647
3902
  */
3648
- buildOperatorClauses(column, op, params) {
3903
+ buildOperatorClauses(column, op, params, refCtx) {
3649
3904
  const clauses = [];
3650
3905
  if (op.equals !== undefined) {
3651
3906
  if (op.equals === null) {
3652
3907
  clauses.push(`${column} IS NULL`);
3653
3908
  }
3909
+ else if (isColumnRef(op.equals)) {
3910
+ clauses.push(`${column} = ${this.columnRefSql(op.equals, refCtx, op.mode)}`);
3911
+ }
3654
3912
  else {
3655
3913
  assertBindableEqualsOperand(op.equals, column);
3656
3914
  params.push(op.equals);
@@ -3658,25 +3916,48 @@ export class QueryInterface {
3658
3916
  }
3659
3917
  }
3660
3918
  if (op.gt !== undefined) {
3661
- params.push(op.gt);
3662
- clauses.push(`${column} > ${this.p(params.length)}`);
3919
+ if (isColumnRef(op.gt)) {
3920
+ clauses.push(`${column} > ${this.columnRefSql(op.gt, refCtx, op.mode)}`);
3921
+ }
3922
+ else {
3923
+ params.push(op.gt);
3924
+ clauses.push(`${column} > ${this.p(params.length)}`);
3925
+ }
3663
3926
  }
3664
3927
  if (op.gte !== undefined) {
3665
- params.push(op.gte);
3666
- clauses.push(`${column} >= ${this.p(params.length)}`);
3928
+ if (isColumnRef(op.gte)) {
3929
+ clauses.push(`${column} >= ${this.columnRefSql(op.gte, refCtx, op.mode)}`);
3930
+ }
3931
+ else {
3932
+ params.push(op.gte);
3933
+ clauses.push(`${column} >= ${this.p(params.length)}`);
3934
+ }
3667
3935
  }
3668
3936
  if (op.lt !== undefined) {
3669
- params.push(op.lt);
3670
- clauses.push(`${column} < ${this.p(params.length)}`);
3937
+ if (isColumnRef(op.lt)) {
3938
+ clauses.push(`${column} < ${this.columnRefSql(op.lt, refCtx, op.mode)}`);
3939
+ }
3940
+ else {
3941
+ params.push(op.lt);
3942
+ clauses.push(`${column} < ${this.p(params.length)}`);
3943
+ }
3671
3944
  }
3672
3945
  if (op.lte !== undefined) {
3673
- params.push(op.lte);
3674
- clauses.push(`${column} <= ${this.p(params.length)}`);
3946
+ if (isColumnRef(op.lte)) {
3947
+ clauses.push(`${column} <= ${this.columnRefSql(op.lte, refCtx, op.mode)}`);
3948
+ }
3949
+ else {
3950
+ params.push(op.lte);
3951
+ clauses.push(`${column} <= ${this.p(params.length)}`);
3952
+ }
3675
3953
  }
3676
3954
  if (op.not !== undefined) {
3677
3955
  if (op.not === null) {
3678
3956
  clauses.push(`${column} IS NOT NULL`);
3679
3957
  }
3958
+ else if (isColumnRef(op.not)) {
3959
+ clauses.push(`${column} != ${this.columnRefSql(op.not, refCtx, op.mode)}`);
3960
+ }
3680
3961
  else {
3681
3962
  params.push(op.not);
3682
3963
  clauses.push(`${column} != ${this.p(params.length)}`);
@@ -3721,19 +4002,47 @@ export class QueryInterface {
3721
4002
  * vs relation-column never collide on one cached SQL string. Captures the
3722
4003
  * SQL-shaping bits (direction, nulls, metric, relation keys) — never values.
3723
4004
  */
3724
- orderByEntryFingerprint(d) {
4005
+ orderByEntryFingerprint(d, targetTable) {
3725
4006
  // Vector KNN ordering changes the emitted operator by metric and adds a
3726
4007
  // `::vector` param, so metric + direction must be part of the cache key.
3727
4008
  if (isVectorOrderBy(d)) {
3728
4009
  return `vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
3729
4010
  }
4011
+ // JSON-path ordering: direction, cast kind, and nulls placement change the
4012
+ // SQL text; the path itself is a bound param and stays OUT of the key.
4013
+ if (isJsonPathOrderBy(d)) {
4014
+ return `jp(${d.direction ?? 'asc'},${d.type === 'numeric' ? 'num' : 'text'},${d.nulls ?? ''})`;
4015
+ }
4016
+ // Pick-row relation ordering: the by-shape (column vs JSON path vs cast),
4017
+ // direction, nulls, pick.orderBy shape, and pick.where SHAPE are all SQL
4018
+ // text; the JSON paths and pick.where values are bound params and stay OUT
4019
+ // of the key. `targetTable` (the relation's target, resolved by the
4020
+ // caller) lets the pick.where fingerprint distinguish relation-filter
4021
+ // shapes inside it: two pick.wheres that differ only in shape must never
4022
+ // share one cached SQL string.
4023
+ if (isRelationPickOrderBy(d)) {
4024
+ const by = typeof d.by === 'string'
4025
+ ? `col=${JSON.stringify(d.by)}`
4026
+ : `jp(${JSON.stringify(d.by?.field)},${d.by?.type === 'numeric' ? 'num' : 'text'})`;
4027
+ const pickOrder = Object.entries(d.pick?.orderBy ?? {})
4028
+ .map(([k, v]) => `${k}:${this.orderByEntryFingerprint(v)}`)
4029
+ .join(',');
4030
+ const pickWhere = d.pick?.where
4031
+ ? `;pw=${this.fingerprintAliasWhere(d.pick.where, targetTable)}`
4032
+ : '';
4033
+ return `pick(${by},${d.direction ?? 'asc'},${d.nulls ?? ''};po=${pickOrder}${pickWhere})`;
4034
+ }
3730
4035
  if (isOrderBySpec(d))
3731
4036
  return `spec(${d.sort},${d.nulls ?? ''})`;
3732
4037
  if (d && typeof d === 'object') {
3733
4038
  // Relation ordering (`{ _count: 'desc' }` or `{ name: 'asc' }`).
4039
+ // INSERTION order, never sorted: the compile side (buildRelationOrderBy)
4040
+ // emits one ORDER BY term per entry in Object.entries order, so entry
4041
+ // order is SQL-shaping precedence. A sorted fingerprint made
4042
+ // `{ name: 'asc', email: 'desc' }` and the swapped literal share one
4043
+ // cached SQL string — silently mis-ordered results on a warm cache.
3734
4044
  return `rel(${Object.entries(d)
3735
4045
  .map(([k, v]) => `${k}=${this.orderByEntryFingerprint(v)}`)
3736
- .sort()
3737
4046
  .join(',')})`;
3738
4047
  }
3739
4048
  return String(d);
@@ -3772,6 +4081,11 @@ export class QueryInterface {
3772
4081
  const safeDir = value.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3773
4082
  return `${this.q(rawColumn)} ${operator} ${placeholder} ${safeDir}`;
3774
4083
  }
4084
+ // JSON-path ordering: { path: [...], direction?, type?, nulls? } on a
4085
+ // json/jsonb column of THIS table. Path is bound as one text[] param.
4086
+ if (isJsonPathOrderBy(value)) {
4087
+ return this.buildJsonPathOrderEntry(this.table, this.tableMeta, key, value, '', params);
4088
+ }
3775
4089
  // Relation ordering: an object value that is not a vector or OrderBySpec,
3776
4090
  // keyed by a relation name (`{ posts: { _count: 'desc' } }` / `{ author:
3777
4091
  // { name: 'asc' } }`).
@@ -3798,6 +4112,7 @@ export class QueryInterface {
3798
4112
  value !== null &&
3799
4113
  !Array.isArray(value) &&
3800
4114
  !isVectorOrderBy(value) &&
4115
+ !isJsonPathOrderBy(value) &&
3801
4116
  !isOrderBySpec(value));
3802
4117
  }
3803
4118
  /**
@@ -3814,6 +4129,63 @@ export class QueryInterface {
3814
4129
  }
3815
4130
  return nulls === 'first' ? ' NULLS FIRST' : ' NULLS LAST';
3816
4131
  }
4132
+ /**
4133
+ * Resolve an orderBy key to its snake_case column via the table's columnMap
4134
+ * (camelToSnake fallback), throwing the SAME unknown-field E003 the top-level
4135
+ * where path uses. Shared by top-level JSON-path ordering and every nested
4136
+ * relation orderBy path so nested orderBy accepts exactly what top-level
4137
+ * accepts (the 0.30.x bug: nested orderBy skipped the columnMap and rejected
4138
+ * camelCase-named DB columns like "sortOrder").
4139
+ */
4140
+ resolveOrderByColumn(table, meta, key) {
4141
+ const col = meta.columnMap[key] ?? camelToSnake(key);
4142
+ if (!meta.allColumns.includes(col)) {
4143
+ throw new ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${table}". ` +
4144
+ `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
4145
+ }
4146
+ return col;
4147
+ }
4148
+ /**
4149
+ * Validate a {@link JsonPathOrderBy} entry: column must exist AND be
4150
+ * json/jsonb, path must be a non-empty array of keys/indexes: and return
4151
+ * the resolved column. Shared by the SQL-build path
4152
+ * ({@link buildJsonPathOrderEntry}) and the cache-hit param-collect mirrors
4153
+ * so both always throw identically.
4154
+ */
4155
+ validateJsonPathOrderBy(table, meta, field, spec) {
4156
+ const col = this.resolveOrderByColumn(table, meta, field);
4157
+ if (spec.path.length === 0 ||
4158
+ spec.path.some((el) => typeof el !== 'string' && !(typeof el === 'number' && Number.isFinite(el)))) {
4159
+ throw new ValidationError(`[turbine] JSON-path orderBy on "${field}" (table "${table}") requires a non-empty \`path\` array ` +
4160
+ `of keys/indexes (e.g. { path: ['weight'], direction: 'asc' }).`);
4161
+ }
4162
+ const colType = this.pgTypeForColumn(meta, col);
4163
+ if (!this.isJsonColumnType(colType)) {
4164
+ throw new ValidationError(`[turbine] JSON-path orderBy on "${field}": column "${col}" on table "${table}" is not a JSON column ` +
4165
+ `(actual type: ${colType}).`);
4166
+ }
4167
+ return col;
4168
+ }
4169
+ /**
4170
+ * Compile one {@link JsonPathOrderBy} entry:
4171
+ * `("col" #>> $n::text[])::numeric ASC`: the numeric cast only with
4172
+ * `type: 'numeric'` (default is text comparison), the extraction routed
4173
+ * through the dialect's JSON hook exactly like the JSON where-filters, the
4174
+ * path bound as ONE text[] param (mirrored by the order-param collectors).
4175
+ * `prefix` scopes the column (`''` top-level, `t0.` inside a relation
4176
+ * subquery).
4177
+ */
4178
+ buildJsonPathOrderEntry(table, meta, field, spec, prefix, params) {
4179
+ const col = this.validateJsonPathOrderBy(table, meta, field, spec);
4180
+ if (!params) {
4181
+ throw new ValidationError(`[turbine] JSON-path ordering on "${field}" is not supported in this orderBy context.`);
4182
+ }
4183
+ params.push(this.jsonPathParam(spec.path));
4184
+ const extract = this.dialect.buildJsonPathExtract(`${prefix}${this.q(col)}`, this.p(params.length));
4185
+ const lhs = spec.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
4186
+ const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
4187
+ return `${lhs} ${dir}${this.nullsSuffix(spec.nulls)}`;
4188
+ }
3817
4189
  /**
3818
4190
  * Compile a relation ordering term. For a to-many relation the only allowed
3819
4191
  * key is `_count`, which becomes a correlated `COUNT(*)` subquery. For a
@@ -3822,29 +4194,45 @@ export class QueryInterface {
3822
4194
  *
3823
4195
  * Validation: relation must exist (E005); to-many only allows `_count`, and
3824
4196
  * to-one only allows real target columns (E003).
4197
+ *
4198
+ * `ctx` generalizes the term beyond the root table: inside a relation
4199
+ * subquery's orderBy the relations live on the TARGET table's metadata and
4200
+ * the correlation parent is the relation's alias, not `this.table`.
3825
4201
  */
3826
- buildRelationOrderBy(relName, value, alias, params) {
3827
- const relDef = this.tableMeta.relations[relName];
4202
+ buildRelationOrderBy(relName, value, alias, params, ctx) {
4203
+ const ownerMeta = ctx?.meta ?? this.tableMeta;
4204
+ const ownerTable = ctx?.table ?? this.table;
4205
+ const parentRef = ctx?.parentRef ?? this.table;
4206
+ const relDef = ownerMeta.relations[relName];
3828
4207
  if (!relDef) {
3829
- throw new RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${this.table}". ` +
3830
- `Available: ${Object.keys(this.tableMeta.relations).join(', ')}`);
4208
+ throw new RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${ownerTable}". ` +
4209
+ `Available: ${Object.keys(ownerMeta.relations).join(', ')}`);
4210
+ }
4211
+ // Pick-row ordering (`{ pick, by }`): order by a value from ONE related
4212
+ // row: a correlated scalar subquery with its own ORDER BY … LIMIT 1.
4213
+ // Top-level findMany only (`ctx` present means we are inside a relation
4214
+ // subquery's orderBy) and hasMany only: validatePickOrderBy throws the
4215
+ // scope errors, shared with the cache-hit collect mirror.
4216
+ if (isRelationPickOrderBy(value)) {
4217
+ this.validatePickOrderBy(relName, relDef, value, ctx !== undefined);
4218
+ return this.buildRelationPickOrderBy(relName, relDef, value, alias, parentRef, params);
3831
4219
  }
3832
4220
  // To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
3833
4221
  if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
3834
4222
  const keys = Object.keys(value);
3835
4223
  if (keys.length !== 1 || keys[0] !== '_count') {
3836
4224
  throw new ValidationError(`[turbine] orderBy on to-many relation "${relName}" only supports "_count" ` +
3837
- `(got: ${keys.join(', ') || '(empty)'}).`);
4225
+ `or a pick-row ordering ({ pick, by }) (got: ${keys.join(', ') || '(empty)'}).`);
3838
4226
  }
3839
4227
  const { dir } = normalizeOrderBy(value._count);
3840
- return `${this.buildRelationCountExpr(relDef, this.table, alias, params)} ${dir}`;
4228
+ return `${this.buildRelationCountExpr(relDef, parentRef, alias, params)} ${dir}`;
3841
4229
  }
3842
4230
  // To-one: each entry orders by a correlated scalar subquery on a target column.
3843
4231
  const targetMeta = this.schema.tables[relDef.to];
3844
4232
  if (!targetMeta)
3845
4233
  throw new RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
3846
4234
  const qTarget = this.q(relDef.to);
3847
- const qParent = this.q(this.table);
4235
+ const qParent = this.q(parentRef);
3848
4236
  // belongsTo: alias.referenceKey = parent.foreignKey; hasOne: reversed.
3849
4237
  const correlation = relDef.type === 'belongsTo'
3850
4238
  ? this.dialect.buildCorrelation(alias, relDef.referenceKey, qParent, relDef.foreignKey)
@@ -3855,7 +4243,9 @@ export class QueryInterface {
3855
4243
  }
3856
4244
  return entries
3857
4245
  .map(([col, dirValue]) => {
3858
- const snakeCol = camelToSnake(col);
4246
+ // columnMap-first resolution (camelToSnake fallback): mirrors the
4247
+ // scalar orderBy path so camelCase-named DB columns resolve here too.
4248
+ const snakeCol = targetMeta.columnMap[col] ?? camelToSnake(col);
3859
4249
  if (!targetMeta.allColumns.includes(snakeCol)) {
3860
4250
  throw new ValidationError(`[turbine] Unknown column "${col}" in orderBy on relation "${relName}" (table "${relDef.to}").`);
3861
4251
  }
@@ -3873,6 +4263,218 @@ export class QueryInterface {
3873
4263
  })
3874
4264
  .join(', ');
3875
4265
  }
4266
+ /**
4267
+ * Validate a {@link RelationPickOrderBy} entry's scope and shape. Shared by
4268
+ * the SQL-build path ({@link buildRelationPickOrderBy}) and the cache-hit
4269
+ * param-collect mirror ({@link collectRelationPickOrderParams}) so both
4270
+ * always throw identically:
4271
+ *
4272
+ * - `nested` (inside a relation subquery's orderBy or a pick.orderBy):
4273
+ * top-level findMany only in this release (E003),
4274
+ * - manyToMany: not supported (E003 naming the limitation),
4275
+ * - to-one: order by the target column directly instead (E003),
4276
+ * - `pick.orderBy` is REQUIRED (deterministic row choice),
4277
+ * - `by` must be a target column name or a `{ field, path }` JSON-path spec.
4278
+ */
4279
+ pickOrderNestedError(relName) {
4280
+ return new ValidationError(`[turbine] Pick-row ordering on relation "${relName}" is only supported in a top-level ` +
4281
+ 'findMany orderBy: nested `with` orderBy does not support it.');
4282
+ }
4283
+ validatePickOrderBy(relName, relDef, spec, nested) {
4284
+ if (nested) {
4285
+ throw this.pickOrderNestedError(relName);
4286
+ }
4287
+ if (relDef.type === 'manyToMany') {
4288
+ throw new ValidationError(`[turbine] Pick-row ordering is not supported on manyToMany relation "${relName}": ` +
4289
+ 'hasMany relations only.');
4290
+ }
4291
+ if (relDef.type !== 'hasMany') {
4292
+ throw new ValidationError(`[turbine] Pick-row ordering is only for to-many (hasMany) relations; "${relName}" is ${relDef.type}. ` +
4293
+ `Order by the target column directly instead ({ ${relName}: { <column>: 'asc' } }).`);
4294
+ }
4295
+ const pickOrder = spec.pick?.orderBy;
4296
+ if (typeof spec.pick !== 'object' ||
4297
+ spec.pick === null ||
4298
+ typeof pickOrder !== 'object' ||
4299
+ pickOrder === null ||
4300
+ Object.keys(pickOrder).length === 0) {
4301
+ throw new ValidationError(`[turbine] Pick-row ordering on relation "${relName}" requires \`pick.orderBy\` to choose ONE ` +
4302
+ "related row deterministically (e.g. pick: { orderBy: { createdAt: 'desc' } }).");
4303
+ }
4304
+ const by = spec.by;
4305
+ const validJsonBy = typeof by === 'object' && by !== null && typeof by.field === 'string' && Array.isArray(by.path);
4306
+ if (typeof by !== 'string' && !validJsonBy) {
4307
+ throw new ValidationError(`[turbine] Pick-row ordering on relation "${relName}" requires \`by\`: a target column name ` +
4308
+ "or a JSON-path spec ({ field: 'data', path: ['title'] }).");
4309
+ }
4310
+ }
4311
+ /**
4312
+ * Compile a {@link RelationPickOrderBy} term: a correlated scalar subquery
4313
+ * that picks ONE related row (`ORDER BY <pick.orderBy> LIMIT 1`, optionally
4314
+ * filtered by `pick.where` and the target's global filter) and surfaces one
4315
+ * value from it (a plain target column or a JSON-path extraction) as the
4316
+ * parent ORDER BY key:
4317
+ *
4318
+ * ```sql
4319
+ * (SELECT ord0."data" #>> $1::text[] FROM "versions" ord0
4320
+ * WHERE ord0."instance_id" = "instances"."id" AND ord0."is_current" = $2
4321
+ * ORDER BY ord0."created_at" DESC LIMIT 1) ASC NULLS LAST
4322
+ * ```
4323
+ *
4324
+ * Param-push order (mirrored EXACTLY by
4325
+ * {@link collectRelationPickOrderParams}): `by` JSON path (if any) →
4326
+ * target global filter → `pick.where` → `pick.orderBy` JSON paths.
4327
+ */
4328
+ buildRelationPickOrderBy(relName, relDef, spec, alias, parentRef, params) {
4329
+ if (!params) {
4330
+ throw new ValidationError(`[turbine] Pick-row ordering on relation "${relName}" is only supported in a top-level findMany orderBy.`);
4331
+ }
4332
+ const targetMeta = this.schema.tables[relDef.to];
4333
+ if (!targetMeta)
4334
+ throw new RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
4335
+ // The value surfaced from the picked row (SELECT list: its param binds first).
4336
+ let byExpr;
4337
+ if (typeof spec.by === 'string') {
4338
+ const col = this.resolveOrderByColumn(relDef.to, targetMeta, spec.by);
4339
+ byExpr = `${alias}.${this.q(col)}`;
4340
+ }
4341
+ else {
4342
+ const col = this.validateJsonPathOrderBy(relDef.to, targetMeta, spec.by.field, {
4343
+ path: spec.by.path,
4344
+ });
4345
+ params.push(this.jsonPathParam(spec.by.path));
4346
+ const extract = this.dialect.buildJsonPathExtract(`${alias}.${this.q(col)}`, this.p(params.length));
4347
+ byExpr = spec.by.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
4348
+ }
4349
+ // Correlation to the parent row, then the target's global filter (a
4350
+ // soft-deleted / other-tenant row must never be picked: matches the
4351
+ // `with` subquery and to-one relation-orderBy semantics), then pick.where.
4352
+ let where = this.dialect.buildCorrelation(alias, relDef.foreignKey, this.q(parentRef), relDef.referenceKey);
4353
+ const gf = this.targetGlobalFilterAlias(relDef.to, alias, params);
4354
+ if (gf)
4355
+ where += ` AND ${gf}`;
4356
+ if (spec.pick.where) {
4357
+ const pickWhere = this.buildAliasWhere(relDef.to, targetMeta, alias, spec.pick.where, params);
4358
+ if (pickWhere)
4359
+ where += ` AND ${pickWhere}`;
4360
+ }
4361
+ // pick.orderBy: same surface as a relation `with` orderBy on the target
4362
+ // (plain columns, OrderBySpec nulls, JSON-path specs); a nested pick in
4363
+ // here routes back through buildRelationOrderBy with ctx set and throws
4364
+ // the top-level-only E003.
4365
+ const orderClause = this.buildRelationOrderClause(relDef.to, targetMeta, alias, Object.entries(spec.pick.orderBy), params);
4366
+ const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
4367
+ const limitOne = this.buildPagination('1', undefined, true);
4368
+ // Parents with ZERO surviving related rows make the correlated subquery
4369
+ // yield NULL. Without a nulls clause, Postgres DESC defaults to NULLS
4370
+ // FIRST — every childless parent would top a "highest first" sort. Default
4371
+ // to NULLS LAST in BOTH directions (deterministic across engines: SQLite's
4372
+ // NULL-is-smallest default diverges from Postgres) unless the caller set
4373
+ // `nulls` explicitly; the grammar gate matches nullsSuffix (PG + SQLite).
4374
+ const nullsSql = spec.nulls
4375
+ ? this.nullsSuffix(spec.nulls)
4376
+ : this.dialect.name === 'postgresql' || this.dialect.name === 'sqlite'
4377
+ ? ' NULLS LAST'
4378
+ : '';
4379
+ return `(SELECT ${byExpr} FROM ${this.q(relDef.to)} ${alias} WHERE ${where}${orderClause}${limitOne}) ${dir}${nullsSql}`;
4380
+ }
4381
+ /**
4382
+ * Param-collect mirror of {@link buildRelationPickOrderBy}: re-runs the same
4383
+ * validation (a warmed cache can never skip it), then pushes in the same
4384
+ * order: `by` JSON path → target global filter → `pick.where` →
4385
+ * `pick.orderBy` JSON paths.
4386
+ */
4387
+ collectRelationPickOrderParams(relName, relDef, spec, params) {
4388
+ this.validatePickOrderBy(relName, relDef, spec, false);
4389
+ const targetMeta = this.schema.tables[relDef.to];
4390
+ if (!targetMeta)
4391
+ throw new RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
4392
+ if (typeof spec.by === 'string') {
4393
+ this.resolveOrderByColumn(relDef.to, targetMeta, spec.by);
4394
+ }
4395
+ else {
4396
+ this.validateJsonPathOrderBy(relDef.to, targetMeta, spec.by.field, { path: spec.by.path });
4397
+ params.push(this.jsonPathParam(spec.by.path));
4398
+ }
4399
+ this.collectTargetGlobalFilterAlias(relDef.to, params);
4400
+ if (spec.pick.where) {
4401
+ this.collectAliasWhereParams(relDef.to, targetMeta, spec.pick.where, params);
4402
+ }
4403
+ this.collectRelationOrderParams(relDef.to, targetMeta, Object.entries(spec.pick.orderBy), params);
4404
+ }
4405
+ /**
4406
+ * Compile the ORDER BY terms of a relation `with` clause against the
4407
+ * relation's table alias. One unified path for every relation shape
4408
+ * (hasMany / manyToMany / belongsTo / hasOne) supporting exactly what the
4409
+ * top-level orderBy accepts at this level:
4410
+ *
4411
+ * - scalar columns via columnMap resolution (camelToSnake fallback) with
4412
+ * {@link OrderBySpec} nulls placement,
4413
+ * - {@link JsonPathOrderBy} entries (path bound as one text[] param),
4414
+ * - relation ordering on the TARGET's relations (`_count` for to-many, a
4415
+ * target column for to-one), correlated to the relation alias,
4416
+ * - vector KNN ordering stays top-level-only (E003, same as before).
4417
+ *
4418
+ * Param pushes (JSON paths, relation-order global filters) MUST be mirrored,
4419
+ * in the same order, by {@link collectRelationOrderParams}.
4420
+ */
4421
+ buildRelationOrderClause(targetTable, targetMeta, alias, orderEntries, params) {
4422
+ let relOrdCounter = 0;
4423
+ const orders = orderEntries
4424
+ .map(([key, dirValue]) => {
4425
+ if (isVectorOrderBy(dirValue)) {
4426
+ throw new ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
4427
+ }
4428
+ if (isJsonPathOrderBy(dirValue)) {
4429
+ return this.buildJsonPathOrderEntry(targetTable, targetMeta, key, dirValue, `${alias}.`, params);
4430
+ }
4431
+ if (this.isRelationOrderByValue(dirValue)) {
4432
+ return this.buildRelationOrderBy(key, dirValue, `${alias}ord${relOrdCounter++}`, params, { meta: targetMeta, table: targetTable, parentRef: alias });
4433
+ }
4434
+ const col = this.resolveOrderByColumn(targetTable, targetMeta, key);
4435
+ const { dir, nulls } = normalizeOrderBy(dirValue);
4436
+ return `${alias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
4437
+ })
4438
+ .join(', ');
4439
+ return ` ORDER BY ${orders}`;
4440
+ }
4441
+ /**
4442
+ * Param-collect mirror of {@link buildRelationOrderClause}: JSON-path
4443
+ * entries push their path (one text[] param each); relation-order entries
4444
+ * mirror {@link collectOrderByParams}' relation branch (count / to-one
4445
+ * global-filter params); scalar entries push nothing but re-run the same
4446
+ * column validation so a warmed cache can never skip it.
4447
+ */
4448
+ collectRelationOrderParams(targetTable, targetMeta, orderEntries, params) {
4449
+ for (const [key, dirValue] of orderEntries) {
4450
+ if (isVectorOrderBy(dirValue)) {
4451
+ throw new ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
4452
+ }
4453
+ if (isJsonPathOrderBy(dirValue)) {
4454
+ this.validateJsonPathOrderBy(targetTable, targetMeta, key, dirValue);
4455
+ params.push(this.jsonPathParam(dirValue.path));
4456
+ continue;
4457
+ }
4458
+ if (this.isRelationOrderByValue(dirValue)) {
4459
+ // Pick-row ordering is top-level-only: the build path throws the same
4460
+ // E003 (buildRelationOrderBy with ctx set), so the mirror must too.
4461
+ if (isRelationPickOrderBy(dirValue)) {
4462
+ throw this.pickOrderNestedError(key);
4463
+ }
4464
+ const relDef = targetMeta.relations[key];
4465
+ if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
4466
+ this.collectRelationCountParams(relDef, params);
4467
+ }
4468
+ else if (relDef) {
4469
+ for (const _col of Object.keys(dirValue)) {
4470
+ this.collectTargetGlobalFilterAlias(relDef.to, params);
4471
+ }
4472
+ }
4473
+ continue;
4474
+ }
4475
+ this.resolveOrderByColumn(targetTable, targetMeta, key);
4476
+ }
4477
+ }
3876
4478
  /**
3877
4479
  * Build a correlated `(SELECT COUNT(*) …)` scalar subquery for a to-many
3878
4480
  * relation, correlated to `parentRef`. hasMany counts child rows via the FK;
@@ -4592,20 +5194,13 @@ export class QueryInterface {
4592
5194
  // Quote parent ref — can be a table name or auto-generated alias
4593
5195
  const qParent = this.q(parentRef);
4594
5196
  const qTarget = this.q(targetTable);
4595
- // Build ORDER BY for json_agg
5197
+ // Build ORDER BY for json_agg: unified with the top-level orderBy surface
5198
+ // (columnMap resolution, OrderBySpec nulls, JSON-path, relation ordering).
5199
+ // Param pushes here land BEFORE the spec.where params, mirrored by
5200
+ // collectRelationSubqueryParams.
4596
5201
  let orderClause = '';
4597
5202
  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}`;
5203
+ orderClause = this.buildRelationOrderClause(targetTable, targetMeta, alias, relOrderEntries, params);
4609
5204
  }
4610
5205
  // Build WHERE — correlate to parent via parentRef (alias or table name).
4611
5206
  // For hasMany/hasOne: TARGET has the FK (RelationDef.foreignKey is always
@@ -4679,8 +5274,10 @@ export class QueryInterface {
4679
5274
  const inlineOrder = this.dialect.aggSupportsInlineOrderBy ? orderClause.trim() || undefined : undefined;
4680
5275
  return `SELECT ${this.dialect.buildJsonArrayAgg(jsonObj, inlineOrder)} FROM ${qTarget} ${alias} WHERE ${whereClause}`;
4681
5276
  }
4682
- // belongsTo / hasOne return single object
4683
- return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause} LIMIT 1`;
5277
+ // belongsTo / hasOne: return single object. An orderBy picks WHICH row
5278
+ // the LIMIT 1 keeps (deterministic hasOne over a non-unique FK): matching
5279
+ // the batched strategy, which orders its flat follow-up and takes bucket[0].
5280
+ return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause}${orderClause} LIMIT 1`;
4684
5281
  }
4685
5282
  /**
4686
5283
  * Build the json_agg subquery for a `manyToMany` relation, JOINing the target
@@ -4738,22 +5335,15 @@ export class QueryInterface {
4738
5335
  let whereClause = sourceKeys
4739
5336
  .map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
4740
5337
  .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 `.
5338
+ // ORDER BY on the target rows: unified with the top-level orderBy surface
5339
+ // (columnMap resolution, OrderBySpec nulls, JSON-path, relation ordering).
5340
+ // `orderBy: {}` (no defined entries) is treated as absent: it must not
5341
+ // render a dangling `ORDER BY `. Param pushes here land BEFORE the
5342
+ // spec.where params, mirrored by collectRelationSubqueryParams' m2m branch.
4743
5343
  const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
4744
5344
  let orderClause = '';
4745
5345
  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}`;
5346
+ orderClause = this.buildRelationOrderClause(targetTable, targetMeta, talias, relOrderEntries, params);
4757
5347
  }
4758
5348
  // Additional WHERE filters on the target — full scalar where surface,
4759
5349
  // properly parameterized against the target alias.
@@ -4829,6 +5419,16 @@ export class QueryInterface {
4829
5419
  * Used to detect JSONB/array columns for specialized operators.
4830
5420
  * Uses pre-computed Map for O(1) lookup instead of linear scan.
4831
5421
  */
5422
+ /**
5423
+ * Case-insensitive json/jsonb column-type check. Postgres reports lowercase
5424
+ * udt_names, but SQLite/MySQL introspection surfaces the DECLARED type
5425
+ * (e.g. `JSON`), so every JSON-feature gate compares through this predicate
5426
+ * — build and collect sides alike, keeping the SQL-cache lockstep.
5427
+ */
5428
+ isJsonColumnType(colType) {
5429
+ const t = colType.toLowerCase();
5430
+ return t === 'json' || t === 'jsonb';
5431
+ }
4832
5432
  getColumnPgType(column) {
4833
5433
  return this.columnPgTypeMap.get(column) ?? 'text';
4834
5434
  }
@@ -4898,7 +5498,8 @@ export class QueryInterface {
4898
5498
  let pathParamIdx = null;
4899
5499
  const pathExtract = () => {
4900
5500
  if (pathParamIdx === null) {
4901
- params.push(filter.path);
5501
+ // Only reached when a path-requiring clause validated filter.path.
5502
+ params.push(this.jsonPathParam(filter.path, filter.path));
4902
5503
  pathParamIdx = params.length;
4903
5504
  }
4904
5505
  return this.dialect.buildJsonPathExtract(column, this.p(pathParamIdx));
@@ -4934,6 +5535,24 @@ export class QueryInterface {
4934
5535
  }
4935
5536
  return clauses;
4936
5537
  }
5538
+ /**
5539
+ * Bind value for a JSON path parameter, encoded per dialect. PostgreSQL's
5540
+ * `#>>` takes a `text[]` (the segments as strings — or `nativeForm` when the
5541
+ * caller has a specific native binding, e.g. JsonFilter's raw path array).
5542
+ * Every other engine's JSON function (`json_extract` / `JSON_EXTRACT` /
5543
+ * `JSON_VALUE`) takes a `'$'`-rooted JSONPath STRING: binding the raw array
5544
+ * would arrive as `'["a"]'` (the driver shims JSON.stringify non-primitive
5545
+ * params) and fail at runtime with the engine's bad-JSON-path error. The
5546
+ * encoded path stays a bound parameter — never spliced into SQL text — so
5547
+ * the build/collect param mirrors stay in lockstep and injection-safe.
5548
+ */
5549
+ jsonPathParam(path, nativeForm) {
5550
+ if (this.dialect.jsonPathSupport === 'native')
5551
+ return nativeForm ?? path.map(String);
5552
+ return `$${path
5553
+ .map((seg) => typeof seg === 'number' || /^\d+$/.test(String(seg)) ? `[${seg}]` : `."${String(seg).replace(/"/g, '\\"')}"`)
5554
+ .join('')}`;
5555
+ }
4937
5556
  /**
4938
5557
  * Cast an extracted JSON path text value to a numeric type for range
4939
5558
  * comparison. PostgreSQL uses `(expr)::numeric` (exact — the right way to