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