turbine-orm 0.31.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.
@@ -327,6 +327,10 @@ class QueryInterface {
327
327
  */
328
328
  async runFindManyBatched(args) {
329
329
  const withClause = args.with;
330
+ // Scope-rule parity with the join strategy (which throws at SQL build):
331
+ // reject nested pick-row ordering BEFORE the base query so acceptance
332
+ // never depends on how many rows come back.
333
+ (0, batched_loader_js_1.rejectNestedPickOrder)(withClause);
330
334
  // Capture the opt-out from the ARGS before any await: this.currentSkip is
331
335
  // instance state on a cached accessor, so a concurrent build during the
332
336
  // base-query await would overwrite it (tenant query loading relations with
@@ -552,6 +556,8 @@ class QueryInterface {
552
556
  */
553
557
  async runFindUniqueBatched(args) {
554
558
  const withClause = args.with;
559
+ // Same scope-rule parity as runFindManyBatched: reject before querying.
560
+ (0, batched_loader_js_1.rejectNestedPickOrder)(withClause);
555
561
  const needed = (0, batched_loader_js_1.neededParentKeyFields)(this.tableMeta, withClause);
556
562
  const proj = (0, batched_loader_js_1.includeKeysForBatching)(args.select, args.omit, needed);
557
563
  const baseArgs = { ...args, with: undefined, select: proj.select, omit: proj.omit };
@@ -738,6 +744,21 @@ class QueryInterface {
738
744
  // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
739
745
  buildFindMany(args) {
740
746
  this.currentSkip = args?.skipGlobalFilters;
747
+ // `distinct` + relation orderBy is refused up front (E003): the distinct
748
+ // path re-orders in an outer wrapper (`... AS "<table>_distinct" ORDER BY
749
+ // <userOrder>`) where a correlated relation subquery (pick-row, `_count`,
750
+ // to-one relation ordering) would reference the parent table name out of
751
+ // scope — a guaranteed "missing FROM-clause entry" crash on Postgres.
752
+ // Checked BEFORE the SQL cache so build and warm-cache paths throw
753
+ // identically (same rule as the vector guard inside the distinct branch).
754
+ if (args?.distinct && args.distinct.length > 0 && args.orderBy) {
755
+ for (const d of Object.values(args.orderBy)) {
756
+ if (this.isRelationOrderByValue(d)) {
757
+ throw new errors_js_1.ValidationError('[turbine] `distinct` cannot be combined with relation orderBy (pick-row, `_count`, or ' +
758
+ 'to-one relation ordering): the outer re-order cannot reference the parent table.');
759
+ }
760
+ }
761
+ }
741
762
  const columnsList = this.resolveColumns(args?.select, args?.omit);
742
763
  const colKey = columnsList ? columnsList.join(',') : '*';
743
764
  // AND-merge this table's global filter into the user where; `hasWhere` gates
@@ -751,7 +772,7 @@ class QueryInterface {
751
772
  const withFp = args?.with ? this.withFingerprint(args.with) : '';
752
773
  const orderFp = args?.orderBy
753
774
  ? Object.entries(args.orderBy)
754
- .map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d)}`)
775
+ .map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, this.tableMeta.relations[k]?.to)}`)
755
776
  .join(',')
756
777
  : '';
757
778
  const cursorFp = args?.cursor
@@ -1612,67 +1633,124 @@ class QueryInterface {
1612
1633
  const meta = this.schema.tables[this.table];
1613
1634
  if (meta) {
1614
1635
  for (const key of args.by) {
1615
- if (!(key in meta.columnMap)) {
1636
+ if (typeof key === 'string' && !(key in meta.columnMap)) {
1616
1637
  throw new errors_js_1.ValidationError(`Unknown column "${key}" in groupBy for table "${this.table}"`);
1617
1638
  }
1618
1639
  }
1619
1640
  }
1620
1641
  this.currentSkip = args.skipGlobalFilters;
1621
- const groupColsRaw = args.by.map((k) => this.toColumn(k));
1622
- const groupCols = groupColsRaw.map((c) => this.q(c));
1623
1642
  const gbWhere = this.mergeGlobalFilter(args.where);
1624
1643
  const { sql: whereSql, params } = gbWhere
1625
1644
  ? this.buildWhere(gbWhere)
1626
1645
  : { sql: '', params: [] };
1627
- // Build SELECT expressions: group-by columns + aggregate functions
1628
- const selectExprs = [...groupCols];
1646
+ // Row source. Plain: `"table"<WHERE>`. With `distinctOn` (PostgreSQL
1647
+ // only), the groupBy runs over one representative row per column
1648
+ // combination: the wrapper carries args.where INSIDE it (filter before
1649
+ // picking) and is aliased as the table name so every outer expression is
1650
+ // byte-identical either way.
1651
+ const fromSql = args.distinctOn
1652
+ ? this.buildDistinctOnSource(args.distinctOn, whereSql, params)
1653
+ : `${this.q(this.table)}${whereSql}`;
1654
+ // Group keys: plain columns and/or JSON-path keys. Output-name collisions
1655
+ // are rejected up front — and the check runs over the EMITTED SQL output
1656
+ // column names (snake_case column / JSON alias / `_agg_key` aggregate
1657
+ // alias), not just the given arg keys: the driver keeps only the LAST
1658
+ // duplicate field per row object, so a JSON alias equal to another key's
1659
+ // snake_case column (or an aggregate output alias) would silently clobber
1660
+ // that value in the results.
1661
+ const groupExprs = [];
1662
+ const selectExprs = [];
1663
+ /** by entries in order: how to read each group key off the result row. */
1664
+ const byReaders = [];
1665
+ const usedResultKeys = new Set();
1666
+ const claimResultKey = (key, what) => {
1667
+ if (key === '_count' || usedResultKeys.has(key)) {
1668
+ throw new errors_js_1.ValidationError(`[turbine] groupBy output name "${key}" (${what}) collides with another output column on table ` +
1669
+ `"${this.table}": set an explicit \`alias\` (or rename the aggregate key) to disambiguate.`);
1670
+ }
1671
+ usedResultKeys.add(key);
1672
+ };
1673
+ for (const entry of args.by) {
1674
+ if (typeof entry === 'string') {
1675
+ const col = this.toColumn(entry);
1676
+ claimResultKey(entry, `column "${col}"`);
1677
+ // The emitted output column is the snake_case name; claim it too (when
1678
+ // it differs from the result key) so a JSON alias like 'created_at'
1679
+ // cannot silently shadow the 'createdAt' group key on the wire.
1680
+ if (col !== entry)
1681
+ claimResultKey(col, `column "${col}"`);
1682
+ groupExprs.push(this.q(col));
1683
+ selectExprs.push(this.q(col));
1684
+ byReaders.push({ resultKey: entry, rowKey: col, raw: false });
1685
+ }
1686
+ else {
1687
+ const col = this.resolveJsonPathTarget('group key', entry.field, entry.path);
1688
+ params.push(this.jsonPathParam(entry.path));
1689
+ const extract = this.dialect.buildJsonPathExtract(this.q(col), this.p(params.length));
1690
+ const alias = entry.alias ?? String(entry.path[entry.path.length - 1]);
1691
+ claimResultKey(alias, `JSON path on "${entry.field}"`);
1692
+ // Same expression (and the same $n placeholder) in SELECT and GROUP BY.
1693
+ selectExprs.push(`(${extract}) AS ${this.q(alias)}`);
1694
+ groupExprs.push(extract);
1695
+ byReaders.push({ resultKey: alias, rowKey: alias, raw: true });
1696
+ }
1697
+ }
1629
1698
  // _count
1630
1699
  if (args._count === true || args._count === undefined) {
1631
1700
  // default: always include count
1632
1701
  selectExprs.push(`${this.castAgg('COUNT(*)', 'int')} AS _count`);
1633
1702
  }
1634
- // _sum
1635
- if (args._sum) {
1636
- for (const [field, enabled] of Object.entries(args._sum)) {
1637
- if (enabled) {
1638
- const col = this.toColumn(field);
1639
- selectExprs.push(`SUM(${this.q(col)}) AS ${this.q(`_sum_${col}`)}`);
1640
- }
1641
- }
1642
- }
1643
- // _avg
1644
- if (args._avg) {
1645
- for (const [field, enabled] of Object.entries(args._avg)) {
1646
- if (enabled) {
1647
- const col = this.toColumn(field);
1648
- selectExprs.push(`${this.castAgg(`AVG(${this.q(col)})`, 'float')} AS ${this.q(`_avg_${col}`)}`);
1649
- }
1650
- }
1651
- }
1652
- // _min
1653
- if (args._min) {
1654
- for (const [field, enabled] of Object.entries(args._min)) {
1655
- if (enabled) {
1656
- const col = this.toColumn(field);
1657
- selectExprs.push(`MIN(${this.q(col)}) AS ${this.q(`_min_${col}`)}`);
1703
+ // _sum / _avg / _min / _max: `true` keeps the plain-column behavior; a
1704
+ // {@link JsonPathAggregateTarget} aggregates a JSON path under the arg key
1705
+ // as alias. `jsonAggFields` routes each JSON-aggregate row key back to its
1706
+ // alias (and coercion kind) in the transform; `jsonAggExprs` lets HAVING
1707
+ // reuse the exact aggregate expression (same placeholders) by alias.
1708
+ const jsonAggFields = new Map();
1709
+ const jsonAggExprs = new Map();
1710
+ const buildAggregates = (aggKey, sqlFn, spec) => {
1711
+ if (!spec)
1712
+ return;
1713
+ for (const [key, target] of Object.entries(spec)) {
1714
+ if (!target)
1715
+ continue;
1716
+ if (target === true) {
1717
+ const col = this.toColumn(key);
1718
+ // Aggregate output aliases share the same output-name namespace as
1719
+ // the group keys: `_sum: { totalPrice: true, total_price: {json} }`
1720
+ // would emit two "_sum_total_price" columns and silently drop one.
1721
+ claimResultKey(`${aggKey}_${col}`, `${aggKey} of column "${col}"`);
1722
+ const inner = `${sqlFn}(${this.q(col)})`;
1723
+ const expr = aggKey === '_avg' ? this.castAgg(inner, 'float') : inner;
1724
+ selectExprs.push(`${expr} AS ${this.q(`${aggKey}_${col}`)}`);
1725
+ continue;
1658
1726
  }
1659
- }
1660
- }
1661
- // _max
1662
- if (args._max) {
1663
- for (const [field, enabled] of Object.entries(args._max)) {
1664
- if (enabled) {
1665
- const col = this.toColumn(field);
1666
- selectExprs.push(`MAX(${this.q(col)}) AS ${this.q(`_max_${col}`)}`);
1727
+ const col = this.resolveJsonPathTarget(`${aggKey} target "${key}"`, target.field, target.path);
1728
+ const alwaysNumeric = aggKey === '_sum' || aggKey === '_avg';
1729
+ if (alwaysNumeric && target.type === 'text') {
1730
+ throw new errors_js_1.ValidationError(`[turbine] groupBy ${aggKey} target "${key}" on table "${this.table}": ` +
1731
+ `${aggKey} over a JSON path is always numeric: remove \`type: 'text'\`.`);
1667
1732
  }
1733
+ const numeric = alwaysNumeric || target.type === 'numeric';
1734
+ claimResultKey(`${aggKey}_${key}`, `${aggKey} JSON target "${key}"`);
1735
+ params.push(this.jsonPathParam(target.path));
1736
+ const extract = this.dialect.buildJsonPathExtract(this.q(col), this.p(params.length));
1737
+ const inner = `${sqlFn}(${numeric ? this.castJsonNumeric(extract) : extract})`;
1738
+ const expr = aggKey === '_avg' ? this.castAgg(inner, 'float') : inner;
1739
+ selectExprs.push(`${expr} AS ${this.q(`${aggKey}_${key}`)}`);
1740
+ jsonAggFields.set(`${aggKey}_${key}`, { field: key, numeric });
1741
+ jsonAggExprs.set(`${key}:${aggKey}`, expr);
1668
1742
  }
1669
- }
1670
- let sql = `SELECT ${selectExprs.join(', ')} FROM ${this.q(this.table)}${whereSql} GROUP BY ${groupCols.join(', ')}`;
1743
+ };
1744
+ buildAggregates('_sum', 'SUM', args._sum);
1745
+ buildAggregates('_avg', 'AVG', args._avg);
1746
+ buildAggregates('_min', 'MIN', args._min);
1747
+ buildAggregates('_max', 'MAX', args._max);
1748
+ let sql = `SELECT ${selectExprs.join(', ')} FROM ${fromSql} GROUP BY ${groupExprs.join(', ')}`;
1671
1749
  // HAVING — filter whole groups by their aggregate values.
1672
1750
  // Appends to the same `params` array, so placeholders continue from the
1673
1751
  // WHERE clause's parameter positions (this.p(params.length) below).
1674
1752
  if (args.having) {
1675
- const havingClauses = this.buildHavingClauses(args.having, params);
1753
+ const havingClauses = this.buildHavingClauses(args.having, params, jsonAggExprs);
1676
1754
  if (havingClauses.length > 0) {
1677
1755
  sql += ` HAVING ${havingClauses.join(' AND ')}`;
1678
1756
  }
@@ -1688,9 +1766,11 @@ class QueryInterface {
1688
1766
  const parsed = this.parseRow(row, this.table);
1689
1767
  // Restructure aggregate results into nested objects (Prisma-style)
1690
1768
  const restructured = {};
1691
- // Copy group-by fields
1692
- for (const field of args.by) {
1693
- restructured[field] = parsed[field];
1769
+ // Copy group-by fields. JSON-path keys read their alias off the raw
1770
+ // row (the alias is not a table column, so parseRow's snake→camel
1771
+ // mapping must not touch it).
1772
+ for (const reader of byReaders) {
1773
+ restructured[reader.resultKey] = reader.raw ? row[reader.rowKey] : parsed[reader.resultKey];
1694
1774
  }
1695
1775
  // _count
1696
1776
  if ('_count' in row) {
@@ -1705,29 +1785,27 @@ class QueryInterface {
1705
1785
  const minObj = {};
1706
1786
  const maxObj = {};
1707
1787
  let hasSums = false, hasAvgs = false, hasMins = false, hasMaxs = false;
1788
+ // JSON-path aggregates keep their arg key verbatim; plain-column
1789
+ // aggregates keep the snake→camel field mapping.
1790
+ const jsonAgg = (rawKey) => jsonAggFields.get(rawKey);
1791
+ const fieldFor = (rawKey, col) => jsonAgg(rawKey)?.field ?? this.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
1708
1792
  for (const [rawKey, rawValue] of Object.entries(row)) {
1709
1793
  if (rawKey.startsWith('_sum_')) {
1710
- const col = rawKey.slice(5);
1711
- const field = this.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
1712
- sumObj[field] = rawValue !== null ? Number(rawValue) : null;
1794
+ sumObj[fieldFor(rawKey, rawKey.slice(5))] = rawValue !== null ? Number(rawValue) : null;
1713
1795
  hasSums = true;
1714
1796
  }
1715
1797
  else if (rawKey.startsWith('_avg_')) {
1716
- const col = rawKey.slice(5);
1717
- const field = this.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
1718
- avgObj[field] = rawValue !== null ? Number(rawValue) : null;
1798
+ avgObj[fieldFor(rawKey, rawKey.slice(5))] = rawValue !== null ? Number(rawValue) : null;
1719
1799
  hasAvgs = true;
1720
1800
  }
1721
1801
  else if (rawKey.startsWith('_min_')) {
1722
- const col = rawKey.slice(5);
1723
- const field = this.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
1724
- minObj[field] = rawValue;
1802
+ const j = jsonAgg(rawKey);
1803
+ minObj[fieldFor(rawKey, rawKey.slice(5))] = j?.numeric && rawValue !== null ? Number(rawValue) : rawValue;
1725
1804
  hasMins = true;
1726
1805
  }
1727
1806
  else if (rawKey.startsWith('_max_')) {
1728
- const col = rawKey.slice(5);
1729
- const field = this.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
1730
- maxObj[field] = rawValue;
1807
+ const j = jsonAgg(rawKey);
1808
+ maxObj[fieldFor(rawKey, rawKey.slice(5))] = j?.numeric && rawValue !== null ? Number(rawValue) : rawValue;
1731
1809
  hasMaxs = true;
1732
1810
  }
1733
1811
  }
@@ -1744,6 +1822,75 @@ class QueryInterface {
1744
1822
  tag: `${this.table}.groupBy`,
1745
1823
  };
1746
1824
  }
1825
+ /**
1826
+ * Validate a JSON-path target (group key or aggregate target) in groupBy:
1827
+ * the field must resolve to a real json/jsonb column and the path must be a
1828
+ * non-empty array of keys/indexes. Returns the resolved snake_case column.
1829
+ */
1830
+ resolveJsonPathTarget(context, field, path) {
1831
+ if (typeof field !== 'string') {
1832
+ throw new errors_js_1.ValidationError(`[turbine] groupBy ${context} on table "${this.table}" requires a string \`field\`.`);
1833
+ }
1834
+ const col = this.toColumn(field);
1835
+ if (!Array.isArray(path) ||
1836
+ path.length === 0 ||
1837
+ path.some((el) => typeof el !== 'string' && !(typeof el === 'number' && Number.isFinite(el)))) {
1838
+ throw new errors_js_1.ValidationError(`[turbine] groupBy ${context} on "${field}" (table "${this.table}") requires a non-empty \`path\` ` +
1839
+ `array of keys/indexes (e.g. { field: '${field}', path: ['category'] }).`);
1840
+ }
1841
+ const colType = this.pgTypeForColumn(this.tableMeta, col);
1842
+ if (!this.isJsonColumnType(colType)) {
1843
+ throw new errors_js_1.ValidationError(`[turbine] groupBy ${context} on "${field}": column "${col}" on table "${this.table}" is not a JSON ` +
1844
+ `column (actual type: ${colType}).`);
1845
+ }
1846
+ return col;
1847
+ }
1848
+ /**
1849
+ * Build the `distinctOn` row source for groupBy (PostgreSQL only: other
1850
+ * engines throw {@link UnsupportedFeatureError} E017):
1851
+ *
1852
+ * ```sql
1853
+ * (SELECT DISTINCT ON ("c1") * FROM "table"<WHERE> ORDER BY "c1", <orderBy>) AS "table"
1854
+ * ```
1855
+ *
1856
+ * The wrapper is aliased as the table name so every outer expression (group
1857
+ * keys, aggregates, HAVING, ORDER BY) is byte-identical to the plain path.
1858
+ * `distinctOn.orderBy` is required (it decides which row survives) and
1859
+ * supports plain columns, {@link OrderBySpec} nulls, and JSON-path specs;
1860
+ * JSON paths push their text[] param here, after the WHERE params.
1861
+ */
1862
+ buildDistinctOnSource(distinctOn, whereSql, params) {
1863
+ if (this.dialect.name !== 'postgresql') {
1864
+ throw new errors_js_1.UnsupportedFeatureError('DISTINCT ON row source (groupBy distinctOn)', this.dialect.name, 'groupBy({ distinctOn }) requires PostgreSQL: SELECT DISTINCT ON is not portable.');
1865
+ }
1866
+ if (!Array.isArray(distinctOn.columns) || distinctOn.columns.length === 0) {
1867
+ throw new errors_js_1.ValidationError(`[turbine] groupBy distinctOn on table "${this.table}" requires a non-empty \`columns\` array.`);
1868
+ }
1869
+ const orderEntries = Object.entries(distinctOn.orderBy ?? {});
1870
+ if (orderEntries.length === 0) {
1871
+ throw new errors_js_1.ValidationError(`[turbine] groupBy distinctOn on table "${this.table}" requires \`orderBy\` to pick ONE row per ` +
1872
+ "column combination deterministically (e.g. orderBy: { createdAt: 'desc' }).");
1873
+ }
1874
+ const distinctCols = distinctOn.columns.map((c) => this.q(this.toColumn(c)));
1875
+ // DISTINCT ON expressions must lead the ORDER BY; the user's orderBy then
1876
+ // decides which row survives per combination.
1877
+ const orderParts = [...distinctCols];
1878
+ for (const [key, value] of orderEntries) {
1879
+ if ((0, filters_js_1.isJsonPathOrderBy)(value)) {
1880
+ orderParts.push(this.buildJsonPathOrderEntry(this.table, this.tableMeta, key, value, '', params));
1881
+ continue;
1882
+ }
1883
+ if ((0, filters_js_1.isVectorOrderBy)(value) || this.isRelationOrderByValue(value)) {
1884
+ throw new errors_js_1.ValidationError(`[turbine] groupBy distinctOn.orderBy on "${key}" (table "${this.table}") supports plain columns, ` +
1885
+ 'sort specs, and JSON-path orderings only.');
1886
+ }
1887
+ const col = this.resolveOrderByColumn(this.table, this.tableMeta, key);
1888
+ const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(value);
1889
+ orderParts.push(`${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`);
1890
+ }
1891
+ return (`(SELECT DISTINCT ON (${distinctCols.join(', ')}) * FROM ${this.q(this.table)}${whereSql} ` +
1892
+ `ORDER BY ${orderParts.join(', ')}) AS ${this.q(this.table)}`);
1893
+ }
1747
1894
  /**
1748
1895
  * Build the SQL fragments for a {@link HavingClause}.
1749
1896
  *
@@ -1754,8 +1901,14 @@ class QueryInterface {
1754
1901
  * comparison value is pushed onto the shared `params` array and referenced by
1755
1902
  * a `$N` placeholder via {@link buildHavingNumericClauses} — there is no string
1756
1903
  * interpolation of user values.
1904
+ *
1905
+ * `jsonAggExprs` (from {@link buildGroupBy}) maps `alias:aggKey` to the
1906
+ * exact aggregate expression a JSON-path aggregate emitted in SELECT
1907
+ * (including its already-bound path placeholder), so HAVING on a JSON-path
1908
+ * aggregate alias reuses the same expression instead of resolving the alias
1909
+ * as a column.
1757
1910
  */
1758
- buildHavingClauses(having, params) {
1911
+ buildHavingClauses(having, params, jsonAggExprs) {
1759
1912
  const clauses = [];
1760
1913
  // Maps the per-field aggregate key to its SQL function name. The set of
1761
1914
  // allowed keys is fixed here — any other key on a field's filter object is
@@ -1782,8 +1935,14 @@ class QueryInterface {
1782
1935
  }
1783
1936
  // toColumn validates the field against schema metadata (throws
1784
1937
  // ValidationError on unknown columns) and q() quotes the identifier — no
1785
- // unvalidated identifier ever reaches the SQL string.
1786
- const quotedCol = this.q(this.toColumn(key));
1938
+ // unvalidated identifier ever reaches the SQL string. Resolution is lazy:
1939
+ // a JSON-path aggregate alias is not a column, so it must not hit
1940
+ // toColumn when every aggregate under it resolves via `jsonAggExprs`.
1941
+ let quotedCol = null;
1942
+ const columnExpr = () => {
1943
+ quotedCol ??= this.q(this.toColumn(key));
1944
+ return quotedCol;
1945
+ };
1787
1946
  for (const [aggKey, filter] of Object.entries(value)) {
1788
1947
  if (filter === undefined)
1789
1948
  continue;
@@ -1792,7 +1951,7 @@ class QueryInterface {
1792
1951
  throw new errors_js_1.ValidationError(`[turbine] Unknown aggregate "${aggKey}" in having for field "${key}" on table "${this.table}". ` +
1793
1952
  `Supported: ${Object.keys(aggFnByKey).join(', ')}.`);
1794
1953
  }
1795
- const expr = `${fn}(${quotedCol})`;
1954
+ const expr = jsonAggExprs?.get(`${key}:${aggKey}`) ?? `${fn}(${columnExpr()})`;
1796
1955
  clauses.push(...this.buildHavingNumericClauses(expr, filter, params));
1797
1956
  }
1798
1957
  }
@@ -2439,7 +2598,7 @@ class QueryInterface {
2439
2598
  // JSONB filter
2440
2599
  if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
2441
2600
  const colType = this.getColumnPgType(rawColumn);
2442
- if (colType === 'json' || colType === 'jsonb') {
2601
+ if (this.isJsonColumnType(colType)) {
2443
2602
  this.collectJsonFilterParams(value, params, this.q(rawColumn));
2444
2603
  continue;
2445
2604
  }
@@ -2550,7 +2709,7 @@ class QueryInterface {
2550
2709
  // the target column is json/jsonb.
2551
2710
  if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
2552
2711
  const colType = this.pgTypeForColumn(meta, col);
2553
- if (colType === 'json' || colType === 'jsonb') {
2712
+ if (this.isJsonColumnType(colType)) {
2554
2713
  this.collectJsonFilterParams(value, params, `${this.q(targetTable)}.${this.q(col)}`);
2555
2714
  continue;
2556
2715
  }
@@ -2620,7 +2779,8 @@ class QueryInterface {
2620
2779
  let pathPushed = false;
2621
2780
  const pushPathOnce = () => {
2622
2781
  if (!pathPushed) {
2623
- params.push(filter.path);
2782
+ // Only reached when a path-requiring clause validated filter.path.
2783
+ params.push(this.jsonPathParam(filter.path, filter.path));
2624
2784
  pathPushed = true;
2625
2785
  }
2626
2786
  };
@@ -2672,15 +2832,20 @@ class QueryInterface {
2672
2832
  // then the path bound as one text[] param.
2673
2833
  if ((0, filters_js_1.isJsonPathOrderBy)(dir)) {
2674
2834
  this.validateJsonPathOrderBy(this.table, this.tableMeta, key, dir);
2675
- params.push(dir.path.map(String));
2835
+ params.push(this.jsonPathParam(dir.path));
2676
2836
  continue;
2677
2837
  }
2678
2838
  // To-many relation orderBy (`{ posts: { _count } }`) uses the same count
2679
- // subquery as `_count` mirror its global-filter params. To-one relation
2680
- // orderBy carries the target's global filter once per ordered column.
2839
+ // subquery as `_count`: mirror its global-filter params. Pick-row
2840
+ // ordering mirrors its full param chain (by-path / global filter /
2841
+ // pick.where / pick.orderBy paths). To-one relation orderBy carries the
2842
+ // target's global filter once per ordered column.
2681
2843
  if (this.isRelationOrderByValue(dir)) {
2682
2844
  const relDef = this.tableMeta.relations[key];
2683
- if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
2845
+ if (relDef && (0, filters_js_1.isRelationPickOrderBy)(dir)) {
2846
+ this.collectRelationPickOrderParams(key, relDef, dir, params);
2847
+ }
2848
+ else if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
2684
2849
  this.collectRelationCountParams(relDef, params);
2685
2850
  }
2686
2851
  else if (relDef) {
@@ -2770,7 +2935,8 @@ class QueryInterface {
2770
2935
  }
2771
2936
  // orderBy shape (OrderBySpec nulls placement changes the SQL, so fingerprint it)
2772
2937
  if (opts.orderBy) {
2773
- const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d)}`);
2938
+ const targetRels = this.schema.tables[relDef.to]?.relations;
2939
+ const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, targetRels?.[k]?.to)}`);
2774
2940
  subParts.push(`o=${oEntries.join(',')}`);
2775
2941
  }
2776
2942
  // limit presence
@@ -3180,7 +3346,7 @@ class QueryInterface {
3180
3346
  // Handle JSONB filter operators (for json/jsonb columns)
3181
3347
  if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
3182
3348
  const colType = this.getColumnPgType(rawColumn);
3183
- if (colType === 'json' || colType === 'jsonb') {
3349
+ if (this.isJsonColumnType(colType)) {
3184
3350
  const jsonClauses = this.buildJsonFilterClauses(column, value, params);
3185
3351
  andClauses.push(...jsonClauses);
3186
3352
  continue;
@@ -3395,7 +3561,7 @@ class QueryInterface {
3395
3561
  // jsonb value, silently matching nothing.
3396
3562
  if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
3397
3563
  const colType = this.pgTypeForColumn(meta, col);
3398
- if (colType === 'json' || colType === 'jsonb') {
3564
+ if (this.isJsonColumnType(colType)) {
3399
3565
  conditions.push(...this.buildJsonFilterClauses(qCol, value, params));
3400
3566
  continue;
3401
3567
  }
@@ -3495,7 +3661,7 @@ class QueryInterface {
3495
3661
  assertBindableEqualityValue(rawColumn, value, columnPgType, table) {
3496
3662
  if (!(0, filters_js_1.isUnmatchedPlainObject)(value))
3497
3663
  return;
3498
- if (columnPgType === 'json' || columnPgType === 'jsonb')
3664
+ if (this.isJsonColumnType(columnPgType))
3499
3665
  return;
3500
3666
  const badKeys = Object.keys(value);
3501
3667
  throw new errors_js_1.ValidationError(badKeys.length === 0
@@ -3566,7 +3732,7 @@ class QueryInterface {
3566
3732
  // bound as a plain equality value.
3567
3733
  if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
3568
3734
  const colType = this.pgTypeForColumn(targetMeta, col);
3569
- if (colType === 'json' || colType === 'jsonb') {
3735
+ if (this.isJsonColumnType(colType)) {
3570
3736
  clauses.push(...this.buildJsonFilterClauses(qCol, value, params));
3571
3737
  continue;
3572
3738
  }
@@ -3639,7 +3805,7 @@ class QueryInterface {
3639
3805
  // JSONB filter — mirrors buildAliasWhere.
3640
3806
  if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
3641
3807
  const colType = this.pgTypeForColumn(targetMeta, col);
3642
- if (colType === 'json' || colType === 'jsonb') {
3808
+ if (this.isJsonColumnType(colType)) {
3643
3809
  this.collectJsonFilterParams(value, params, this.q(col));
3644
3810
  continue;
3645
3811
  }
@@ -3872,7 +4038,7 @@ class QueryInterface {
3872
4038
  * vs relation-column never collide on one cached SQL string. Captures the
3873
4039
  * SQL-shaping bits (direction, nulls, metric, relation keys) — never values.
3874
4040
  */
3875
- orderByEntryFingerprint(d) {
4041
+ orderByEntryFingerprint(d, targetTable) {
3876
4042
  // Vector KNN ordering changes the emitted operator by metric and adds a
3877
4043
  // `::vector` param, so metric + direction must be part of the cache key.
3878
4044
  if ((0, filters_js_1.isVectorOrderBy)(d)) {
@@ -3883,13 +4049,36 @@ class QueryInterface {
3883
4049
  if ((0, filters_js_1.isJsonPathOrderBy)(d)) {
3884
4050
  return `jp(${d.direction ?? 'asc'},${d.type === 'numeric' ? 'num' : 'text'},${d.nulls ?? ''})`;
3885
4051
  }
4052
+ // Pick-row relation ordering: the by-shape (column vs JSON path vs cast),
4053
+ // direction, nulls, pick.orderBy shape, and pick.where SHAPE are all SQL
4054
+ // text; the JSON paths and pick.where values are bound params and stay OUT
4055
+ // of the key. `targetTable` (the relation's target, resolved by the
4056
+ // caller) lets the pick.where fingerprint distinguish relation-filter
4057
+ // shapes inside it: two pick.wheres that differ only in shape must never
4058
+ // share one cached SQL string.
4059
+ if ((0, filters_js_1.isRelationPickOrderBy)(d)) {
4060
+ const by = typeof d.by === 'string'
4061
+ ? `col=${JSON.stringify(d.by)}`
4062
+ : `jp(${JSON.stringify(d.by?.field)},${d.by?.type === 'numeric' ? 'num' : 'text'})`;
4063
+ const pickOrder = Object.entries(d.pick?.orderBy ?? {})
4064
+ .map(([k, v]) => `${k}:${this.orderByEntryFingerprint(v)}`)
4065
+ .join(',');
4066
+ const pickWhere = d.pick?.where
4067
+ ? `;pw=${this.fingerprintAliasWhere(d.pick.where, targetTable)}`
4068
+ : '';
4069
+ return `pick(${by},${d.direction ?? 'asc'},${d.nulls ?? ''};po=${pickOrder}${pickWhere})`;
4070
+ }
3886
4071
  if ((0, filters_js_1.isOrderBySpec)(d))
3887
4072
  return `spec(${d.sort},${d.nulls ?? ''})`;
3888
4073
  if (d && typeof d === 'object') {
3889
4074
  // Relation ordering (`{ _count: 'desc' }` or `{ name: 'asc' }`).
4075
+ // INSERTION order, never sorted: the compile side (buildRelationOrderBy)
4076
+ // emits one ORDER BY term per entry in Object.entries order, so entry
4077
+ // order is SQL-shaping precedence. A sorted fingerprint made
4078
+ // `{ name: 'asc', email: 'desc' }` and the swapped literal share one
4079
+ // cached SQL string — silently mis-ordered results on a warm cache.
3890
4080
  return `rel(${Object.entries(d)
3891
4081
  .map(([k, v]) => `${k}=${this.orderByEntryFingerprint(v)}`)
3892
- .sort()
3893
4082
  .join(',')})`;
3894
4083
  }
3895
4084
  return String(d);
@@ -4007,7 +4196,7 @@ class QueryInterface {
4007
4196
  `of keys/indexes (e.g. { path: ['weight'], direction: 'asc' }).`);
4008
4197
  }
4009
4198
  const colType = this.pgTypeForColumn(meta, col);
4010
- if (colType !== 'json' && colType !== 'jsonb') {
4199
+ if (!this.isJsonColumnType(colType)) {
4011
4200
  throw new errors_js_1.ValidationError(`[turbine] JSON-path orderBy on "${field}": column "${col}" on table "${table}" is not a JSON column ` +
4012
4201
  `(actual type: ${colType}).`);
4013
4202
  }
@@ -4027,7 +4216,7 @@ class QueryInterface {
4027
4216
  if (!params) {
4028
4217
  throw new errors_js_1.ValidationError(`[turbine] JSON-path ordering on "${field}" is not supported in this orderBy context.`);
4029
4218
  }
4030
- params.push(spec.path.map(String));
4219
+ params.push(this.jsonPathParam(spec.path));
4031
4220
  const extract = this.dialect.buildJsonPathExtract(`${prefix}${this.q(col)}`, this.p(params.length));
4032
4221
  const lhs = spec.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
4033
4222
  const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
@@ -4055,12 +4244,21 @@ class QueryInterface {
4055
4244
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${ownerTable}". ` +
4056
4245
  `Available: ${Object.keys(ownerMeta.relations).join(', ')}`);
4057
4246
  }
4247
+ // Pick-row ordering (`{ pick, by }`): order by a value from ONE related
4248
+ // row: a correlated scalar subquery with its own ORDER BY … LIMIT 1.
4249
+ // Top-level findMany only (`ctx` present means we are inside a relation
4250
+ // subquery's orderBy) and hasMany only: validatePickOrderBy throws the
4251
+ // scope errors, shared with the cache-hit collect mirror.
4252
+ if ((0, filters_js_1.isRelationPickOrderBy)(value)) {
4253
+ this.validatePickOrderBy(relName, relDef, value, ctx !== undefined);
4254
+ return this.buildRelationPickOrderBy(relName, relDef, value, alias, parentRef, params);
4255
+ }
4058
4256
  // To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
4059
4257
  if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
4060
4258
  const keys = Object.keys(value);
4061
4259
  if (keys.length !== 1 || keys[0] !== '_count') {
4062
4260
  throw new errors_js_1.ValidationError(`[turbine] orderBy on to-many relation "${relName}" only supports "_count" ` +
4063
- `(got: ${keys.join(', ') || '(empty)'}).`);
4261
+ `or a pick-row ordering ({ pick, by }) (got: ${keys.join(', ') || '(empty)'}).`);
4064
4262
  }
4065
4263
  const { dir } = (0, filters_js_1.normalizeOrderBy)(value._count);
4066
4264
  return `${this.buildRelationCountExpr(relDef, parentRef, alias, params)} ${dir}`;
@@ -4101,6 +4299,145 @@ class QueryInterface {
4101
4299
  })
4102
4300
  .join(', ');
4103
4301
  }
4302
+ /**
4303
+ * Validate a {@link RelationPickOrderBy} entry's scope and shape. Shared by
4304
+ * the SQL-build path ({@link buildRelationPickOrderBy}) and the cache-hit
4305
+ * param-collect mirror ({@link collectRelationPickOrderParams}) so both
4306
+ * always throw identically:
4307
+ *
4308
+ * - `nested` (inside a relation subquery's orderBy or a pick.orderBy):
4309
+ * top-level findMany only in this release (E003),
4310
+ * - manyToMany: not supported (E003 naming the limitation),
4311
+ * - to-one: order by the target column directly instead (E003),
4312
+ * - `pick.orderBy` is REQUIRED (deterministic row choice),
4313
+ * - `by` must be a target column name or a `{ field, path }` JSON-path spec.
4314
+ */
4315
+ pickOrderNestedError(relName) {
4316
+ return new errors_js_1.ValidationError(`[turbine] Pick-row ordering on relation "${relName}" is only supported in a top-level ` +
4317
+ 'findMany orderBy: nested `with` orderBy does not support it.');
4318
+ }
4319
+ validatePickOrderBy(relName, relDef, spec, nested) {
4320
+ if (nested) {
4321
+ throw this.pickOrderNestedError(relName);
4322
+ }
4323
+ if (relDef.type === 'manyToMany') {
4324
+ throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering is not supported on manyToMany relation "${relName}": ` +
4325
+ 'hasMany relations only.');
4326
+ }
4327
+ if (relDef.type !== 'hasMany') {
4328
+ throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering is only for to-many (hasMany) relations; "${relName}" is ${relDef.type}. ` +
4329
+ `Order by the target column directly instead ({ ${relName}: { <column>: 'asc' } }).`);
4330
+ }
4331
+ const pickOrder = spec.pick?.orderBy;
4332
+ if (typeof spec.pick !== 'object' ||
4333
+ spec.pick === null ||
4334
+ typeof pickOrder !== 'object' ||
4335
+ pickOrder === null ||
4336
+ Object.keys(pickOrder).length === 0) {
4337
+ throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering on relation "${relName}" requires \`pick.orderBy\` to choose ONE ` +
4338
+ "related row deterministically (e.g. pick: { orderBy: { createdAt: 'desc' } }).");
4339
+ }
4340
+ const by = spec.by;
4341
+ const validJsonBy = typeof by === 'object' && by !== null && typeof by.field === 'string' && Array.isArray(by.path);
4342
+ if (typeof by !== 'string' && !validJsonBy) {
4343
+ throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering on relation "${relName}" requires \`by\`: a target column name ` +
4344
+ "or a JSON-path spec ({ field: 'data', path: ['title'] }).");
4345
+ }
4346
+ }
4347
+ /**
4348
+ * Compile a {@link RelationPickOrderBy} term: a correlated scalar subquery
4349
+ * that picks ONE related row (`ORDER BY <pick.orderBy> LIMIT 1`, optionally
4350
+ * filtered by `pick.where` and the target's global filter) and surfaces one
4351
+ * value from it (a plain target column or a JSON-path extraction) as the
4352
+ * parent ORDER BY key:
4353
+ *
4354
+ * ```sql
4355
+ * (SELECT ord0."data" #>> $1::text[] FROM "versions" ord0
4356
+ * WHERE ord0."instance_id" = "instances"."id" AND ord0."is_current" = $2
4357
+ * ORDER BY ord0."created_at" DESC LIMIT 1) ASC NULLS LAST
4358
+ * ```
4359
+ *
4360
+ * Param-push order (mirrored EXACTLY by
4361
+ * {@link collectRelationPickOrderParams}): `by` JSON path (if any) →
4362
+ * target global filter → `pick.where` → `pick.orderBy` JSON paths.
4363
+ */
4364
+ buildRelationPickOrderBy(relName, relDef, spec, alias, parentRef, params) {
4365
+ if (!params) {
4366
+ throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering on relation "${relName}" is only supported in a top-level findMany orderBy.`);
4367
+ }
4368
+ const targetMeta = this.schema.tables[relDef.to];
4369
+ if (!targetMeta)
4370
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
4371
+ // The value surfaced from the picked row (SELECT list: its param binds first).
4372
+ let byExpr;
4373
+ if (typeof spec.by === 'string') {
4374
+ const col = this.resolveOrderByColumn(relDef.to, targetMeta, spec.by);
4375
+ byExpr = `${alias}.${this.q(col)}`;
4376
+ }
4377
+ else {
4378
+ const col = this.validateJsonPathOrderBy(relDef.to, targetMeta, spec.by.field, {
4379
+ path: spec.by.path,
4380
+ });
4381
+ params.push(this.jsonPathParam(spec.by.path));
4382
+ const extract = this.dialect.buildJsonPathExtract(`${alias}.${this.q(col)}`, this.p(params.length));
4383
+ byExpr = spec.by.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
4384
+ }
4385
+ // Correlation to the parent row, then the target's global filter (a
4386
+ // soft-deleted / other-tenant row must never be picked: matches the
4387
+ // `with` subquery and to-one relation-orderBy semantics), then pick.where.
4388
+ let where = this.dialect.buildCorrelation(alias, relDef.foreignKey, this.q(parentRef), relDef.referenceKey);
4389
+ const gf = this.targetGlobalFilterAlias(relDef.to, alias, params);
4390
+ if (gf)
4391
+ where += ` AND ${gf}`;
4392
+ if (spec.pick.where) {
4393
+ const pickWhere = this.buildAliasWhere(relDef.to, targetMeta, alias, spec.pick.where, params);
4394
+ if (pickWhere)
4395
+ where += ` AND ${pickWhere}`;
4396
+ }
4397
+ // pick.orderBy: same surface as a relation `with` orderBy on the target
4398
+ // (plain columns, OrderBySpec nulls, JSON-path specs); a nested pick in
4399
+ // here routes back through buildRelationOrderBy with ctx set and throws
4400
+ // the top-level-only E003.
4401
+ const orderClause = this.buildRelationOrderClause(relDef.to, targetMeta, alias, Object.entries(spec.pick.orderBy), params);
4402
+ const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
4403
+ const limitOne = this.buildPagination('1', undefined, true);
4404
+ // Parents with ZERO surviving related rows make the correlated subquery
4405
+ // yield NULL. Without a nulls clause, Postgres DESC defaults to NULLS
4406
+ // FIRST — every childless parent would top a "highest first" sort. Default
4407
+ // to NULLS LAST in BOTH directions (deterministic across engines: SQLite's
4408
+ // NULL-is-smallest default diverges from Postgres) unless the caller set
4409
+ // `nulls` explicitly; the grammar gate matches nullsSuffix (PG + SQLite).
4410
+ const nullsSql = spec.nulls
4411
+ ? this.nullsSuffix(spec.nulls)
4412
+ : this.dialect.name === 'postgresql' || this.dialect.name === 'sqlite'
4413
+ ? ' NULLS LAST'
4414
+ : '';
4415
+ return `(SELECT ${byExpr} FROM ${this.q(relDef.to)} ${alias} WHERE ${where}${orderClause}${limitOne}) ${dir}${nullsSql}`;
4416
+ }
4417
+ /**
4418
+ * Param-collect mirror of {@link buildRelationPickOrderBy}: re-runs the same
4419
+ * validation (a warmed cache can never skip it), then pushes in the same
4420
+ * order: `by` JSON path → target global filter → `pick.where` →
4421
+ * `pick.orderBy` JSON paths.
4422
+ */
4423
+ collectRelationPickOrderParams(relName, relDef, spec, params) {
4424
+ this.validatePickOrderBy(relName, relDef, spec, false);
4425
+ const targetMeta = this.schema.tables[relDef.to];
4426
+ if (!targetMeta)
4427
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
4428
+ if (typeof spec.by === 'string') {
4429
+ this.resolveOrderByColumn(relDef.to, targetMeta, spec.by);
4430
+ }
4431
+ else {
4432
+ this.validateJsonPathOrderBy(relDef.to, targetMeta, spec.by.field, { path: spec.by.path });
4433
+ params.push(this.jsonPathParam(spec.by.path));
4434
+ }
4435
+ this.collectTargetGlobalFilterAlias(relDef.to, params);
4436
+ if (spec.pick.where) {
4437
+ this.collectAliasWhereParams(relDef.to, targetMeta, spec.pick.where, params);
4438
+ }
4439
+ this.collectRelationOrderParams(relDef.to, targetMeta, Object.entries(spec.pick.orderBy), params);
4440
+ }
4104
4441
  /**
4105
4442
  * Compile the ORDER BY terms of a relation `with` clause against the
4106
4443
  * relation's table alias. One unified path for every relation shape
@@ -4151,10 +4488,15 @@ class QueryInterface {
4151
4488
  }
4152
4489
  if ((0, filters_js_1.isJsonPathOrderBy)(dirValue)) {
4153
4490
  this.validateJsonPathOrderBy(targetTable, targetMeta, key, dirValue);
4154
- params.push(dirValue.path.map(String));
4491
+ params.push(this.jsonPathParam(dirValue.path));
4155
4492
  continue;
4156
4493
  }
4157
4494
  if (this.isRelationOrderByValue(dirValue)) {
4495
+ // Pick-row ordering is top-level-only: the build path throws the same
4496
+ // E003 (buildRelationOrderBy with ctx set), so the mirror must too.
4497
+ if ((0, filters_js_1.isRelationPickOrderBy)(dirValue)) {
4498
+ throw this.pickOrderNestedError(key);
4499
+ }
4158
4500
  const relDef = targetMeta.relations[key];
4159
4501
  if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
4160
4502
  this.collectRelationCountParams(relDef, params);
@@ -5113,6 +5455,16 @@ class QueryInterface {
5113
5455
  * Used to detect JSONB/array columns for specialized operators.
5114
5456
  * Uses pre-computed Map for O(1) lookup instead of linear scan.
5115
5457
  */
5458
+ /**
5459
+ * Case-insensitive json/jsonb column-type check. Postgres reports lowercase
5460
+ * udt_names, but SQLite/MySQL introspection surfaces the DECLARED type
5461
+ * (e.g. `JSON`), so every JSON-feature gate compares through this predicate
5462
+ * — build and collect sides alike, keeping the SQL-cache lockstep.
5463
+ */
5464
+ isJsonColumnType(colType) {
5465
+ const t = colType.toLowerCase();
5466
+ return t === 'json' || t === 'jsonb';
5467
+ }
5116
5468
  getColumnPgType(column) {
5117
5469
  return this.columnPgTypeMap.get(column) ?? 'text';
5118
5470
  }
@@ -5182,7 +5534,8 @@ class QueryInterface {
5182
5534
  let pathParamIdx = null;
5183
5535
  const pathExtract = () => {
5184
5536
  if (pathParamIdx === null) {
5185
- params.push(filter.path);
5537
+ // Only reached when a path-requiring clause validated filter.path.
5538
+ params.push(this.jsonPathParam(filter.path, filter.path));
5186
5539
  pathParamIdx = params.length;
5187
5540
  }
5188
5541
  return this.dialect.buildJsonPathExtract(column, this.p(pathParamIdx));
@@ -5218,6 +5571,24 @@ class QueryInterface {
5218
5571
  }
5219
5572
  return clauses;
5220
5573
  }
5574
+ /**
5575
+ * Bind value for a JSON path parameter, encoded per dialect. PostgreSQL's
5576
+ * `#>>` takes a `text[]` (the segments as strings — or `nativeForm` when the
5577
+ * caller has a specific native binding, e.g. JsonFilter's raw path array).
5578
+ * Every other engine's JSON function (`json_extract` / `JSON_EXTRACT` /
5579
+ * `JSON_VALUE`) takes a `'$'`-rooted JSONPath STRING: binding the raw array
5580
+ * would arrive as `'["a"]'` (the driver shims JSON.stringify non-primitive
5581
+ * params) and fail at runtime with the engine's bad-JSON-path error. The
5582
+ * encoded path stays a bound parameter — never spliced into SQL text — so
5583
+ * the build/collect param mirrors stay in lockstep and injection-safe.
5584
+ */
5585
+ jsonPathParam(path, nativeForm) {
5586
+ if (this.dialect.jsonPathSupport === 'native')
5587
+ return nativeForm ?? path.map(String);
5588
+ return `$${path
5589
+ .map((seg) => typeof seg === 'number' || /^\d+$/.test(String(seg)) ? `[${seg}]` : `."${String(seg).replace(/"/g, '\\"')}"`)
5590
+ .join('')}`;
5591
+ }
5221
5592
  /**
5222
5593
  * Cast an extracted JSON path text value to a numeric type for range
5223
5594
  * comparison. PostgreSQL uses `(expr)::numeric` (exact — the right way to