turbine-orm 0.35.0 → 0.36.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/README.md +18 -16
- package/dist/cjs/cli/index.js +109 -16
- package/dist/cjs/cli/migrate.js +78 -3
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.js +333 -22
- package/dist/cjs/cli/ui.js +7 -1
- package/dist/cjs/dialect.js +1 -1
- package/dist/cjs/generate.js +23 -2
- package/dist/cjs/index.js +2 -1
- package/dist/cjs/mssql.js +22 -5
- package/dist/cjs/powdb.js +41 -1
- package/dist/cjs/powql.js +80 -25
- package/dist/cjs/query/aggregates.js +683 -0
- package/dist/cjs/query/batched-loader.js +2 -0
- package/dist/cjs/query/builder.js +297 -4504
- package/dist/cjs/query/filters.js +12 -0
- package/dist/cjs/query/relations.js +1698 -0
- package/dist/cjs/query/where-compile.js +180 -0
- package/dist/cjs/query/where.js +1491 -0
- package/dist/cjs/query/writes.js +680 -0
- package/dist/cjs/schema-builder.js +6 -0
- package/dist/cjs/schema-metadata.js +4 -0
- package/dist/cjs/schema-sql.js +265 -3
- package/dist/cjs/sqlite.js +1 -1
- package/dist/cli/index.d.ts +8 -2
- package/dist/cli/index.js +111 -18
- package/dist/cli/migrate.d.ts +24 -1
- package/dist/cli/migrate.js +77 -3
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +46 -13
- package/dist/cli/studio.js +331 -23
- package/dist/cli/ui.js +7 -1
- package/dist/dialect.d.ts +15 -6
- package/dist/dialect.js +1 -1
- package/dist/generate.js +23 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mssql.js +22 -5
- package/dist/powdb.d.ts +20 -0
- package/dist/powdb.js +40 -0
- package/dist/powql.d.ts +33 -1
- package/dist/powql.js +80 -25
- package/dist/query/aggregates.d.ts +74 -0
- package/dist/query/aggregates.js +641 -0
- package/dist/query/batched-loader.d.ts +6 -0
- package/dist/query/batched-loader.js +2 -0
- package/dist/query/builder.d.ts +62 -829
- package/dist/query/builder.js +302 -4509
- package/dist/query/deferred.d.ts +7 -0
- package/dist/query/filters.d.ts +7 -0
- package/dist/query/filters.js +11 -0
- package/dist/query/relations.d.ts +441 -0
- package/dist/query/relations.js +1627 -0
- package/dist/query/types.d.ts +15 -0
- package/dist/query/where-compile.d.ts +139 -0
- package/dist/query/where-compile.js +175 -0
- package/dist/query/where.d.ts +494 -0
- package/dist/query/where.js +1431 -0
- package/dist/query/writes.d.ts +131 -0
- package/dist/query/writes.js +626 -0
- package/dist/schema-builder.d.ts +18 -3
- package/dist/schema-builder.js +6 -0
- package/dist/schema-metadata.js +4 -0
- package/dist/schema-sql.d.ts +60 -3
- package/dist/schema-sql.js +261 -4
- package/dist/schema.d.ts +10 -0
- package/dist/sqlite.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turbine-orm: aggregate / groupBy compilation (extracted from builder.ts)
|
|
3
|
+
*
|
|
4
|
+
* buildAggregate + buildGroupBy and their helpers (HAVING clauses, groupBy
|
|
5
|
+
* ordering, DISTINCT-ON sources, JSON-path aggregate targets). All functions
|
|
6
|
+
* take a {@link BuilderCtx} first argument; WHERE compilation is reused from
|
|
7
|
+
* where.ts (via `whereMod`), and the shared orderBy / row-parse primitives
|
|
8
|
+
* stay class-resident, reached through the ctx. See builder.ts for the thin
|
|
9
|
+
* delegating methods (buildGroupBy / buildAggregate).
|
|
10
|
+
*/
|
|
11
|
+
import { UnsupportedFeatureError, ValidationError } from '../errors.js';
|
|
12
|
+
import { snakeToCamel } from '../schema.js';
|
|
13
|
+
import { isJsonPathOrderBy, isVectorOrderBy, normalizeOrderBy } from './filters.js';
|
|
14
|
+
import * as whereMod from './where.js';
|
|
15
|
+
export function buildGroupBy(qi, args) {
|
|
16
|
+
const meta = qi.schema.tables[qi.table];
|
|
17
|
+
if (meta) {
|
|
18
|
+
for (const key of args.by) {
|
|
19
|
+
if (typeof key === 'string' && !(key in meta.columnMap)) {
|
|
20
|
+
throw new ValidationError(`Unknown column "${key}" in groupBy for table "${qi.table}"`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
qi.currentSkip = args.skipGlobalFilters;
|
|
25
|
+
const gbWhere = whereMod.mergeGlobalFilter(qi, args.where);
|
|
26
|
+
const { sql: whereSql, params } = gbWhere
|
|
27
|
+
? whereMod.buildWhere(qi, gbWhere)
|
|
28
|
+
: { sql: '', params: [] };
|
|
29
|
+
// Row source. Plain: `"table"<WHERE>`. With `distinctOn` (PostgreSQL
|
|
30
|
+
// only), the groupBy runs over one representative row per column
|
|
31
|
+
// combination: the wrapper carries args.where INSIDE it (filter before
|
|
32
|
+
// picking) and is aliased as the table name so every outer expression is
|
|
33
|
+
// byte-identical either way.
|
|
34
|
+
const fromSql = args.distinctOn
|
|
35
|
+
? buildDistinctOnSource(qi, args.distinctOn, whereSql, params)
|
|
36
|
+
: `${qi.q(qi.table)}${whereSql}`;
|
|
37
|
+
// Group keys: plain columns and/or JSON-path keys. Output-name collisions
|
|
38
|
+
// are rejected up front — and the check runs over the EMITTED SQL output
|
|
39
|
+
// column names (snake_case column / JSON alias / `_agg_key` aggregate
|
|
40
|
+
// alias), not just the given arg keys: the driver keeps only the LAST
|
|
41
|
+
// duplicate field per row object, so a JSON alias equal to another key's
|
|
42
|
+
// snake_case column (or an aggregate output alias) would silently clobber
|
|
43
|
+
// that value in the results.
|
|
44
|
+
const groupExprs = [];
|
|
45
|
+
const selectExprs = [];
|
|
46
|
+
/** by entries in order: how to read each group key off the result row. */
|
|
47
|
+
const byReaders = [];
|
|
48
|
+
// ORDER BY registries: map each key the groupBy RESULT actually contains to
|
|
49
|
+
// the exact SELECT expression that produced it, so `orderBy` re-emits that
|
|
50
|
+
// expression (never a SELECT alias, since not every dialect accepts alias
|
|
51
|
+
// references in ORDER BY, and re-emitting mirrors HAVING's `jsonAggExprs`).
|
|
52
|
+
// `byOrderExprs`: plain by-field name / JSON group-key alias → column or
|
|
53
|
+
// extract expression. `aggOrderExprs`: `${aggKey}:${field}` → aggregate
|
|
54
|
+
// expression (including any already-bound JSON-path placeholder, reused
|
|
55
|
+
// exactly like HAVING since ORDER BY is appended after all other params).
|
|
56
|
+
const byOrderExprs = new Map();
|
|
57
|
+
const usedResultKeys = new Set();
|
|
58
|
+
const claimResultKey = (key, what) => {
|
|
59
|
+
if (key === '_count' || usedResultKeys.has(key)) {
|
|
60
|
+
throw new ValidationError(`[turbine] groupBy output name "${key}" (${what}) collides with another output column on table ` +
|
|
61
|
+
`"${qi.table}": set an explicit \`alias\` (or rename the aggregate key) to disambiguate.`);
|
|
62
|
+
}
|
|
63
|
+
usedResultKeys.add(key);
|
|
64
|
+
};
|
|
65
|
+
for (const entry of args.by) {
|
|
66
|
+
if (typeof entry === 'string') {
|
|
67
|
+
const col = qi.toColumn(entry);
|
|
68
|
+
claimResultKey(entry, `column "${col}"`);
|
|
69
|
+
// The emitted output column is the snake_case name; claim it too (when
|
|
70
|
+
// it differs from the result key) so a JSON alias like 'created_at'
|
|
71
|
+
// cannot silently shadow the 'createdAt' group key on the wire.
|
|
72
|
+
if (col !== entry)
|
|
73
|
+
claimResultKey(col, `column "${col}"`);
|
|
74
|
+
groupExprs.push(qi.q(col));
|
|
75
|
+
selectExprs.push(qi.q(col));
|
|
76
|
+
byReaders.push({ resultKey: entry, rowKey: col, raw: false });
|
|
77
|
+
byOrderExprs.set(entry, qi.q(col));
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
const col = resolveJsonPathTarget(qi, 'group key', entry.field, entry.path);
|
|
81
|
+
params.push(whereMod.jsonPathParam(qi, entry.path));
|
|
82
|
+
const extract = qi.dialect.buildJsonPathExtract(qi.q(col), qi.p(params.length));
|
|
83
|
+
const alias = entry.alias ?? String(entry.path[entry.path.length - 1]);
|
|
84
|
+
claimResultKey(alias, `JSON path on "${entry.field}"`);
|
|
85
|
+
// Same expression (and the same $n placeholder) in SELECT and GROUP BY.
|
|
86
|
+
selectExprs.push(`(${extract}) AS ${qi.q(alias)}`);
|
|
87
|
+
groupExprs.push(extract);
|
|
88
|
+
byReaders.push({ resultKey: alias, rowKey: alias, raw: true });
|
|
89
|
+
// ORDER BY by this JSON alias re-emits the extract expression (with its
|
|
90
|
+
// already-bound $n): the same reuse HAVING does for JSON aggregates.
|
|
91
|
+
byOrderExprs.set(alias, extract);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// _count
|
|
95
|
+
const countSelected = args._count === true || args._count === undefined;
|
|
96
|
+
if (countSelected) {
|
|
97
|
+
// default: always include count
|
|
98
|
+
selectExprs.push(`${qi.castAgg('COUNT(*)', 'int')} AS _count`);
|
|
99
|
+
}
|
|
100
|
+
// ORDER BY aggregate expressions, keyed `${aggKey}:${field}` (plus a bare
|
|
101
|
+
// `_count`). Populated alongside the SELECT list below so `orderBy` can only
|
|
102
|
+
// reference an aggregate that is actually requested. `COUNT(*)` (uncast) is
|
|
103
|
+
// the ordering expression (the SELECT cast is only for the returned value).
|
|
104
|
+
const aggOrderExprs = new Map();
|
|
105
|
+
if (countSelected)
|
|
106
|
+
aggOrderExprs.set('_count', 'COUNT(*)');
|
|
107
|
+
// _sum / _avg / _min / _max: `true` keeps the plain-column behavior; a
|
|
108
|
+
// {@link JsonPathAggregateTarget} aggregates a JSON path under the arg key
|
|
109
|
+
// as alias. `jsonAggFields` routes each JSON-aggregate row key back to its
|
|
110
|
+
// alias (and coercion kind) in the transform; `jsonAggExprs` lets HAVING
|
|
111
|
+
// reuse the exact aggregate expression (same placeholders) by alias.
|
|
112
|
+
const jsonAggFields = new Map();
|
|
113
|
+
const jsonAggExprs = new Map();
|
|
114
|
+
const buildAggregates = (aggKey, sqlFn, spec) => {
|
|
115
|
+
if (!spec)
|
|
116
|
+
return;
|
|
117
|
+
for (const [key, target] of Object.entries(spec)) {
|
|
118
|
+
if (!target)
|
|
119
|
+
continue;
|
|
120
|
+
if (target === true) {
|
|
121
|
+
const col = qi.toColumn(key);
|
|
122
|
+
// Aggregate output aliases share the same output-name namespace as
|
|
123
|
+
// the group keys: `_sum: { totalPrice: true, total_price: {json} }`
|
|
124
|
+
// would emit two "_sum_total_price" columns and silently drop one.
|
|
125
|
+
claimResultKey(`${aggKey}_${col}`, `${aggKey} of column "${col}"`);
|
|
126
|
+
const inner = `${sqlFn}(${qi.q(col)})`;
|
|
127
|
+
const expr = aggKey === '_avg' ? qi.castAgg(inner, 'float') : inner;
|
|
128
|
+
selectExprs.push(`${expr} AS ${qi.q(`${aggKey}_${col}`)}`);
|
|
129
|
+
aggOrderExprs.set(`${aggKey}:${key}`, expr);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const col = resolveJsonPathTarget(qi, `${aggKey} target "${key}"`, target.field, target.path);
|
|
133
|
+
const alwaysNumeric = aggKey === '_sum' || aggKey === '_avg';
|
|
134
|
+
if (alwaysNumeric && target.type === 'text') {
|
|
135
|
+
throw new ValidationError(`[turbine] groupBy ${aggKey} target "${key}" on table "${qi.table}": ` +
|
|
136
|
+
`${aggKey} over a JSON path is always numeric: remove \`type: 'text'\`.`);
|
|
137
|
+
}
|
|
138
|
+
const numeric = alwaysNumeric || target.type === 'numeric';
|
|
139
|
+
claimResultKey(`${aggKey}_${key}`, `${aggKey} JSON target "${key}"`);
|
|
140
|
+
params.push(whereMod.jsonPathParam(qi, target.path));
|
|
141
|
+
const extract = qi.dialect.buildJsonPathExtract(qi.q(col), qi.p(params.length));
|
|
142
|
+
const inner = `${sqlFn}(${numeric ? whereMod.castJsonNumeric(qi, extract) : extract})`;
|
|
143
|
+
const expr = aggKey === '_avg' ? qi.castAgg(inner, 'float') : inner;
|
|
144
|
+
selectExprs.push(`${expr} AS ${qi.q(`${aggKey}_${key}`)}`);
|
|
145
|
+
jsonAggFields.set(`${aggKey}_${key}`, { field: key, numeric });
|
|
146
|
+
jsonAggExprs.set(`${key}:${aggKey}`, expr);
|
|
147
|
+
aggOrderExprs.set(`${aggKey}:${key}`, expr);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
buildAggregates('_sum', 'SUM', args._sum);
|
|
151
|
+
buildAggregates('_avg', 'AVG', args._avg);
|
|
152
|
+
buildAggregates('_min', 'MIN', args._min);
|
|
153
|
+
buildAggregates('_max', 'MAX', args._max);
|
|
154
|
+
let sql = `SELECT ${selectExprs.join(', ')} FROM ${fromSql} GROUP BY ${groupExprs.join(', ')}`;
|
|
155
|
+
// HAVING — filter whole groups by their aggregate values.
|
|
156
|
+
// Appends to the same `params` array, so placeholders continue from the
|
|
157
|
+
// WHERE clause's parameter positions (qi.p(params.length) below).
|
|
158
|
+
if (args.having) {
|
|
159
|
+
const havingClauses = buildHavingClauses(qi, args.having, params, jsonAggExprs);
|
|
160
|
+
if (havingClauses.length > 0) {
|
|
161
|
+
sql += ` HAVING ${havingClauses.join(' AND ')}`;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// ORDER BY, over the groupBy RESULT columns (by-fields, JSON aliases, and
|
|
165
|
+
// requested aggregates), not the table's physical columns.
|
|
166
|
+
if (args.orderBy) {
|
|
167
|
+
const orderSql = buildGroupByOrderBy(qi, args.orderBy, byOrderExprs, aggOrderExprs);
|
|
168
|
+
if (orderSql)
|
|
169
|
+
sql += ` ORDER BY ${orderSql}`;
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
sql,
|
|
173
|
+
params,
|
|
174
|
+
transform: (result) => result.rows.map((row) => {
|
|
175
|
+
const parsed = qi.parseRow(row, qi.table);
|
|
176
|
+
// Restructure aggregate results into nested objects (Prisma-style)
|
|
177
|
+
const restructured = {};
|
|
178
|
+
// Copy group-by fields. JSON-path keys read their alias off the raw
|
|
179
|
+
// row (the alias is not a table column, so parseRow's snake→camel
|
|
180
|
+
// mapping must not touch it).
|
|
181
|
+
for (const reader of byReaders) {
|
|
182
|
+
restructured[reader.resultKey] = reader.raw ? row[reader.rowKey] : parsed[reader.resultKey];
|
|
183
|
+
}
|
|
184
|
+
// _count
|
|
185
|
+
if ('_count' in row) {
|
|
186
|
+
restructured._count = row._count;
|
|
187
|
+
}
|
|
188
|
+
else if ('count' in row) {
|
|
189
|
+
restructured._count = row.count;
|
|
190
|
+
}
|
|
191
|
+
// Collect aggregates into nested objects
|
|
192
|
+
const sumObj = {};
|
|
193
|
+
const avgObj = {};
|
|
194
|
+
const minObj = {};
|
|
195
|
+
const maxObj = {};
|
|
196
|
+
let hasSums = false, hasAvgs = false, hasMins = false, hasMaxs = false;
|
|
197
|
+
// JSON-path aggregates keep their arg key verbatim; plain-column
|
|
198
|
+
// aggregates keep the snake→camel field mapping.
|
|
199
|
+
const jsonAgg = (rawKey) => jsonAggFields.get(rawKey);
|
|
200
|
+
const fieldFor = (rawKey, col) => jsonAgg(rawKey)?.field ?? qi.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
|
|
201
|
+
for (const [rawKey, rawValue] of Object.entries(row)) {
|
|
202
|
+
if (rawKey.startsWith('_sum_')) {
|
|
203
|
+
sumObj[fieldFor(rawKey, rawKey.slice(5))] = rawValue !== null ? Number(rawValue) : null;
|
|
204
|
+
hasSums = true;
|
|
205
|
+
}
|
|
206
|
+
else if (rawKey.startsWith('_avg_')) {
|
|
207
|
+
avgObj[fieldFor(rawKey, rawKey.slice(5))] = rawValue !== null ? Number(rawValue) : null;
|
|
208
|
+
hasAvgs = true;
|
|
209
|
+
}
|
|
210
|
+
else if (rawKey.startsWith('_min_')) {
|
|
211
|
+
const j = jsonAgg(rawKey);
|
|
212
|
+
minObj[fieldFor(rawKey, rawKey.slice(5))] = j?.numeric && rawValue !== null ? Number(rawValue) : rawValue;
|
|
213
|
+
hasMins = true;
|
|
214
|
+
}
|
|
215
|
+
else if (rawKey.startsWith('_max_')) {
|
|
216
|
+
const j = jsonAgg(rawKey);
|
|
217
|
+
maxObj[fieldFor(rawKey, rawKey.slice(5))] = j?.numeric && rawValue !== null ? Number(rawValue) : rawValue;
|
|
218
|
+
hasMaxs = true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (hasSums)
|
|
222
|
+
restructured._sum = sumObj;
|
|
223
|
+
if (hasAvgs)
|
|
224
|
+
restructured._avg = avgObj;
|
|
225
|
+
if (hasMins)
|
|
226
|
+
restructured._min = minObj;
|
|
227
|
+
if (hasMaxs)
|
|
228
|
+
restructured._max = maxObj;
|
|
229
|
+
return restructured;
|
|
230
|
+
}),
|
|
231
|
+
tag: `${qi.table}.groupBy`,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Compile a groupBy `orderBy` into an ORDER BY body. Unlike findMany ORDER BY
|
|
236
|
+
* ({@link buildOrderBy}, which validates keys against the table's physical
|
|
237
|
+
* columns), groupBy ordering targets the columns the RESULT actually
|
|
238
|
+
* contains: plain by-fields, JSON group-key aliases, and requested aggregates
|
|
239
|
+
* (`_count` / `_sum` / `_avg` / `_min` / `_max`). Each key re-emits the exact
|
|
240
|
+
* SELECT expression that produced it (`byOrderExprs` / `aggOrderExprs`),
|
|
241
|
+
* mirroring how HAVING re-emits aggregate expressions, so no dialect ever has
|
|
242
|
+
* to accept a SELECT-alias reference in ORDER BY, and any already-bound
|
|
243
|
+
* JSON-path placeholder is reused verbatim (ORDER BY is the last clause, so
|
|
244
|
+
* no `$n` renumbering). An aggregate key that was not requested, or an unknown
|
|
245
|
+
* by-key, throws {@link ValidationError} E003 listing the valid keys.
|
|
246
|
+
*/
|
|
247
|
+
export function buildGroupByOrderBy(qi, orderBy, byOrderExprs, aggOrderExprs) {
|
|
248
|
+
const aggBlocks = new Set(['_count', '_sum', '_avg', '_min', '_max']);
|
|
249
|
+
/** Human-readable list of every key this call can order by (for E003). */
|
|
250
|
+
const validKeys = () => {
|
|
251
|
+
const keys = [...byOrderExprs.keys()];
|
|
252
|
+
for (const k of aggOrderExprs.keys()) {
|
|
253
|
+
keys.push(k.includes(':') ? k.replace(':', '.') : k);
|
|
254
|
+
}
|
|
255
|
+
return keys.join(', ') || '(none)';
|
|
256
|
+
};
|
|
257
|
+
const parts = [];
|
|
258
|
+
for (const [key, value] of Object.entries(orderBy)) {
|
|
259
|
+
if (value === undefined)
|
|
260
|
+
continue;
|
|
261
|
+
// Aggregate ordering blocks.
|
|
262
|
+
if (aggBlocks.has(key)) {
|
|
263
|
+
if (key === '_count') {
|
|
264
|
+
const expr = aggOrderExprs.get('_count');
|
|
265
|
+
if (!expr) {
|
|
266
|
+
throw new ValidationError(`[turbine] Cannot order groupBy by "_count" on table "${qi.table}": _count is not selected. ` +
|
|
267
|
+
`Orderable keys: ${validKeys()}.`);
|
|
268
|
+
}
|
|
269
|
+
const { dir, nulls } = normalizeOrderBy(value);
|
|
270
|
+
parts.push(`${expr} ${dir}${qi.nullsSuffix(nulls)}`);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
// `_sum` / `_avg` / `_min` / `_max`: an object of field → direction/spec.
|
|
274
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
275
|
+
throw new ValidationError(`[turbine] Invalid groupBy orderBy for "${key}" on table "${qi.table}": ` +
|
|
276
|
+
`expected a field map like { ${key}: { amount: 'desc' } }.`);
|
|
277
|
+
}
|
|
278
|
+
for (const [field, dirSpec] of Object.entries(value)) {
|
|
279
|
+
if (dirSpec === undefined)
|
|
280
|
+
continue;
|
|
281
|
+
const expr = aggOrderExprs.get(`${key}:${field}`);
|
|
282
|
+
if (!expr) {
|
|
283
|
+
throw new ValidationError(`[turbine] Cannot order groupBy by "${key}.${field}" on table "${qi.table}": ` +
|
|
284
|
+
`that aggregate is not requested in this call. Orderable keys: ${validKeys()}.`);
|
|
285
|
+
}
|
|
286
|
+
const { dir, nulls } = normalizeOrderBy(dirSpec);
|
|
287
|
+
parts.push(`${expr} ${dir}${qi.nullsSuffix(nulls)}`);
|
|
288
|
+
}
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
// Plain by-field name or JSON group-key alias.
|
|
292
|
+
const expr = byOrderExprs.get(key);
|
|
293
|
+
if (!expr) {
|
|
294
|
+
throw new ValidationError(`[turbine] Unknown field "${key}" in groupBy orderBy on table "${qi.table}". ` +
|
|
295
|
+
`Orderable keys: ${validKeys()}.`);
|
|
296
|
+
}
|
|
297
|
+
const { dir, nulls } = normalizeOrderBy(value);
|
|
298
|
+
parts.push(`${expr} ${dir}${qi.nullsSuffix(nulls)}`);
|
|
299
|
+
}
|
|
300
|
+
return parts.join(', ');
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Validate a JSON-path target (group key or aggregate target) in groupBy:
|
|
304
|
+
* the field must resolve to a real json/jsonb column and the path must be a
|
|
305
|
+
* non-empty array of keys/indexes. Returns the resolved snake_case column.
|
|
306
|
+
*/
|
|
307
|
+
export function resolveJsonPathTarget(qi, context, field, path) {
|
|
308
|
+
if (typeof field !== 'string') {
|
|
309
|
+
throw new ValidationError(`[turbine] groupBy ${context} on table "${qi.table}" requires a string \`field\`.`);
|
|
310
|
+
}
|
|
311
|
+
const col = qi.toColumn(field);
|
|
312
|
+
if (!Array.isArray(path) ||
|
|
313
|
+
path.length === 0 ||
|
|
314
|
+
path.some((el) => typeof el !== 'string' && !(typeof el === 'number' && Number.isFinite(el)))) {
|
|
315
|
+
throw new ValidationError(`[turbine] groupBy ${context} on "${field}" (table "${qi.table}") requires a non-empty \`path\` ` +
|
|
316
|
+
`array of keys/indexes (e.g. { field: '${field}', path: ['category'] }).`);
|
|
317
|
+
}
|
|
318
|
+
const colType = whereMod.pgTypeForColumn(qi, qi.tableMeta, col);
|
|
319
|
+
if (!whereMod.isJsonColumnType(qi, colType)) {
|
|
320
|
+
throw new ValidationError(`[turbine] groupBy ${context} on "${field}": column "${col}" on table "${qi.table}" is not a JSON ` +
|
|
321
|
+
`column (actual type: ${colType}).`);
|
|
322
|
+
}
|
|
323
|
+
return col;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Build the `distinctOn` row source for groupBy (PostgreSQL only: other
|
|
327
|
+
* engines throw {@link UnsupportedFeatureError} E017):
|
|
328
|
+
*
|
|
329
|
+
* ```sql
|
|
330
|
+
* (SELECT DISTINCT ON ("c1") * FROM "table"<WHERE> ORDER BY "c1", <orderBy>) AS "table"
|
|
331
|
+
* ```
|
|
332
|
+
*
|
|
333
|
+
* The wrapper is aliased as the table name so every outer expression (group
|
|
334
|
+
* keys, aggregates, HAVING, ORDER BY) is byte-identical to the plain path.
|
|
335
|
+
* `distinctOn.orderBy` is required (it decides which row survives) and
|
|
336
|
+
* supports plain columns, {@link OrderBySpec} nulls, and JSON-path specs;
|
|
337
|
+
* JSON paths push their text[] param here, after the WHERE params.
|
|
338
|
+
*/
|
|
339
|
+
export function buildDistinctOnSource(qi, distinctOn, whereSql, params) {
|
|
340
|
+
if (qi.dialect.name !== 'postgresql') {
|
|
341
|
+
throw new UnsupportedFeatureError('DISTINCT ON row source (groupBy distinctOn)', qi.dialect.name, 'groupBy({ distinctOn }) requires PostgreSQL: SELECT DISTINCT ON is not portable.');
|
|
342
|
+
}
|
|
343
|
+
if (!Array.isArray(distinctOn.columns) || distinctOn.columns.length === 0) {
|
|
344
|
+
throw new ValidationError(`[turbine] groupBy distinctOn on table "${qi.table}" requires a non-empty \`columns\` array.`);
|
|
345
|
+
}
|
|
346
|
+
const orderEntries = Object.entries(distinctOn.orderBy ?? {});
|
|
347
|
+
if (orderEntries.length === 0) {
|
|
348
|
+
throw new ValidationError(`[turbine] groupBy distinctOn on table "${qi.table}" requires \`orderBy\` to pick ONE row per ` +
|
|
349
|
+
"column combination deterministically (e.g. orderBy: { createdAt: 'desc' }).");
|
|
350
|
+
}
|
|
351
|
+
const distinctCols = distinctOn.columns.map((c) => qi.q(qi.toColumn(c)));
|
|
352
|
+
// DISTINCT ON expressions must lead the ORDER BY; the user's orderBy then
|
|
353
|
+
// decides which row survives per combination.
|
|
354
|
+
const orderParts = [...distinctCols];
|
|
355
|
+
for (const [key, value] of orderEntries) {
|
|
356
|
+
if (isJsonPathOrderBy(value)) {
|
|
357
|
+
orderParts.push(qi.buildJsonPathOrderEntry(qi.table, qi.tableMeta, key, value, '', params));
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (isVectorOrderBy(value) || qi.isRelationOrderByValue(value)) {
|
|
361
|
+
throw new ValidationError(`[turbine] groupBy distinctOn.orderBy on "${key}" (table "${qi.table}") supports plain columns, ` +
|
|
362
|
+
'sort specs, and JSON-path orderings only.');
|
|
363
|
+
}
|
|
364
|
+
const col = qi.resolveOrderByColumn(qi.table, qi.tableMeta, key);
|
|
365
|
+
const { dir, nulls } = normalizeOrderBy(value);
|
|
366
|
+
orderParts.push(`${qi.q(col)} ${dir}${qi.nullsSuffix(nulls)}`);
|
|
367
|
+
}
|
|
368
|
+
return (`(SELECT DISTINCT ON (${distinctCols.join(', ')}) * FROM ${qi.q(qi.table)}${whereSql} ` +
|
|
369
|
+
`ORDER BY ${orderParts.join(', ')}) AS ${qi.q(qi.table)}`);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Build the SQL fragments for a {@link HavingClause}.
|
|
373
|
+
*
|
|
374
|
+
* Each aggregate expression (`COUNT(*)`, `SUM("col")`, etc.) is constructed
|
|
375
|
+
* from a **schema-validated, quoted** column identifier: `qi.toColumn()`
|
|
376
|
+
* throws {@link ValidationError} for unknown fields and `qi.q()` quotes via
|
|
377
|
+
* the dialect, so no unvalidated identifier ever reaches the SQL string. Every
|
|
378
|
+
* comparison value is pushed onto the shared `params` array and referenced by
|
|
379
|
+
* a `$N` placeholder via {@link buildHavingNumericClauses} — there is no string
|
|
380
|
+
* interpolation of user values.
|
|
381
|
+
*
|
|
382
|
+
* `jsonAggExprs` (from {@link buildGroupBy}) maps `alias:aggKey` to the
|
|
383
|
+
* exact aggregate expression a JSON-path aggregate emitted in SELECT
|
|
384
|
+
* (including its already-bound path placeholder), so HAVING on a JSON-path
|
|
385
|
+
* aggregate alias reuses the same expression instead of resolving the alias
|
|
386
|
+
* as a column.
|
|
387
|
+
*/
|
|
388
|
+
export function buildHavingClauses(qi, having, params, jsonAggExprs) {
|
|
389
|
+
const clauses = [];
|
|
390
|
+
// Maps the per-field aggregate key to its SQL function name. The set of
|
|
391
|
+
// allowed keys is fixed here — any other key on a field's filter object is
|
|
392
|
+
// rejected by ValidationError below (never interpolated).
|
|
393
|
+
const aggFnByKey = {
|
|
394
|
+
_sum: 'SUM',
|
|
395
|
+
_avg: 'AVG',
|
|
396
|
+
_min: 'MIN',
|
|
397
|
+
_max: 'MAX',
|
|
398
|
+
_count: 'COUNT',
|
|
399
|
+
};
|
|
400
|
+
for (const [key, value] of Object.entries(having)) {
|
|
401
|
+
if (value === undefined)
|
|
402
|
+
continue;
|
|
403
|
+
// Top-level `_count` (no field) → COUNT(*) for the whole group.
|
|
404
|
+
if (key === '_count') {
|
|
405
|
+
clauses.push(...buildHavingNumericClauses(qi, 'COUNT(*)', value, params));
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
// Otherwise `key` is a field name mapping to a per-aggregate filter object.
|
|
409
|
+
if (typeof value !== 'object' || value === null) {
|
|
410
|
+
throw new ValidationError(`[turbine] Invalid having filter for field "${key}" on table "${qi.table}": ` +
|
|
411
|
+
`expected an aggregate object like { _sum: { gt: 100 } }.`);
|
|
412
|
+
}
|
|
413
|
+
// toColumn validates the field against schema metadata (throws
|
|
414
|
+
// ValidationError on unknown columns) and q() quotes the identifier — no
|
|
415
|
+
// unvalidated identifier ever reaches the SQL string. Resolution is lazy:
|
|
416
|
+
// a JSON-path aggregate alias is not a column, so it must not hit
|
|
417
|
+
// toColumn when every aggregate under it resolves via `jsonAggExprs`.
|
|
418
|
+
let quotedCol = null;
|
|
419
|
+
const columnExpr = () => {
|
|
420
|
+
quotedCol ??= qi.q(qi.toColumn(key));
|
|
421
|
+
return quotedCol;
|
|
422
|
+
};
|
|
423
|
+
for (const [aggKey, filter] of Object.entries(value)) {
|
|
424
|
+
if (filter === undefined)
|
|
425
|
+
continue;
|
|
426
|
+
const fn = aggFnByKey[aggKey];
|
|
427
|
+
if (!fn) {
|
|
428
|
+
throw new ValidationError(`[turbine] Unknown aggregate "${aggKey}" in having for field "${key}" on table "${qi.table}". ` +
|
|
429
|
+
`Supported: ${Object.keys(aggFnByKey).join(', ')}.`);
|
|
430
|
+
}
|
|
431
|
+
const expr = jsonAggExprs?.get(`${key}:${aggKey}`) ?? `${fn}(${columnExpr()})`;
|
|
432
|
+
clauses.push(...buildHavingNumericClauses(qi, expr, filter, params));
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return clauses;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Convert a single having filter into one or more parameterized SQL
|
|
439
|
+
* comparisons against the given aggregate expression. A bare number is
|
|
440
|
+
* shorthand for equality. Unknown operator keys throw {@link ValidationError}.
|
|
441
|
+
*/
|
|
442
|
+
export function buildHavingNumericClauses(qi, expr, filter, params) {
|
|
443
|
+
// Bare number → equality.
|
|
444
|
+
if (typeof filter === 'number') {
|
|
445
|
+
params.push(filter);
|
|
446
|
+
return [`${expr} = ${qi.p(params.length)}`];
|
|
447
|
+
}
|
|
448
|
+
if (typeof filter !== 'object' || filter === null) {
|
|
449
|
+
throw new ValidationError(`[turbine] Invalid having filter on "${expr}" for table "${qi.table}": expected a number or operator object.`);
|
|
450
|
+
}
|
|
451
|
+
const op = filter;
|
|
452
|
+
const allowedKeys = new Set(['equals', 'not', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn']);
|
|
453
|
+
for (const k of Object.keys(op)) {
|
|
454
|
+
if (!allowedKeys.has(k)) {
|
|
455
|
+
throw new ValidationError(`[turbine] Unknown having operator "${k}" on "${expr}" for table "${qi.table}". ` +
|
|
456
|
+
`Supported: ${[...allowedKeys].join(', ')}.`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const clauses = [];
|
|
460
|
+
if (op.equals !== undefined) {
|
|
461
|
+
params.push(op.equals);
|
|
462
|
+
clauses.push(`${expr} = ${qi.p(params.length)}`);
|
|
463
|
+
}
|
|
464
|
+
if (op.not !== undefined) {
|
|
465
|
+
params.push(op.not);
|
|
466
|
+
clauses.push(`${expr} != ${qi.p(params.length)}`);
|
|
467
|
+
}
|
|
468
|
+
if (op.gt !== undefined) {
|
|
469
|
+
params.push(op.gt);
|
|
470
|
+
clauses.push(`${expr} > ${qi.p(params.length)}`);
|
|
471
|
+
}
|
|
472
|
+
if (op.gte !== undefined) {
|
|
473
|
+
params.push(op.gte);
|
|
474
|
+
clauses.push(`${expr} >= ${qi.p(params.length)}`);
|
|
475
|
+
}
|
|
476
|
+
if (op.lt !== undefined) {
|
|
477
|
+
params.push(op.lt);
|
|
478
|
+
clauses.push(`${expr} < ${qi.p(params.length)}`);
|
|
479
|
+
}
|
|
480
|
+
if (op.lte !== undefined) {
|
|
481
|
+
params.push(op.lte);
|
|
482
|
+
clauses.push(`${expr} <= ${qi.p(params.length)}`);
|
|
483
|
+
}
|
|
484
|
+
if (op.in !== undefined) {
|
|
485
|
+
params.push(qi.inParam(op.in));
|
|
486
|
+
clauses.push(qi.inClause(expr, qi.p(params.length), false));
|
|
487
|
+
}
|
|
488
|
+
if (op.notIn !== undefined) {
|
|
489
|
+
params.push(qi.inParam(op.notIn));
|
|
490
|
+
clauses.push(qi.inClause(expr, qi.p(params.length), true));
|
|
491
|
+
}
|
|
492
|
+
return clauses;
|
|
493
|
+
}
|
|
494
|
+
export function buildAggregate(qi, args) {
|
|
495
|
+
qi.currentSkip = args.skipGlobalFilters;
|
|
496
|
+
const aggWhere = whereMod.mergeGlobalFilter(qi, args.where);
|
|
497
|
+
const { sql: whereSql, params } = aggWhere
|
|
498
|
+
? whereMod.buildWhere(qi, aggWhere)
|
|
499
|
+
: { sql: '', params: [] };
|
|
500
|
+
const meta = qi.schema.tables[qi.table];
|
|
501
|
+
if (meta) {
|
|
502
|
+
for (const group of [args._sum, args._avg, args._min, args._max]) {
|
|
503
|
+
if (group && typeof group === 'object') {
|
|
504
|
+
for (const key of Object.keys(group)) {
|
|
505
|
+
if (!(key in meta.columnMap)) {
|
|
506
|
+
throw new ValidationError(`Unknown column "${key}" in aggregate for table "${qi.table}"`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
if (args._count && typeof args._count === 'object') {
|
|
512
|
+
for (const key of Object.keys(args._count)) {
|
|
513
|
+
if (!(key in meta.columnMap)) {
|
|
514
|
+
throw new ValidationError(`Unknown column "${key}" in aggregate for table "${qi.table}"`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
const selectExprs = [];
|
|
520
|
+
// _count
|
|
521
|
+
if (args._count === true) {
|
|
522
|
+
selectExprs.push(`${qi.castAgg('COUNT(*)', 'int')} AS _count`);
|
|
523
|
+
}
|
|
524
|
+
else if (args._count && typeof args._count === 'object') {
|
|
525
|
+
for (const [field, enabled] of Object.entries(args._count)) {
|
|
526
|
+
if (enabled) {
|
|
527
|
+
const col = qi.toColumn(field);
|
|
528
|
+
selectExprs.push(`${qi.castAgg(`COUNT(${qi.q(col)})`, 'int')} AS ${qi.q(`_count_${col}`)}`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
// _sum
|
|
533
|
+
if (args._sum) {
|
|
534
|
+
for (const [field, enabled] of Object.entries(args._sum)) {
|
|
535
|
+
if (enabled) {
|
|
536
|
+
const col = qi.toColumn(field);
|
|
537
|
+
selectExprs.push(`SUM(${qi.q(col)}) AS ${qi.q(`_sum_${col}`)}`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
// _avg
|
|
542
|
+
if (args._avg) {
|
|
543
|
+
for (const [field, enabled] of Object.entries(args._avg)) {
|
|
544
|
+
if (enabled) {
|
|
545
|
+
const col = qi.toColumn(field);
|
|
546
|
+
selectExprs.push(`${qi.castAgg(`AVG(${qi.q(col)})`, 'float')} AS ${qi.q(`_avg_${col}`)}`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
// _min
|
|
551
|
+
if (args._min) {
|
|
552
|
+
for (const [field, enabled] of Object.entries(args._min)) {
|
|
553
|
+
if (enabled) {
|
|
554
|
+
const col = qi.toColumn(field);
|
|
555
|
+
selectExprs.push(`MIN(${qi.q(col)}) AS ${qi.q(`_min_${col}`)}`);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
// _max
|
|
560
|
+
if (args._max) {
|
|
561
|
+
for (const [field, enabled] of Object.entries(args._max)) {
|
|
562
|
+
if (enabled) {
|
|
563
|
+
const col = qi.toColumn(field);
|
|
564
|
+
selectExprs.push(`MAX(${qi.q(col)}) AS ${qi.q(`_max_${col}`)}`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
if (selectExprs.length === 0) {
|
|
569
|
+
selectExprs.push(`${qi.castAgg('COUNT(*)', 'int')} AS _count`);
|
|
570
|
+
}
|
|
571
|
+
const sql = `SELECT ${selectExprs.join(', ')} FROM ${qi.q(qi.table)}${whereSql}`;
|
|
572
|
+
return {
|
|
573
|
+
sql,
|
|
574
|
+
params,
|
|
575
|
+
transform: (result) => {
|
|
576
|
+
const row = result.rows[0];
|
|
577
|
+
const aggResult = {};
|
|
578
|
+
// _count
|
|
579
|
+
if (row._count !== undefined) {
|
|
580
|
+
aggResult._count = row._count;
|
|
581
|
+
}
|
|
582
|
+
else {
|
|
583
|
+
// Check for per-column counts
|
|
584
|
+
const countObj = {};
|
|
585
|
+
let hasCountFields = false;
|
|
586
|
+
for (const [key, val] of Object.entries(row)) {
|
|
587
|
+
if (key.startsWith('_count_')) {
|
|
588
|
+
const col = key.slice(7);
|
|
589
|
+
const field = qi.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
|
|
590
|
+
countObj[field] = val;
|
|
591
|
+
hasCountFields = true;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (hasCountFields)
|
|
595
|
+
aggResult._count = countObj;
|
|
596
|
+
}
|
|
597
|
+
// Build nested aggregate objects
|
|
598
|
+
const sumObj = {};
|
|
599
|
+
const avgObj = {};
|
|
600
|
+
const minObj = {};
|
|
601
|
+
const maxObj = {};
|
|
602
|
+
let hasSums = false, hasAvgs = false, hasMins = false, hasMaxs = false;
|
|
603
|
+
for (const [key, val] of Object.entries(row)) {
|
|
604
|
+
if (key.startsWith('_sum_')) {
|
|
605
|
+
const col = key.slice(5);
|
|
606
|
+
const field = qi.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
|
|
607
|
+
sumObj[field] = val !== null ? Number(val) : null;
|
|
608
|
+
hasSums = true;
|
|
609
|
+
}
|
|
610
|
+
else if (key.startsWith('_avg_')) {
|
|
611
|
+
const col = key.slice(5);
|
|
612
|
+
const field = qi.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
|
|
613
|
+
avgObj[field] = val !== null ? Number(val) : null;
|
|
614
|
+
hasAvgs = true;
|
|
615
|
+
}
|
|
616
|
+
else if (key.startsWith('_min_')) {
|
|
617
|
+
const col = key.slice(5);
|
|
618
|
+
const field = qi.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
|
|
619
|
+
minObj[field] = val;
|
|
620
|
+
hasMins = true;
|
|
621
|
+
}
|
|
622
|
+
else if (key.startsWith('_max_')) {
|
|
623
|
+
const col = key.slice(5);
|
|
624
|
+
const field = qi.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
|
|
625
|
+
maxObj[field] = val;
|
|
626
|
+
hasMaxs = true;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
if (hasSums)
|
|
630
|
+
aggResult._sum = sumObj;
|
|
631
|
+
if (hasAvgs)
|
|
632
|
+
aggResult._avg = avgObj;
|
|
633
|
+
if (hasMins)
|
|
634
|
+
aggResult._min = minObj;
|
|
635
|
+
if (hasMaxs)
|
|
636
|
+
aggResult._max = maxObj;
|
|
637
|
+
return aggResult;
|
|
638
|
+
},
|
|
639
|
+
tag: `${qi.table}.aggregate`,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
@@ -94,6 +94,12 @@ export interface RelationLoadContext {
|
|
|
94
94
|
* global filter exactly as the join strategy would.
|
|
95
95
|
*/
|
|
96
96
|
skipGlobalFilters?: SkipGlobalFilters;
|
|
97
|
+
/**
|
|
98
|
+
* The query's `includePii` opt-in, threaded onto every child `buildFindMany`
|
|
99
|
+
* so a batched relation load excludes (or includes) PII-tagged columns exactly
|
|
100
|
+
* as the join strategy does at every nested level. Default `false`.
|
|
101
|
+
*/
|
|
102
|
+
includePii?: boolean;
|
|
97
103
|
/**
|
|
98
104
|
* Render `table`'s global filter against `alias` for a raw follow-up query
|
|
99
105
|
* (the batched `_count`), numbering its `$n` placeholders AFTER
|
|
@@ -287,6 +287,7 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
|
|
|
287
287
|
omit: proj.omit,
|
|
288
288
|
orderBy: options.orderBy,
|
|
289
289
|
skipGlobalFilters: ctx.skipGlobalFilters,
|
|
290
|
+
includePii: ctx.includePii,
|
|
290
291
|
});
|
|
291
292
|
const result = await ctx.exec(deferred.sql, deferred.params, deferred.preparedName);
|
|
292
293
|
return deferred.transform(result);
|
|
@@ -386,6 +387,7 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
|
|
|
386
387
|
omit: proj.omit,
|
|
387
388
|
orderBy: options.orderBy,
|
|
388
389
|
skipGlobalFilters: ctx.skipGlobalFilters,
|
|
390
|
+
includePii: ctx.includePii,
|
|
389
391
|
});
|
|
390
392
|
const result = await ctx.exec(deferred.sql, deferred.params, deferred.preparedName);
|
|
391
393
|
return deferred.transform(result);
|