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.
@@ -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, isColumnRef, isJsonFilter, isJsonPathOrderBy, isOrderBySpec, isTextSearchFilter, isUnmatchedPlainObject, isVectorFilter, isVectorOrderBy, isWhereOperator, JSON_RANGE_OPERATORS, normalizeOrderBy, sortedEntries, sortedKeys, UPDATE_OPERATOR_KEYS, VECTOR_DISTANCE_COMPARATORS, VECTOR_METRIC_OPERATORS, validateTextSearchConfig, } from './filters.js';
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
  }
@@ -2514,7 +2673,7 @@ export class QueryInterface {
2514
2673
  // the target column is json/jsonb.
2515
2674
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
2516
2675
  const colType = this.pgTypeForColumn(meta, col);
2517
- if (colType === 'json' || colType === 'jsonb') {
2676
+ if (this.isJsonColumnType(colType)) {
2518
2677
  this.collectJsonFilterParams(value, params, `${this.q(targetTable)}.${this.q(col)}`);
2519
2678
  continue;
2520
2679
  }
@@ -2584,7 +2743,8 @@ export class QueryInterface {
2584
2743
  let pathPushed = false;
2585
2744
  const pushPathOnce = () => {
2586
2745
  if (!pathPushed) {
2587
- params.push(filter.path);
2746
+ // Only reached when a path-requiring clause validated filter.path.
2747
+ params.push(this.jsonPathParam(filter.path, filter.path));
2588
2748
  pathPushed = true;
2589
2749
  }
2590
2750
  };
@@ -2636,15 +2796,20 @@ export class QueryInterface {
2636
2796
  // then the path bound as one text[] param.
2637
2797
  if (isJsonPathOrderBy(dir)) {
2638
2798
  this.validateJsonPathOrderBy(this.table, this.tableMeta, key, dir);
2639
- params.push(dir.path.map(String));
2799
+ params.push(this.jsonPathParam(dir.path));
2640
2800
  continue;
2641
2801
  }
2642
2802
  // To-many relation orderBy (`{ posts: { _count } }`) uses the same count
2643
- // subquery as `_count` mirror its global-filter params. To-one relation
2644
- // 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.
2645
2807
  if (this.isRelationOrderByValue(dir)) {
2646
2808
  const relDef = this.tableMeta.relations[key];
2647
- 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')) {
2648
2813
  this.collectRelationCountParams(relDef, params);
2649
2814
  }
2650
2815
  else if (relDef) {
@@ -2734,7 +2899,8 @@ export class QueryInterface {
2734
2899
  }
2735
2900
  // orderBy shape (OrderBySpec nulls placement changes the SQL, so fingerprint it)
2736
2901
  if (opts.orderBy) {
2737
- 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)}`);
2738
2904
  subParts.push(`o=${oEntries.join(',')}`);
2739
2905
  }
2740
2906
  // limit presence
@@ -3144,7 +3310,7 @@ export class QueryInterface {
3144
3310
  // Handle JSONB filter operators (for json/jsonb columns)
3145
3311
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
3146
3312
  const colType = this.getColumnPgType(rawColumn);
3147
- if (colType === 'json' || colType === 'jsonb') {
3313
+ if (this.isJsonColumnType(colType)) {
3148
3314
  const jsonClauses = this.buildJsonFilterClauses(column, value, params);
3149
3315
  andClauses.push(...jsonClauses);
3150
3316
  continue;
@@ -3359,7 +3525,7 @@ export class QueryInterface {
3359
3525
  // jsonb value, silently matching nothing.
3360
3526
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
3361
3527
  const colType = this.pgTypeForColumn(meta, col);
3362
- if (colType === 'json' || colType === 'jsonb') {
3528
+ if (this.isJsonColumnType(colType)) {
3363
3529
  conditions.push(...this.buildJsonFilterClauses(qCol, value, params));
3364
3530
  continue;
3365
3531
  }
@@ -3459,7 +3625,7 @@ export class QueryInterface {
3459
3625
  assertBindableEqualityValue(rawColumn, value, columnPgType, table) {
3460
3626
  if (!isUnmatchedPlainObject(value))
3461
3627
  return;
3462
- if (columnPgType === 'json' || columnPgType === 'jsonb')
3628
+ if (this.isJsonColumnType(columnPgType))
3463
3629
  return;
3464
3630
  const badKeys = Object.keys(value);
3465
3631
  throw new ValidationError(badKeys.length === 0
@@ -3530,7 +3696,7 @@ export class QueryInterface {
3530
3696
  // bound as a plain equality value.
3531
3697
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
3532
3698
  const colType = this.pgTypeForColumn(targetMeta, col);
3533
- if (colType === 'json' || colType === 'jsonb') {
3699
+ if (this.isJsonColumnType(colType)) {
3534
3700
  clauses.push(...this.buildJsonFilterClauses(qCol, value, params));
3535
3701
  continue;
3536
3702
  }
@@ -3603,7 +3769,7 @@ export class QueryInterface {
3603
3769
  // JSONB filter — mirrors buildAliasWhere.
3604
3770
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
3605
3771
  const colType = this.pgTypeForColumn(targetMeta, col);
3606
- if (colType === 'json' || colType === 'jsonb') {
3772
+ if (this.isJsonColumnType(colType)) {
3607
3773
  this.collectJsonFilterParams(value, params, this.q(col));
3608
3774
  continue;
3609
3775
  }
@@ -3836,7 +4002,7 @@ export class QueryInterface {
3836
4002
  * vs relation-column never collide on one cached SQL string. Captures the
3837
4003
  * SQL-shaping bits (direction, nulls, metric, relation keys) — never values.
3838
4004
  */
3839
- orderByEntryFingerprint(d) {
4005
+ orderByEntryFingerprint(d, targetTable) {
3840
4006
  // Vector KNN ordering changes the emitted operator by metric and adds a
3841
4007
  // `::vector` param, so metric + direction must be part of the cache key.
3842
4008
  if (isVectorOrderBy(d)) {
@@ -3847,13 +4013,36 @@ export class QueryInterface {
3847
4013
  if (isJsonPathOrderBy(d)) {
3848
4014
  return `jp(${d.direction ?? 'asc'},${d.type === 'numeric' ? 'num' : 'text'},${d.nulls ?? ''})`;
3849
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
+ }
3850
4035
  if (isOrderBySpec(d))
3851
4036
  return `spec(${d.sort},${d.nulls ?? ''})`;
3852
4037
  if (d && typeof d === 'object') {
3853
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.
3854
4044
  return `rel(${Object.entries(d)
3855
4045
  .map(([k, v]) => `${k}=${this.orderByEntryFingerprint(v)}`)
3856
- .sort()
3857
4046
  .join(',')})`;
3858
4047
  }
3859
4048
  return String(d);
@@ -3971,7 +4160,7 @@ export class QueryInterface {
3971
4160
  `of keys/indexes (e.g. { path: ['weight'], direction: 'asc' }).`);
3972
4161
  }
3973
4162
  const colType = this.pgTypeForColumn(meta, col);
3974
- if (colType !== 'json' && colType !== 'jsonb') {
4163
+ if (!this.isJsonColumnType(colType)) {
3975
4164
  throw new ValidationError(`[turbine] JSON-path orderBy on "${field}": column "${col}" on table "${table}" is not a JSON column ` +
3976
4165
  `(actual type: ${colType}).`);
3977
4166
  }
@@ -3991,7 +4180,7 @@ export class QueryInterface {
3991
4180
  if (!params) {
3992
4181
  throw new ValidationError(`[turbine] JSON-path ordering on "${field}" is not supported in this orderBy context.`);
3993
4182
  }
3994
- params.push(spec.path.map(String));
4183
+ params.push(this.jsonPathParam(spec.path));
3995
4184
  const extract = this.dialect.buildJsonPathExtract(`${prefix}${this.q(col)}`, this.p(params.length));
3996
4185
  const lhs = spec.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
3997
4186
  const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
@@ -4019,12 +4208,21 @@ export class QueryInterface {
4019
4208
  throw new RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${ownerTable}". ` +
4020
4209
  `Available: ${Object.keys(ownerMeta.relations).join(', ')}`);
4021
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);
4219
+ }
4022
4220
  // To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
4023
4221
  if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
4024
4222
  const keys = Object.keys(value);
4025
4223
  if (keys.length !== 1 || keys[0] !== '_count') {
4026
4224
  throw new ValidationError(`[turbine] orderBy on to-many relation "${relName}" only supports "_count" ` +
4027
- `(got: ${keys.join(', ') || '(empty)'}).`);
4225
+ `or a pick-row ordering ({ pick, by }) (got: ${keys.join(', ') || '(empty)'}).`);
4028
4226
  }
4029
4227
  const { dir } = normalizeOrderBy(value._count);
4030
4228
  return `${this.buildRelationCountExpr(relDef, parentRef, alias, params)} ${dir}`;
@@ -4065,6 +4263,145 @@ export class QueryInterface {
4065
4263
  })
4066
4264
  .join(', ');
4067
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
+ }
4068
4405
  /**
4069
4406
  * Compile the ORDER BY terms of a relation `with` clause against the
4070
4407
  * relation's table alias. One unified path for every relation shape
@@ -4115,10 +4452,15 @@ export class QueryInterface {
4115
4452
  }
4116
4453
  if (isJsonPathOrderBy(dirValue)) {
4117
4454
  this.validateJsonPathOrderBy(targetTable, targetMeta, key, dirValue);
4118
- params.push(dirValue.path.map(String));
4455
+ params.push(this.jsonPathParam(dirValue.path));
4119
4456
  continue;
4120
4457
  }
4121
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
+ }
4122
4464
  const relDef = targetMeta.relations[key];
4123
4465
  if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
4124
4466
  this.collectRelationCountParams(relDef, params);
@@ -5077,6 +5419,16 @@ export class QueryInterface {
5077
5419
  * Used to detect JSONB/array columns for specialized operators.
5078
5420
  * Uses pre-computed Map for O(1) lookup instead of linear scan.
5079
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
+ }
5080
5432
  getColumnPgType(column) {
5081
5433
  return this.columnPgTypeMap.get(column) ?? 'text';
5082
5434
  }
@@ -5146,7 +5498,8 @@ export class QueryInterface {
5146
5498
  let pathParamIdx = null;
5147
5499
  const pathExtract = () => {
5148
5500
  if (pathParamIdx === null) {
5149
- params.push(filter.path);
5501
+ // Only reached when a path-requiring clause validated filter.path.
5502
+ params.push(this.jsonPathParam(filter.path, filter.path));
5150
5503
  pathParamIdx = params.length;
5151
5504
  }
5152
5505
  return this.dialect.buildJsonPathExtract(column, this.p(pathParamIdx));
@@ -5182,6 +5535,24 @@ export class QueryInterface {
5182
5535
  }
5183
5536
  return clauses;
5184
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
+ }
5185
5556
  /**
5186
5557
  * Cast an extracted JSON path text value to a numeric type for range
5187
5558
  * comparison. PostgreSQL uses `(expr)::numeric` (exact — the right way to