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.
Files changed (68) hide show
  1. package/README.md +18 -16
  2. package/dist/cjs/cli/index.js +109 -16
  3. package/dist/cjs/cli/migrate.js +78 -3
  4. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  5. package/dist/cjs/cli/studio.js +333 -22
  6. package/dist/cjs/cli/ui.js +7 -1
  7. package/dist/cjs/dialect.js +1 -1
  8. package/dist/cjs/generate.js +23 -2
  9. package/dist/cjs/index.js +2 -1
  10. package/dist/cjs/mssql.js +22 -5
  11. package/dist/cjs/powdb.js +41 -1
  12. package/dist/cjs/powql.js +80 -25
  13. package/dist/cjs/query/aggregates.js +683 -0
  14. package/dist/cjs/query/batched-loader.js +2 -0
  15. package/dist/cjs/query/builder.js +297 -4504
  16. package/dist/cjs/query/filters.js +12 -0
  17. package/dist/cjs/query/relations.js +1698 -0
  18. package/dist/cjs/query/where-compile.js +180 -0
  19. package/dist/cjs/query/where.js +1491 -0
  20. package/dist/cjs/query/writes.js +680 -0
  21. package/dist/cjs/schema-builder.js +6 -0
  22. package/dist/cjs/schema-metadata.js +4 -0
  23. package/dist/cjs/schema-sql.js +265 -3
  24. package/dist/cjs/sqlite.js +1 -1
  25. package/dist/cli/index.d.ts +8 -2
  26. package/dist/cli/index.js +111 -18
  27. package/dist/cli/migrate.d.ts +24 -1
  28. package/dist/cli/migrate.js +77 -3
  29. package/dist/cli/studio-ui.generated.js +1 -1
  30. package/dist/cli/studio.d.ts +46 -13
  31. package/dist/cli/studio.js +331 -23
  32. package/dist/cli/ui.js +7 -1
  33. package/dist/dialect.d.ts +15 -6
  34. package/dist/dialect.js +1 -1
  35. package/dist/generate.js +23 -2
  36. package/dist/index.d.ts +1 -1
  37. package/dist/index.js +1 -1
  38. package/dist/mssql.js +22 -5
  39. package/dist/powdb.d.ts +20 -0
  40. package/dist/powdb.js +40 -0
  41. package/dist/powql.d.ts +33 -1
  42. package/dist/powql.js +80 -25
  43. package/dist/query/aggregates.d.ts +74 -0
  44. package/dist/query/aggregates.js +641 -0
  45. package/dist/query/batched-loader.d.ts +6 -0
  46. package/dist/query/batched-loader.js +2 -0
  47. package/dist/query/builder.d.ts +62 -829
  48. package/dist/query/builder.js +302 -4509
  49. package/dist/query/deferred.d.ts +7 -0
  50. package/dist/query/filters.d.ts +7 -0
  51. package/dist/query/filters.js +11 -0
  52. package/dist/query/relations.d.ts +441 -0
  53. package/dist/query/relations.js +1627 -0
  54. package/dist/query/types.d.ts +15 -0
  55. package/dist/query/where-compile.d.ts +139 -0
  56. package/dist/query/where-compile.js +175 -0
  57. package/dist/query/where.d.ts +494 -0
  58. package/dist/query/where.js +1431 -0
  59. package/dist/query/writes.d.ts +131 -0
  60. package/dist/query/writes.js +626 -0
  61. package/dist/schema-builder.d.ts +18 -3
  62. package/dist/schema-builder.js +6 -0
  63. package/dist/schema-metadata.js +4 -0
  64. package/dist/schema-sql.d.ts +60 -3
  65. package/dist/schema-sql.js +261 -4
  66. package/dist/schema.d.ts +10 -0
  67. package/dist/sqlite.js +1 -1
  68. 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);