turbine-orm 0.32.1 → 0.32.2
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/powql.js +11 -0
- package/dist/cjs/query/builder.js +97 -3
- package/dist/powql.js +11 -0
- package/dist/query/builder.d.ts +14 -0
- package/dist/query/builder.js +97 -3
- package/dist/query/types.d.ts +40 -2
- package/package.json +1 -1
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);
|
|
@@ -1854,6 +1854,15 @@ class QueryInterface {
|
|
|
1854
1854
|
const selectExprs = [];
|
|
1855
1855
|
/** by entries in order: how to read each group key off the result row. */
|
|
1856
1856
|
const byReaders = [];
|
|
1857
|
+
// ORDER BY registries: map each key the groupBy RESULT actually contains to
|
|
1858
|
+
// the exact SELECT expression that produced it, so `orderBy` re-emits that
|
|
1859
|
+
// expression (never a SELECT alias, since not every dialect accepts alias
|
|
1860
|
+
// references in ORDER BY, and re-emitting mirrors HAVING's `jsonAggExprs`).
|
|
1861
|
+
// `byOrderExprs`: plain by-field name / JSON group-key alias → column or
|
|
1862
|
+
// extract expression. `aggOrderExprs`: `${aggKey}:${field}` → aggregate
|
|
1863
|
+
// expression (including any already-bound JSON-path placeholder, reused
|
|
1864
|
+
// exactly like HAVING since ORDER BY is appended after all other params).
|
|
1865
|
+
const byOrderExprs = new Map();
|
|
1857
1866
|
const usedResultKeys = new Set();
|
|
1858
1867
|
const claimResultKey = (key, what) => {
|
|
1859
1868
|
if (key === '_count' || usedResultKeys.has(key)) {
|
|
@@ -1874,6 +1883,7 @@ class QueryInterface {
|
|
|
1874
1883
|
groupExprs.push(this.q(col));
|
|
1875
1884
|
selectExprs.push(this.q(col));
|
|
1876
1885
|
byReaders.push({ resultKey: entry, rowKey: col, raw: false });
|
|
1886
|
+
byOrderExprs.set(entry, this.q(col));
|
|
1877
1887
|
}
|
|
1878
1888
|
else {
|
|
1879
1889
|
const col = this.resolveJsonPathTarget('group key', entry.field, entry.path);
|
|
@@ -1885,13 +1895,24 @@ class QueryInterface {
|
|
|
1885
1895
|
selectExprs.push(`(${extract}) AS ${this.q(alias)}`);
|
|
1886
1896
|
groupExprs.push(extract);
|
|
1887
1897
|
byReaders.push({ resultKey: alias, rowKey: alias, raw: true });
|
|
1898
|
+
// ORDER BY by this JSON alias re-emits the extract expression (with its
|
|
1899
|
+
// already-bound $n): the same reuse HAVING does for JSON aggregates.
|
|
1900
|
+
byOrderExprs.set(alias, extract);
|
|
1888
1901
|
}
|
|
1889
1902
|
}
|
|
1890
1903
|
// _count
|
|
1891
|
-
|
|
1904
|
+
const countSelected = args._count === true || args._count === undefined;
|
|
1905
|
+
if (countSelected) {
|
|
1892
1906
|
// default: always include count
|
|
1893
1907
|
selectExprs.push(`${this.castAgg('COUNT(*)', 'int')} AS _count`);
|
|
1894
1908
|
}
|
|
1909
|
+
// ORDER BY aggregate expressions, keyed `${aggKey}:${field}` (plus a bare
|
|
1910
|
+
// `_count`). Populated alongside the SELECT list below so `orderBy` can only
|
|
1911
|
+
// reference an aggregate that is actually requested. `COUNT(*)` (uncast) is
|
|
1912
|
+
// the ordering expression (the SELECT cast is only for the returned value).
|
|
1913
|
+
const aggOrderExprs = new Map();
|
|
1914
|
+
if (countSelected)
|
|
1915
|
+
aggOrderExprs.set('_count', 'COUNT(*)');
|
|
1895
1916
|
// _sum / _avg / _min / _max: `true` keeps the plain-column behavior; a
|
|
1896
1917
|
// {@link JsonPathAggregateTarget} aggregates a JSON path under the arg key
|
|
1897
1918
|
// as alias. `jsonAggFields` routes each JSON-aggregate row key back to its
|
|
@@ -1914,6 +1935,7 @@ class QueryInterface {
|
|
|
1914
1935
|
const inner = `${sqlFn}(${this.q(col)})`;
|
|
1915
1936
|
const expr = aggKey === '_avg' ? this.castAgg(inner, 'float') : inner;
|
|
1916
1937
|
selectExprs.push(`${expr} AS ${this.q(`${aggKey}_${col}`)}`);
|
|
1938
|
+
aggOrderExprs.set(`${aggKey}:${key}`, expr);
|
|
1917
1939
|
continue;
|
|
1918
1940
|
}
|
|
1919
1941
|
const col = this.resolveJsonPathTarget(`${aggKey} target "${key}"`, target.field, target.path);
|
|
@@ -1931,6 +1953,7 @@ class QueryInterface {
|
|
|
1931
1953
|
selectExprs.push(`${expr} AS ${this.q(`${aggKey}_${key}`)}`);
|
|
1932
1954
|
jsonAggFields.set(`${aggKey}_${key}`, { field: key, numeric });
|
|
1933
1955
|
jsonAggExprs.set(`${key}:${aggKey}`, expr);
|
|
1956
|
+
aggOrderExprs.set(`${aggKey}:${key}`, expr);
|
|
1934
1957
|
}
|
|
1935
1958
|
};
|
|
1936
1959
|
buildAggregates('_sum', 'SUM', args._sum);
|
|
@@ -1947,9 +1970,12 @@ class QueryInterface {
|
|
|
1947
1970
|
sql += ` HAVING ${havingClauses.join(' AND ')}`;
|
|
1948
1971
|
}
|
|
1949
1972
|
}
|
|
1950
|
-
// ORDER BY
|
|
1973
|
+
// ORDER BY, over the groupBy RESULT columns (by-fields, JSON aliases, and
|
|
1974
|
+
// requested aggregates), not the table's physical columns.
|
|
1951
1975
|
if (args.orderBy) {
|
|
1952
|
-
|
|
1976
|
+
const orderSql = this.buildGroupByOrderBy(args.orderBy, byOrderExprs, aggOrderExprs);
|
|
1977
|
+
if (orderSql)
|
|
1978
|
+
sql += ` ORDER BY ${orderSql}`;
|
|
1953
1979
|
}
|
|
1954
1980
|
return {
|
|
1955
1981
|
sql,
|
|
@@ -2014,6 +2040,74 @@ class QueryInterface {
|
|
|
2014
2040
|
tag: `${this.table}.groupBy`,
|
|
2015
2041
|
};
|
|
2016
2042
|
}
|
|
2043
|
+
/**
|
|
2044
|
+
* Compile a groupBy `orderBy` into an ORDER BY body. Unlike findMany ORDER BY
|
|
2045
|
+
* ({@link buildOrderBy}, which validates keys against the table's physical
|
|
2046
|
+
* columns), groupBy ordering targets the columns the RESULT actually
|
|
2047
|
+
* contains: plain by-fields, JSON group-key aliases, and requested aggregates
|
|
2048
|
+
* (`_count` / `_sum` / `_avg` / `_min` / `_max`). Each key re-emits the exact
|
|
2049
|
+
* SELECT expression that produced it (`byOrderExprs` / `aggOrderExprs`),
|
|
2050
|
+
* mirroring how HAVING re-emits aggregate expressions, so no dialect ever has
|
|
2051
|
+
* to accept a SELECT-alias reference in ORDER BY, and any already-bound
|
|
2052
|
+
* JSON-path placeholder is reused verbatim (ORDER BY is the last clause, so
|
|
2053
|
+
* no `$n` renumbering). An aggregate key that was not requested, or an unknown
|
|
2054
|
+
* by-key, throws {@link ValidationError} E003 listing the valid keys.
|
|
2055
|
+
*/
|
|
2056
|
+
buildGroupByOrderBy(orderBy, byOrderExprs, aggOrderExprs) {
|
|
2057
|
+
const aggBlocks = new Set(['_count', '_sum', '_avg', '_min', '_max']);
|
|
2058
|
+
/** Human-readable list of every key this call can order by (for E003). */
|
|
2059
|
+
const validKeys = () => {
|
|
2060
|
+
const keys = [...byOrderExprs.keys()];
|
|
2061
|
+
for (const k of aggOrderExprs.keys()) {
|
|
2062
|
+
keys.push(k.includes(':') ? k.replace(':', '.') : k);
|
|
2063
|
+
}
|
|
2064
|
+
return keys.join(', ') || '(none)';
|
|
2065
|
+
};
|
|
2066
|
+
const parts = [];
|
|
2067
|
+
for (const [key, value] of Object.entries(orderBy)) {
|
|
2068
|
+
if (value === undefined)
|
|
2069
|
+
continue;
|
|
2070
|
+
// Aggregate ordering blocks.
|
|
2071
|
+
if (aggBlocks.has(key)) {
|
|
2072
|
+
if (key === '_count') {
|
|
2073
|
+
const expr = aggOrderExprs.get('_count');
|
|
2074
|
+
if (!expr) {
|
|
2075
|
+
throw new errors_js_1.ValidationError(`[turbine] Cannot order groupBy by "_count" on table "${this.table}": _count is not selected. ` +
|
|
2076
|
+
`Orderable keys: ${validKeys()}.`);
|
|
2077
|
+
}
|
|
2078
|
+
const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(value);
|
|
2079
|
+
parts.push(`${expr} ${dir}${this.nullsSuffix(nulls)}`);
|
|
2080
|
+
continue;
|
|
2081
|
+
}
|
|
2082
|
+
// `_sum` / `_avg` / `_min` / `_max`: an object of field → direction/spec.
|
|
2083
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
2084
|
+
throw new errors_js_1.ValidationError(`[turbine] Invalid groupBy orderBy for "${key}" on table "${this.table}": ` +
|
|
2085
|
+
`expected a field map like { ${key}: { amount: 'desc' } }.`);
|
|
2086
|
+
}
|
|
2087
|
+
for (const [field, dirSpec] of Object.entries(value)) {
|
|
2088
|
+
if (dirSpec === undefined)
|
|
2089
|
+
continue;
|
|
2090
|
+
const expr = aggOrderExprs.get(`${key}:${field}`);
|
|
2091
|
+
if (!expr) {
|
|
2092
|
+
throw new errors_js_1.ValidationError(`[turbine] Cannot order groupBy by "${key}.${field}" on table "${this.table}": ` +
|
|
2093
|
+
`that aggregate is not requested in this call. Orderable keys: ${validKeys()}.`);
|
|
2094
|
+
}
|
|
2095
|
+
const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(dirSpec);
|
|
2096
|
+
parts.push(`${expr} ${dir}${this.nullsSuffix(nulls)}`);
|
|
2097
|
+
}
|
|
2098
|
+
continue;
|
|
2099
|
+
}
|
|
2100
|
+
// Plain by-field name or JSON group-key alias.
|
|
2101
|
+
const expr = byOrderExprs.get(key);
|
|
2102
|
+
if (!expr) {
|
|
2103
|
+
throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in groupBy orderBy on table "${this.table}". ` +
|
|
2104
|
+
`Orderable keys: ${validKeys()}.`);
|
|
2105
|
+
}
|
|
2106
|
+
const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(value);
|
|
2107
|
+
parts.push(`${expr} ${dir}${this.nullsSuffix(nulls)}`);
|
|
2108
|
+
}
|
|
2109
|
+
return parts.join(', ');
|
|
2110
|
+
}
|
|
2017
2111
|
/**
|
|
2018
2112
|
* Validate a JSON-path target (group key or aggregate target) in groupBy:
|
|
2019
2113
|
* the field must resolve to a real json/jsonb column and the path must be a
|
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
|
package/dist/query/builder.js
CHANGED
|
@@ -1818,6 +1818,15 @@ export class QueryInterface {
|
|
|
1818
1818
|
const selectExprs = [];
|
|
1819
1819
|
/** by entries in order: how to read each group key off the result row. */
|
|
1820
1820
|
const byReaders = [];
|
|
1821
|
+
// ORDER BY registries: map each key the groupBy RESULT actually contains to
|
|
1822
|
+
// the exact SELECT expression that produced it, so `orderBy` re-emits that
|
|
1823
|
+
// expression (never a SELECT alias, since not every dialect accepts alias
|
|
1824
|
+
// references in ORDER BY, and re-emitting mirrors HAVING's `jsonAggExprs`).
|
|
1825
|
+
// `byOrderExprs`: plain by-field name / JSON group-key alias → column or
|
|
1826
|
+
// extract expression. `aggOrderExprs`: `${aggKey}:${field}` → aggregate
|
|
1827
|
+
// expression (including any already-bound JSON-path placeholder, reused
|
|
1828
|
+
// exactly like HAVING since ORDER BY is appended after all other params).
|
|
1829
|
+
const byOrderExprs = new Map();
|
|
1821
1830
|
const usedResultKeys = new Set();
|
|
1822
1831
|
const claimResultKey = (key, what) => {
|
|
1823
1832
|
if (key === '_count' || usedResultKeys.has(key)) {
|
|
@@ -1838,6 +1847,7 @@ export class QueryInterface {
|
|
|
1838
1847
|
groupExprs.push(this.q(col));
|
|
1839
1848
|
selectExprs.push(this.q(col));
|
|
1840
1849
|
byReaders.push({ resultKey: entry, rowKey: col, raw: false });
|
|
1850
|
+
byOrderExprs.set(entry, this.q(col));
|
|
1841
1851
|
}
|
|
1842
1852
|
else {
|
|
1843
1853
|
const col = this.resolveJsonPathTarget('group key', entry.field, entry.path);
|
|
@@ -1849,13 +1859,24 @@ export class QueryInterface {
|
|
|
1849
1859
|
selectExprs.push(`(${extract}) AS ${this.q(alias)}`);
|
|
1850
1860
|
groupExprs.push(extract);
|
|
1851
1861
|
byReaders.push({ resultKey: alias, rowKey: alias, raw: true });
|
|
1862
|
+
// ORDER BY by this JSON alias re-emits the extract expression (with its
|
|
1863
|
+
// already-bound $n): the same reuse HAVING does for JSON aggregates.
|
|
1864
|
+
byOrderExprs.set(alias, extract);
|
|
1852
1865
|
}
|
|
1853
1866
|
}
|
|
1854
1867
|
// _count
|
|
1855
|
-
|
|
1868
|
+
const countSelected = args._count === true || args._count === undefined;
|
|
1869
|
+
if (countSelected) {
|
|
1856
1870
|
// default: always include count
|
|
1857
1871
|
selectExprs.push(`${this.castAgg('COUNT(*)', 'int')} AS _count`);
|
|
1858
1872
|
}
|
|
1873
|
+
// ORDER BY aggregate expressions, keyed `${aggKey}:${field}` (plus a bare
|
|
1874
|
+
// `_count`). Populated alongside the SELECT list below so `orderBy` can only
|
|
1875
|
+
// reference an aggregate that is actually requested. `COUNT(*)` (uncast) is
|
|
1876
|
+
// the ordering expression (the SELECT cast is only for the returned value).
|
|
1877
|
+
const aggOrderExprs = new Map();
|
|
1878
|
+
if (countSelected)
|
|
1879
|
+
aggOrderExprs.set('_count', 'COUNT(*)');
|
|
1859
1880
|
// _sum / _avg / _min / _max: `true` keeps the plain-column behavior; a
|
|
1860
1881
|
// {@link JsonPathAggregateTarget} aggregates a JSON path under the arg key
|
|
1861
1882
|
// as alias. `jsonAggFields` routes each JSON-aggregate row key back to its
|
|
@@ -1878,6 +1899,7 @@ export class QueryInterface {
|
|
|
1878
1899
|
const inner = `${sqlFn}(${this.q(col)})`;
|
|
1879
1900
|
const expr = aggKey === '_avg' ? this.castAgg(inner, 'float') : inner;
|
|
1880
1901
|
selectExprs.push(`${expr} AS ${this.q(`${aggKey}_${col}`)}`);
|
|
1902
|
+
aggOrderExprs.set(`${aggKey}:${key}`, expr);
|
|
1881
1903
|
continue;
|
|
1882
1904
|
}
|
|
1883
1905
|
const col = this.resolveJsonPathTarget(`${aggKey} target "${key}"`, target.field, target.path);
|
|
@@ -1895,6 +1917,7 @@ export class QueryInterface {
|
|
|
1895
1917
|
selectExprs.push(`${expr} AS ${this.q(`${aggKey}_${key}`)}`);
|
|
1896
1918
|
jsonAggFields.set(`${aggKey}_${key}`, { field: key, numeric });
|
|
1897
1919
|
jsonAggExprs.set(`${key}:${aggKey}`, expr);
|
|
1920
|
+
aggOrderExprs.set(`${aggKey}:${key}`, expr);
|
|
1898
1921
|
}
|
|
1899
1922
|
};
|
|
1900
1923
|
buildAggregates('_sum', 'SUM', args._sum);
|
|
@@ -1911,9 +1934,12 @@ export class QueryInterface {
|
|
|
1911
1934
|
sql += ` HAVING ${havingClauses.join(' AND ')}`;
|
|
1912
1935
|
}
|
|
1913
1936
|
}
|
|
1914
|
-
// ORDER BY
|
|
1937
|
+
// ORDER BY, over the groupBy RESULT columns (by-fields, JSON aliases, and
|
|
1938
|
+
// requested aggregates), not the table's physical columns.
|
|
1915
1939
|
if (args.orderBy) {
|
|
1916
|
-
|
|
1940
|
+
const orderSql = this.buildGroupByOrderBy(args.orderBy, byOrderExprs, aggOrderExprs);
|
|
1941
|
+
if (orderSql)
|
|
1942
|
+
sql += ` ORDER BY ${orderSql}`;
|
|
1917
1943
|
}
|
|
1918
1944
|
return {
|
|
1919
1945
|
sql,
|
|
@@ -1978,6 +2004,74 @@ export class QueryInterface {
|
|
|
1978
2004
|
tag: `${this.table}.groupBy`,
|
|
1979
2005
|
};
|
|
1980
2006
|
}
|
|
2007
|
+
/**
|
|
2008
|
+
* Compile a groupBy `orderBy` into an ORDER BY body. Unlike findMany ORDER BY
|
|
2009
|
+
* ({@link buildOrderBy}, which validates keys against the table's physical
|
|
2010
|
+
* columns), groupBy ordering targets the columns the RESULT actually
|
|
2011
|
+
* contains: plain by-fields, JSON group-key aliases, and requested aggregates
|
|
2012
|
+
* (`_count` / `_sum` / `_avg` / `_min` / `_max`). Each key re-emits the exact
|
|
2013
|
+
* SELECT expression that produced it (`byOrderExprs` / `aggOrderExprs`),
|
|
2014
|
+
* mirroring how HAVING re-emits aggregate expressions, so no dialect ever has
|
|
2015
|
+
* to accept a SELECT-alias reference in ORDER BY, and any already-bound
|
|
2016
|
+
* JSON-path placeholder is reused verbatim (ORDER BY is the last clause, so
|
|
2017
|
+
* no `$n` renumbering). An aggregate key that was not requested, or an unknown
|
|
2018
|
+
* by-key, throws {@link ValidationError} E003 listing the valid keys.
|
|
2019
|
+
*/
|
|
2020
|
+
buildGroupByOrderBy(orderBy, byOrderExprs, aggOrderExprs) {
|
|
2021
|
+
const aggBlocks = new Set(['_count', '_sum', '_avg', '_min', '_max']);
|
|
2022
|
+
/** Human-readable list of every key this call can order by (for E003). */
|
|
2023
|
+
const validKeys = () => {
|
|
2024
|
+
const keys = [...byOrderExprs.keys()];
|
|
2025
|
+
for (const k of aggOrderExprs.keys()) {
|
|
2026
|
+
keys.push(k.includes(':') ? k.replace(':', '.') : k);
|
|
2027
|
+
}
|
|
2028
|
+
return keys.join(', ') || '(none)';
|
|
2029
|
+
};
|
|
2030
|
+
const parts = [];
|
|
2031
|
+
for (const [key, value] of Object.entries(orderBy)) {
|
|
2032
|
+
if (value === undefined)
|
|
2033
|
+
continue;
|
|
2034
|
+
// Aggregate ordering blocks.
|
|
2035
|
+
if (aggBlocks.has(key)) {
|
|
2036
|
+
if (key === '_count') {
|
|
2037
|
+
const expr = aggOrderExprs.get('_count');
|
|
2038
|
+
if (!expr) {
|
|
2039
|
+
throw new ValidationError(`[turbine] Cannot order groupBy by "_count" on table "${this.table}": _count is not selected. ` +
|
|
2040
|
+
`Orderable keys: ${validKeys()}.`);
|
|
2041
|
+
}
|
|
2042
|
+
const { dir, nulls } = normalizeOrderBy(value);
|
|
2043
|
+
parts.push(`${expr} ${dir}${this.nullsSuffix(nulls)}`);
|
|
2044
|
+
continue;
|
|
2045
|
+
}
|
|
2046
|
+
// `_sum` / `_avg` / `_min` / `_max`: an object of field → direction/spec.
|
|
2047
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
2048
|
+
throw new ValidationError(`[turbine] Invalid groupBy orderBy for "${key}" on table "${this.table}": ` +
|
|
2049
|
+
`expected a field map like { ${key}: { amount: 'desc' } }.`);
|
|
2050
|
+
}
|
|
2051
|
+
for (const [field, dirSpec] of Object.entries(value)) {
|
|
2052
|
+
if (dirSpec === undefined)
|
|
2053
|
+
continue;
|
|
2054
|
+
const expr = aggOrderExprs.get(`${key}:${field}`);
|
|
2055
|
+
if (!expr) {
|
|
2056
|
+
throw new ValidationError(`[turbine] Cannot order groupBy by "${key}.${field}" on table "${this.table}": ` +
|
|
2057
|
+
`that aggregate is not requested in this call. Orderable keys: ${validKeys()}.`);
|
|
2058
|
+
}
|
|
2059
|
+
const { dir, nulls } = normalizeOrderBy(dirSpec);
|
|
2060
|
+
parts.push(`${expr} ${dir}${this.nullsSuffix(nulls)}`);
|
|
2061
|
+
}
|
|
2062
|
+
continue;
|
|
2063
|
+
}
|
|
2064
|
+
// Plain by-field name or JSON group-key alias.
|
|
2065
|
+
const expr = byOrderExprs.get(key);
|
|
2066
|
+
if (!expr) {
|
|
2067
|
+
throw new ValidationError(`[turbine] Unknown field "${key}" in groupBy orderBy on table "${this.table}". ` +
|
|
2068
|
+
`Orderable keys: ${validKeys()}.`);
|
|
2069
|
+
}
|
|
2070
|
+
const { dir, nulls } = normalizeOrderBy(value);
|
|
2071
|
+
parts.push(`${expr} ${dir}${this.nullsSuffix(nulls)}`);
|
|
2072
|
+
}
|
|
2073
|
+
return parts.join(', ');
|
|
2074
|
+
}
|
|
1981
2075
|
/**
|
|
1982
2076
|
* Validate a JSON-path target (group key or aggregate target) in groupBy:
|
|
1983
2077
|
* the field must resolve to a real json/jsonb column and the path must be a
|
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}. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "turbine-orm",
|
|
3
|
-
"version": "0.32.
|
|
3
|
+
"version": "0.32.2",
|
|
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": {
|