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