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.
- package/dist/cjs/client.js +14 -3
- package/dist/cjs/mssql.js +24 -3
- package/dist/cjs/powdb.js +197 -50
- package/dist/cjs/powql.js +45 -2
- package/dist/cjs/query/batched-loader.js +34 -0
- package/dist/cjs/query/builder.js +758 -139
- package/dist/cjs/query/filters.js +77 -2
- package/dist/client.d.ts +13 -0
- package/dist/client.js +14 -3
- package/dist/index.d.ts +1 -1
- package/dist/mssql.js +24 -3
- package/dist/powdb.d.ts +33 -0
- package/dist/powdb.js +197 -50
- package/dist/powql.js +45 -2
- package/dist/query/batched-loader.d.ts +12 -0
- package/dist/query/batched-loader.js +33 -0
- package/dist/query/builder.d.ts +171 -5
- package/dist/query/builder.js +760 -141
- package/dist/query/filters.d.ts +40 -1
- package/dist/query/filters.js +73 -1
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +213 -22
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
1628
|
-
|
|
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
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
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
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1693
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1723
|
-
|
|
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
|
|
1729
|
-
|
|
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
|
-
|
|
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}(${
|
|
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
|
|
2601
|
+
if (this.isJsonColumnType(colType)) {
|
|
2443
2602
|
this.collectJsonFilterParams(value, params, this.q(rawColumn));
|
|
2444
2603
|
continue;
|
|
2445
2604
|
}
|
|
@@ -2459,7 +2618,11 @@ class QueryInterface {
|
|
|
2459
2618
|
}
|
|
2460
2619
|
// Operator objects
|
|
2461
2620
|
if ((0, filters_js_1.isWhereOperator)(value)) {
|
|
2462
|
-
this.collectOperatorParams(rawColumn, value, params
|
|
2621
|
+
this.collectOperatorParams(rawColumn, value, params, {
|
|
2622
|
+
meta: this.tableMeta,
|
|
2623
|
+
table: this.table,
|
|
2624
|
+
prefix: '',
|
|
2625
|
+
});
|
|
2463
2626
|
continue;
|
|
2464
2627
|
}
|
|
2465
2628
|
// Plain equality — same strict validation as the build path, so a
|
|
@@ -2546,7 +2709,7 @@ class QueryInterface {
|
|
|
2546
2709
|
// the target column is json/jsonb.
|
|
2547
2710
|
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
2548
2711
|
const colType = this.pgTypeForColumn(meta, col);
|
|
2549
|
-
if (colType
|
|
2712
|
+
if (this.isJsonColumnType(colType)) {
|
|
2550
2713
|
this.collectJsonFilterParams(value, params, `${this.q(targetTable)}.${this.q(col)}`);
|
|
2551
2714
|
continue;
|
|
2552
2715
|
}
|
|
@@ -2560,28 +2723,40 @@ class QueryInterface {
|
|
|
2560
2723
|
}
|
|
2561
2724
|
}
|
|
2562
2725
|
if ((0, filters_js_1.isWhereOperator)(value)) {
|
|
2563
|
-
this.collectOperatorParams(col, value, params);
|
|
2726
|
+
this.collectOperatorParams(col, value, params, { meta, table: targetTable, prefix: '' });
|
|
2564
2727
|
continue;
|
|
2565
2728
|
}
|
|
2566
2729
|
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
|
|
2567
2730
|
params.push(value);
|
|
2568
2731
|
}
|
|
2569
2732
|
}
|
|
2570
|
-
/**
|
|
2571
|
-
|
|
2572
|
-
|
|
2733
|
+
/**
|
|
2734
|
+
* Collect params from operator clauses. Mirrors buildOperatorClauses:
|
|
2735
|
+
* {@link ColumnRef} values compile into the SQL text, so they push NOTHING -
|
|
2736
|
+
* but they re-run the same validation (unknown ref / insensitive mode) so a
|
|
2737
|
+
* warmed cache can never skip a check the build path enforces.
|
|
2738
|
+
*/
|
|
2739
|
+
collectOperatorParams(column, op, params, refCtx) {
|
|
2740
|
+
const skipRef = (v) => {
|
|
2741
|
+
if (!(0, filters_js_1.isColumnRef)(v))
|
|
2742
|
+
return false;
|
|
2743
|
+
if (refCtx)
|
|
2744
|
+
this.resolveColumnRef(v, refCtx, op.mode);
|
|
2745
|
+
return true;
|
|
2746
|
+
};
|
|
2747
|
+
if (op.equals !== undefined && op.equals !== null && !skipRef(op.equals)) {
|
|
2573
2748
|
(0, filters_js_1.assertBindableEqualsOperand)(op.equals, `"${column}"`);
|
|
2574
2749
|
params.push(op.equals);
|
|
2575
2750
|
}
|
|
2576
|
-
if (op.gt !== undefined)
|
|
2751
|
+
if (op.gt !== undefined && !skipRef(op.gt))
|
|
2577
2752
|
params.push(op.gt);
|
|
2578
|
-
if (op.gte !== undefined)
|
|
2753
|
+
if (op.gte !== undefined && !skipRef(op.gte))
|
|
2579
2754
|
params.push(op.gte);
|
|
2580
|
-
if (op.lt !== undefined)
|
|
2755
|
+
if (op.lt !== undefined && !skipRef(op.lt))
|
|
2581
2756
|
params.push(op.lt);
|
|
2582
|
-
if (op.lte !== undefined)
|
|
2757
|
+
if (op.lte !== undefined && !skipRef(op.lte))
|
|
2583
2758
|
params.push(op.lte);
|
|
2584
|
-
if (op.not !== undefined && op.not !== null)
|
|
2759
|
+
if (op.not !== undefined && op.not !== null && !skipRef(op.not))
|
|
2585
2760
|
params.push(op.not);
|
|
2586
2761
|
if (op.in !== undefined)
|
|
2587
2762
|
params.push(this.inParam(op.in));
|
|
@@ -2604,7 +2779,8 @@ class QueryInterface {
|
|
|
2604
2779
|
let pathPushed = false;
|
|
2605
2780
|
const pushPathOnce = () => {
|
|
2606
2781
|
if (!pathPushed) {
|
|
2607
|
-
|
|
2782
|
+
// Only reached when a path-requiring clause validated filter.path.
|
|
2783
|
+
params.push(this.jsonPathParam(filter.path, filter.path));
|
|
2608
2784
|
pathPushed = true;
|
|
2609
2785
|
}
|
|
2610
2786
|
};
|
|
@@ -2637,10 +2813,10 @@ class QueryInterface {
|
|
|
2637
2813
|
// isEmpty has no params (IS NULL / IS NOT NULL)
|
|
2638
2814
|
}
|
|
2639
2815
|
/**
|
|
2640
|
-
* Collect params for an orderBy clause.
|
|
2641
|
-
*
|
|
2642
|
-
* parameterless. Mirrors buildOrderBy's push
|
|
2643
|
-
* param re-collection stays in lockstep.
|
|
2816
|
+
* Collect params for an orderBy clause. Vector KNN ordering pushes the
|
|
2817
|
+
* `$n::vector` query vector and JSON-path ordering pushes its text[] path;
|
|
2818
|
+
* plain direction ordering is parameterless. Mirrors buildOrderBy's push
|
|
2819
|
+
* order exactly so the cached-SQL param re-collection stays in lockstep.
|
|
2644
2820
|
*/
|
|
2645
2821
|
collectOrderByParams(orderBy, params) {
|
|
2646
2822
|
for (const [key, dir] of Object.entries(orderBy)) {
|
|
@@ -2652,12 +2828,24 @@ class QueryInterface {
|
|
|
2652
2828
|
this.pushVectorParam(key, rawColumn, dir.distance.to, params);
|
|
2653
2829
|
continue;
|
|
2654
2830
|
}
|
|
2831
|
+
// JSON-path ordering: mirrors buildJsonPathOrderEntry: same validation,
|
|
2832
|
+
// then the path bound as one text[] param.
|
|
2833
|
+
if ((0, filters_js_1.isJsonPathOrderBy)(dir)) {
|
|
2834
|
+
this.validateJsonPathOrderBy(this.table, this.tableMeta, key, dir);
|
|
2835
|
+
params.push(this.jsonPathParam(dir.path));
|
|
2836
|
+
continue;
|
|
2837
|
+
}
|
|
2655
2838
|
// To-many relation orderBy (`{ posts: { _count } }`) uses the same count
|
|
2656
|
-
// subquery as `_count
|
|
2657
|
-
//
|
|
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.
|
|
2658
2843
|
if (this.isRelationOrderByValue(dir)) {
|
|
2659
2844
|
const relDef = this.tableMeta.relations[key];
|
|
2660
|
-
if (relDef && (
|
|
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')) {
|
|
2661
2849
|
this.collectRelationCountParams(relDef, params);
|
|
2662
2850
|
}
|
|
2663
2851
|
else if (relDef) {
|
|
@@ -2747,7 +2935,8 @@ class QueryInterface {
|
|
|
2747
2935
|
}
|
|
2748
2936
|
// orderBy shape (OrderBySpec nulls placement changes the SQL, so fingerprint it)
|
|
2749
2937
|
if (opts.orderBy) {
|
|
2750
|
-
const
|
|
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)}`);
|
|
2751
2940
|
subParts.push(`o=${oEntries.join(',')}`);
|
|
2752
2941
|
}
|
|
2753
2942
|
// limit presence
|
|
@@ -2798,9 +2987,20 @@ class QueryInterface {
|
|
|
2798
2987
|
const targetMeta = this.schema.tables[targetTable];
|
|
2799
2988
|
if (!targetMeta)
|
|
2800
2989
|
return;
|
|
2990
|
+
// A dialect that owns the whole subquery (buildRelationSubquery override,
|
|
2991
|
+
// SQL Server FOR JSON) compiles orderBy through its OWN paging clause -
|
|
2992
|
+
// plain directions only, no order params: so the native order-param
|
|
2993
|
+
// mirrors below must stay off for it (its documented contract remains
|
|
2994
|
+
// where → limit → nested).
|
|
2995
|
+
const nativeOrderPath = !this.dialect.buildRelationSubquery;
|
|
2801
2996
|
// manyToMany param order mirrors buildManyToManySubquery:
|
|
2802
|
-
// where params → limit param → nested-with params
|
|
2997
|
+
// orderBy params → where params → limit param → nested-with params
|
|
2998
|
+
// (always, both paths).
|
|
2803
2999
|
if (relDef.type === 'manyToMany') {
|
|
3000
|
+
const m2mOrderEntries = spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
|
|
3001
|
+
if (nativeOrderPath && m2mOrderEntries.length > 0) {
|
|
3002
|
+
this.collectRelationOrderParams(targetTable, targetMeta, m2mOrderEntries, params);
|
|
3003
|
+
}
|
|
2804
3004
|
if (spec.where) {
|
|
2805
3005
|
this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
|
|
2806
3006
|
}
|
|
@@ -2819,7 +3019,8 @@ class QueryInterface {
|
|
|
2819
3019
|
return;
|
|
2820
3020
|
}
|
|
2821
3021
|
// Mirrors buildRelationSubquery's willWrap: `orderBy: {}` is treated as absent.
|
|
2822
|
-
const
|
|
3022
|
+
const relOrderEntries = spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
|
|
3023
|
+
const hasOrder = relOrderEntries.length > 0;
|
|
2823
3024
|
const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
|
|
2824
3025
|
// Non-wrapped path: nested relations BEFORE where/limit
|
|
2825
3026
|
if (!willWrap && spec.with) {
|
|
@@ -2830,6 +3031,12 @@ class QueryInterface {
|
|
|
2830
3031
|
this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
|
|
2831
3032
|
}
|
|
2832
3033
|
}
|
|
3034
|
+
// orderBy params (JSON paths / relation-order global filters): mirrors
|
|
3035
|
+
// buildRelationSubquery, which builds its ORDER BY terms BEFORE compiling
|
|
3036
|
+
// spec.where (both wrapped and non-wrapped paths).
|
|
3037
|
+
if (nativeOrderPath && hasOrder) {
|
|
3038
|
+
this.collectRelationOrderParams(targetTable, targetMeta, relOrderEntries, params);
|
|
3039
|
+
}
|
|
2833
3040
|
// where params — mirrors buildAliasWhere push order
|
|
2834
3041
|
if (spec.where) {
|
|
2835
3042
|
this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
|
|
@@ -3139,7 +3346,7 @@ class QueryInterface {
|
|
|
3139
3346
|
// Handle JSONB filter operators (for json/jsonb columns)
|
|
3140
3347
|
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
3141
3348
|
const colType = this.getColumnPgType(rawColumn);
|
|
3142
|
-
if (colType
|
|
3349
|
+
if (this.isJsonColumnType(colType)) {
|
|
3143
3350
|
const jsonClauses = this.buildJsonFilterClauses(column, value, params);
|
|
3144
3351
|
andClauses.push(...jsonClauses);
|
|
3145
3352
|
continue;
|
|
@@ -3180,7 +3387,11 @@ class QueryInterface {
|
|
|
3180
3387
|
}
|
|
3181
3388
|
// Handle operator objects
|
|
3182
3389
|
if ((0, filters_js_1.isWhereOperator)(value)) {
|
|
3183
|
-
const opClauses = this.buildOperatorClauses(column, value, params
|
|
3390
|
+
const opClauses = this.buildOperatorClauses(column, value, params, {
|
|
3391
|
+
meta: this.tableMeta,
|
|
3392
|
+
table: this.table,
|
|
3393
|
+
prefix: '',
|
|
3394
|
+
});
|
|
3184
3395
|
andClauses.push(...opClauses);
|
|
3185
3396
|
continue;
|
|
3186
3397
|
}
|
|
@@ -3350,7 +3561,7 @@ class QueryInterface {
|
|
|
3350
3561
|
// jsonb value, silently matching nothing.
|
|
3351
3562
|
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
3352
3563
|
const colType = this.pgTypeForColumn(meta, col);
|
|
3353
|
-
if (colType
|
|
3564
|
+
if (this.isJsonColumnType(colType)) {
|
|
3354
3565
|
conditions.push(...this.buildJsonFilterClauses(qCol, value, params));
|
|
3355
3566
|
continue;
|
|
3356
3567
|
}
|
|
@@ -3374,7 +3585,11 @@ class QueryInterface {
|
|
|
3374
3585
|
}
|
|
3375
3586
|
}
|
|
3376
3587
|
if ((0, filters_js_1.isWhereOperator)(value)) {
|
|
3377
|
-
const opClauses = this.buildOperatorClauses(qCol, value, params
|
|
3588
|
+
const opClauses = this.buildOperatorClauses(qCol, value, params, {
|
|
3589
|
+
meta,
|
|
3590
|
+
table: targetTable,
|
|
3591
|
+
prefix: `${qt}.`,
|
|
3592
|
+
});
|
|
3378
3593
|
conditions.push(...opClauses);
|
|
3379
3594
|
continue;
|
|
3380
3595
|
}
|
|
@@ -3446,7 +3661,7 @@ class QueryInterface {
|
|
|
3446
3661
|
assertBindableEqualityValue(rawColumn, value, columnPgType, table) {
|
|
3447
3662
|
if (!(0, filters_js_1.isUnmatchedPlainObject)(value))
|
|
3448
3663
|
return;
|
|
3449
|
-
if (columnPgType
|
|
3664
|
+
if (this.isJsonColumnType(columnPgType))
|
|
3450
3665
|
return;
|
|
3451
3666
|
const badKeys = Object.keys(value);
|
|
3452
3667
|
throw new errors_js_1.ValidationError(badKeys.length === 0
|
|
@@ -3517,7 +3732,7 @@ class QueryInterface {
|
|
|
3517
3732
|
// bound as a plain equality value.
|
|
3518
3733
|
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
3519
3734
|
const colType = this.pgTypeForColumn(targetMeta, col);
|
|
3520
|
-
if (colType
|
|
3735
|
+
if (this.isJsonColumnType(colType)) {
|
|
3521
3736
|
clauses.push(...this.buildJsonFilterClauses(qCol, value, params));
|
|
3522
3737
|
continue;
|
|
3523
3738
|
}
|
|
@@ -3541,7 +3756,11 @@ class QueryInterface {
|
|
|
3541
3756
|
}
|
|
3542
3757
|
}
|
|
3543
3758
|
if ((0, filters_js_1.isWhereOperator)(value)) {
|
|
3544
|
-
clauses.push(...this.buildOperatorClauses(qCol, value, params
|
|
3759
|
+
clauses.push(...this.buildOperatorClauses(qCol, value, params, {
|
|
3760
|
+
meta: targetMeta,
|
|
3761
|
+
table: targetTable,
|
|
3762
|
+
prefix: `${alias}.`,
|
|
3763
|
+
}));
|
|
3545
3764
|
continue;
|
|
3546
3765
|
}
|
|
3547
3766
|
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
|
|
@@ -3586,7 +3805,7 @@ class QueryInterface {
|
|
|
3586
3805
|
// JSONB filter — mirrors buildAliasWhere.
|
|
3587
3806
|
if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
|
|
3588
3807
|
const colType = this.pgTypeForColumn(targetMeta, col);
|
|
3589
|
-
if (colType
|
|
3808
|
+
if (this.isJsonColumnType(colType)) {
|
|
3590
3809
|
this.collectJsonFilterParams(value, params, this.q(col));
|
|
3591
3810
|
continue;
|
|
3592
3811
|
}
|
|
@@ -3600,7 +3819,7 @@ class QueryInterface {
|
|
|
3600
3819
|
}
|
|
3601
3820
|
}
|
|
3602
3821
|
if ((0, filters_js_1.isWhereOperator)(value)) {
|
|
3603
|
-
this.collectOperatorParams(col, value, params);
|
|
3822
|
+
this.collectOperatorParams(col, value, params, { meta: targetMeta, table: targetTable, prefix: '' });
|
|
3604
3823
|
continue;
|
|
3605
3824
|
}
|
|
3606
3825
|
this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
|
|
@@ -3677,16 +3896,55 @@ class QueryInterface {
|
|
|
3677
3896
|
}
|
|
3678
3897
|
return parts.join('&');
|
|
3679
3898
|
}
|
|
3899
|
+
/**
|
|
3900
|
+
* Validate a `{ col }` column reference against its table and return the
|
|
3901
|
+
* resolved snake_case column name. Shared by the SQL-build path
|
|
3902
|
+
* ({@link buildOperatorClauses}) and the cache-hit param-collect path
|
|
3903
|
+
* (`collectOperatorParams`) so both always throw identically: a warmed
|
|
3904
|
+
* cache can never skip the check.
|
|
3905
|
+
*/
|
|
3906
|
+
resolveColumnRef(ref, ctx, mode) {
|
|
3907
|
+
if (mode === 'insensitive') {
|
|
3908
|
+
throw new errors_js_1.ValidationError(`[turbine] mode: 'insensitive' cannot be combined with a column reference ({ col: "${ref.col}" }). ` +
|
|
3909
|
+
`Case-insensitive column-to-column comparison is not supported: use client.sql\`...\` ` +
|
|
3910
|
+
`for lower(a) = lower(b).`);
|
|
3911
|
+
}
|
|
3912
|
+
const col = ctx.meta.columnMap[ref.col] ?? (0, schema_js_1.camelToSnake)(ref.col);
|
|
3913
|
+
if (!ctx.meta.allColumns.includes(col)) {
|
|
3914
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown field "${ref.col}" referenced by { col } in where on table "${ctx.table}". ` +
|
|
3915
|
+
`Known fields: ${Object.keys(ctx.meta.columnMap).join(', ') || '(none)'}.`);
|
|
3916
|
+
}
|
|
3917
|
+
return col;
|
|
3918
|
+
}
|
|
3919
|
+
/**
|
|
3920
|
+
* Compile a `{ col }` reference to its quoted, prefix-matched SQL identifier.
|
|
3921
|
+
* NO param is bound: the referenced column is part of the SQL text (and of
|
|
3922
|
+
* the where fingerprint, see {@link fingerprintOperatorShape}).
|
|
3923
|
+
*/
|
|
3924
|
+
columnRefSql(ref, ctx, mode) {
|
|
3925
|
+
if (!ctx) {
|
|
3926
|
+
throw new errors_js_1.ValidationError(`[turbine] Column reference { col: "${ref.col}" } is not supported in this filter context.`);
|
|
3927
|
+
}
|
|
3928
|
+
return `${ctx.prefix}${this.q(this.resolveColumnRef(ref, ctx, mode))}`;
|
|
3929
|
+
}
|
|
3680
3930
|
/**
|
|
3681
3931
|
* Build SQL clauses for a single operator object on a column.
|
|
3682
3932
|
* Each operator key becomes its own clause, all ANDed together.
|
|
3933
|
+
*
|
|
3934
|
+
* `equals`/`not`/`gt`/`gte`/`lt`/`lte` also accept a {@link ColumnRef}
|
|
3935
|
+
* (`{ col: 'otherField' }`) which compiles to a column-to-column comparison
|
|
3936
|
+
* against `refCtx`: no param bound, so `collectOperatorParams` mirrors by
|
|
3937
|
+
* pushing nothing and the referenced name lives in the fingerprint.
|
|
3683
3938
|
*/
|
|
3684
|
-
buildOperatorClauses(column, op, params) {
|
|
3939
|
+
buildOperatorClauses(column, op, params, refCtx) {
|
|
3685
3940
|
const clauses = [];
|
|
3686
3941
|
if (op.equals !== undefined) {
|
|
3687
3942
|
if (op.equals === null) {
|
|
3688
3943
|
clauses.push(`${column} IS NULL`);
|
|
3689
3944
|
}
|
|
3945
|
+
else if ((0, filters_js_1.isColumnRef)(op.equals)) {
|
|
3946
|
+
clauses.push(`${column} = ${this.columnRefSql(op.equals, refCtx, op.mode)}`);
|
|
3947
|
+
}
|
|
3690
3948
|
else {
|
|
3691
3949
|
(0, filters_js_1.assertBindableEqualsOperand)(op.equals, column);
|
|
3692
3950
|
params.push(op.equals);
|
|
@@ -3694,25 +3952,48 @@ class QueryInterface {
|
|
|
3694
3952
|
}
|
|
3695
3953
|
}
|
|
3696
3954
|
if (op.gt !== undefined) {
|
|
3697
|
-
|
|
3698
|
-
|
|
3955
|
+
if ((0, filters_js_1.isColumnRef)(op.gt)) {
|
|
3956
|
+
clauses.push(`${column} > ${this.columnRefSql(op.gt, refCtx, op.mode)}`);
|
|
3957
|
+
}
|
|
3958
|
+
else {
|
|
3959
|
+
params.push(op.gt);
|
|
3960
|
+
clauses.push(`${column} > ${this.p(params.length)}`);
|
|
3961
|
+
}
|
|
3699
3962
|
}
|
|
3700
3963
|
if (op.gte !== undefined) {
|
|
3701
|
-
|
|
3702
|
-
|
|
3964
|
+
if ((0, filters_js_1.isColumnRef)(op.gte)) {
|
|
3965
|
+
clauses.push(`${column} >= ${this.columnRefSql(op.gte, refCtx, op.mode)}`);
|
|
3966
|
+
}
|
|
3967
|
+
else {
|
|
3968
|
+
params.push(op.gte);
|
|
3969
|
+
clauses.push(`${column} >= ${this.p(params.length)}`);
|
|
3970
|
+
}
|
|
3703
3971
|
}
|
|
3704
3972
|
if (op.lt !== undefined) {
|
|
3705
|
-
|
|
3706
|
-
|
|
3973
|
+
if ((0, filters_js_1.isColumnRef)(op.lt)) {
|
|
3974
|
+
clauses.push(`${column} < ${this.columnRefSql(op.lt, refCtx, op.mode)}`);
|
|
3975
|
+
}
|
|
3976
|
+
else {
|
|
3977
|
+
params.push(op.lt);
|
|
3978
|
+
clauses.push(`${column} < ${this.p(params.length)}`);
|
|
3979
|
+
}
|
|
3707
3980
|
}
|
|
3708
3981
|
if (op.lte !== undefined) {
|
|
3709
|
-
|
|
3710
|
-
|
|
3982
|
+
if ((0, filters_js_1.isColumnRef)(op.lte)) {
|
|
3983
|
+
clauses.push(`${column} <= ${this.columnRefSql(op.lte, refCtx, op.mode)}`);
|
|
3984
|
+
}
|
|
3985
|
+
else {
|
|
3986
|
+
params.push(op.lte);
|
|
3987
|
+
clauses.push(`${column} <= ${this.p(params.length)}`);
|
|
3988
|
+
}
|
|
3711
3989
|
}
|
|
3712
3990
|
if (op.not !== undefined) {
|
|
3713
3991
|
if (op.not === null) {
|
|
3714
3992
|
clauses.push(`${column} IS NOT NULL`);
|
|
3715
3993
|
}
|
|
3994
|
+
else if ((0, filters_js_1.isColumnRef)(op.not)) {
|
|
3995
|
+
clauses.push(`${column} != ${this.columnRefSql(op.not, refCtx, op.mode)}`);
|
|
3996
|
+
}
|
|
3716
3997
|
else {
|
|
3717
3998
|
params.push(op.not);
|
|
3718
3999
|
clauses.push(`${column} != ${this.p(params.length)}`);
|
|
@@ -3757,19 +4038,47 @@ class QueryInterface {
|
|
|
3757
4038
|
* vs relation-column never collide on one cached SQL string. Captures the
|
|
3758
4039
|
* SQL-shaping bits (direction, nulls, metric, relation keys) — never values.
|
|
3759
4040
|
*/
|
|
3760
|
-
orderByEntryFingerprint(d) {
|
|
4041
|
+
orderByEntryFingerprint(d, targetTable) {
|
|
3761
4042
|
// Vector KNN ordering changes the emitted operator by metric and adds a
|
|
3762
4043
|
// `::vector` param, so metric + direction must be part of the cache key.
|
|
3763
4044
|
if ((0, filters_js_1.isVectorOrderBy)(d)) {
|
|
3764
4045
|
return `vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
|
|
3765
4046
|
}
|
|
4047
|
+
// JSON-path ordering: direction, cast kind, and nulls placement change the
|
|
4048
|
+
// SQL text; the path itself is a bound param and stays OUT of the key.
|
|
4049
|
+
if ((0, filters_js_1.isJsonPathOrderBy)(d)) {
|
|
4050
|
+
return `jp(${d.direction ?? 'asc'},${d.type === 'numeric' ? 'num' : 'text'},${d.nulls ?? ''})`;
|
|
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
|
+
}
|
|
3766
4071
|
if ((0, filters_js_1.isOrderBySpec)(d))
|
|
3767
4072
|
return `spec(${d.sort},${d.nulls ?? ''})`;
|
|
3768
4073
|
if (d && typeof d === 'object') {
|
|
3769
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.
|
|
3770
4080
|
return `rel(${Object.entries(d)
|
|
3771
4081
|
.map(([k, v]) => `${k}=${this.orderByEntryFingerprint(v)}`)
|
|
3772
|
-
.sort()
|
|
3773
4082
|
.join(',')})`;
|
|
3774
4083
|
}
|
|
3775
4084
|
return String(d);
|
|
@@ -3808,6 +4117,11 @@ class QueryInterface {
|
|
|
3808
4117
|
const safeDir = value.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
3809
4118
|
return `${this.q(rawColumn)} ${operator} ${placeholder} ${safeDir}`;
|
|
3810
4119
|
}
|
|
4120
|
+
// JSON-path ordering: { path: [...], direction?, type?, nulls? } on a
|
|
4121
|
+
// json/jsonb column of THIS table. Path is bound as one text[] param.
|
|
4122
|
+
if ((0, filters_js_1.isJsonPathOrderBy)(value)) {
|
|
4123
|
+
return this.buildJsonPathOrderEntry(this.table, this.tableMeta, key, value, '', params);
|
|
4124
|
+
}
|
|
3811
4125
|
// Relation ordering: an object value that is not a vector or OrderBySpec,
|
|
3812
4126
|
// keyed by a relation name (`{ posts: { _count: 'desc' } }` / `{ author:
|
|
3813
4127
|
// { name: 'asc' } }`).
|
|
@@ -3834,6 +4148,7 @@ class QueryInterface {
|
|
|
3834
4148
|
value !== null &&
|
|
3835
4149
|
!Array.isArray(value) &&
|
|
3836
4150
|
!(0, filters_js_1.isVectorOrderBy)(value) &&
|
|
4151
|
+
!(0, filters_js_1.isJsonPathOrderBy)(value) &&
|
|
3837
4152
|
!(0, filters_js_1.isOrderBySpec)(value));
|
|
3838
4153
|
}
|
|
3839
4154
|
/**
|
|
@@ -3850,6 +4165,63 @@ class QueryInterface {
|
|
|
3850
4165
|
}
|
|
3851
4166
|
return nulls === 'first' ? ' NULLS FIRST' : ' NULLS LAST';
|
|
3852
4167
|
}
|
|
4168
|
+
/**
|
|
4169
|
+
* Resolve an orderBy key to its snake_case column via the table's columnMap
|
|
4170
|
+
* (camelToSnake fallback), throwing the SAME unknown-field E003 the top-level
|
|
4171
|
+
* where path uses. Shared by top-level JSON-path ordering and every nested
|
|
4172
|
+
* relation orderBy path so nested orderBy accepts exactly what top-level
|
|
4173
|
+
* accepts (the 0.30.x bug: nested orderBy skipped the columnMap and rejected
|
|
4174
|
+
* camelCase-named DB columns like "sortOrder").
|
|
4175
|
+
*/
|
|
4176
|
+
resolveOrderByColumn(table, meta, key) {
|
|
4177
|
+
const col = meta.columnMap[key] ?? (0, schema_js_1.camelToSnake)(key);
|
|
4178
|
+
if (!meta.allColumns.includes(col)) {
|
|
4179
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${table}". ` +
|
|
4180
|
+
`Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
|
|
4181
|
+
}
|
|
4182
|
+
return col;
|
|
4183
|
+
}
|
|
4184
|
+
/**
|
|
4185
|
+
* Validate a {@link JsonPathOrderBy} entry: column must exist AND be
|
|
4186
|
+
* json/jsonb, path must be a non-empty array of keys/indexes: and return
|
|
4187
|
+
* the resolved column. Shared by the SQL-build path
|
|
4188
|
+
* ({@link buildJsonPathOrderEntry}) and the cache-hit param-collect mirrors
|
|
4189
|
+
* so both always throw identically.
|
|
4190
|
+
*/
|
|
4191
|
+
validateJsonPathOrderBy(table, meta, field, spec) {
|
|
4192
|
+
const col = this.resolveOrderByColumn(table, meta, field);
|
|
4193
|
+
if (spec.path.length === 0 ||
|
|
4194
|
+
spec.path.some((el) => typeof el !== 'string' && !(typeof el === 'number' && Number.isFinite(el)))) {
|
|
4195
|
+
throw new errors_js_1.ValidationError(`[turbine] JSON-path orderBy on "${field}" (table "${table}") requires a non-empty \`path\` array ` +
|
|
4196
|
+
`of keys/indexes (e.g. { path: ['weight'], direction: 'asc' }).`);
|
|
4197
|
+
}
|
|
4198
|
+
const colType = this.pgTypeForColumn(meta, col);
|
|
4199
|
+
if (!this.isJsonColumnType(colType)) {
|
|
4200
|
+
throw new errors_js_1.ValidationError(`[turbine] JSON-path orderBy on "${field}": column "${col}" on table "${table}" is not a JSON column ` +
|
|
4201
|
+
`(actual type: ${colType}).`);
|
|
4202
|
+
}
|
|
4203
|
+
return col;
|
|
4204
|
+
}
|
|
4205
|
+
/**
|
|
4206
|
+
* Compile one {@link JsonPathOrderBy} entry:
|
|
4207
|
+
* `("col" #>> $n::text[])::numeric ASC`: the numeric cast only with
|
|
4208
|
+
* `type: 'numeric'` (default is text comparison), the extraction routed
|
|
4209
|
+
* through the dialect's JSON hook exactly like the JSON where-filters, the
|
|
4210
|
+
* path bound as ONE text[] param (mirrored by the order-param collectors).
|
|
4211
|
+
* `prefix` scopes the column (`''` top-level, `t0.` inside a relation
|
|
4212
|
+
* subquery).
|
|
4213
|
+
*/
|
|
4214
|
+
buildJsonPathOrderEntry(table, meta, field, spec, prefix, params) {
|
|
4215
|
+
const col = this.validateJsonPathOrderBy(table, meta, field, spec);
|
|
4216
|
+
if (!params) {
|
|
4217
|
+
throw new errors_js_1.ValidationError(`[turbine] JSON-path ordering on "${field}" is not supported in this orderBy context.`);
|
|
4218
|
+
}
|
|
4219
|
+
params.push(this.jsonPathParam(spec.path));
|
|
4220
|
+
const extract = this.dialect.buildJsonPathExtract(`${prefix}${this.q(col)}`, this.p(params.length));
|
|
4221
|
+
const lhs = spec.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
|
|
4222
|
+
const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
4223
|
+
return `${lhs} ${dir}${this.nullsSuffix(spec.nulls)}`;
|
|
4224
|
+
}
|
|
3853
4225
|
/**
|
|
3854
4226
|
* Compile a relation ordering term. For a to-many relation the only allowed
|
|
3855
4227
|
* key is `_count`, which becomes a correlated `COUNT(*)` subquery. For a
|
|
@@ -3858,29 +4230,45 @@ class QueryInterface {
|
|
|
3858
4230
|
*
|
|
3859
4231
|
* Validation: relation must exist (E005); to-many only allows `_count`, and
|
|
3860
4232
|
* to-one only allows real target columns (E003).
|
|
4233
|
+
*
|
|
4234
|
+
* `ctx` generalizes the term beyond the root table: inside a relation
|
|
4235
|
+
* subquery's orderBy the relations live on the TARGET table's metadata and
|
|
4236
|
+
* the correlation parent is the relation's alias, not `this.table`.
|
|
3861
4237
|
*/
|
|
3862
|
-
buildRelationOrderBy(relName, value, alias, params) {
|
|
3863
|
-
const
|
|
4238
|
+
buildRelationOrderBy(relName, value, alias, params, ctx) {
|
|
4239
|
+
const ownerMeta = ctx?.meta ?? this.tableMeta;
|
|
4240
|
+
const ownerTable = ctx?.table ?? this.table;
|
|
4241
|
+
const parentRef = ctx?.parentRef ?? this.table;
|
|
4242
|
+
const relDef = ownerMeta.relations[relName];
|
|
3864
4243
|
if (!relDef) {
|
|
3865
|
-
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${
|
|
3866
|
-
`Available: ${Object.keys(
|
|
4244
|
+
throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${ownerTable}". ` +
|
|
4245
|
+
`Available: ${Object.keys(ownerMeta.relations).join(', ')}`);
|
|
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);
|
|
3867
4255
|
}
|
|
3868
4256
|
// To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
|
|
3869
4257
|
if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
|
|
3870
4258
|
const keys = Object.keys(value);
|
|
3871
4259
|
if (keys.length !== 1 || keys[0] !== '_count') {
|
|
3872
4260
|
throw new errors_js_1.ValidationError(`[turbine] orderBy on to-many relation "${relName}" only supports "_count" ` +
|
|
3873
|
-
`(got: ${keys.join(', ') || '(empty)'}).`);
|
|
4261
|
+
`or a pick-row ordering ({ pick, by }) (got: ${keys.join(', ') || '(empty)'}).`);
|
|
3874
4262
|
}
|
|
3875
4263
|
const { dir } = (0, filters_js_1.normalizeOrderBy)(value._count);
|
|
3876
|
-
return `${this.buildRelationCountExpr(relDef,
|
|
4264
|
+
return `${this.buildRelationCountExpr(relDef, parentRef, alias, params)} ${dir}`;
|
|
3877
4265
|
}
|
|
3878
4266
|
// To-one: each entry orders by a correlated scalar subquery on a target column.
|
|
3879
4267
|
const targetMeta = this.schema.tables[relDef.to];
|
|
3880
4268
|
if (!targetMeta)
|
|
3881
4269
|
throw new errors_js_1.RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
|
|
3882
4270
|
const qTarget = this.q(relDef.to);
|
|
3883
|
-
const qParent = this.q(
|
|
4271
|
+
const qParent = this.q(parentRef);
|
|
3884
4272
|
// belongsTo: alias.referenceKey = parent.foreignKey; hasOne: reversed.
|
|
3885
4273
|
const correlation = relDef.type === 'belongsTo'
|
|
3886
4274
|
? this.dialect.buildCorrelation(alias, relDef.referenceKey, qParent, relDef.foreignKey)
|
|
@@ -3891,7 +4279,9 @@ class QueryInterface {
|
|
|
3891
4279
|
}
|
|
3892
4280
|
return entries
|
|
3893
4281
|
.map(([col, dirValue]) => {
|
|
3894
|
-
|
|
4282
|
+
// columnMap-first resolution (camelToSnake fallback): mirrors the
|
|
4283
|
+
// scalar orderBy path so camelCase-named DB columns resolve here too.
|
|
4284
|
+
const snakeCol = targetMeta.columnMap[col] ?? (0, schema_js_1.camelToSnake)(col);
|
|
3895
4285
|
if (!targetMeta.allColumns.includes(snakeCol)) {
|
|
3896
4286
|
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${col}" in orderBy on relation "${relName}" (table "${relDef.to}").`);
|
|
3897
4287
|
}
|
|
@@ -3909,6 +4299,218 @@ class QueryInterface {
|
|
|
3909
4299
|
})
|
|
3910
4300
|
.join(', ');
|
|
3911
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
|
+
}
|
|
4441
|
+
/**
|
|
4442
|
+
* Compile the ORDER BY terms of a relation `with` clause against the
|
|
4443
|
+
* relation's table alias. One unified path for every relation shape
|
|
4444
|
+
* (hasMany / manyToMany / belongsTo / hasOne) supporting exactly what the
|
|
4445
|
+
* top-level orderBy accepts at this level:
|
|
4446
|
+
*
|
|
4447
|
+
* - scalar columns via columnMap resolution (camelToSnake fallback) with
|
|
4448
|
+
* {@link OrderBySpec} nulls placement,
|
|
4449
|
+
* - {@link JsonPathOrderBy} entries (path bound as one text[] param),
|
|
4450
|
+
* - relation ordering on the TARGET's relations (`_count` for to-many, a
|
|
4451
|
+
* target column for to-one), correlated to the relation alias,
|
|
4452
|
+
* - vector KNN ordering stays top-level-only (E003, same as before).
|
|
4453
|
+
*
|
|
4454
|
+
* Param pushes (JSON paths, relation-order global filters) MUST be mirrored,
|
|
4455
|
+
* in the same order, by {@link collectRelationOrderParams}.
|
|
4456
|
+
*/
|
|
4457
|
+
buildRelationOrderClause(targetTable, targetMeta, alias, orderEntries, params) {
|
|
4458
|
+
let relOrdCounter = 0;
|
|
4459
|
+
const orders = orderEntries
|
|
4460
|
+
.map(([key, dirValue]) => {
|
|
4461
|
+
if ((0, filters_js_1.isVectorOrderBy)(dirValue)) {
|
|
4462
|
+
throw new errors_js_1.ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
|
|
4463
|
+
}
|
|
4464
|
+
if ((0, filters_js_1.isJsonPathOrderBy)(dirValue)) {
|
|
4465
|
+
return this.buildJsonPathOrderEntry(targetTable, targetMeta, key, dirValue, `${alias}.`, params);
|
|
4466
|
+
}
|
|
4467
|
+
if (this.isRelationOrderByValue(dirValue)) {
|
|
4468
|
+
return this.buildRelationOrderBy(key, dirValue, `${alias}ord${relOrdCounter++}`, params, { meta: targetMeta, table: targetTable, parentRef: alias });
|
|
4469
|
+
}
|
|
4470
|
+
const col = this.resolveOrderByColumn(targetTable, targetMeta, key);
|
|
4471
|
+
const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(dirValue);
|
|
4472
|
+
return `${alias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
|
|
4473
|
+
})
|
|
4474
|
+
.join(', ');
|
|
4475
|
+
return ` ORDER BY ${orders}`;
|
|
4476
|
+
}
|
|
4477
|
+
/**
|
|
4478
|
+
* Param-collect mirror of {@link buildRelationOrderClause}: JSON-path
|
|
4479
|
+
* entries push their path (one text[] param each); relation-order entries
|
|
4480
|
+
* mirror {@link collectOrderByParams}' relation branch (count / to-one
|
|
4481
|
+
* global-filter params); scalar entries push nothing but re-run the same
|
|
4482
|
+
* column validation so a warmed cache can never skip it.
|
|
4483
|
+
*/
|
|
4484
|
+
collectRelationOrderParams(targetTable, targetMeta, orderEntries, params) {
|
|
4485
|
+
for (const [key, dirValue] of orderEntries) {
|
|
4486
|
+
if ((0, filters_js_1.isVectorOrderBy)(dirValue)) {
|
|
4487
|
+
throw new errors_js_1.ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
|
|
4488
|
+
}
|
|
4489
|
+
if ((0, filters_js_1.isJsonPathOrderBy)(dirValue)) {
|
|
4490
|
+
this.validateJsonPathOrderBy(targetTable, targetMeta, key, dirValue);
|
|
4491
|
+
params.push(this.jsonPathParam(dirValue.path));
|
|
4492
|
+
continue;
|
|
4493
|
+
}
|
|
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
|
+
}
|
|
4500
|
+
const relDef = targetMeta.relations[key];
|
|
4501
|
+
if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
|
|
4502
|
+
this.collectRelationCountParams(relDef, params);
|
|
4503
|
+
}
|
|
4504
|
+
else if (relDef) {
|
|
4505
|
+
for (const _col of Object.keys(dirValue)) {
|
|
4506
|
+
this.collectTargetGlobalFilterAlias(relDef.to, params);
|
|
4507
|
+
}
|
|
4508
|
+
}
|
|
4509
|
+
continue;
|
|
4510
|
+
}
|
|
4511
|
+
this.resolveOrderByColumn(targetTable, targetMeta, key);
|
|
4512
|
+
}
|
|
4513
|
+
}
|
|
3912
4514
|
/**
|
|
3913
4515
|
* Build a correlated `(SELECT COUNT(*) …)` scalar subquery for a to-many
|
|
3914
4516
|
* relation, correlated to `parentRef`. hasMany counts child rows via the FK;
|
|
@@ -4628,20 +5230,13 @@ class QueryInterface {
|
|
|
4628
5230
|
// Quote parent ref — can be a table name or auto-generated alias
|
|
4629
5231
|
const qParent = this.q(parentRef);
|
|
4630
5232
|
const qTarget = this.q(targetTable);
|
|
4631
|
-
// Build ORDER BY for json_agg
|
|
5233
|
+
// Build ORDER BY for json_agg: unified with the top-level orderBy surface
|
|
5234
|
+
// (columnMap resolution, OrderBySpec nulls, JSON-path, relation ordering).
|
|
5235
|
+
// Param pushes here land BEFORE the spec.where params, mirrored by
|
|
5236
|
+
// collectRelationSubqueryParams.
|
|
4632
5237
|
let orderClause = '';
|
|
4633
5238
|
if (relOrderEntries.length > 0) {
|
|
4634
|
-
|
|
4635
|
-
.map(([k, dirValue]) => {
|
|
4636
|
-
const col = (0, schema_js_1.camelToSnake)(k);
|
|
4637
|
-
if (!targetMeta.allColumns.includes(col)) {
|
|
4638
|
-
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
4639
|
-
}
|
|
4640
|
-
const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(dirValue);
|
|
4641
|
-
return `${alias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
|
|
4642
|
-
})
|
|
4643
|
-
.join(', ');
|
|
4644
|
-
orderClause = ` ORDER BY ${orders}`;
|
|
5239
|
+
orderClause = this.buildRelationOrderClause(targetTable, targetMeta, alias, relOrderEntries, params);
|
|
4645
5240
|
}
|
|
4646
5241
|
// Build WHERE — correlate to parent via parentRef (alias or table name).
|
|
4647
5242
|
// For hasMany/hasOne: TARGET has the FK (RelationDef.foreignKey is always
|
|
@@ -4715,8 +5310,10 @@ class QueryInterface {
|
|
|
4715
5310
|
const inlineOrder = this.dialect.aggSupportsInlineOrderBy ? orderClause.trim() || undefined : undefined;
|
|
4716
5311
|
return `SELECT ${this.dialect.buildJsonArrayAgg(jsonObj, inlineOrder)} FROM ${qTarget} ${alias} WHERE ${whereClause}`;
|
|
4717
5312
|
}
|
|
4718
|
-
// belongsTo / hasOne
|
|
4719
|
-
|
|
5313
|
+
// belongsTo / hasOne: return single object. An orderBy picks WHICH row
|
|
5314
|
+
// the LIMIT 1 keeps (deterministic hasOne over a non-unique FK): matching
|
|
5315
|
+
// the batched strategy, which orders its flat follow-up and takes bucket[0].
|
|
5316
|
+
return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause}${orderClause} LIMIT 1`;
|
|
4720
5317
|
}
|
|
4721
5318
|
/**
|
|
4722
5319
|
* Build the json_agg subquery for a `manyToMany` relation, JOINing the target
|
|
@@ -4774,22 +5371,15 @@ class QueryInterface {
|
|
|
4774
5371
|
let whereClause = sourceKeys
|
|
4775
5372
|
.map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
|
|
4776
5373
|
.join(' AND ');
|
|
4777
|
-
// ORDER BY on the target rows
|
|
4778
|
-
//
|
|
5374
|
+
// ORDER BY on the target rows: unified with the top-level orderBy surface
|
|
5375
|
+
// (columnMap resolution, OrderBySpec nulls, JSON-path, relation ordering).
|
|
5376
|
+
// `orderBy: {}` (no defined entries) is treated as absent: it must not
|
|
5377
|
+
// render a dangling `ORDER BY `. Param pushes here land BEFORE the
|
|
5378
|
+
// spec.where params, mirrored by collectRelationSubqueryParams' m2m branch.
|
|
4779
5379
|
const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
|
|
4780
5380
|
let orderClause = '';
|
|
4781
5381
|
if (relOrderEntries.length > 0) {
|
|
4782
|
-
|
|
4783
|
-
.map(([k, dirValue]) => {
|
|
4784
|
-
const col = (0, schema_js_1.camelToSnake)(k);
|
|
4785
|
-
if (!targetMeta.allColumns.includes(col)) {
|
|
4786
|
-
throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
|
|
4787
|
-
}
|
|
4788
|
-
const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(dirValue);
|
|
4789
|
-
return `${talias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
|
|
4790
|
-
})
|
|
4791
|
-
.join(', ');
|
|
4792
|
-
orderClause = ` ORDER BY ${orders}`;
|
|
5382
|
+
orderClause = this.buildRelationOrderClause(targetTable, targetMeta, talias, relOrderEntries, params);
|
|
4793
5383
|
}
|
|
4794
5384
|
// Additional WHERE filters on the target — full scalar where surface,
|
|
4795
5385
|
// properly parameterized against the target alias.
|
|
@@ -4865,6 +5455,16 @@ class QueryInterface {
|
|
|
4865
5455
|
* Used to detect JSONB/array columns for specialized operators.
|
|
4866
5456
|
* Uses pre-computed Map for O(1) lookup instead of linear scan.
|
|
4867
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
|
+
}
|
|
4868
5468
|
getColumnPgType(column) {
|
|
4869
5469
|
return this.columnPgTypeMap.get(column) ?? 'text';
|
|
4870
5470
|
}
|
|
@@ -4934,7 +5534,8 @@ class QueryInterface {
|
|
|
4934
5534
|
let pathParamIdx = null;
|
|
4935
5535
|
const pathExtract = () => {
|
|
4936
5536
|
if (pathParamIdx === null) {
|
|
4937
|
-
|
|
5537
|
+
// Only reached when a path-requiring clause validated filter.path.
|
|
5538
|
+
params.push(this.jsonPathParam(filter.path, filter.path));
|
|
4938
5539
|
pathParamIdx = params.length;
|
|
4939
5540
|
}
|
|
4940
5541
|
return this.dialect.buildJsonPathExtract(column, this.p(pathParamIdx));
|
|
@@ -4970,6 +5571,24 @@ class QueryInterface {
|
|
|
4970
5571
|
}
|
|
4971
5572
|
return clauses;
|
|
4972
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
|
+
}
|
|
4973
5592
|
/**
|
|
4974
5593
|
* Cast an extracted JSON path text value to a numeric type for range
|
|
4975
5594
|
* comparison. PostgreSQL uses `(expr)::numeric` (exact — the right way to
|