turbine-orm 0.32.1 → 0.33.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/dialect.js +1 -0
- package/dist/cjs/mssql.js +3 -0
- package/dist/cjs/mysql.js +3 -0
- package/dist/cjs/powdb.js +3 -0
- package/dist/cjs/powql.js +11 -0
- package/dist/cjs/query/builder.js +221 -55
- package/dist/cjs/query/filters.js +4 -4
- package/dist/cjs/sqlite.js +2 -0
- package/dist/dialect.d.ts +7 -0
- package/dist/dialect.js +1 -0
- package/dist/mssql.js +3 -0
- package/dist/mysql.js +3 -0
- package/dist/powdb.js +3 -0
- package/dist/powql.js +11 -0
- package/dist/query/builder.d.ts +25 -0
- package/dist/query/builder.js +221 -55
- package/dist/query/filters.d.ts +3 -3
- package/dist/query/filters.js +4 -4
- package/dist/query/types.d.ts +51 -2
- package/dist/sqlite.js +2 -0
- package/package.json +1 -1
package/dist/cjs/dialect.js
CHANGED
package/dist/cjs/mssql.js
CHANGED
|
@@ -478,6 +478,9 @@ exports.mssqlDialect = {
|
|
|
478
478
|
supportsVector: false,
|
|
479
479
|
supportsListenNotify: false,
|
|
480
480
|
supportsRLS: false,
|
|
481
|
+
// SQL Server has OUTER APPLY, not FROM-clause LATERAL: the lateral pick plan
|
|
482
|
+
// is Postgres-only (out of scope here).
|
|
483
|
+
supportsLateralJoin: false,
|
|
481
484
|
// sp_getapplock / sp_releaseapplock exist (used by a future migrate adapter).
|
|
482
485
|
supportsAdvisoryLock: true,
|
|
483
486
|
// FOR JSON over zero rows is NULL → coalesced in the relation override.
|
package/dist/cjs/mysql.js
CHANGED
|
@@ -379,6 +379,9 @@ exports.mysqlDialect = {
|
|
|
379
379
|
supportsVector: false,
|
|
380
380
|
supportsListenNotify: false,
|
|
381
381
|
supportsRLS: false,
|
|
382
|
+
// MySQL 8.0.14+ supports LATERAL, but the opt-in lateral pick plan stays
|
|
383
|
+
// Postgres-only in this release (flipping it on is a one-line change + tests).
|
|
384
|
+
supportsLateralJoin: false,
|
|
382
385
|
// GET_LOCK / RELEASE_LOCK exist (used by a future migrate adapter).
|
|
383
386
|
supportsAdvisoryLock: true,
|
|
384
387
|
// JSON_ARRAYAGG has no inline ORDER BY argument → force the inner-subquery
|
package/dist/cjs/powdb.js
CHANGED
|
@@ -142,6 +142,9 @@ exports.powdbDialect = {
|
|
|
142
142
|
supportsRLS: false,
|
|
143
143
|
supportsAdvisoryLock: false,
|
|
144
144
|
supportsILike: false,
|
|
145
|
+
// PowQL has no LATERAL construct; PowqlInterface refuses pick ordering
|
|
146
|
+
// earlier, this override keeps the flag truthful if a future path consults it.
|
|
147
|
+
supportsLateralJoin: false,
|
|
145
148
|
beginStatement: () => 'begin',
|
|
146
149
|
commitStatement: () => 'commit',
|
|
147
150
|
rollbackStatement: () => 'rollback',
|
package/dist/cjs/powql.js
CHANGED
|
@@ -1216,6 +1216,17 @@ class PowqlInterface {
|
|
|
1216
1216
|
proj.push(`${a.alias}: ${a.fn}(${a.field ? this.ref(a.field) : `.${this.meta.primaryKey[0]}`})`);
|
|
1217
1217
|
}
|
|
1218
1218
|
const having = this.buildHaving(args.having, params);
|
|
1219
|
+
// groupBy aggregate ordering (`_count` / `_sum` / … keys) has no PowQL
|
|
1220
|
+
// equivalent here: `buildOrder` treats an `orderBy` key as a field ref, so
|
|
1221
|
+
// a bare `_count: 'desc'` would silently emit an invalid `._count` sort.
|
|
1222
|
+
// Refuse those keys explicitly; plain by-field ordering still flows through.
|
|
1223
|
+
if (args.orderBy) {
|
|
1224
|
+
for (const key of Object.keys(args.orderBy)) {
|
|
1225
|
+
if (key === '_count' || key === '_sum' || key === '_avg' || key === '_min' || key === '_max') {
|
|
1226
|
+
throw new errors_js_1.UnsupportedFeatureError('groupBy ordering by an aggregate', 'PowDB', `orderBy key "${key}"`);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1219
1230
|
const order = this.buildOrder(args.orderBy);
|
|
1220
1231
|
const powql = `${this.qt}${filter} group ${groupKeys.join(', ')}${having}${order} { ${proj.join(', ')} }`;
|
|
1221
1232
|
const { rows } = await this.exec(powql, params, args.timeout);
|
|
@@ -995,14 +995,22 @@ class QueryInterface {
|
|
|
995
995
|
else {
|
|
996
996
|
selectClause = `${qt}.*`;
|
|
997
997
|
}
|
|
998
|
-
|
|
998
|
+
// Piece-then-assemble. The join-sink between FROM and WHERE carries any
|
|
999
|
+
// `plan: 'lateral'` pick joins; it is populated during the ORDER BY build
|
|
1000
|
+
// below and spliced in at final assembly. Empty for every other query
|
|
1001
|
+
// shape → the assembled SQL is byte-identical to the incremental-append
|
|
1002
|
+
// form for the default plan (asserted by the byte-equality snapshot test).
|
|
1003
|
+
const lateralJoins = [];
|
|
1004
|
+
// WHERE + cursor conditions accumulate into `tail`, pushing params in
|
|
1005
|
+
// where → cursor order (the collect path mirrors this exactly).
|
|
1006
|
+
let tail = freshWhereSql;
|
|
999
1007
|
if (args?.cursor) {
|
|
1000
1008
|
// Sorted (canonical) order — MUST match cursorFp and the cache-hit collect below.
|
|
1001
1009
|
const cursorEntries = (0, filters_js_1.sortedEntries)(args.cursor).filter(([, v]) => v !== undefined);
|
|
1002
1010
|
if (cursorEntries.length > 0) {
|
|
1003
1011
|
const cursorConditions = cursorEntries.map(([k, v]) => {
|
|
1004
1012
|
const col = this.toSqlColumn(k);
|
|
1005
|
-
// orderBy values can be the { sort, nulls } spec form
|
|
1013
|
+
// orderBy values can be the { sort, nulls } spec form: normalize
|
|
1006
1014
|
// before comparing, or a desc spec would seek the ascending side.
|
|
1007
1015
|
const dir = args.orderBy?.[k];
|
|
1008
1016
|
const desc = (0, filters_js_1.isOrderBySpec)(dir) ? dir.sort === 'desc' : dir === 'desc';
|
|
@@ -1010,33 +1018,38 @@ class QueryInterface {
|
|
|
1010
1018
|
freshParams.push(v);
|
|
1011
1019
|
return `${qt}.${col} ${op} ${this.p(freshParams.length)}`;
|
|
1012
1020
|
});
|
|
1013
|
-
|
|
1014
|
-
sql += ` AND ${cursorConditions.join(' AND ')}`;
|
|
1015
|
-
}
|
|
1016
|
-
else {
|
|
1017
|
-
sql += ` WHERE ${cursorConditions.join(' AND ')}`;
|
|
1018
|
-
}
|
|
1021
|
+
tail += freshWhereSql ? ` AND ${cursorConditions.join(' AND ')}` : ` WHERE ${cursorConditions.join(' AND ')}`;
|
|
1019
1022
|
}
|
|
1020
1023
|
}
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
else {
|
|
1036
|
-
// Pass freshParams so vector KNN ordering binds its `$n::vector` query
|
|
1037
|
-
// vector at the correct position (after cursor params, before LIMIT).
|
|
1038
|
-
sql += ` ORDER BY ${this.buildOrderBy(args.orderBy, freshParams)}`;
|
|
1024
|
+
// ORDER BY is built AFTER the cursor pushes (param order
|
|
1025
|
+
// where → with → cursor → orderBy → limit → offset) and BEFORE final
|
|
1026
|
+
// assembly (so the lateral sink is filled before the FROM clause is
|
|
1027
|
+
// written). distinct + relation orderBy is refused up front, so a lateral
|
|
1028
|
+
// pick can never reach the distinct branch (lateralJoins stays empty).
|
|
1029
|
+
let sql;
|
|
1030
|
+
if (args?.orderBy && distinctPrefix) {
|
|
1031
|
+
// Postgres requires DISTINCT ON expressions to lead the ORDER BY. Prisma
|
|
1032
|
+
// semantics ("first row per combination, result in the user's order")
|
|
1033
|
+
// need two levels: inner DISTINCT ON ordered by the distinct columns then
|
|
1034
|
+
// the user's order (picks the right representative row), outer re-ordered
|
|
1035
|
+
// by the user's order alone.
|
|
1036
|
+
if (Object.values(args.orderBy).some((d) => (0, filters_js_1.isVectorOrderBy)(d))) {
|
|
1037
|
+
throw new errors_js_1.ValidationError('[turbine] `distinct` cannot be combined with vector distance ordering.');
|
|
1039
1038
|
}
|
|
1039
|
+
const userOrder = this.buildOrderBy(args.orderBy, freshParams);
|
|
1040
|
+
const inner = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${tail} ORDER BY ${distinctCols
|
|
1041
|
+
.map((c) => `${c} ASC`)
|
|
1042
|
+
.join(', ')}, ${userOrder}`;
|
|
1043
|
+
sql = `SELECT * FROM (${inner}) AS ${this.q(`${this.table}_distinct`)} ORDER BY ${userOrder}`;
|
|
1044
|
+
}
|
|
1045
|
+
else {
|
|
1046
|
+
// Pass freshParams so vector KNN ordering binds its `$n::vector` query
|
|
1047
|
+
// vector at the correct position (after cursor params, before LIMIT), and
|
|
1048
|
+
// lateralJoins so a lateral pick splices its join into the FROM clause.
|
|
1049
|
+
const orderBySql = args?.orderBy
|
|
1050
|
+
? ` ORDER BY ${this.buildOrderBy(args.orderBy, freshParams, lateralJoins)}`
|
|
1051
|
+
: '';
|
|
1052
|
+
sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${lateralJoins.join('')}${tail}${orderBySql}`;
|
|
1040
1053
|
}
|
|
1041
1054
|
// Pagination — push params in the same order the collect path mirrors
|
|
1042
1055
|
// (limit before offset); the SQL TEXT shape is dialect-owned via
|
|
@@ -1854,6 +1867,15 @@ class QueryInterface {
|
|
|
1854
1867
|
const selectExprs = [];
|
|
1855
1868
|
/** by entries in order: how to read each group key off the result row. */
|
|
1856
1869
|
const byReaders = [];
|
|
1870
|
+
// ORDER BY registries: map each key the groupBy RESULT actually contains to
|
|
1871
|
+
// the exact SELECT expression that produced it, so `orderBy` re-emits that
|
|
1872
|
+
// expression (never a SELECT alias, since not every dialect accepts alias
|
|
1873
|
+
// references in ORDER BY, and re-emitting mirrors HAVING's `jsonAggExprs`).
|
|
1874
|
+
// `byOrderExprs`: plain by-field name / JSON group-key alias → column or
|
|
1875
|
+
// extract expression. `aggOrderExprs`: `${aggKey}:${field}` → aggregate
|
|
1876
|
+
// expression (including any already-bound JSON-path placeholder, reused
|
|
1877
|
+
// exactly like HAVING since ORDER BY is appended after all other params).
|
|
1878
|
+
const byOrderExprs = new Map();
|
|
1857
1879
|
const usedResultKeys = new Set();
|
|
1858
1880
|
const claimResultKey = (key, what) => {
|
|
1859
1881
|
if (key === '_count' || usedResultKeys.has(key)) {
|
|
@@ -1874,6 +1896,7 @@ class QueryInterface {
|
|
|
1874
1896
|
groupExprs.push(this.q(col));
|
|
1875
1897
|
selectExprs.push(this.q(col));
|
|
1876
1898
|
byReaders.push({ resultKey: entry, rowKey: col, raw: false });
|
|
1899
|
+
byOrderExprs.set(entry, this.q(col));
|
|
1877
1900
|
}
|
|
1878
1901
|
else {
|
|
1879
1902
|
const col = this.resolveJsonPathTarget('group key', entry.field, entry.path);
|
|
@@ -1885,13 +1908,24 @@ class QueryInterface {
|
|
|
1885
1908
|
selectExprs.push(`(${extract}) AS ${this.q(alias)}`);
|
|
1886
1909
|
groupExprs.push(extract);
|
|
1887
1910
|
byReaders.push({ resultKey: alias, rowKey: alias, raw: true });
|
|
1911
|
+
// ORDER BY by this JSON alias re-emits the extract expression (with its
|
|
1912
|
+
// already-bound $n): the same reuse HAVING does for JSON aggregates.
|
|
1913
|
+
byOrderExprs.set(alias, extract);
|
|
1888
1914
|
}
|
|
1889
1915
|
}
|
|
1890
1916
|
// _count
|
|
1891
|
-
|
|
1917
|
+
const countSelected = args._count === true || args._count === undefined;
|
|
1918
|
+
if (countSelected) {
|
|
1892
1919
|
// default: always include count
|
|
1893
1920
|
selectExprs.push(`${this.castAgg('COUNT(*)', 'int')} AS _count`);
|
|
1894
1921
|
}
|
|
1922
|
+
// ORDER BY aggregate expressions, keyed `${aggKey}:${field}` (plus a bare
|
|
1923
|
+
// `_count`). Populated alongside the SELECT list below so `orderBy` can only
|
|
1924
|
+
// reference an aggregate that is actually requested. `COUNT(*)` (uncast) is
|
|
1925
|
+
// the ordering expression (the SELECT cast is only for the returned value).
|
|
1926
|
+
const aggOrderExprs = new Map();
|
|
1927
|
+
if (countSelected)
|
|
1928
|
+
aggOrderExprs.set('_count', 'COUNT(*)');
|
|
1895
1929
|
// _sum / _avg / _min / _max: `true` keeps the plain-column behavior; a
|
|
1896
1930
|
// {@link JsonPathAggregateTarget} aggregates a JSON path under the arg key
|
|
1897
1931
|
// as alias. `jsonAggFields` routes each JSON-aggregate row key back to its
|
|
@@ -1914,6 +1948,7 @@ class QueryInterface {
|
|
|
1914
1948
|
const inner = `${sqlFn}(${this.q(col)})`;
|
|
1915
1949
|
const expr = aggKey === '_avg' ? this.castAgg(inner, 'float') : inner;
|
|
1916
1950
|
selectExprs.push(`${expr} AS ${this.q(`${aggKey}_${col}`)}`);
|
|
1951
|
+
aggOrderExprs.set(`${aggKey}:${key}`, expr);
|
|
1917
1952
|
continue;
|
|
1918
1953
|
}
|
|
1919
1954
|
const col = this.resolveJsonPathTarget(`${aggKey} target "${key}"`, target.field, target.path);
|
|
@@ -1931,6 +1966,7 @@ class QueryInterface {
|
|
|
1931
1966
|
selectExprs.push(`${expr} AS ${this.q(`${aggKey}_${key}`)}`);
|
|
1932
1967
|
jsonAggFields.set(`${aggKey}_${key}`, { field: key, numeric });
|
|
1933
1968
|
jsonAggExprs.set(`${key}:${aggKey}`, expr);
|
|
1969
|
+
aggOrderExprs.set(`${aggKey}:${key}`, expr);
|
|
1934
1970
|
}
|
|
1935
1971
|
};
|
|
1936
1972
|
buildAggregates('_sum', 'SUM', args._sum);
|
|
@@ -1947,9 +1983,12 @@ class QueryInterface {
|
|
|
1947
1983
|
sql += ` HAVING ${havingClauses.join(' AND ')}`;
|
|
1948
1984
|
}
|
|
1949
1985
|
}
|
|
1950
|
-
// ORDER BY
|
|
1986
|
+
// ORDER BY, over the groupBy RESULT columns (by-fields, JSON aliases, and
|
|
1987
|
+
// requested aggregates), not the table's physical columns.
|
|
1951
1988
|
if (args.orderBy) {
|
|
1952
|
-
|
|
1989
|
+
const orderSql = this.buildGroupByOrderBy(args.orderBy, byOrderExprs, aggOrderExprs);
|
|
1990
|
+
if (orderSql)
|
|
1991
|
+
sql += ` ORDER BY ${orderSql}`;
|
|
1953
1992
|
}
|
|
1954
1993
|
return {
|
|
1955
1994
|
sql,
|
|
@@ -2014,6 +2053,74 @@ class QueryInterface {
|
|
|
2014
2053
|
tag: `${this.table}.groupBy`,
|
|
2015
2054
|
};
|
|
2016
2055
|
}
|
|
2056
|
+
/**
|
|
2057
|
+
* Compile a groupBy `orderBy` into an ORDER BY body. Unlike findMany ORDER BY
|
|
2058
|
+
* ({@link buildOrderBy}, which validates keys against the table's physical
|
|
2059
|
+
* columns), groupBy ordering targets the columns the RESULT actually
|
|
2060
|
+
* contains: plain by-fields, JSON group-key aliases, and requested aggregates
|
|
2061
|
+
* (`_count` / `_sum` / `_avg` / `_min` / `_max`). Each key re-emits the exact
|
|
2062
|
+
* SELECT expression that produced it (`byOrderExprs` / `aggOrderExprs`),
|
|
2063
|
+
* mirroring how HAVING re-emits aggregate expressions, so no dialect ever has
|
|
2064
|
+
* to accept a SELECT-alias reference in ORDER BY, and any already-bound
|
|
2065
|
+
* JSON-path placeholder is reused verbatim (ORDER BY is the last clause, so
|
|
2066
|
+
* no `$n` renumbering). An aggregate key that was not requested, or an unknown
|
|
2067
|
+
* by-key, throws {@link ValidationError} E003 listing the valid keys.
|
|
2068
|
+
*/
|
|
2069
|
+
buildGroupByOrderBy(orderBy, byOrderExprs, aggOrderExprs) {
|
|
2070
|
+
const aggBlocks = new Set(['_count', '_sum', '_avg', '_min', '_max']);
|
|
2071
|
+
/** Human-readable list of every key this call can order by (for E003). */
|
|
2072
|
+
const validKeys = () => {
|
|
2073
|
+
const keys = [...byOrderExprs.keys()];
|
|
2074
|
+
for (const k of aggOrderExprs.keys()) {
|
|
2075
|
+
keys.push(k.includes(':') ? k.replace(':', '.') : k);
|
|
2076
|
+
}
|
|
2077
|
+
return keys.join(', ') || '(none)';
|
|
2078
|
+
};
|
|
2079
|
+
const parts = [];
|
|
2080
|
+
for (const [key, value] of Object.entries(orderBy)) {
|
|
2081
|
+
if (value === undefined)
|
|
2082
|
+
continue;
|
|
2083
|
+
// Aggregate ordering blocks.
|
|
2084
|
+
if (aggBlocks.has(key)) {
|
|
2085
|
+
if (key === '_count') {
|
|
2086
|
+
const expr = aggOrderExprs.get('_count');
|
|
2087
|
+
if (!expr) {
|
|
2088
|
+
throw new errors_js_1.ValidationError(`[turbine] Cannot order groupBy by "_count" on table "${this.table}": _count is not selected. ` +
|
|
2089
|
+
`Orderable keys: ${validKeys()}.`);
|
|
2090
|
+
}
|
|
2091
|
+
const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(value);
|
|
2092
|
+
parts.push(`${expr} ${dir}${this.nullsSuffix(nulls)}`);
|
|
2093
|
+
continue;
|
|
2094
|
+
}
|
|
2095
|
+
// `_sum` / `_avg` / `_min` / `_max`: an object of field → direction/spec.
|
|
2096
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
2097
|
+
throw new errors_js_1.ValidationError(`[turbine] Invalid groupBy orderBy for "${key}" on table "${this.table}": ` +
|
|
2098
|
+
`expected a field map like { ${key}: { amount: 'desc' } }.`);
|
|
2099
|
+
}
|
|
2100
|
+
for (const [field, dirSpec] of Object.entries(value)) {
|
|
2101
|
+
if (dirSpec === undefined)
|
|
2102
|
+
continue;
|
|
2103
|
+
const expr = aggOrderExprs.get(`${key}:${field}`);
|
|
2104
|
+
if (!expr) {
|
|
2105
|
+
throw new errors_js_1.ValidationError(`[turbine] Cannot order groupBy by "${key}.${field}" on table "${this.table}": ` +
|
|
2106
|
+
`that aggregate is not requested in this call. Orderable keys: ${validKeys()}.`);
|
|
2107
|
+
}
|
|
2108
|
+
const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(dirSpec);
|
|
2109
|
+
parts.push(`${expr} ${dir}${this.nullsSuffix(nulls)}`);
|
|
2110
|
+
}
|
|
2111
|
+
continue;
|
|
2112
|
+
}
|
|
2113
|
+
// Plain by-field name or JSON group-key alias.
|
|
2114
|
+
const expr = byOrderExprs.get(key);
|
|
2115
|
+
if (!expr) {
|
|
2116
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in groupBy orderBy on table "${this.table}". ` +
|
|
2117
|
+
`Orderable keys: ${validKeys()}.`);
|
|
2118
|
+
}
|
|
2119
|
+
const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(value);
|
|
2120
|
+
parts.push(`${expr} ${dir}${this.nullsSuffix(nulls)}`);
|
|
2121
|
+
}
|
|
2122
|
+
return parts.join(', ');
|
|
2123
|
+
}
|
|
2017
2124
|
/**
|
|
2018
2125
|
* Validate a JSON-path target (group key or aggregate target) in groupBy:
|
|
2019
2126
|
* the field must resolve to a real json/jsonb column and the path must be a
|
|
@@ -4260,7 +4367,13 @@ class QueryInterface {
|
|
|
4260
4367
|
const pickWhere = d.pick?.where
|
|
4261
4368
|
? `;pw=${this.fingerprintAliasWhere(d.pick.where, targetTable)}`
|
|
4262
4369
|
: '';
|
|
4263
|
-
|
|
4370
|
+
// Plan discriminator: the lateral plan emits DIFFERENT SQL (a FROM-clause
|
|
4371
|
+
// join + a qualified order term) so a warm cache must never serve one
|
|
4372
|
+
// plan's SQL for the other. Emitted ONLY for `'lateral'`: absent means
|
|
4373
|
+
// the default subquery plan, keeping every pre-existing cache key
|
|
4374
|
+
// byte-identical (no cold-cache churn on upgrade).
|
|
4375
|
+
const planTag = d.plan === 'lateral' ? ';plan=lat' : '';
|
|
4376
|
+
return `pick(${by},${d.direction ?? 'asc'},${d.nulls ?? ''};po=${pickOrder}${pickWhere}${planTag})`;
|
|
4264
4377
|
}
|
|
4265
4378
|
if ((0, filters_js_1.isOrderBySpec)(d))
|
|
4266
4379
|
return `spec(${d.sort},${d.nulls ?? ''})`;
|
|
@@ -4277,7 +4390,7 @@ class QueryInterface {
|
|
|
4277
4390
|
}
|
|
4278
4391
|
return String(d);
|
|
4279
4392
|
}
|
|
4280
|
-
buildOrderBy(orderBy, params) {
|
|
4393
|
+
buildOrderBy(orderBy, params, lateralSink) {
|
|
4281
4394
|
// Dev-only: validate that orderBy fields exist in the table schema. Relation
|
|
4282
4395
|
// orderBy keys (object values that are neither a vector nor an OrderBySpec)
|
|
4283
4396
|
// are validated in the relation branch below, so skip them here.
|
|
@@ -4320,7 +4433,7 @@ class QueryInterface {
|
|
|
4320
4433
|
// keyed by a relation name (`{ posts: { _count: 'desc' } }` / `{ author:
|
|
4321
4434
|
// { name: 'asc' } }`).
|
|
4322
4435
|
if (this.isRelationOrderByValue(value)) {
|
|
4323
|
-
return this.buildRelationOrderBy(key, value, `ord${relOrdCounter++}`, params);
|
|
4436
|
+
return this.buildRelationOrderBy(key, value, `ord${relOrdCounter++}`, params, undefined, lateralSink);
|
|
4324
4437
|
}
|
|
4325
4438
|
// Scalar column ordering — a plain direction or an OrderBySpec (nulls).
|
|
4326
4439
|
if (meta && !(key in meta.columnMap)) {
|
|
@@ -4429,7 +4542,7 @@ class QueryInterface {
|
|
|
4429
4542
|
* subquery's orderBy the relations live on the TARGET table's metadata and
|
|
4430
4543
|
* the correlation parent is the relation's alias, not `this.table`.
|
|
4431
4544
|
*/
|
|
4432
|
-
buildRelationOrderBy(relName, value, alias, params, ctx) {
|
|
4545
|
+
buildRelationOrderBy(relName, value, alias, params, ctx, lateralSink) {
|
|
4433
4546
|
const ownerMeta = ctx?.meta ?? this.tableMeta;
|
|
4434
4547
|
const ownerTable = ctx?.table ?? this.table;
|
|
4435
4548
|
const parentRef = ctx?.parentRef ?? this.table;
|
|
@@ -4445,7 +4558,7 @@ class QueryInterface {
|
|
|
4445
4558
|
// scope errors, shared with the cache-hit collect mirror.
|
|
4446
4559
|
if ((0, filters_js_1.isRelationPickOrderBy)(value)) {
|
|
4447
4560
|
this.validatePickOrderBy(relName, relDef, value, ctx !== undefined);
|
|
4448
|
-
return this.buildRelationPickOrderBy(relName, relDef, value, alias, parentRef, params);
|
|
4561
|
+
return this.buildRelationPickOrderBy(relName, relDef, value, alias, parentRef, params, lateralSink);
|
|
4449
4562
|
}
|
|
4450
4563
|
// To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
|
|
4451
4564
|
if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
|
|
@@ -4537,6 +4650,25 @@ class QueryInterface {
|
|
|
4537
4650
|
throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering on relation "${relName}" requires \`by\`: a target column name ` +
|
|
4538
4651
|
"or a JSON-path spec ({ field: 'data', path: ['title'] }).");
|
|
4539
4652
|
}
|
|
4653
|
+
// Physical plan gate. A typo like `plan: 'latreal'` must never silently run
|
|
4654
|
+
// the subquery plan (a silent plan change wearing a validation gap). Shared
|
|
4655
|
+
// by build and cache-hit collect so a warmed cache throws identically.
|
|
4656
|
+
if (spec.plan !== undefined && spec.plan !== 'subquery' && spec.plan !== 'lateral') {
|
|
4657
|
+
throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering on relation "${relName}" has an invalid \`plan\`: ` +
|
|
4658
|
+
`${JSON.stringify(spec.plan)}. Use 'subquery' (default) or 'lateral'.`);
|
|
4659
|
+
}
|
|
4660
|
+
if (spec.plan === 'lateral') {
|
|
4661
|
+
if (!this.dialect.supportsLateralJoin) {
|
|
4662
|
+
throw new errors_js_1.UnsupportedFeatureError("pick-row ordering with plan: 'lateral'", this.dialect.name, "LATERAL joins are only available on PostgreSQL. Omit `plan` (or use 'subquery').");
|
|
4663
|
+
}
|
|
4664
|
+
// The lateral exposes one reserved output column, `__turbine_pick`. A
|
|
4665
|
+
// parent column with that exact name would make the unqualified WHERE
|
|
4666
|
+
// reference ambiguous once the join is in scope; refuse it explicitly.
|
|
4667
|
+
if (this.tableMeta.allColumns.includes('__turbine_pick')) {
|
|
4668
|
+
throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering with plan: 'lateral' cannot be used: table "${this.tableMeta.name}" ` +
|
|
4669
|
+
'has a column named "__turbine_pick", which the lateral join output reserves.');
|
|
4670
|
+
}
|
|
4671
|
+
}
|
|
4540
4672
|
}
|
|
4541
4673
|
/**
|
|
4542
4674
|
* Compile a {@link RelationPickOrderBy} term: a correlated scalar subquery
|
|
@@ -4555,36 +4687,83 @@ class QueryInterface {
|
|
|
4555
4687
|
* {@link collectRelationPickOrderParams}): `by` JSON path (if any) →
|
|
4556
4688
|
* target global filter → `pick.where` → `pick.orderBy` JSON paths.
|
|
4557
4689
|
*/
|
|
4558
|
-
buildRelationPickOrderBy(relName, relDef, spec, alias, parentRef, params) {
|
|
4690
|
+
buildRelationPickOrderBy(relName, relDef, spec, alias, parentRef, params, lateralSink) {
|
|
4559
4691
|
if (!params) {
|
|
4560
4692
|
throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering on relation "${relName}" is only supported in a top-level findMany orderBy.`);
|
|
4561
4693
|
}
|
|
4562
4694
|
const targetMeta = this.schema.tables[relDef.to];
|
|
4563
4695
|
if (!targetMeta)
|
|
4564
4696
|
throw new errors_js_1.RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
|
|
4697
|
+
const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
4698
|
+
const limitOne = this.buildPagination('1', undefined, true);
|
|
4699
|
+
// Parents with ZERO surviving related rows have no row to pick: the
|
|
4700
|
+
// correlated subquery yields NULL, and the LEFT JOIN LATERAL null-extends
|
|
4701
|
+
// its single row identically. Without a nulls clause, Postgres DESC
|
|
4702
|
+
// defaults to NULLS FIRST (every childless parent tops a "highest first"
|
|
4703
|
+
// sort). Default to NULLS LAST in BOTH directions (deterministic across
|
|
4704
|
+
// engines: SQLite's NULL-is-smallest default diverges from Postgres) unless
|
|
4705
|
+
// the caller set `nulls` explicitly; the grammar gate matches nullsSuffix.
|
|
4706
|
+
const nullsSql = spec.nulls
|
|
4707
|
+
? this.nullsSuffix(spec.nulls)
|
|
4708
|
+
: this.dialect.name === 'postgresql' || this.dialect.name === 'sqlite'
|
|
4709
|
+
? ' NULLS LAST'
|
|
4710
|
+
: '';
|
|
4711
|
+
// Lateral plan: splice a `LEFT JOIN LATERAL (... LIMIT 1) ON true` into the
|
|
4712
|
+
// FROM clause (via the sink) and order by its single reserved output column.
|
|
4713
|
+
// Param push order is IDENTICAL to the subquery plan (compilePickPieces is
|
|
4714
|
+
// shared), so the cache-hit collect mirror needs no changes. Scope +
|
|
4715
|
+
// capability were already enforced by validatePickOrderBy (shared with the
|
|
4716
|
+
// collect path); the missing-sink guard catches a non-findMany build
|
|
4717
|
+
// context and hard-fails rather than silently emitting a subquery.
|
|
4718
|
+
if (spec.plan === 'lateral') {
|
|
4719
|
+
if (!lateralSink) {
|
|
4720
|
+
throw new errors_js_1.ValidationError(`[turbine] Pick-row ordering with plan: 'lateral' on relation "${relName}" is only supported ` +
|
|
4721
|
+
'in a top-level findMany orderBy.');
|
|
4722
|
+
}
|
|
4723
|
+
const childAlias = `${alias}i`;
|
|
4724
|
+
const { byExpr, where, orderClause } = this.compilePickPieces(relDef, targetMeta, spec, childAlias, parentRef, params);
|
|
4725
|
+
lateralSink.push(` LEFT JOIN LATERAL (SELECT ${byExpr} AS ${this.q('__turbine_pick')} FROM ${this.q(relDef.to)} ${childAlias}` +
|
|
4726
|
+
` WHERE ${where}${orderClause}${limitOne}) ${alias} ON true`);
|
|
4727
|
+
return `${alias}.${this.q('__turbine_pick')} ${dir}${nullsSql}`;
|
|
4728
|
+
}
|
|
4729
|
+
// Subquery plan (default): a correlated scalar subquery in ORDER BY.
|
|
4730
|
+
const { byExpr, where, orderClause } = this.compilePickPieces(relDef, targetMeta, spec, alias, parentRef, params);
|
|
4731
|
+
return `(SELECT ${byExpr} FROM ${this.q(relDef.to)} ${alias} WHERE ${where}${orderClause}${limitOne}) ${dir}${nullsSql}`;
|
|
4732
|
+
}
|
|
4733
|
+
/**
|
|
4734
|
+
* Compile the shared inner pieces of a pick-row ordering against `childAlias`
|
|
4735
|
+
* (the table alias the related row is read from): the `by` value expression,
|
|
4736
|
+
* the correlation + target global filter + `pick.where` predicate, and the
|
|
4737
|
+
* `pick.orderBy` clause. Factored out of {@link buildRelationPickOrderBy} so
|
|
4738
|
+
* the subquery and lateral plans build IDENTICAL pieces in the SAME param
|
|
4739
|
+
* push order (`by` JSON path → target global filter → `pick.where` →
|
|
4740
|
+
* `pick.orderBy` JSON paths), which is why the collect mirror
|
|
4741
|
+
* ({@link collectRelationPickOrderParams}) is plan-agnostic.
|
|
4742
|
+
*/
|
|
4743
|
+
compilePickPieces(relDef, targetMeta, spec, childAlias, parentRef, params) {
|
|
4565
4744
|
// The value surfaced from the picked row (SELECT list: its param binds first).
|
|
4566
4745
|
let byExpr;
|
|
4567
4746
|
if (typeof spec.by === 'string') {
|
|
4568
4747
|
const col = this.resolveOrderByColumn(relDef.to, targetMeta, spec.by);
|
|
4569
|
-
byExpr = `${
|
|
4748
|
+
byExpr = `${childAlias}.${this.q(col)}`;
|
|
4570
4749
|
}
|
|
4571
4750
|
else {
|
|
4572
4751
|
const col = this.validateJsonPathOrderBy(relDef.to, targetMeta, spec.by.field, {
|
|
4573
4752
|
path: spec.by.path,
|
|
4574
4753
|
});
|
|
4575
4754
|
params.push(this.jsonPathParam(spec.by.path));
|
|
4576
|
-
const extract = this.dialect.buildJsonPathExtract(`${
|
|
4755
|
+
const extract = this.dialect.buildJsonPathExtract(`${childAlias}.${this.q(col)}`, this.p(params.length));
|
|
4577
4756
|
byExpr = spec.by.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
|
|
4578
4757
|
}
|
|
4579
4758
|
// Correlation to the parent row, then the target's global filter (a
|
|
4580
4759
|
// soft-deleted / other-tenant row must never be picked: matches the
|
|
4581
4760
|
// `with` subquery and to-one relation-orderBy semantics), then pick.where.
|
|
4582
|
-
let where = this.dialect.buildCorrelation(
|
|
4583
|
-
const gf = this.targetGlobalFilterAlias(relDef.to,
|
|
4761
|
+
let where = this.dialect.buildCorrelation(childAlias, relDef.foreignKey, this.q(parentRef), relDef.referenceKey);
|
|
4762
|
+
const gf = this.targetGlobalFilterAlias(relDef.to, childAlias, params);
|
|
4584
4763
|
if (gf)
|
|
4585
4764
|
where += ` AND ${gf}`;
|
|
4586
4765
|
if (spec.pick.where) {
|
|
4587
|
-
const pickWhere = this.buildAliasWhere(relDef.to, targetMeta,
|
|
4766
|
+
const pickWhere = this.buildAliasWhere(relDef.to, targetMeta, childAlias, spec.pick.where, params);
|
|
4588
4767
|
if (pickWhere)
|
|
4589
4768
|
where += ` AND ${pickWhere}`;
|
|
4590
4769
|
}
|
|
@@ -4592,21 +4771,8 @@ class QueryInterface {
|
|
|
4592
4771
|
// (plain columns, OrderBySpec nulls, JSON-path specs); a nested pick in
|
|
4593
4772
|
// here routes back through buildRelationOrderBy with ctx set and throws
|
|
4594
4773
|
// the top-level-only E003.
|
|
4595
|
-
const orderClause = this.buildRelationOrderClause(relDef.to, targetMeta,
|
|
4596
|
-
|
|
4597
|
-
const limitOne = this.buildPagination('1', undefined, true);
|
|
4598
|
-
// Parents with ZERO surviving related rows make the correlated subquery
|
|
4599
|
-
// yield NULL. Without a nulls clause, Postgres DESC defaults to NULLS
|
|
4600
|
-
// FIRST — every childless parent would top a "highest first" sort. Default
|
|
4601
|
-
// to NULLS LAST in BOTH directions (deterministic across engines: SQLite's
|
|
4602
|
-
// NULL-is-smallest default diverges from Postgres) unless the caller set
|
|
4603
|
-
// `nulls` explicitly; the grammar gate matches nullsSuffix (PG + SQLite).
|
|
4604
|
-
const nullsSql = spec.nulls
|
|
4605
|
-
? this.nullsSuffix(spec.nulls)
|
|
4606
|
-
: this.dialect.name === 'postgresql' || this.dialect.name === 'sqlite'
|
|
4607
|
-
? ' NULLS LAST'
|
|
4608
|
-
: '';
|
|
4609
|
-
return `(SELECT ${byExpr} FROM ${this.q(relDef.to)} ${alias} WHERE ${where}${orderClause}${limitOne}) ${dir}${nullsSql}`;
|
|
4774
|
+
const orderClause = this.buildRelationOrderClause(relDef.to, targetMeta, childAlias, Object.entries(spec.pick.orderBy), params);
|
|
4775
|
+
return { byExpr, where, orderClause };
|
|
4610
4776
|
}
|
|
4611
4777
|
/**
|
|
4612
4778
|
* Param-collect mirror of {@link buildRelationPickOrderBy}: re-runs the same
|
|
@@ -319,9 +319,9 @@ function isJsonPathOrderBy(value) {
|
|
|
319
319
|
}
|
|
320
320
|
/**
|
|
321
321
|
* Check if an orderBy value is a pick-row relation ordering:
|
|
322
|
-
* `{ pick: { orderBy, ... }, by, direction?, nulls? }`. The full shape
|
|
323
|
-
* required
|
|
324
|
-
* present, and no keys outside `{ pick, by, direction, nulls }`
|
|
322
|
+
* `{ pick: { orderBy, ... }, by, direction?, nulls?, plan? }`. The full shape
|
|
323
|
+
* is required (`pick` must be an object carrying `orderBy`, `by` must be
|
|
324
|
+
* present, and no keys outside `{ pick, by, direction, nulls, plan }`), so a to-one
|
|
325
325
|
* relation whose target has real columns literally named `pick` and `by`
|
|
326
326
|
* (whose values are direction strings or `{ sort, nulls }` specs, never an
|
|
327
327
|
* object with `orderBy`) still falls through to column ordering. `distance`
|
|
@@ -339,7 +339,7 @@ function isRelationPickOrderBy(value) {
|
|
|
339
339
|
if (typeof v.pick !== 'object' || v.pick === null || Array.isArray(v.pick) || !('orderBy' in v.pick))
|
|
340
340
|
return false;
|
|
341
341
|
for (const key of Object.keys(v)) {
|
|
342
|
-
if (key !== 'pick' && key !== 'by' && key !== 'direction' && key !== 'nulls')
|
|
342
|
+
if (key !== 'pick' && key !== 'by' && key !== 'direction' && key !== 'nulls' && key !== 'plan')
|
|
343
343
|
return false;
|
|
344
344
|
}
|
|
345
345
|
return true;
|
package/dist/cjs/sqlite.js
CHANGED
|
@@ -378,6 +378,8 @@ exports.sqliteDialect = {
|
|
|
378
378
|
supportsListenNotify: false,
|
|
379
379
|
supportsRLS: false,
|
|
380
380
|
supportsAdvisoryLock: false,
|
|
381
|
+
// No FROM-clause LATERAL: the opt-in lateral pick plan is Postgres-only.
|
|
382
|
+
supportsLateralJoin: false,
|
|
381
383
|
// json_group_array / json_object have no inline ORDER BY argument, so every
|
|
382
384
|
// ordered to-many relation is forced through the inner-subquery rewrite.
|
|
383
385
|
aggSupportsInlineOrderBy: false,
|
package/dist/dialect.d.ts
CHANGED
|
@@ -295,6 +295,13 @@ export interface Dialect {
|
|
|
295
295
|
readonly supportsRLS: boolean;
|
|
296
296
|
/** Whether this dialect/engine supports advisory-lock-style migration locking. */
|
|
297
297
|
readonly supportsAdvisoryLock: boolean;
|
|
298
|
+
/**
|
|
299
|
+
* Whether this dialect supports `LEFT JOIN LATERAL (...) ON true` in the FROM
|
|
300
|
+
* clause. Gates the opt-in `plan: 'lateral'` pick-row ordering. Optional:
|
|
301
|
+
* absent is treated as `false`, so only dialects that set it true admit the
|
|
302
|
+
* lateral plan (else E017). PostgreSQL only in this release.
|
|
303
|
+
*/
|
|
304
|
+
readonly supportsLateralJoin?: boolean;
|
|
298
305
|
/** Build a dialect-specific RETURNING clause. Return an empty string when unsupported. */
|
|
299
306
|
buildReturningClause(selection?: string): string;
|
|
300
307
|
/** Build a single-row INSERT statement. Inputs are SQL-ready quoted fragments. */
|
package/dist/dialect.js
CHANGED
package/dist/mssql.js
CHANGED
|
@@ -467,6 +467,9 @@ export const mssqlDialect = {
|
|
|
467
467
|
supportsVector: false,
|
|
468
468
|
supportsListenNotify: false,
|
|
469
469
|
supportsRLS: false,
|
|
470
|
+
// SQL Server has OUTER APPLY, not FROM-clause LATERAL: the lateral pick plan
|
|
471
|
+
// is Postgres-only (out of scope here).
|
|
472
|
+
supportsLateralJoin: false,
|
|
470
473
|
// sp_getapplock / sp_releaseapplock exist (used by a future migrate adapter).
|
|
471
474
|
supportsAdvisoryLock: true,
|
|
472
475
|
// FOR JSON over zero rows is NULL → coalesced in the relation override.
|
package/dist/mysql.js
CHANGED
|
@@ -368,6 +368,9 @@ export const mysqlDialect = {
|
|
|
368
368
|
supportsVector: false,
|
|
369
369
|
supportsListenNotify: false,
|
|
370
370
|
supportsRLS: false,
|
|
371
|
+
// MySQL 8.0.14+ supports LATERAL, but the opt-in lateral pick plan stays
|
|
372
|
+
// Postgres-only in this release (flipping it on is a one-line change + tests).
|
|
373
|
+
supportsLateralJoin: false,
|
|
371
374
|
// GET_LOCK / RELEASE_LOCK exist (used by a future migrate adapter).
|
|
372
375
|
supportsAdvisoryLock: true,
|
|
373
376
|
// JSON_ARRAYAGG has no inline ORDER BY argument → force the inner-subquery
|
package/dist/powdb.js
CHANGED
|
@@ -92,6 +92,9 @@ export const powdbDialect = {
|
|
|
92
92
|
supportsRLS: false,
|
|
93
93
|
supportsAdvisoryLock: false,
|
|
94
94
|
supportsILike: false,
|
|
95
|
+
// PowQL has no LATERAL construct; PowqlInterface refuses pick ordering
|
|
96
|
+
// earlier, this override keeps the flag truthful if a future path consults it.
|
|
97
|
+
supportsLateralJoin: false,
|
|
95
98
|
beginStatement: () => 'begin',
|
|
96
99
|
commitStatement: () => 'commit',
|
|
97
100
|
rollbackStatement: () => 'rollback',
|
package/dist/powql.js
CHANGED
|
@@ -1180,6 +1180,17 @@ export class PowqlInterface {
|
|
|
1180
1180
|
proj.push(`${a.alias}: ${a.fn}(${a.field ? this.ref(a.field) : `.${this.meta.primaryKey[0]}`})`);
|
|
1181
1181
|
}
|
|
1182
1182
|
const having = this.buildHaving(args.having, params);
|
|
1183
|
+
// groupBy aggregate ordering (`_count` / `_sum` / … keys) has no PowQL
|
|
1184
|
+
// equivalent here: `buildOrder` treats an `orderBy` key as a field ref, so
|
|
1185
|
+
// a bare `_count: 'desc'` would silently emit an invalid `._count` sort.
|
|
1186
|
+
// Refuse those keys explicitly; plain by-field ordering still flows through.
|
|
1187
|
+
if (args.orderBy) {
|
|
1188
|
+
for (const key of Object.keys(args.orderBy)) {
|
|
1189
|
+
if (key === '_count' || key === '_sum' || key === '_avg' || key === '_min' || key === '_max') {
|
|
1190
|
+
throw new UnsupportedFeatureError('groupBy ordering by an aggregate', 'PowDB', `orderBy key "${key}"`);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1183
1194
|
const order = this.buildOrder(args.orderBy);
|
|
1184
1195
|
const powql = `${this.qt}${filter} group ${groupKeys.join(', ')}${having}${order} { ${proj.join(', ')} }`;
|
|
1185
1196
|
const { rows } = await this.exec(powql, params, args.timeout);
|
package/dist/query/builder.d.ts
CHANGED
|
@@ -382,6 +382,20 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
382
382
|
buildCount(args?: CountArgs<T>): DeferredQuery<number>;
|
|
383
383
|
groupBy(args: GroupByArgs<T>): Promise<Record<string, unknown>[]>;
|
|
384
384
|
buildGroupBy(args: GroupByArgs<T>): DeferredQuery<Record<string, unknown>[]>;
|
|
385
|
+
/**
|
|
386
|
+
* Compile a groupBy `orderBy` into an ORDER BY body. Unlike findMany ORDER BY
|
|
387
|
+
* ({@link buildOrderBy}, which validates keys against the table's physical
|
|
388
|
+
* columns), groupBy ordering targets the columns the RESULT actually
|
|
389
|
+
* contains: plain by-fields, JSON group-key aliases, and requested aggregates
|
|
390
|
+
* (`_count` / `_sum` / `_avg` / `_min` / `_max`). Each key re-emits the exact
|
|
391
|
+
* SELECT expression that produced it (`byOrderExprs` / `aggOrderExprs`),
|
|
392
|
+
* mirroring how HAVING re-emits aggregate expressions, so no dialect ever has
|
|
393
|
+
* to accept a SELECT-alias reference in ORDER BY, and any already-bound
|
|
394
|
+
* JSON-path placeholder is reused verbatim (ORDER BY is the last clause, so
|
|
395
|
+
* no `$n` renumbering). An aggregate key that was not requested, or an unknown
|
|
396
|
+
* by-key, throws {@link ValidationError} E003 listing the valid keys.
|
|
397
|
+
*/
|
|
398
|
+
private buildGroupByOrderBy;
|
|
385
399
|
/**
|
|
386
400
|
* Validate a JSON-path target (group key or aggregate target) in groupBy:
|
|
387
401
|
* the field must resolve to a real json/jsonb column and the path must be a
|
|
@@ -815,6 +829,17 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
815
829
|
* target global filter → `pick.where` → `pick.orderBy` JSON paths.
|
|
816
830
|
*/
|
|
817
831
|
private buildRelationPickOrderBy;
|
|
832
|
+
/**
|
|
833
|
+
* Compile the shared inner pieces of a pick-row ordering against `childAlias`
|
|
834
|
+
* (the table alias the related row is read from): the `by` value expression,
|
|
835
|
+
* the correlation + target global filter + `pick.where` predicate, and the
|
|
836
|
+
* `pick.orderBy` clause. Factored out of {@link buildRelationPickOrderBy} so
|
|
837
|
+
* the subquery and lateral plans build IDENTICAL pieces in the SAME param
|
|
838
|
+
* push order (`by` JSON path → target global filter → `pick.where` →
|
|
839
|
+
* `pick.orderBy` JSON paths), which is why the collect mirror
|
|
840
|
+
* ({@link collectRelationPickOrderParams}) is plan-agnostic.
|
|
841
|
+
*/
|
|
842
|
+
private compilePickPieces;
|
|
818
843
|
/**
|
|
819
844
|
* Param-collect mirror of {@link buildRelationPickOrderBy}: re-runs the same
|
|
820
845
|
* validation (a warmed cache can never skip it), then pushes in the same
|
package/dist/query/builder.js
CHANGED
|
@@ -959,14 +959,22 @@ export class QueryInterface {
|
|
|
959
959
|
else {
|
|
960
960
|
selectClause = `${qt}.*`;
|
|
961
961
|
}
|
|
962
|
-
|
|
962
|
+
// Piece-then-assemble. The join-sink between FROM and WHERE carries any
|
|
963
|
+
// `plan: 'lateral'` pick joins; it is populated during the ORDER BY build
|
|
964
|
+
// below and spliced in at final assembly. Empty for every other query
|
|
965
|
+
// shape → the assembled SQL is byte-identical to the incremental-append
|
|
966
|
+
// form for the default plan (asserted by the byte-equality snapshot test).
|
|
967
|
+
const lateralJoins = [];
|
|
968
|
+
// WHERE + cursor conditions accumulate into `tail`, pushing params in
|
|
969
|
+
// where → cursor order (the collect path mirrors this exactly).
|
|
970
|
+
let tail = freshWhereSql;
|
|
963
971
|
if (args?.cursor) {
|
|
964
972
|
// Sorted (canonical) order — MUST match cursorFp and the cache-hit collect below.
|
|
965
973
|
const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
|
|
966
974
|
if (cursorEntries.length > 0) {
|
|
967
975
|
const cursorConditions = cursorEntries.map(([k, v]) => {
|
|
968
976
|
const col = this.toSqlColumn(k);
|
|
969
|
-
// orderBy values can be the { sort, nulls } spec form
|
|
977
|
+
// orderBy values can be the { sort, nulls } spec form: normalize
|
|
970
978
|
// before comparing, or a desc spec would seek the ascending side.
|
|
971
979
|
const dir = args.orderBy?.[k];
|
|
972
980
|
const desc = isOrderBySpec(dir) ? dir.sort === 'desc' : dir === 'desc';
|
|
@@ -974,33 +982,38 @@ export class QueryInterface {
|
|
|
974
982
|
freshParams.push(v);
|
|
975
983
|
return `${qt}.${col} ${op} ${this.p(freshParams.length)}`;
|
|
976
984
|
});
|
|
977
|
-
|
|
978
|
-
sql += ` AND ${cursorConditions.join(' AND ')}`;
|
|
979
|
-
}
|
|
980
|
-
else {
|
|
981
|
-
sql += ` WHERE ${cursorConditions.join(' AND ')}`;
|
|
982
|
-
}
|
|
985
|
+
tail += freshWhereSql ? ` AND ${cursorConditions.join(' AND ')}` : ` WHERE ${cursorConditions.join(' AND ')}`;
|
|
983
986
|
}
|
|
984
987
|
}
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
else {
|
|
1000
|
-
// Pass freshParams so vector KNN ordering binds its `$n::vector` query
|
|
1001
|
-
// vector at the correct position (after cursor params, before LIMIT).
|
|
1002
|
-
sql += ` ORDER BY ${this.buildOrderBy(args.orderBy, freshParams)}`;
|
|
988
|
+
// ORDER BY is built AFTER the cursor pushes (param order
|
|
989
|
+
// where → with → cursor → orderBy → limit → offset) and BEFORE final
|
|
990
|
+
// assembly (so the lateral sink is filled before the FROM clause is
|
|
991
|
+
// written). distinct + relation orderBy is refused up front, so a lateral
|
|
992
|
+
// pick can never reach the distinct branch (lateralJoins stays empty).
|
|
993
|
+
let sql;
|
|
994
|
+
if (args?.orderBy && distinctPrefix) {
|
|
995
|
+
// Postgres requires DISTINCT ON expressions to lead the ORDER BY. Prisma
|
|
996
|
+
// semantics ("first row per combination, result in the user's order")
|
|
997
|
+
// need two levels: inner DISTINCT ON ordered by the distinct columns then
|
|
998
|
+
// the user's order (picks the right representative row), outer re-ordered
|
|
999
|
+
// by the user's order alone.
|
|
1000
|
+
if (Object.values(args.orderBy).some((d) => isVectorOrderBy(d))) {
|
|
1001
|
+
throw new ValidationError('[turbine] `distinct` cannot be combined with vector distance ordering.');
|
|
1003
1002
|
}
|
|
1003
|
+
const userOrder = this.buildOrderBy(args.orderBy, freshParams);
|
|
1004
|
+
const inner = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${tail} ORDER BY ${distinctCols
|
|
1005
|
+
.map((c) => `${c} ASC`)
|
|
1006
|
+
.join(', ')}, ${userOrder}`;
|
|
1007
|
+
sql = `SELECT * FROM (${inner}) AS ${this.q(`${this.table}_distinct`)} ORDER BY ${userOrder}`;
|
|
1008
|
+
}
|
|
1009
|
+
else {
|
|
1010
|
+
// Pass freshParams so vector KNN ordering binds its `$n::vector` query
|
|
1011
|
+
// vector at the correct position (after cursor params, before LIMIT), and
|
|
1012
|
+
// lateralJoins so a lateral pick splices its join into the FROM clause.
|
|
1013
|
+
const orderBySql = args?.orderBy
|
|
1014
|
+
? ` ORDER BY ${this.buildOrderBy(args.orderBy, freshParams, lateralJoins)}`
|
|
1015
|
+
: '';
|
|
1016
|
+
sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${lateralJoins.join('')}${tail}${orderBySql}`;
|
|
1004
1017
|
}
|
|
1005
1018
|
// Pagination — push params in the same order the collect path mirrors
|
|
1006
1019
|
// (limit before offset); the SQL TEXT shape is dialect-owned via
|
|
@@ -1818,6 +1831,15 @@ export class QueryInterface {
|
|
|
1818
1831
|
const selectExprs = [];
|
|
1819
1832
|
/** by entries in order: how to read each group key off the result row. */
|
|
1820
1833
|
const byReaders = [];
|
|
1834
|
+
// ORDER BY registries: map each key the groupBy RESULT actually contains to
|
|
1835
|
+
// the exact SELECT expression that produced it, so `orderBy` re-emits that
|
|
1836
|
+
// expression (never a SELECT alias, since not every dialect accepts alias
|
|
1837
|
+
// references in ORDER BY, and re-emitting mirrors HAVING's `jsonAggExprs`).
|
|
1838
|
+
// `byOrderExprs`: plain by-field name / JSON group-key alias → column or
|
|
1839
|
+
// extract expression. `aggOrderExprs`: `${aggKey}:${field}` → aggregate
|
|
1840
|
+
// expression (including any already-bound JSON-path placeholder, reused
|
|
1841
|
+
// exactly like HAVING since ORDER BY is appended after all other params).
|
|
1842
|
+
const byOrderExprs = new Map();
|
|
1821
1843
|
const usedResultKeys = new Set();
|
|
1822
1844
|
const claimResultKey = (key, what) => {
|
|
1823
1845
|
if (key === '_count' || usedResultKeys.has(key)) {
|
|
@@ -1838,6 +1860,7 @@ export class QueryInterface {
|
|
|
1838
1860
|
groupExprs.push(this.q(col));
|
|
1839
1861
|
selectExprs.push(this.q(col));
|
|
1840
1862
|
byReaders.push({ resultKey: entry, rowKey: col, raw: false });
|
|
1863
|
+
byOrderExprs.set(entry, this.q(col));
|
|
1841
1864
|
}
|
|
1842
1865
|
else {
|
|
1843
1866
|
const col = this.resolveJsonPathTarget('group key', entry.field, entry.path);
|
|
@@ -1849,13 +1872,24 @@ export class QueryInterface {
|
|
|
1849
1872
|
selectExprs.push(`(${extract}) AS ${this.q(alias)}`);
|
|
1850
1873
|
groupExprs.push(extract);
|
|
1851
1874
|
byReaders.push({ resultKey: alias, rowKey: alias, raw: true });
|
|
1875
|
+
// ORDER BY by this JSON alias re-emits the extract expression (with its
|
|
1876
|
+
// already-bound $n): the same reuse HAVING does for JSON aggregates.
|
|
1877
|
+
byOrderExprs.set(alias, extract);
|
|
1852
1878
|
}
|
|
1853
1879
|
}
|
|
1854
1880
|
// _count
|
|
1855
|
-
|
|
1881
|
+
const countSelected = args._count === true || args._count === undefined;
|
|
1882
|
+
if (countSelected) {
|
|
1856
1883
|
// default: always include count
|
|
1857
1884
|
selectExprs.push(`${this.castAgg('COUNT(*)', 'int')} AS _count`);
|
|
1858
1885
|
}
|
|
1886
|
+
// ORDER BY aggregate expressions, keyed `${aggKey}:${field}` (plus a bare
|
|
1887
|
+
// `_count`). Populated alongside the SELECT list below so `orderBy` can only
|
|
1888
|
+
// reference an aggregate that is actually requested. `COUNT(*)` (uncast) is
|
|
1889
|
+
// the ordering expression (the SELECT cast is only for the returned value).
|
|
1890
|
+
const aggOrderExprs = new Map();
|
|
1891
|
+
if (countSelected)
|
|
1892
|
+
aggOrderExprs.set('_count', 'COUNT(*)');
|
|
1859
1893
|
// _sum / _avg / _min / _max: `true` keeps the plain-column behavior; a
|
|
1860
1894
|
// {@link JsonPathAggregateTarget} aggregates a JSON path under the arg key
|
|
1861
1895
|
// as alias. `jsonAggFields` routes each JSON-aggregate row key back to its
|
|
@@ -1878,6 +1912,7 @@ export class QueryInterface {
|
|
|
1878
1912
|
const inner = `${sqlFn}(${this.q(col)})`;
|
|
1879
1913
|
const expr = aggKey === '_avg' ? this.castAgg(inner, 'float') : inner;
|
|
1880
1914
|
selectExprs.push(`${expr} AS ${this.q(`${aggKey}_${col}`)}`);
|
|
1915
|
+
aggOrderExprs.set(`${aggKey}:${key}`, expr);
|
|
1881
1916
|
continue;
|
|
1882
1917
|
}
|
|
1883
1918
|
const col = this.resolveJsonPathTarget(`${aggKey} target "${key}"`, target.field, target.path);
|
|
@@ -1895,6 +1930,7 @@ export class QueryInterface {
|
|
|
1895
1930
|
selectExprs.push(`${expr} AS ${this.q(`${aggKey}_${key}`)}`);
|
|
1896
1931
|
jsonAggFields.set(`${aggKey}_${key}`, { field: key, numeric });
|
|
1897
1932
|
jsonAggExprs.set(`${key}:${aggKey}`, expr);
|
|
1933
|
+
aggOrderExprs.set(`${aggKey}:${key}`, expr);
|
|
1898
1934
|
}
|
|
1899
1935
|
};
|
|
1900
1936
|
buildAggregates('_sum', 'SUM', args._sum);
|
|
@@ -1911,9 +1947,12 @@ export class QueryInterface {
|
|
|
1911
1947
|
sql += ` HAVING ${havingClauses.join(' AND ')}`;
|
|
1912
1948
|
}
|
|
1913
1949
|
}
|
|
1914
|
-
// ORDER BY
|
|
1950
|
+
// ORDER BY, over the groupBy RESULT columns (by-fields, JSON aliases, and
|
|
1951
|
+
// requested aggregates), not the table's physical columns.
|
|
1915
1952
|
if (args.orderBy) {
|
|
1916
|
-
|
|
1953
|
+
const orderSql = this.buildGroupByOrderBy(args.orderBy, byOrderExprs, aggOrderExprs);
|
|
1954
|
+
if (orderSql)
|
|
1955
|
+
sql += ` ORDER BY ${orderSql}`;
|
|
1917
1956
|
}
|
|
1918
1957
|
return {
|
|
1919
1958
|
sql,
|
|
@@ -1978,6 +2017,74 @@ export class QueryInterface {
|
|
|
1978
2017
|
tag: `${this.table}.groupBy`,
|
|
1979
2018
|
};
|
|
1980
2019
|
}
|
|
2020
|
+
/**
|
|
2021
|
+
* Compile a groupBy `orderBy` into an ORDER BY body. Unlike findMany ORDER BY
|
|
2022
|
+
* ({@link buildOrderBy}, which validates keys against the table's physical
|
|
2023
|
+
* columns), groupBy ordering targets the columns the RESULT actually
|
|
2024
|
+
* contains: plain by-fields, JSON group-key aliases, and requested aggregates
|
|
2025
|
+
* (`_count` / `_sum` / `_avg` / `_min` / `_max`). Each key re-emits the exact
|
|
2026
|
+
* SELECT expression that produced it (`byOrderExprs` / `aggOrderExprs`),
|
|
2027
|
+
* mirroring how HAVING re-emits aggregate expressions, so no dialect ever has
|
|
2028
|
+
* to accept a SELECT-alias reference in ORDER BY, and any already-bound
|
|
2029
|
+
* JSON-path placeholder is reused verbatim (ORDER BY is the last clause, so
|
|
2030
|
+
* no `$n` renumbering). An aggregate key that was not requested, or an unknown
|
|
2031
|
+
* by-key, throws {@link ValidationError} E003 listing the valid keys.
|
|
2032
|
+
*/
|
|
2033
|
+
buildGroupByOrderBy(orderBy, byOrderExprs, aggOrderExprs) {
|
|
2034
|
+
const aggBlocks = new Set(['_count', '_sum', '_avg', '_min', '_max']);
|
|
2035
|
+
/** Human-readable list of every key this call can order by (for E003). */
|
|
2036
|
+
const validKeys = () => {
|
|
2037
|
+
const keys = [...byOrderExprs.keys()];
|
|
2038
|
+
for (const k of aggOrderExprs.keys()) {
|
|
2039
|
+
keys.push(k.includes(':') ? k.replace(':', '.') : k);
|
|
2040
|
+
}
|
|
2041
|
+
return keys.join(', ') || '(none)';
|
|
2042
|
+
};
|
|
2043
|
+
const parts = [];
|
|
2044
|
+
for (const [key, value] of Object.entries(orderBy)) {
|
|
2045
|
+
if (value === undefined)
|
|
2046
|
+
continue;
|
|
2047
|
+
// Aggregate ordering blocks.
|
|
2048
|
+
if (aggBlocks.has(key)) {
|
|
2049
|
+
if (key === '_count') {
|
|
2050
|
+
const expr = aggOrderExprs.get('_count');
|
|
2051
|
+
if (!expr) {
|
|
2052
|
+
throw new ValidationError(`[turbine] Cannot order groupBy by "_count" on table "${this.table}": _count is not selected. ` +
|
|
2053
|
+
`Orderable keys: ${validKeys()}.`);
|
|
2054
|
+
}
|
|
2055
|
+
const { dir, nulls } = normalizeOrderBy(value);
|
|
2056
|
+
parts.push(`${expr} ${dir}${this.nullsSuffix(nulls)}`);
|
|
2057
|
+
continue;
|
|
2058
|
+
}
|
|
2059
|
+
// `_sum` / `_avg` / `_min` / `_max`: an object of field → direction/spec.
|
|
2060
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
2061
|
+
throw new ValidationError(`[turbine] Invalid groupBy orderBy for "${key}" on table "${this.table}": ` +
|
|
2062
|
+
`expected a field map like { ${key}: { amount: 'desc' } }.`);
|
|
2063
|
+
}
|
|
2064
|
+
for (const [field, dirSpec] of Object.entries(value)) {
|
|
2065
|
+
if (dirSpec === undefined)
|
|
2066
|
+
continue;
|
|
2067
|
+
const expr = aggOrderExprs.get(`${key}:${field}`);
|
|
2068
|
+
if (!expr) {
|
|
2069
|
+
throw new ValidationError(`[turbine] Cannot order groupBy by "${key}.${field}" on table "${this.table}": ` +
|
|
2070
|
+
`that aggregate is not requested in this call. Orderable keys: ${validKeys()}.`);
|
|
2071
|
+
}
|
|
2072
|
+
const { dir, nulls } = normalizeOrderBy(dirSpec);
|
|
2073
|
+
parts.push(`${expr} ${dir}${this.nullsSuffix(nulls)}`);
|
|
2074
|
+
}
|
|
2075
|
+
continue;
|
|
2076
|
+
}
|
|
2077
|
+
// Plain by-field name or JSON group-key alias.
|
|
2078
|
+
const expr = byOrderExprs.get(key);
|
|
2079
|
+
if (!expr) {
|
|
2080
|
+
throw new ValidationError(`[turbine] Unknown field "${key}" in groupBy orderBy on table "${this.table}". ` +
|
|
2081
|
+
`Orderable keys: ${validKeys()}.`);
|
|
2082
|
+
}
|
|
2083
|
+
const { dir, nulls } = normalizeOrderBy(value);
|
|
2084
|
+
parts.push(`${expr} ${dir}${this.nullsSuffix(nulls)}`);
|
|
2085
|
+
}
|
|
2086
|
+
return parts.join(', ');
|
|
2087
|
+
}
|
|
1981
2088
|
/**
|
|
1982
2089
|
* Validate a JSON-path target (group key or aggregate target) in groupBy:
|
|
1983
2090
|
* the field must resolve to a real json/jsonb column and the path must be a
|
|
@@ -4224,7 +4331,13 @@ export class QueryInterface {
|
|
|
4224
4331
|
const pickWhere = d.pick?.where
|
|
4225
4332
|
? `;pw=${this.fingerprintAliasWhere(d.pick.where, targetTable)}`
|
|
4226
4333
|
: '';
|
|
4227
|
-
|
|
4334
|
+
// Plan discriminator: the lateral plan emits DIFFERENT SQL (a FROM-clause
|
|
4335
|
+
// join + a qualified order term) so a warm cache must never serve one
|
|
4336
|
+
// plan's SQL for the other. Emitted ONLY for `'lateral'`: absent means
|
|
4337
|
+
// the default subquery plan, keeping every pre-existing cache key
|
|
4338
|
+
// byte-identical (no cold-cache churn on upgrade).
|
|
4339
|
+
const planTag = d.plan === 'lateral' ? ';plan=lat' : '';
|
|
4340
|
+
return `pick(${by},${d.direction ?? 'asc'},${d.nulls ?? ''};po=${pickOrder}${pickWhere}${planTag})`;
|
|
4228
4341
|
}
|
|
4229
4342
|
if (isOrderBySpec(d))
|
|
4230
4343
|
return `spec(${d.sort},${d.nulls ?? ''})`;
|
|
@@ -4241,7 +4354,7 @@ export class QueryInterface {
|
|
|
4241
4354
|
}
|
|
4242
4355
|
return String(d);
|
|
4243
4356
|
}
|
|
4244
|
-
buildOrderBy(orderBy, params) {
|
|
4357
|
+
buildOrderBy(orderBy, params, lateralSink) {
|
|
4245
4358
|
// Dev-only: validate that orderBy fields exist in the table schema. Relation
|
|
4246
4359
|
// orderBy keys (object values that are neither a vector nor an OrderBySpec)
|
|
4247
4360
|
// are validated in the relation branch below, so skip them here.
|
|
@@ -4284,7 +4397,7 @@ export class QueryInterface {
|
|
|
4284
4397
|
// keyed by a relation name (`{ posts: { _count: 'desc' } }` / `{ author:
|
|
4285
4398
|
// { name: 'asc' } }`).
|
|
4286
4399
|
if (this.isRelationOrderByValue(value)) {
|
|
4287
|
-
return this.buildRelationOrderBy(key, value, `ord${relOrdCounter++}`, params);
|
|
4400
|
+
return this.buildRelationOrderBy(key, value, `ord${relOrdCounter++}`, params, undefined, lateralSink);
|
|
4288
4401
|
}
|
|
4289
4402
|
// Scalar column ordering — a plain direction or an OrderBySpec (nulls).
|
|
4290
4403
|
if (meta && !(key in meta.columnMap)) {
|
|
@@ -4393,7 +4506,7 @@ export class QueryInterface {
|
|
|
4393
4506
|
* subquery's orderBy the relations live on the TARGET table's metadata and
|
|
4394
4507
|
* the correlation parent is the relation's alias, not `this.table`.
|
|
4395
4508
|
*/
|
|
4396
|
-
buildRelationOrderBy(relName, value, alias, params, ctx) {
|
|
4509
|
+
buildRelationOrderBy(relName, value, alias, params, ctx, lateralSink) {
|
|
4397
4510
|
const ownerMeta = ctx?.meta ?? this.tableMeta;
|
|
4398
4511
|
const ownerTable = ctx?.table ?? this.table;
|
|
4399
4512
|
const parentRef = ctx?.parentRef ?? this.table;
|
|
@@ -4409,7 +4522,7 @@ export class QueryInterface {
|
|
|
4409
4522
|
// scope errors, shared with the cache-hit collect mirror.
|
|
4410
4523
|
if (isRelationPickOrderBy(value)) {
|
|
4411
4524
|
this.validatePickOrderBy(relName, relDef, value, ctx !== undefined);
|
|
4412
|
-
return this.buildRelationPickOrderBy(relName, relDef, value, alias, parentRef, params);
|
|
4525
|
+
return this.buildRelationPickOrderBy(relName, relDef, value, alias, parentRef, params, lateralSink);
|
|
4413
4526
|
}
|
|
4414
4527
|
// To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
|
|
4415
4528
|
if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
|
|
@@ -4501,6 +4614,25 @@ export class QueryInterface {
|
|
|
4501
4614
|
throw new ValidationError(`[turbine] Pick-row ordering on relation "${relName}" requires \`by\`: a target column name ` +
|
|
4502
4615
|
"or a JSON-path spec ({ field: 'data', path: ['title'] }).");
|
|
4503
4616
|
}
|
|
4617
|
+
// Physical plan gate. A typo like `plan: 'latreal'` must never silently run
|
|
4618
|
+
// the subquery plan (a silent plan change wearing a validation gap). Shared
|
|
4619
|
+
// by build and cache-hit collect so a warmed cache throws identically.
|
|
4620
|
+
if (spec.plan !== undefined && spec.plan !== 'subquery' && spec.plan !== 'lateral') {
|
|
4621
|
+
throw new ValidationError(`[turbine] Pick-row ordering on relation "${relName}" has an invalid \`plan\`: ` +
|
|
4622
|
+
`${JSON.stringify(spec.plan)}. Use 'subquery' (default) or 'lateral'.`);
|
|
4623
|
+
}
|
|
4624
|
+
if (spec.plan === 'lateral') {
|
|
4625
|
+
if (!this.dialect.supportsLateralJoin) {
|
|
4626
|
+
throw new UnsupportedFeatureError("pick-row ordering with plan: 'lateral'", this.dialect.name, "LATERAL joins are only available on PostgreSQL. Omit `plan` (or use 'subquery').");
|
|
4627
|
+
}
|
|
4628
|
+
// The lateral exposes one reserved output column, `__turbine_pick`. A
|
|
4629
|
+
// parent column with that exact name would make the unqualified WHERE
|
|
4630
|
+
// reference ambiguous once the join is in scope; refuse it explicitly.
|
|
4631
|
+
if (this.tableMeta.allColumns.includes('__turbine_pick')) {
|
|
4632
|
+
throw new ValidationError(`[turbine] Pick-row ordering with plan: 'lateral' cannot be used: table "${this.tableMeta.name}" ` +
|
|
4633
|
+
'has a column named "__turbine_pick", which the lateral join output reserves.');
|
|
4634
|
+
}
|
|
4635
|
+
}
|
|
4504
4636
|
}
|
|
4505
4637
|
/**
|
|
4506
4638
|
* Compile a {@link RelationPickOrderBy} term: a correlated scalar subquery
|
|
@@ -4519,36 +4651,83 @@ export class QueryInterface {
|
|
|
4519
4651
|
* {@link collectRelationPickOrderParams}): `by` JSON path (if any) →
|
|
4520
4652
|
* target global filter → `pick.where` → `pick.orderBy` JSON paths.
|
|
4521
4653
|
*/
|
|
4522
|
-
buildRelationPickOrderBy(relName, relDef, spec, alias, parentRef, params) {
|
|
4654
|
+
buildRelationPickOrderBy(relName, relDef, spec, alias, parentRef, params, lateralSink) {
|
|
4523
4655
|
if (!params) {
|
|
4524
4656
|
throw new ValidationError(`[turbine] Pick-row ordering on relation "${relName}" is only supported in a top-level findMany orderBy.`);
|
|
4525
4657
|
}
|
|
4526
4658
|
const targetMeta = this.schema.tables[relDef.to];
|
|
4527
4659
|
if (!targetMeta)
|
|
4528
4660
|
throw new RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
|
|
4661
|
+
const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
4662
|
+
const limitOne = this.buildPagination('1', undefined, true);
|
|
4663
|
+
// Parents with ZERO surviving related rows have no row to pick: the
|
|
4664
|
+
// correlated subquery yields NULL, and the LEFT JOIN LATERAL null-extends
|
|
4665
|
+
// its single row identically. Without a nulls clause, Postgres DESC
|
|
4666
|
+
// defaults to NULLS FIRST (every childless parent tops a "highest first"
|
|
4667
|
+
// sort). Default to NULLS LAST in BOTH directions (deterministic across
|
|
4668
|
+
// engines: SQLite's NULL-is-smallest default diverges from Postgres) unless
|
|
4669
|
+
// the caller set `nulls` explicitly; the grammar gate matches nullsSuffix.
|
|
4670
|
+
const nullsSql = spec.nulls
|
|
4671
|
+
? this.nullsSuffix(spec.nulls)
|
|
4672
|
+
: this.dialect.name === 'postgresql' || this.dialect.name === 'sqlite'
|
|
4673
|
+
? ' NULLS LAST'
|
|
4674
|
+
: '';
|
|
4675
|
+
// Lateral plan: splice a `LEFT JOIN LATERAL (... LIMIT 1) ON true` into the
|
|
4676
|
+
// FROM clause (via the sink) and order by its single reserved output column.
|
|
4677
|
+
// Param push order is IDENTICAL to the subquery plan (compilePickPieces is
|
|
4678
|
+
// shared), so the cache-hit collect mirror needs no changes. Scope +
|
|
4679
|
+
// capability were already enforced by validatePickOrderBy (shared with the
|
|
4680
|
+
// collect path); the missing-sink guard catches a non-findMany build
|
|
4681
|
+
// context and hard-fails rather than silently emitting a subquery.
|
|
4682
|
+
if (spec.plan === 'lateral') {
|
|
4683
|
+
if (!lateralSink) {
|
|
4684
|
+
throw new ValidationError(`[turbine] Pick-row ordering with plan: 'lateral' on relation "${relName}" is only supported ` +
|
|
4685
|
+
'in a top-level findMany orderBy.');
|
|
4686
|
+
}
|
|
4687
|
+
const childAlias = `${alias}i`;
|
|
4688
|
+
const { byExpr, where, orderClause } = this.compilePickPieces(relDef, targetMeta, spec, childAlias, parentRef, params);
|
|
4689
|
+
lateralSink.push(` LEFT JOIN LATERAL (SELECT ${byExpr} AS ${this.q('__turbine_pick')} FROM ${this.q(relDef.to)} ${childAlias}` +
|
|
4690
|
+
` WHERE ${where}${orderClause}${limitOne}) ${alias} ON true`);
|
|
4691
|
+
return `${alias}.${this.q('__turbine_pick')} ${dir}${nullsSql}`;
|
|
4692
|
+
}
|
|
4693
|
+
// Subquery plan (default): a correlated scalar subquery in ORDER BY.
|
|
4694
|
+
const { byExpr, where, orderClause } = this.compilePickPieces(relDef, targetMeta, spec, alias, parentRef, params);
|
|
4695
|
+
return `(SELECT ${byExpr} FROM ${this.q(relDef.to)} ${alias} WHERE ${where}${orderClause}${limitOne}) ${dir}${nullsSql}`;
|
|
4696
|
+
}
|
|
4697
|
+
/**
|
|
4698
|
+
* Compile the shared inner pieces of a pick-row ordering against `childAlias`
|
|
4699
|
+
* (the table alias the related row is read from): the `by` value expression,
|
|
4700
|
+
* the correlation + target global filter + `pick.where` predicate, and the
|
|
4701
|
+
* `pick.orderBy` clause. Factored out of {@link buildRelationPickOrderBy} so
|
|
4702
|
+
* the subquery and lateral plans build IDENTICAL pieces in the SAME param
|
|
4703
|
+
* push order (`by` JSON path → target global filter → `pick.where` →
|
|
4704
|
+
* `pick.orderBy` JSON paths), which is why the collect mirror
|
|
4705
|
+
* ({@link collectRelationPickOrderParams}) is plan-agnostic.
|
|
4706
|
+
*/
|
|
4707
|
+
compilePickPieces(relDef, targetMeta, spec, childAlias, parentRef, params) {
|
|
4529
4708
|
// The value surfaced from the picked row (SELECT list: its param binds first).
|
|
4530
4709
|
let byExpr;
|
|
4531
4710
|
if (typeof spec.by === 'string') {
|
|
4532
4711
|
const col = this.resolveOrderByColumn(relDef.to, targetMeta, spec.by);
|
|
4533
|
-
byExpr = `${
|
|
4712
|
+
byExpr = `${childAlias}.${this.q(col)}`;
|
|
4534
4713
|
}
|
|
4535
4714
|
else {
|
|
4536
4715
|
const col = this.validateJsonPathOrderBy(relDef.to, targetMeta, spec.by.field, {
|
|
4537
4716
|
path: spec.by.path,
|
|
4538
4717
|
});
|
|
4539
4718
|
params.push(this.jsonPathParam(spec.by.path));
|
|
4540
|
-
const extract = this.dialect.buildJsonPathExtract(`${
|
|
4719
|
+
const extract = this.dialect.buildJsonPathExtract(`${childAlias}.${this.q(col)}`, this.p(params.length));
|
|
4541
4720
|
byExpr = spec.by.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
|
|
4542
4721
|
}
|
|
4543
4722
|
// Correlation to the parent row, then the target's global filter (a
|
|
4544
4723
|
// soft-deleted / other-tenant row must never be picked: matches the
|
|
4545
4724
|
// `with` subquery and to-one relation-orderBy semantics), then pick.where.
|
|
4546
|
-
let where = this.dialect.buildCorrelation(
|
|
4547
|
-
const gf = this.targetGlobalFilterAlias(relDef.to,
|
|
4725
|
+
let where = this.dialect.buildCorrelation(childAlias, relDef.foreignKey, this.q(parentRef), relDef.referenceKey);
|
|
4726
|
+
const gf = this.targetGlobalFilterAlias(relDef.to, childAlias, params);
|
|
4548
4727
|
if (gf)
|
|
4549
4728
|
where += ` AND ${gf}`;
|
|
4550
4729
|
if (spec.pick.where) {
|
|
4551
|
-
const pickWhere = this.buildAliasWhere(relDef.to, targetMeta,
|
|
4730
|
+
const pickWhere = this.buildAliasWhere(relDef.to, targetMeta, childAlias, spec.pick.where, params);
|
|
4552
4731
|
if (pickWhere)
|
|
4553
4732
|
where += ` AND ${pickWhere}`;
|
|
4554
4733
|
}
|
|
@@ -4556,21 +4735,8 @@ export class QueryInterface {
|
|
|
4556
4735
|
// (plain columns, OrderBySpec nulls, JSON-path specs); a nested pick in
|
|
4557
4736
|
// here routes back through buildRelationOrderBy with ctx set and throws
|
|
4558
4737
|
// the top-level-only E003.
|
|
4559
|
-
const orderClause = this.buildRelationOrderClause(relDef.to, targetMeta,
|
|
4560
|
-
|
|
4561
|
-
const limitOne = this.buildPagination('1', undefined, true);
|
|
4562
|
-
// Parents with ZERO surviving related rows make the correlated subquery
|
|
4563
|
-
// yield NULL. Without a nulls clause, Postgres DESC defaults to NULLS
|
|
4564
|
-
// FIRST — every childless parent would top a "highest first" sort. Default
|
|
4565
|
-
// to NULLS LAST in BOTH directions (deterministic across engines: SQLite's
|
|
4566
|
-
// NULL-is-smallest default diverges from Postgres) unless the caller set
|
|
4567
|
-
// `nulls` explicitly; the grammar gate matches nullsSuffix (PG + SQLite).
|
|
4568
|
-
const nullsSql = spec.nulls
|
|
4569
|
-
? this.nullsSuffix(spec.nulls)
|
|
4570
|
-
: this.dialect.name === 'postgresql' || this.dialect.name === 'sqlite'
|
|
4571
|
-
? ' NULLS LAST'
|
|
4572
|
-
: '';
|
|
4573
|
-
return `(SELECT ${byExpr} FROM ${this.q(relDef.to)} ${alias} WHERE ${where}${orderClause}${limitOne}) ${dir}${nullsSql}`;
|
|
4738
|
+
const orderClause = this.buildRelationOrderClause(relDef.to, targetMeta, childAlias, Object.entries(spec.pick.orderBy), params);
|
|
4739
|
+
return { byExpr, where, orderClause };
|
|
4574
4740
|
}
|
|
4575
4741
|
/**
|
|
4576
4742
|
* Param-collect mirror of {@link buildRelationPickOrderBy}: re-runs the same
|
package/dist/query/filters.d.ts
CHANGED
|
@@ -156,9 +156,9 @@ export declare function isOrderBySpec(value: unknown): value is OrderBySpec;
|
|
|
156
156
|
export declare function isJsonPathOrderBy(value: unknown): value is JsonPathOrderBy;
|
|
157
157
|
/**
|
|
158
158
|
* Check if an orderBy value is a pick-row relation ordering:
|
|
159
|
-
* `{ pick: { orderBy, ... }, by, direction?, nulls? }`. The full shape
|
|
160
|
-
* required
|
|
161
|
-
* present, and no keys outside `{ pick, by, direction, nulls }`
|
|
159
|
+
* `{ pick: { orderBy, ... }, by, direction?, nulls?, plan? }`. The full shape
|
|
160
|
+
* is required (`pick` must be an object carrying `orderBy`, `by` must be
|
|
161
|
+
* present, and no keys outside `{ pick, by, direction, nulls, plan }`), so a to-one
|
|
162
162
|
* relation whose target has real columns literally named `pick` and `by`
|
|
163
163
|
* (whose values are direction strings or `{ sort, nulls }` specs, never an
|
|
164
164
|
* object with `orderBy`) still falls through to column ordering. `distance`
|
package/dist/query/filters.js
CHANGED
|
@@ -296,9 +296,9 @@ export function isJsonPathOrderBy(value) {
|
|
|
296
296
|
}
|
|
297
297
|
/**
|
|
298
298
|
* Check if an orderBy value is a pick-row relation ordering:
|
|
299
|
-
* `{ pick: { orderBy, ... }, by, direction?, nulls? }`. The full shape
|
|
300
|
-
* required
|
|
301
|
-
* present, and no keys outside `{ pick, by, direction, nulls }`
|
|
299
|
+
* `{ pick: { orderBy, ... }, by, direction?, nulls?, plan? }`. The full shape
|
|
300
|
+
* is required (`pick` must be an object carrying `orderBy`, `by` must be
|
|
301
|
+
* present, and no keys outside `{ pick, by, direction, nulls, plan }`), so a to-one
|
|
302
302
|
* relation whose target has real columns literally named `pick` and `by`
|
|
303
303
|
* (whose values are direction strings or `{ sort, nulls }` specs, never an
|
|
304
304
|
* object with `orderBy`) still falls through to column ordering. `distance`
|
|
@@ -316,7 +316,7 @@ export function isRelationPickOrderBy(value) {
|
|
|
316
316
|
if (typeof v.pick !== 'object' || v.pick === null || Array.isArray(v.pick) || !('orderBy' in v.pick))
|
|
317
317
|
return false;
|
|
318
318
|
for (const key of Object.keys(v)) {
|
|
319
|
-
if (key !== 'pick' && key !== 'by' && key !== 'direction' && key !== 'nulls')
|
|
319
|
+
if (key !== 'pick' && key !== 'by' && key !== 'direction' && key !== 'nulls' && key !== 'plan')
|
|
320
320
|
return false;
|
|
321
321
|
}
|
|
322
322
|
return true;
|
package/dist/query/types.d.ts
CHANGED
|
@@ -657,6 +657,37 @@ export interface GroupByDistinctOn<T> {
|
|
|
657
657
|
/** Which row survives per combination (required for determinism). */
|
|
658
658
|
orderBy: Record<string, OrderDirection | OrderBySpec | JsonPathOrderBy>;
|
|
659
659
|
}
|
|
660
|
+
/** A per-field aggregate ordering block: field/alias → direction or sort spec. */
|
|
661
|
+
export type GroupByAggregateOrderBy = Record<string, OrderDirection | OrderBySpec>;
|
|
662
|
+
/**
|
|
663
|
+
* {@link GroupByArgs.orderBy}: order the result groups by any column the
|
|
664
|
+
* groupBy result contains.
|
|
665
|
+
*
|
|
666
|
+
* - A plain **by-column** field name, or a **JSON group-key alias** (explicit
|
|
667
|
+
* `alias`, else the last path segment): `{ region: 'asc' }`.
|
|
668
|
+
* - An **aggregate block**: `_count` takes a direction/spec directly
|
|
669
|
+
* (`{ _count: 'desc' }`); `_sum`/`_avg`/`_min`/`_max` take a field map keyed
|
|
670
|
+
* by a requested aggregate field/alias (`{ _sum: { amount: 'desc' } }`).
|
|
671
|
+
*
|
|
672
|
+
* An aggregate ordering that references an aggregate not requested in the same
|
|
673
|
+
* call (or an unknown by-key) throws {@link ValidationError} (E003). Every
|
|
674
|
+
* value accepts an {@link OrderBySpec} for `NULLS FIRST/LAST` placement
|
|
675
|
+
* (PostgreSQL / SQLite only).
|
|
676
|
+
*/
|
|
677
|
+
export interface GroupByOrderBy {
|
|
678
|
+
/** Order by the group's row count (requires `_count` to be selected). */
|
|
679
|
+
_count?: OrderDirection | OrderBySpec;
|
|
680
|
+
/** Order by a requested `_sum` aggregate, keyed by its field/alias. */
|
|
681
|
+
_sum?: GroupByAggregateOrderBy;
|
|
682
|
+
/** Order by a requested `_avg` aggregate, keyed by its field/alias. */
|
|
683
|
+
_avg?: GroupByAggregateOrderBy;
|
|
684
|
+
/** Order by a requested `_min` aggregate, keyed by its field/alias. */
|
|
685
|
+
_min?: GroupByAggregateOrderBy;
|
|
686
|
+
/** Order by a requested `_max` aggregate, keyed by its field/alias. */
|
|
687
|
+
_max?: GroupByAggregateOrderBy;
|
|
688
|
+
/** A by-column field name or JSON group-key alias → direction or sort spec. */
|
|
689
|
+
[key: string]: OrderDirection | OrderBySpec | GroupByAggregateOrderBy | undefined;
|
|
690
|
+
}
|
|
660
691
|
export interface GroupByArgs<T> {
|
|
661
692
|
/** Group keys: plain column field names and/or JSON-path keys ({@link JsonPathGroupKey}). */
|
|
662
693
|
by: ((keyof T & string) | JsonPathGroupKey)[];
|
|
@@ -678,8 +709,15 @@ export interface GroupByArgs<T> {
|
|
|
678
709
|
_max?: GroupByAggregateSpec<T>;
|
|
679
710
|
/** Filter whole groups by their aggregate values (SQL HAVING). JSON-path aggregates key by their alias. */
|
|
680
711
|
having?: HavingClause<T>;
|
|
681
|
-
/**
|
|
682
|
-
|
|
712
|
+
/**
|
|
713
|
+
* Order the result groups. Keys may be any column the groupBy result actually
|
|
714
|
+
* contains: a plain by-column field name, a JSON group-key alias (explicit
|
|
715
|
+
* `alias`, or the last path segment when unaliased), or an aggregate block
|
|
716
|
+
* (`_count`, or `_sum`/`_avg`/`_min`/`_max` mapping a requested field/alias to
|
|
717
|
+
* its direction). Every value supports {@link OrderBySpec} for NULLS
|
|
718
|
+
* placement. See {@link GroupByOrderBy}.
|
|
719
|
+
*/
|
|
720
|
+
orderBy?: GroupByOrderBy;
|
|
683
721
|
/** Query timeout in milliseconds. Rejects with an error if exceeded. */
|
|
684
722
|
timeout?: number;
|
|
685
723
|
/** Opt out of configured {@link GlobalFilters}. See {@link SkipGlobalFilters}. */
|
|
@@ -941,6 +979,17 @@ export interface RelationPickOrderBy {
|
|
|
941
979
|
* "highest first" sort). Set `nulls` explicitly to override.
|
|
942
980
|
*/
|
|
943
981
|
nulls?: 'first' | 'last';
|
|
982
|
+
/**
|
|
983
|
+
* Physical plan for the pick. `'subquery'` (default) compiles a correlated
|
|
984
|
+
* scalar subquery in ORDER BY. `'lateral'` (PostgreSQL only, E017 elsewhere)
|
|
985
|
+
* compiles a `LEFT JOIN LATERAL (... LIMIT 1) ON true` and orders by the
|
|
986
|
+
* joined value. Identical results; the lateral form can be significantly
|
|
987
|
+
* faster on large parent sets where the ordering subquery dominates the plan.
|
|
988
|
+
* Never falls back silently: contexts that cannot take a lateral (non-Postgres
|
|
989
|
+
* engines, `distinct`, nested `with` orderBy, a parent column literally named
|
|
990
|
+
* `__turbine_pick`) throw.
|
|
991
|
+
*/
|
|
992
|
+
plan?: 'subquery' | 'lateral';
|
|
944
993
|
}
|
|
945
994
|
/**
|
|
946
995
|
* An orderBy clause maps each key to one of:
|
package/dist/sqlite.js
CHANGED
|
@@ -370,6 +370,8 @@ export const sqliteDialect = {
|
|
|
370
370
|
supportsListenNotify: false,
|
|
371
371
|
supportsRLS: false,
|
|
372
372
|
supportsAdvisoryLock: false,
|
|
373
|
+
// No FROM-clause LATERAL: the opt-in lateral pick plan is Postgres-only.
|
|
374
|
+
supportsLateralJoin: false,
|
|
373
375
|
// json_group_array / json_object have no inline ORDER BY argument, so every
|
|
374
376
|
// ordered to-many relation is forced through the inner-subquery rewrite.
|
|
375
377
|
aggSupportsInlineOrderBy: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "turbine-orm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"description": "Postgres-native TypeScript ORM — runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|