@m6d/cortex-cli 0.1.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 (61) hide show
  1. package/README.md +175 -0
  2. package/bin/cortex.js +52 -0
  3. package/package.json +41 -0
  4. package/src/cc/client.ts +295 -0
  5. package/src/cc/flags.ts +135 -0
  6. package/src/cc/output.ts +77 -0
  7. package/src/cc/profiles.ts +105 -0
  8. package/src/cli.ts +56 -0
  9. package/src/commands/cc.ts +504 -0
  10. package/src/commands/graph.ts +229 -0
  11. package/src/commands/new.ts +333 -0
  12. package/src/commands/swagger.ts +261 -0
  13. package/src/config/load.ts +122 -0
  14. package/src/config/validate.ts +46 -0
  15. package/src/contracts/charts/chart-spec.ts +201 -0
  16. package/src/contracts/charts/index.ts +7 -0
  17. package/src/contracts/client-tools/index.ts +82 -0
  18. package/src/contracts/egress/index.ts +161 -0
  19. package/src/contracts/egress/scrub.ts +59 -0
  20. package/src/contracts/graph/clients/embed.ts +45 -0
  21. package/src/contracts/graph/clients/neo4j.ts +85 -0
  22. package/src/contracts/graph/helpers.ts +57 -0
  23. package/src/contracts/graph/index.ts +30 -0
  24. package/src/contracts/graph/schema.ts +65 -0
  25. package/src/contracts/graph/types.ts +125 -0
  26. package/src/contracts/markdown/index.ts +39 -0
  27. package/src/contracts/rich-text/index.ts +220 -0
  28. package/src/contracts/runtime/api.ts +328 -0
  29. package/src/contracts/runtime/auth.ts +41 -0
  30. package/src/contracts/runtime/dataset-sql-guide.ts +67 -0
  31. package/src/contracts/runtime/dataset-sql.ts +2522 -0
  32. package/src/contracts/runtime/datasets.fixtures.json +2849 -0
  33. package/src/contracts/runtime/datasets.ts +1391 -0
  34. package/src/contracts/runtime/grammar.ts +161 -0
  35. package/src/contracts/runtime/index.ts +14 -0
  36. package/src/contracts/runtime/signature.ts +237 -0
  37. package/src/contracts/runtime/sync.ts +172 -0
  38. package/src/contracts/runtime/triggers.ts +224 -0
  39. package/src/contracts/wire/index.ts +264 -0
  40. package/src/graph/expand-domains.ts +288 -0
  41. package/src/graph/generate-cypher.ts +201 -0
  42. package/src/graph/seed.ts +207 -0
  43. package/src/graph/validate.ts +78 -0
  44. package/src/scaffold/features.ts +190 -0
  45. package/src/scaffold/files.ts +411 -0
  46. package/src/scaffold/ports.ts +86 -0
  47. package/src/scaffold/templates/domains-README.md.txt +16 -0
  48. package/src/scaffold/templates/eslint.config.mjs.txt +20 -0
  49. package/src/scaffold/templates/fallowrc.jsonc.txt +42 -0
  50. package/src/scaffold/templates/gitignore.txt +19 -0
  51. package/src/scaffold/templates/index.ts.txt +18 -0
  52. package/src/scaffold/templates/prettier.config.mjs.txt +5 -0
  53. package/src/scaffold/templates/tsconfig.json.txt +25 -0
  54. package/src/swagger/extract-endpoints.ts +266 -0
  55. package/src/swagger/generated-block.ts +74 -0
  56. package/src/swagger/openapi-schema.ts +161 -0
  57. package/src/swagger/parse-response.ts +88 -0
  58. package/src/swagger/types.ts +25 -0
  59. package/src/ui/prompts.ts +125 -0
  60. package/src/ui/report.ts +107 -0
  61. package/tsconfig.json +46 -0
@@ -0,0 +1,2522 @@
1
+ import type {
2
+ DatasetAggregate,
3
+ DatasetArithmeticOp,
4
+ DatasetBucket,
5
+ DatasetCatalog,
6
+ DatasetCondition,
7
+ DatasetDatePart,
8
+ DatasetDefinition,
9
+ DatasetFilter,
10
+ DatasetFilterOp,
11
+ DatasetFunction,
12
+ DatasetNamedOperand,
13
+ DatasetOperand,
14
+ DatasetQueryInput,
15
+ DatasetRelation,
16
+ } from "./datasets";
17
+ import { DATASET_BUCKETS, DATASET_DATE_PARTS, describeOperand, groupKeyName } from "./datasets";
18
+
19
+ /*
20
+ * The SQL a model writes against a dataset, and how it becomes the wire
21
+ * query a host runs. One SELECT over one dataset, JOINs along the relations
22
+ * the host declares (matched by the ON clause, so two links to one dataset
23
+ * are told apart by their keys), dataset names or aliases as qualifiers,
24
+ * WHERE with NOT, dates (DATE_TRUNC, EXTRACT, CURRENT_DATE - INTERVAL),
25
+ * scalar functions and CASE as values, GROUP BY columns or named values,
26
+ * aggregates with FILTER (WHERE ...) or a CASE argument, calculations
27
+ * between aggregates, HAVING, windows (AGG(x) OVER (PARTITION BY ...)),
28
+ * QUALIFY over the finished row, RANK() ranking, ORDER BY, LIMIT and
29
+ * OFFSET. Anything outside the subset is refused with the clause named and
30
+ * what to write instead, so a refusal is the whole lesson for the next
31
+ * attempt. Parsing is deterministic and has no dependency; the grammar is
32
+ * small enough to read here.
33
+ */
34
+
35
+ /**
36
+ * What a parse answers: the wire query, plus where each answered key goes in
37
+ * the row the model reads. A qualified column nests under its qualifier as
38
+ * written (`pets.name` → `{ pets: { name } }`); aliases, aggregates and bare
39
+ * columns stay at the top level.
40
+ */
41
+ type ParsedDatasetSql = {
42
+ query: DatasetQueryInput;
43
+ /** Row key as the host answers it → the path it is placed at. Has no prototype, so a key like `__proto__` or `constructor` is only ever its own entry. */
44
+ shape: Record<string, string[]>;
45
+ /** Aggregates and windows the query added for HAVING, QUALIFY or a calculation but the statement never selected. */
46
+ hidden: string[];
47
+ };
48
+
49
+ /** A refusal the model can act on. */
50
+ export class DatasetSqlError extends Error {}
51
+
52
+ type Token =
53
+ | { kind: "word"; value: string; upper: string; quoted?: true }
54
+ | { kind: "string"; value: string }
55
+ | { kind: "number"; value: number }
56
+ | { kind: "punct"; value: string }
57
+ | { kind: "end" };
58
+
59
+ const PUNCT = ["<=", ">=", "<>", "!=", "(", ")", ",", "*", "=", "<", ">", ".", ";", "+", "-", "/"];
60
+
61
+ /** Reads a `'...'` literal starting at `start`; `''` is an escaped quote. */
62
+ function stringLiteral(sql: string, start: number) {
63
+ let value = "";
64
+ let i = start + 1;
65
+ for (;;) {
66
+ if (i >= sql.length) throw new DatasetSqlError("A string literal is not closed.");
67
+ if (sql[i] === "'" && sql[i + 1] === "'") {
68
+ value += "'";
69
+ i += 2;
70
+ } else if (sql[i] === "'") {
71
+ return { token: { kind: "string", value } satisfies Token, next: i + 1 };
72
+ } else {
73
+ value += sql[i];
74
+ i += 1;
75
+ }
76
+ }
77
+ }
78
+
79
+ /** Reads the token at `i`: a quoted identifier, a number (negative only where a value is expected), a word or punctuation. */
80
+ function lexeme(sql: string, i: number, previous: Token | undefined) {
81
+ const char = sql[i]!;
82
+ if (char === '"' || char === "`") {
83
+ const close = sql.indexOf(char, i + 1);
84
+ if (close === -1) throw new DatasetSqlError("A quoted identifier is not closed.");
85
+ const value = sql.slice(i + 1, close);
86
+ return {
87
+ token: {
88
+ kind: "word",
89
+ value,
90
+ upper: value.toUpperCase(),
91
+ quoted: true,
92
+ } satisfies Token,
93
+ next: close + 1,
94
+ };
95
+ }
96
+ const rest = sql.slice(i);
97
+ const number = /^-?\d+(\.\d+)?/.exec(rest);
98
+ // After a value or a closing parenthesis a minus subtracts; after a keyword or an operator it signs the number.
99
+ const afterValue =
100
+ previous !== undefined &&
101
+ (previous.kind === "punct"
102
+ ? previous.value === ")"
103
+ : previous.kind === "word"
104
+ ? ["END", "NULL", "TRUE", "FALSE"].includes(previous.upper) || isName(previous)
105
+ : true);
106
+ const signIsOperator = char === "-" && afterValue;
107
+ if (number && !signIsOperator) {
108
+ return {
109
+ token: { kind: "number", value: Number(number[0]) } satisfies Token,
110
+ next: i + number[0].length,
111
+ };
112
+ }
113
+ const word = /^[A-Za-z_][A-Za-z0-9_]*/.exec(rest);
114
+ if (word) {
115
+ return {
116
+ token: { kind: "word", value: word[0], upper: word[0].toUpperCase() } satisfies Token,
117
+ next: i + word[0].length,
118
+ };
119
+ }
120
+ const punct = PUNCT.find((candidate) => rest.startsWith(candidate));
121
+ if (punct) {
122
+ return { token: { kind: "punct", value: punct } satisfies Token, next: i + punct.length };
123
+ }
124
+ throw new DatasetSqlError(`Unexpected character "${char}".`);
125
+ }
126
+
127
+ /** Splits the statement into words, quoted strings, numbers and punctuation. Identifiers may be double quoted or backticked. */
128
+ function tokenize(sql: string) {
129
+ const tokens: Token[] = [];
130
+ let i = 0;
131
+ while (i < sql.length) {
132
+ const char = sql[i]!;
133
+ if (/\s/.test(char)) {
134
+ i += 1;
135
+ continue;
136
+ }
137
+ if (char === "-" && sql[i + 1] === "-") {
138
+ throw new DatasetSqlError("Comments are not allowed; send the statement alone.");
139
+ }
140
+ const read = char === "'" ? stringLiteral(sql, i) : lexeme(sql, i, tokens.at(-1));
141
+ tokens.push(read.token);
142
+ i = read.next;
143
+ }
144
+ tokens.push({ kind: "end" });
145
+ return tokens;
146
+ }
147
+
148
+ type WireAggregate = NonNullable<DatasetQueryInput["aggregate"]>[number];
149
+ type WireWindow = NonNullable<DatasetQueryInput["window"]>[number];
150
+ type Literal = string | number | boolean;
151
+
152
+ /**
153
+ * A value expression as written: references keep the statement's own
154
+ * spelling until compile time, aggregate and window calls are resolved to
155
+ * aliases on the answered row, and `date` is CURRENT_DATE or NOW() with any
156
+ * INTERVAL arithmetic already folded in.
157
+ */
158
+ type Term =
159
+ | { kind: "ref"; ref: string }
160
+ | { kind: "num"; value: number }
161
+ | { kind: "lit"; value: string | boolean }
162
+ | { kind: "date"; at: Date }
163
+ | { kind: "interval"; months: number; days: number }
164
+ | { kind: "agg"; item: AggregateCall }
165
+ | { kind: "window"; item: WindowItem }
166
+ | { kind: "arith"; op: DatasetArithmeticOp; left: Term; right: Term }
167
+ | { kind: "dateTrunc"; unit: DatasetBucket; of: Term }
168
+ | { kind: "datePart"; unit: DatasetDatePart; of: Term }
169
+ | { kind: "fn"; fn: DatasetFunction; args: Term[] }
170
+ | { kind: "case"; branches: { when: Predicate[]; then: Term }[]; else?: Term };
171
+
172
+ /** A condition as written; groups keep SQL's AND/OR/NOT structure until compile time, and `rank` is `RANK() OVER (...) <= n`. */
173
+ type Predicate =
174
+ | {
175
+ kind: "cond";
176
+ left: Term;
177
+ op: DatasetFilterOp;
178
+ value?: Literal | Literal[] | { term: Term };
179
+ }
180
+ | { kind: "any"; of: Predicate[] }
181
+ | { kind: "all"; of: Predicate[] }
182
+ | { kind: "not"; of: Predicate }
183
+ | (RankWindow & { kind: "rank"; limit: number });
184
+
185
+ /** `FN(value) [FILTER (WHERE ...)]` as written. */
186
+ type AggregateCall = {
187
+ fn: DatasetAggregate;
188
+ term?: Term;
189
+ filter: Predicate[];
190
+ alias?: string;
191
+ };
192
+
193
+ type AggregateItem = AggregateCall & {
194
+ kind: "aggregate";
195
+ /** Added for the query but never answered: an aggregate the outer SELECT of a derived table left out. */
196
+ hidden?: true;
197
+ };
198
+
199
+ /** `FN(value) [FILTER (WHERE ...)] OVER ([PARTITION BY ...])`: an aggregate over the answered rows sharing the partition. */
200
+ type WindowItem = AggregateCall & { partitionBy: Term[] };
201
+
202
+ type SortList = { term: Term; dir: "asc" | "desc" }[];
203
+ /** `RANK() OVER (PARTITION BY ... ORDER BY ...)`. */
204
+ type RankWindow = { partitionBy: Term[]; sort: SortList };
205
+
206
+ /** A SELECT list entry as written; `ref` is the SQL spelling, resolved to a wire reference at compile time. */
207
+ type SelectItem =
208
+ | { kind: "column"; ref: string; alias?: string }
209
+ | AggregateItem
210
+ | { kind: "window"; item: WindowItem; alias: string; hidden?: true }
211
+ | { kind: "expr"; term: Term; alias?: string }
212
+ | (RankWindow & { kind: "rankWindow"; alias?: string })
213
+ | { kind: "star" };
214
+
215
+ /** `inner` is JOIN or INNER JOIN: rows without a match are dropped, where LEFT JOIN keeps them. */
216
+ type JoinClause = { dataset: string; alias?: string; left: string; right: string; inner: boolean };
217
+
218
+ const AGGREGATE_WORDS = {
219
+ COUNT: "count",
220
+ SUM: "sum",
221
+ AVG: "avg",
222
+ MIN: "min",
223
+ MAX: "max",
224
+ } satisfies Record<string, DatasetAggregate>;
225
+
226
+ const FUNCTION_WORDS = {
227
+ LOWER: "lower",
228
+ UPPER: "upper",
229
+ TRIM: "trim",
230
+ LENGTH: "length",
231
+ LEN: "length",
232
+ CHAR_LENGTH: "length",
233
+ ABS: "abs",
234
+ ROUND: "round",
235
+ COALESCE: "coalesce",
236
+ } satisfies Record<string, DatasetFunction>;
237
+
238
+ const DATE_PART_WORDS = {
239
+ YEAR: "year",
240
+ QUARTER: "quarter",
241
+ MONTH: "month",
242
+ WEEK: "week",
243
+ DAY: "day",
244
+ DAYOFWEEK: "dayOfWeek",
245
+ } satisfies Record<string, DatasetDatePart>;
246
+
247
+ const NOW_WORDS = ["CURRENT_DATE", "CURRENT_TIMESTAMP", "NOW"];
248
+
249
+ const RANK_WORDS = ["ROW_NUMBER", "RANK", "DENSE_RANK"];
250
+
251
+ const RUNNING_TOTAL =
252
+ "OVER (... ORDER BY ...) running totals are not supported; a window here is an aggregate over the whole partition: write OVER () or OVER (PARTITION BY ...).";
253
+ const WINDOW_IN_CONDITION =
254
+ "A window is worked out after WHERE and HAVING: name it in SELECT (AVG(x) OVER (...) AS name), use it in a calculation, or compare with a window in QUALIFY.";
255
+
256
+ const FUNCTIONS = [
257
+ ...Object.keys(AGGREGATE_WORDS),
258
+ "DATE_TRUNC('unit', x)",
259
+ "EXTRACT(part FROM x)",
260
+ ...Object.keys(DATE_PART_WORDS),
261
+ ...Object.keys(FUNCTION_WORDS),
262
+ "NOW()",
263
+ "RANK() OVER (...)",
264
+ ].join(", ");
265
+
266
+ const ONLY_INNER_OR_LEFT =
267
+ "Only JOIN (INNER JOIN, rows without a match dropped) and LEFT JOIN (rows without a match kept) along the joins a dataset declares are supported.";
268
+ const SUBQUERY =
269
+ "Subqueries are not supported, except FROM (SELECT ..., RANK() OVER (...) AS rn FROM ...) WHERE rn <= n: run the inner SELECT on its own and finish the calculation in code.";
270
+ const WINDOW_PLACE =
271
+ "RANK() goes in QUALIFY (QUALIFY RANK() OVER (PARTITION BY a ORDER BY b DESC) <= n) or in a derived table filtered on its alias (FROM (SELECT ..., RANK() OVER (...) AS rn FROM ...) WHERE rn <= n).";
272
+ const INTERVAL_PLACE =
273
+ "INTERVAL only shifts CURRENT_DATE (CURRENT_DATE - INTERVAL '7 days'); compare the column against a date instead.";
274
+
275
+ const REFUSED_CLAUSES = {
276
+ UNION: "UNION is not supported: ask each dataset in its own query.",
277
+ WITH: "WITH (CTEs) is not supported: write one SELECT over one dataset.",
278
+ DISTINCT:
279
+ "DISTINCT on rows is not supported: GROUP BY the columns instead; COUNT(DISTINCT column) is allowed.",
280
+ RIGHT: ONLY_INNER_OR_LEFT,
281
+ FULL: ONLY_INNER_OR_LEFT,
282
+ CROSS: ONLY_INNER_OR_LEFT,
283
+ INSERT: "Datasets are read only.",
284
+ UPDATE: "Datasets are read only.",
285
+ DELETE: "Datasets are read only.",
286
+ } as const;
287
+
288
+ /** Words that never serve as an alias or a name. */
289
+ const RESERVED = [
290
+ "SELECT",
291
+ "FROM",
292
+ "WHERE",
293
+ "GROUP",
294
+ "ORDER",
295
+ "LIMIT",
296
+ "OFFSET",
297
+ "HAVING",
298
+ "QUALIFY",
299
+ "AND",
300
+ "OR",
301
+ "NOT",
302
+ "IN",
303
+ "IS",
304
+ "NULL",
305
+ "LIKE",
306
+ "BETWEEN",
307
+ "ASC",
308
+ "DESC",
309
+ "JOIN",
310
+ "INNER",
311
+ "LEFT",
312
+ "ON",
313
+ "FILTER",
314
+ "OVER",
315
+ "PARTITION",
316
+ "INTERVAL",
317
+ "CASE",
318
+ "WHEN",
319
+ "THEN",
320
+ "ELSE",
321
+ "END",
322
+ ];
323
+
324
+ function isName(token: Token): token is Token & { kind: "word" } {
325
+ if (token.kind !== "word") return false;
326
+ return (
327
+ token.quoted === true ||
328
+ (!RESERVED.includes(token.upper) && !(token.upper in REFUSED_CLAUSES))
329
+ );
330
+ }
331
+
332
+ /** A unit written as `'month'`, `month` or `MONTH`. */
333
+ function unitOf<U extends string>(token: Token, units: readonly U[]) {
334
+ const written = token.kind === "string" || token.kind === "word" ? token.value : "";
335
+ return units.find((unit) => unit.toLowerCase() === written.toLowerCase());
336
+ }
337
+
338
+ function isoDate(at: Date) {
339
+ return at.toISOString().slice(0, 10);
340
+ }
341
+
342
+ /** A month or year step keeps the day, clamped to the target month's last day (Mar 31 minus a month is Feb 28), then the days apply. */
343
+ function shifted(at: Date, sign: 1 | -1, by: { months: number; days: number }) {
344
+ const date = new Date(at);
345
+ const day = date.getUTCDate();
346
+ date.setUTCDate(1);
347
+ date.setUTCMonth(date.getUTCMonth() + sign * by.months);
348
+ const lastDay = new Date(
349
+ Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0),
350
+ ).getUTCDate();
351
+ date.setUTCDate(Math.min(day, lastDay) + sign * by.days);
352
+ return date;
353
+ }
354
+
355
+ function lastSegment(ref: string) {
356
+ return ref.slice(ref.lastIndexOf(".") + 1);
357
+ }
358
+
359
+ /** The column a date function is written directly over, whose name the value is answered under when nothing names it. */
360
+ function dateColumnOf(term: Term) {
361
+ const direct = term.kind === "dateTrunc" || term.kind === "datePart";
362
+ return direct && term.of.kind === "ref" ? term.of.ref : undefined;
363
+ }
364
+
365
+ /** `DATE_TRUNC('month', x)`; over CURRENT_DATE it is the date it names. */
366
+ function dateTruncTerm(unit: DatasetBucket, of: Term) {
367
+ if (of.kind !== "date") return { kind: "dateTrunc", unit, of } satisfies Term;
368
+ const at = new Date(Date.UTC(of.at.getUTCFullYear(), of.at.getUTCMonth(), of.at.getUTCDate()));
369
+ if (unit === "week") at.setUTCDate(at.getUTCDate() - ((at.getUTCDay() + 6) % 7));
370
+ if (unit === "month" || unit === "quarter" || unit === "year") at.setUTCDate(1);
371
+ if (unit === "quarter") at.setUTCMonth(Math.floor(at.getUTCMonth() / 3) * 3);
372
+ if (unit === "year") at.setUTCMonth(0);
373
+ return { kind: "date", at } satisfies Term;
374
+ }
375
+
376
+ /** `YEAR(x)`; over CURRENT_DATE it is the number it names, weeks counted the ISO way and Monday as weekday 1. */
377
+ function datePartTerm(unit: DatasetDatePart, of: Term) {
378
+ if (of.kind !== "date") return { kind: "datePart", unit, of } satisfies Term;
379
+ const at = new Date(Date.UTC(of.at.getUTCFullYear(), of.at.getUTCMonth(), of.at.getUTCDate()));
380
+ const weekday = ((at.getUTCDay() + 6) % 7) + 1;
381
+ const thursday = new Date(at);
382
+ thursday.setUTCDate(at.getUTCDate() - weekday + 4);
383
+ const january = Date.UTC(thursday.getUTCFullYear(), 0, 1);
384
+ const week = Math.ceil(((thursday.getTime() - january) / 86_400_000 + 1) / 7);
385
+ const value = {
386
+ year: at.getUTCFullYear(),
387
+ quarter: Math.floor(at.getUTCMonth() / 3) + 1,
388
+ month: at.getUTCMonth() + 1,
389
+ week,
390
+ day: at.getUTCDate(),
391
+ dayOfWeek: weekday,
392
+ } satisfies Record<DatasetDatePart, number>;
393
+ return { kind: "num", value: value[unit] } satisfies Term;
394
+ }
395
+
396
+ class Parser {
397
+ private position = 0;
398
+
399
+ public constructor(
400
+ private readonly tokens: Token[],
401
+ private readonly now: Date,
402
+ ) {}
403
+
404
+ /** One SELECT, or the one derived-table shape accepted: `SELECT ... FROM (SELECT ..., RANK() OVER (...) AS rn FROM ...) WHERE rn <= n`. */
405
+ public statement() {
406
+ const items = this.selectHead();
407
+ const parsed = this.optionalPunct("(")
408
+ ? this.derivedTable(items)
409
+ : { items, ...this.body() };
410
+ if (parsed.items.some((item) => item.kind === "rankWindow")) {
411
+ throw new DatasetSqlError(WINDOW_PLACE);
412
+ }
413
+ this.optionalPunct(";");
414
+ this.end();
415
+ return parsed;
416
+ }
417
+
418
+ /** What follows a SELECT item that is not a comma or FROM names the shape that is not supported, instead of blaming FROM. */
419
+ private assertSelectEnded() {
420
+ const token = this.peek();
421
+ if (token.kind === "end" || this.peekWord("FROM")) return;
422
+ if (isConditionWord(token) || this.peekWord("NOT")) {
423
+ throw new DatasetSqlError(
424
+ "A condition in SELECT is written CASE WHEN condition THEN value ELSE value END AS name.",
425
+ );
426
+ }
427
+ const shown = token.kind === "string" ? `'${token.value}'` : String(token.value);
428
+ throw new DatasetSqlError(`Unexpected "${shown}" in SELECT.`);
429
+ }
430
+
431
+ /** `SELECT [DISTINCT] items FROM`: what precedes the dataset or the derived table. */
432
+ private selectHead() {
433
+ this.expectWord("SELECT", "A dataset query starts with SELECT.");
434
+ if (this.peekWord("DISTINCT")) throw new DatasetSqlError(REFUSED_CLAUSES.DISTINCT);
435
+ const items = this.selectList();
436
+ this.assertSelectEnded();
437
+ this.expectWord("FROM", "SELECT needs FROM <dataset>.");
438
+ return items;
439
+ }
440
+
441
+ /** `dataset [alias] JOIN ... WHERE ... GROUP BY ... HAVING ... QUALIFY ... ORDER BY ... LIMIT ... OFFSET ...`. */
442
+ private body() {
443
+ const from = this.identifier("FROM needs a dataset name.");
444
+ const alias = this.optionalAlias();
445
+ const joins = this.joins();
446
+ const filter = this.peekWord("WHERE") ? (this.take(), this.predicates()) : [];
447
+ const groupBy = this.groupBy();
448
+ const having = this.peekWord("HAVING") ? (this.take(), this.predicates()) : [];
449
+ const qualify = this.peekWord("QUALIFY") ? (this.take(), this.predicates()) : [];
450
+ return {
451
+ from,
452
+ aliases: alias === undefined ? [] : [alias],
453
+ joins,
454
+ filter,
455
+ groupBy,
456
+ having,
457
+ qualify,
458
+ ...this.tail(),
459
+ };
460
+ }
461
+
462
+ private tail() {
463
+ const sort = this.orderBy();
464
+ const limit = this.limit();
465
+ const offset = this.offset(sort.length > 0);
466
+ return { sort, limit, offset };
467
+ }
468
+
469
+ /**
470
+ * `(SELECT ..., RANK() OVER (...) AS rn FROM ...) [AS t] WHERE rn <= n`:
471
+ * the inner SELECT is the query, its window the rank, the outer list the
472
+ * values kept; ORDER BY and LIMIT belong to the outer SELECT.
473
+ */
474
+ private derivedTable(outer: SelectItem[]) {
475
+ const inner = this.selectHead();
476
+ const body = this.body();
477
+ this.expectPunct(")", "The derived table is not closed.");
478
+ const table = this.optionalAlias();
479
+ const windows = inner.filter((item) => item.kind === "rankWindow");
480
+ const window = windows[0];
481
+ if (!window || windows.length > 1) throw new DatasetSqlError(SUBQUERY);
482
+ if (body.sort.length || body.limit !== undefined || body.qualify.length) {
483
+ throw new DatasetSqlError(
484
+ "Put ORDER BY, LIMIT and QUALIFY on the outer SELECT; the derived table only adds RANK().",
485
+ );
486
+ }
487
+ const rn = window.alias;
488
+ if (rn === undefined) {
489
+ throw new DatasetSqlError(
490
+ "Name the RANK(): RANK() OVER (...) AS rn, then WHERE rn <= n outside.",
491
+ );
492
+ }
493
+ const filterOn = `Filter the derived table on ${rn}: WHERE ${rn} <= n.`;
494
+ this.expectWord("WHERE", filterOn);
495
+ if (unqualified(this.reference(filterOn), table) !== rn)
496
+ throw new DatasetSqlError(filterOn);
497
+ const limit = this.rankLimit(rn);
498
+ if (this.peekWord("AND")) {
499
+ throw new DatasetSqlError(
500
+ `Put other conditions in the inner WHERE; the outer one only keeps ${rn} <= n.`,
501
+ );
502
+ }
503
+ return {
504
+ ...body,
505
+ aliases: [...body.aliases, ...(table === undefined ? [] : [table])],
506
+ items: narrowed(inner, outer, table),
507
+ qualify: [
508
+ { kind: "rank", partitionBy: window.partitionBy, sort: window.sort, limit },
509
+ ] satisfies Predicate[],
510
+ ...this.tail(),
511
+ };
512
+ }
513
+
514
+ private selectList() {
515
+ const items: SelectItem[] = [];
516
+ do {
517
+ items.push(this.selectItem());
518
+ } while (this.optionalPunct(","));
519
+ return items;
520
+ }
521
+
522
+ private selectItem() {
523
+ if (this.optionalPunct("*")) return { kind: "star" } satisfies SelectItem;
524
+ if (this.peekRank()) {
525
+ const window = this.rankWindow(WINDOW_PLACE);
526
+ return {
527
+ kind: "rankWindow",
528
+ ...window,
529
+ alias: this.optionalAlias(),
530
+ } satisfies SelectItem;
531
+ }
532
+ const term = this.term(
533
+ "SELECT takes column names, `dataset.column`, aggregates, functions over them, CASE or arithmetic between aggregates.",
534
+ );
535
+ const alias = this.optionalAlias();
536
+ if (term.kind === "agg")
537
+ return { kind: "aggregate", ...term.item, alias } satisfies SelectItem;
538
+ if (term.kind === "ref")
539
+ return { kind: "column", ref: term.ref, alias } satisfies SelectItem;
540
+ const shown = describeTerm(term);
541
+ if (term.kind === "window") {
542
+ if (alias === undefined) {
543
+ throw new DatasetSqlError(`Give the window a name: ${shown} AS name.`);
544
+ }
545
+ return { kind: "window", item: term.item, alias } satisfies SelectItem;
546
+ }
547
+ const computed = hasAggregate(term) || hasWindow(term);
548
+ if (alias === undefined && computed) {
549
+ throw new DatasetSqlError(`Give the calculation a name: ${shown} AS share.`);
550
+ }
551
+ // A named literal or date (`CURRENT_DATE AS today`) is a constant column; bare arithmetic is not.
552
+ const constant = term.kind === "lit" || term.kind === "date";
553
+ if (!computed && !constant && termRefs(term).length === 0) {
554
+ throw new DatasetSqlError(`${shown} reads no data; SELECT columns or aggregates.`);
555
+ }
556
+ if (alias === undefined && dateColumnOf(term) === undefined) {
557
+ throw new DatasetSqlError(`Give the value a name: ${shown} AS name.`);
558
+ }
559
+ return { kind: "expr", term, alias } satisfies SelectItem;
560
+ }
561
+
562
+ /** `[INNER|LEFT [OUTER]] JOIN dataset [AS alias] ON a.x = b.y`, repeated; a bare JOIN is INNER. */
563
+ private joins() {
564
+ const joins: JoinClause[] = [];
565
+ for (;;) {
566
+ const inner = !this.peekWord("LEFT");
567
+ if (this.peekWord("INNER") || this.peekWord("LEFT")) {
568
+ this.take();
569
+ if (this.peekWord("OUTER")) this.take();
570
+ this.expectWord("JOIN", "INNER and LEFT are followed by JOIN.");
571
+ } else if (!this.peekWord("JOIN")) {
572
+ return joins;
573
+ } else {
574
+ this.take();
575
+ }
576
+ const dataset = this.identifier("JOIN needs a dataset name.");
577
+ const alias = this.optionalAlias();
578
+ this.expectWord("ON", "JOIN needs ON dataset.column = other.column.");
579
+ const left = this.reference("ON needs dataset.column = other.column.");
580
+ this.expectPunct("=", "ON compares two key columns with =.");
581
+ const right = this.reference("ON needs dataset.column = other.column.");
582
+ if (this.peekWord("AND")) {
583
+ throw new DatasetSqlError("ON takes one key pair; put other conditions in WHERE.");
584
+ }
585
+ joins.push({ dataset, alias, left, right, inner });
586
+ }
587
+ }
588
+
589
+ /** `COUNT(*)`, `FN(value)`, `COUNT(DISTINCT column)`, `FN(CASE WHEN ... THEN ... END)`, each with an optional `FILTER (WHERE ...)`. */
590
+ private aggregate() {
591
+ const word = this.take() as Token & { kind: "word" };
592
+ const base = AGGREGATE_WORDS[word.upper as keyof typeof AGGREGATE_WORDS];
593
+ this.expectPunct("(", `${word.upper} needs parentheses.`);
594
+ const argument = this.aggregateArgument(word.upper, base);
595
+ this.expectPunct(")", `${word.upper}(...) is not closed.`);
596
+ const filter = [...argument.filter, ...this.filterClause()];
597
+ return { fn: argument.fn, term: argument.term, filter } satisfies AggregateCall;
598
+ }
599
+
600
+ /** `OVER ([PARTITION BY a, b])` after an aggregate: the rows it is worked out over; an ORDER BY would make it a running total, which the wire has no form for. */
601
+ private over() {
602
+ this.take();
603
+ this.expectPunct("(", "OVER is followed by () or (PARTITION BY ...).");
604
+ let partitionBy: Term[] = [];
605
+ if (this.peekWord("PARTITION")) {
606
+ this.take();
607
+ this.expectWord("BY", "PARTITION is followed by BY.");
608
+ partitionBy = this.termList("PARTITION BY takes column names or aliases.");
609
+ }
610
+ if (this.peekWord("ORDER")) throw new DatasetSqlError(RUNNING_TOTAL);
611
+ this.expectPunct(")", "OVER (...) is not closed.");
612
+ return partitionBy;
613
+ }
614
+
615
+ private aggregateArgument(word: string, base: DatasetAggregate) {
616
+ if (this.optionalPunct("*")) {
617
+ if (base !== "count") {
618
+ throw new DatasetSqlError(`${word}(*) is not valid; name a column.`);
619
+ }
620
+ return { fn: base, term: undefined, filter: [] as Predicate[] };
621
+ }
622
+ const distinct = this.peekWord("DISTINCT") ? (this.take(), true) : false;
623
+ if (distinct && base !== "count") {
624
+ throw new DatasetSqlError(
625
+ `DISTINCT is only valid inside COUNT; ${word}(DISTINCT ...) is not.`,
626
+ );
627
+ }
628
+ const term = this.term(`${word}() takes a column, arithmetic or a function over columns.`);
629
+ const fn = distinct ? ("countDistinct" as const) : base;
630
+ if (term.kind === "num") {
631
+ if (fn === "count") return { fn, term: undefined, filter: [] as Predicate[] };
632
+ throw new DatasetSqlError(`${word}(${term.value}) has no meaning; name a column.`);
633
+ }
634
+ if (term.kind === "case") return conditional(word, fn, term);
635
+ return { fn, term, filter: [] as Predicate[] };
636
+ }
637
+
638
+ private filterClause() {
639
+ if (!this.peekWord("FILTER")) return [] as Predicate[];
640
+ this.take();
641
+ this.expectPunct("(", "FILTER is written FILTER (WHERE condition).");
642
+ this.expectWord("WHERE", "FILTER is written FILTER (WHERE condition).");
643
+ const filter = this.predicates();
644
+ this.expectPunct(")", "FILTER (WHERE ...) is not closed.");
645
+ return filter;
646
+ }
647
+
648
+ private peekRank() {
649
+ const token = this.peek();
650
+ return token.kind === "word" && RANK_WORDS.includes(token.upper) && this.peekPunct("(", 1);
651
+ }
652
+
653
+ /** `RANK() OVER (PARTITION BY a, b ORDER BY x DESC, y)`, the ranking the wire keeps: rows tied on every ORDER BY key share a rank. */
654
+ private rankWindow(message: string) {
655
+ const word = this.take();
656
+ if (word.kind !== "word" || !RANK_WORDS.includes(word.upper)) {
657
+ throw new DatasetSqlError(message);
658
+ }
659
+ if (word.upper !== "RANK") {
660
+ throw new DatasetSqlError(
661
+ `${word.upper}() is not supported; use RANK(): rows tied on every ORDER BY key share a rank, so RANK() <= n keeps a tie whole rather than cutting it at n rows.`,
662
+ );
663
+ }
664
+ this.expectPunct("(", "RANK() takes no argument.");
665
+ this.expectPunct(")", "RANK() takes no argument.");
666
+ const over = "RANK() needs OVER (PARTITION BY ... ORDER BY ...).";
667
+ this.expectWord("OVER", over);
668
+ this.expectPunct("(", over);
669
+ this.expectWord(
670
+ "PARTITION",
671
+ "RANK() needs PARTITION BY: ranking every row as one partition is not supported.",
672
+ );
673
+ this.expectWord("BY", "PARTITION is followed by BY.");
674
+ const partitionBy = this.termList("PARTITION BY takes column names or aliases.");
675
+ this.expectWord("ORDER", "RANK() OVER (...) needs ORDER BY to say which rows come first.");
676
+ this.expectWord("BY", "ORDER is followed by BY.");
677
+ const sort = this.sortList();
678
+ this.expectPunct(")", "OVER (...) is not closed.");
679
+ return { partitionBy, sort } satisfies RankWindow;
680
+ }
681
+
682
+ private termList(message: string) {
683
+ const items: Term[] = [];
684
+ do {
685
+ items.push(this.term(message));
686
+ } while (this.optionalPunct(","));
687
+ return items;
688
+ }
689
+
690
+ /** `<= n`, `< n` (keeps n - 1) or `= 1` after a rank: the ranks of each partition that stay, at most 100. */
691
+ private rankLimit(shown: string) {
692
+ const operator = this.take();
693
+ const count = this.take();
694
+ const n = count.kind === "number" && Number.isInteger(count.value) ? count.value : NaN;
695
+ const op = operator.kind === "punct" ? operator.value : "";
696
+ const limit = op === "<=" ? n : op === "<" ? n - 1 : op === "=" && n === 1 ? 1 : NaN;
697
+ if (!(limit >= 1)) {
698
+ throw new DatasetSqlError(
699
+ `${shown} <= n keeps the rows ranked 1 to n in each partition, ties included (= 1 the first); other comparisons are not supported.`,
700
+ );
701
+ }
702
+ if (limit > 100) {
703
+ throw new DatasetSqlError(
704
+ `${shown} keeps at most the first 100 ranks of each partition.`,
705
+ );
706
+ }
707
+ return limit;
708
+ }
709
+
710
+ private dateTrunc() {
711
+ this.take();
712
+ this.expectPunct("(", "DATE_TRUNC needs parentheses.");
713
+ const unit = unitOf(this.take(), DATASET_BUCKETS);
714
+ if (!unit) {
715
+ throw new DatasetSqlError(
716
+ `DATE_TRUNC takes a unit first: DATE_TRUNC('month', column); units are ${DATASET_BUCKETS.join(", ")}.`,
717
+ );
718
+ }
719
+ this.expectPunct(",", "DATE_TRUNC('unit', column) needs the column after the unit.");
720
+ const of = this.term("DATE_TRUNC needs a date column after the unit.");
721
+ this.expectPunct(")", "DATE_TRUNC(...) is not closed.");
722
+ return dateTruncTerm(unit, of);
723
+ }
724
+
725
+ /** `EXTRACT(YEAR FROM column)`, the standard spelling of YEAR(column). */
726
+ private extract() {
727
+ this.take();
728
+ this.expectPunct("(", "EXTRACT is written EXTRACT(part FROM column).");
729
+ const unit = unitOf(this.take(), DATASET_DATE_PARTS);
730
+ if (!unit) {
731
+ throw new DatasetSqlError(
732
+ `EXTRACT takes a part first: EXTRACT(YEAR FROM column); parts are ${Object.keys(DATE_PART_WORDS).join(", ")}.`,
733
+ );
734
+ }
735
+ this.expectWord("FROM", "EXTRACT is written EXTRACT(part FROM column).");
736
+ const of = this.term("EXTRACT needs a date column after FROM.");
737
+ this.expectPunct(")", "EXTRACT(...) is not closed.");
738
+ return datePartTerm(unit, of);
739
+ }
740
+
741
+ /** `INTERVAL '30 days'`: units are day(s), week(s), month(s) and year(s). */
742
+ private interval() {
743
+ this.take();
744
+ const spec = this.take();
745
+ const match =
746
+ spec.kind === "string"
747
+ ? /^(\d+)\s*(day|week|month|year)s?$/i.exec(spec.value.trim())
748
+ : null;
749
+ if (!match) {
750
+ throw new DatasetSqlError(
751
+ "INTERVAL takes 'n unit', with unit day(s), week(s), month(s) or year(s).",
752
+ );
753
+ }
754
+ const n = Number(match[1]);
755
+ const unit = match[2]!.toLowerCase();
756
+ return {
757
+ kind: "interval",
758
+ months: unit === "month" ? n : unit === "year" ? 12 * n : 0,
759
+ days: unit === "day" ? n : unit === "week" ? 7 * n : 0,
760
+ } satisfies Term;
761
+ }
762
+
763
+ /** `CASE WHEN p THEN v [WHEN ...] [ELSE v] END`; ELSE NULL is the same as no ELSE. */
764
+ /** `CASE WHEN p THEN v ... END`, or the simple form `CASE x WHEN v THEN r ... END` where each WHEN compares x with a value. */
765
+ private caseTerm() {
766
+ this.take();
767
+ const subject = this.peekWord("WHEN")
768
+ ? undefined
769
+ : this.term("CASE is followed by WHEN, or by a value to compare.");
770
+ const branches: { when: Predicate[]; then: Term }[] = [];
771
+ while (this.peekWord("WHEN")) {
772
+ this.take();
773
+ const when = subject ? [this.simpleCaseMatch(subject)] : this.predicates();
774
+ this.expectWord("THEN", "CASE WHEN condition needs THEN value.");
775
+ if (this.peekWord("NULL")) {
776
+ throw new DatasetSqlError(
777
+ "THEN NULL is not supported; a division by zero is already null, so leave that branch out and keep the ELSE.",
778
+ );
779
+ }
780
+ branches.push({
781
+ when,
782
+ then: this.term("THEN takes a column, a literal or arithmetic."),
783
+ });
784
+ }
785
+ if (branches.length === 0) {
786
+ throw new DatasetSqlError(
787
+ "CASE is written CASE WHEN condition THEN value [ELSE value] END.",
788
+ );
789
+ }
790
+ let otherwise: Term | undefined;
791
+ if (this.peekWord("ELSE")) {
792
+ this.take();
793
+ if (this.peekWord("NULL")) this.take();
794
+ else otherwise = this.term("ELSE takes a column, a literal or arithmetic.");
795
+ }
796
+ this.expectWord("END", "CASE ... needs END.");
797
+ return {
798
+ kind: "case",
799
+ branches,
800
+ ...(otherwise === undefined ? {} : { else: otherwise }),
801
+ } satisfies Term;
802
+ }
803
+
804
+ /** `a AND b OR c AND (d OR e)`: AND binds tighter than OR; the result is an AND list whose OR parts are `any` groups. */
805
+ // eslint-disable-next-line m6d/no-explicit-return-type -- mutually recursive with predicate through parenthesized groups
806
+ private predicates(): Predicate[] {
807
+ const branches = [this.conjunction()];
808
+ while (this.peekWord("OR")) {
809
+ this.take();
810
+ branches.push(this.conjunction());
811
+ }
812
+ if (branches.length === 1) return branches[0]!;
813
+ return [{ kind: "any", of: branches.map((branch) => grouped(branch)) }];
814
+ }
815
+
816
+ private conjunction() {
817
+ const predicates: Predicate[] = [];
818
+ do {
819
+ predicates.push(this.predicate());
820
+ } while (this.peekWord("AND") && this.take());
821
+ return predicates;
822
+ }
823
+
824
+ /** `NOT p`, `(p AND q)`, `RANK() OVER (...) <= n` or a condition. */
825
+ private predicate(): Predicate {
826
+ if (this.peekWord("NOT")) {
827
+ this.take();
828
+ return { kind: "not", of: this.predicate() };
829
+ }
830
+ if (this.peekRank()) {
831
+ const window = this.rankWindow(WINDOW_PLACE);
832
+ return { kind: "rank", ...window, limit: this.rankLimit("RANK()") };
833
+ }
834
+ if (this.peekPunct("(") && this.parenthesizedPredicate()) {
835
+ this.take();
836
+ const inner = this.predicates();
837
+ this.expectPunct(")", "A parenthesized condition is not closed.");
838
+ return grouped(inner);
839
+ }
840
+ return this.condition();
841
+ }
842
+
843
+ /** After `(`: a condition group if a comparison or AND/OR/NOT appears at depth one (outside any CASE) before the matching `)`, else a value. */
844
+ private parenthesizedPredicate() {
845
+ let depth = 0;
846
+ let inCase = 0;
847
+ for (let offset = 0; ; offset += 1) {
848
+ const token = this.peek(offset);
849
+ if (token.kind === "end") return false;
850
+ depth += nesting(token, "(", ")");
851
+ if (depth === 0) return false;
852
+ inCase += nesting(token, "CASE", "END");
853
+ if (depth === 1 && inCase === 0 && isConditionWord(token)) return true;
854
+ }
855
+ }
856
+
857
+ private condition() {
858
+ const left = this.term("A condition starts with a column name.");
859
+ const shown = describeTerm(left);
860
+ if (this.peekWord("IS")) return this.nullCheck(left);
861
+ const negated = this.peekWord("NOT") ? (this.take(), true) : false;
862
+ if (this.peekWord("IN")) return this.inList(left, negated);
863
+ let condition: Predicate;
864
+ if (this.peekWord("BETWEEN")) condition = this.between(left);
865
+ else if (this.peekWord("LIKE")) condition = this.like(left, shown);
866
+ else if (negated) {
867
+ throw new DatasetSqlError(
868
+ `NOT before a comparison goes first (NOT ${shown} > 5); after a value it is NOT IN, NOT LIKE or NOT BETWEEN.`,
869
+ );
870
+ } else condition = this.comparison(left, shown);
871
+ return negated ? ({ kind: "not", of: condition } satisfies Predicate) : condition;
872
+ }
873
+
874
+ /** Whether the condition ends here: the statement, a closing parenthesis, or the next clause or connective. */
875
+ private endsCondition() {
876
+ const token = this.peek();
877
+ if (token.kind === "end") return true;
878
+ if (token.kind === "punct") return token.value === ")";
879
+ return (
880
+ token.kind === "word" &&
881
+ [
882
+ "AND",
883
+ "OR",
884
+ "THEN",
885
+ "GROUP",
886
+ "HAVING",
887
+ "ORDER",
888
+ "LIMIT",
889
+ "OFFSET",
890
+ "QUALIFY",
891
+ ].includes(token.upper)
892
+ );
893
+ }
894
+
895
+ /** One `WHEN v` of a simple CASE: the subject compared with the value, a literal or a term. */
896
+ private simpleCaseMatch(subject: Term) {
897
+ const token = this.peek();
898
+ if (token.kind === "string" || token.kind === "number") {
899
+ this.take();
900
+ return { kind: "cond", left: subject, op: "=", value: token.value } satisfies Predicate;
901
+ }
902
+ const value = this.term("WHEN takes a value to compare with.");
903
+ return {
904
+ kind: "cond",
905
+ left: subject,
906
+ op: "=",
907
+ value: { term: value },
908
+ } satisfies Predicate;
909
+ }
910
+
911
+ private nullCheck(left: Term) {
912
+ this.take();
913
+ const negated = this.peekWord("NOT") ? (this.take(), true) : false;
914
+ this.expectWord("NULL", "IS is followed by NULL or NOT NULL.");
915
+ return { kind: "cond", left, op: negated ? "isNotNull" : "isNull" } satisfies Predicate;
916
+ }
917
+
918
+ private inList(left: Term, negated: boolean) {
919
+ this.take();
920
+ this.expectPunct("(", "IN needs a list in parentheses.");
921
+ if (this.peekWord("SELECT")) throw new DatasetSqlError(SUBQUERY);
922
+ const values: Literal[] = [];
923
+ do {
924
+ values.push(this.literal("IN takes literal values."));
925
+ } while (this.optionalPunct(","));
926
+ this.expectPunct(")", "IN (...) is not closed.");
927
+ return {
928
+ kind: "cond",
929
+ left,
930
+ op: negated ? "notIn" : "in",
931
+ value: values,
932
+ } satisfies Predicate;
933
+ }
934
+
935
+ private between(left: Term) {
936
+ this.take();
937
+ const low = this.literal("BETWEEN takes two literal values.");
938
+ this.expectWord("AND", "BETWEEN low AND high.");
939
+ const high = this.literal("BETWEEN takes two literal values.");
940
+ return { kind: "cond", left, op: "between", value: [low, high] } satisfies Predicate;
941
+ }
942
+
943
+ private like(left: Term, shown: string) {
944
+ this.take();
945
+ const pattern = this.take();
946
+ if (pattern.kind !== "string") throw new DatasetSqlError("LIKE takes a string pattern.");
947
+ return { kind: "cond", left, ...likeFilter(shown, pattern.value) } satisfies Predicate;
948
+ }
949
+
950
+ /** `left op right`, where the right side is a literal, or a column or arithmetic (`a < target`, `a > 0.9 * target`). */
951
+ private comparison(left: Term, shown: string) {
952
+ // A bare column stands for `column = TRUE`, as SQL reads a boolean on its own.
953
+ if (left.kind === "ref" && this.endsCondition()) {
954
+ return { kind: "cond", left, op: "=", value: true } satisfies Predicate;
955
+ }
956
+ const operator = this.take();
957
+ if (
958
+ operator.kind !== "punct" ||
959
+ !["=", "!=", "<>", "<", "<=", ">", ">="].includes(operator.value)
960
+ ) {
961
+ throw new DatasetSqlError(
962
+ `"${shown}" needs a comparison: = != < <= > >= IN BETWEEN LIKE IS NULL.`,
963
+ );
964
+ }
965
+ const op = (operator.value === "<>" ? "!=" : operator.value) as DatasetFilterOp;
966
+ if (this.startsTerm()) {
967
+ return { kind: "cond", left, op, value: { term: this.term("") } } satisfies Predicate;
968
+ }
969
+ const value = this.literal(`"${shown} ${operator.value}" needs a value on the right.`);
970
+ return { kind: "cond", left, op, value } satisfies Predicate;
971
+ }
972
+
973
+ /** Whether the next tokens are a value expression rather than a literal: a name, `(`, CASE, or a number followed by an operator. */
974
+ private startsTerm() {
975
+ const token = this.peek();
976
+ if (this.peekPunct("(") || this.peekWord("CASE")) return true;
977
+ if (!isName(token))
978
+ return (
979
+ token.kind === "number" &&
980
+ ARITHMETIC.some((operator) => this.peekPunct(operator, 1))
981
+ );
982
+ const typedLiteral =
983
+ ["DATE", "TIMESTAMP"].includes(token.upper) && this.peek(1).kind === "string";
984
+ return !["TRUE", "FALSE"].includes(token.upper) && !typedLiteral;
985
+ }
986
+
987
+ /** `a + b * (c - 1)`: `*` and `/` bind before `+` and `-`; a date plus or minus an INTERVAL folds to the date it names. */
988
+ // eslint-disable-next-line m6d/no-explicit-return-type -- mutually recursive with factor and unit
989
+ private term(message: string): Term {
990
+ let left = this.factor(message);
991
+ while (this.peekPunct("+") || this.peekPunct("-")) {
992
+ const op = (this.take() as Token & { kind: "punct" }).value as DatasetArithmeticOp;
993
+ const right = this.factor(message);
994
+ const folds = left.kind === "date" && right.kind === "interval";
995
+ if (!folds && (left.kind === "date" || right.kind === "date")) {
996
+ throw new DatasetSqlError(
997
+ "Arithmetic on CURRENT_DATE takes an INTERVAL: CURRENT_DATE - INTERVAL '7 days'.",
998
+ );
999
+ }
1000
+ left =
1001
+ left.kind === "date" && right.kind === "interval"
1002
+ ? { kind: "date", at: shifted(left.at, op === "-" ? -1 : 1, right) }
1003
+ : { kind: "arith", op, left, right };
1004
+ }
1005
+ return left;
1006
+ }
1007
+
1008
+ private factor(message: string) {
1009
+ let left = this.unit(message);
1010
+ while (this.peekPunct("*") || this.peekPunct("/")) {
1011
+ const op = (this.take() as Token & { kind: "punct" }).value as DatasetArithmeticOp;
1012
+ left = { kind: "arith", op, left, right: this.unit(message) };
1013
+ }
1014
+ return left;
1015
+ }
1016
+
1017
+ private unit(message: string) {
1018
+ const token = this.peek();
1019
+ if (token.kind === "number" || token.kind === "string") {
1020
+ this.take();
1021
+ return token.kind === "number"
1022
+ ? ({ kind: "num", value: token.value } satisfies Term)
1023
+ : ({ kind: "lit", value: token.value } satisfies Term);
1024
+ }
1025
+ const next = this.peek(1);
1026
+ if (this.peekPunct("(") && next.kind === "word" && next.upper === "SELECT") {
1027
+ throw new DatasetSqlError(SUBQUERY);
1028
+ }
1029
+ if (this.optionalPunct("(")) {
1030
+ const inner = this.term(message);
1031
+ this.expectPunct(")", "A parenthesis is not closed.");
1032
+ return inner;
1033
+ }
1034
+ return (
1035
+ (token.kind === "word" ? this.wordUnit(token) : undefined) ??
1036
+ ({ kind: "ref", ref: this.reference(message) } satisfies Term)
1037
+ );
1038
+ }
1039
+
1040
+ /** A word that starts a value other than a column: a boolean, CASE, INTERVAL, the current date, or a call. */
1041
+ private wordUnit(token: Token & { kind: "word" }) {
1042
+ switch (token.upper) {
1043
+ case "TRUE":
1044
+ case "FALSE":
1045
+ this.take();
1046
+ return { kind: "lit", value: token.upper === "TRUE" } satisfies Term;
1047
+ case "CASE":
1048
+ return this.caseTerm();
1049
+ case "INTERVAL":
1050
+ return this.interval();
1051
+ case "CURRENT_DATE":
1052
+ case "CURRENT_TIMESTAMP":
1053
+ this.take();
1054
+ if (this.optionalPunct("("))
1055
+ this.expectPunct(")", `${token.upper}() takes no value.`);
1056
+ return { kind: "date", at: this.now } satisfies Term;
1057
+ default:
1058
+ return this.peekPunct("(", 1) ? this.call(token) : undefined;
1059
+ }
1060
+ }
1061
+
1062
+ /** `NAME(`: an aggregate, a date function, a scalar function, NOW(), or a refusal naming what exists. */
1063
+ private call(word: Token & { kind: "word" }) {
1064
+ const { upper } = word;
1065
+ if (upper in AGGREGATE_WORDS) {
1066
+ const item = this.aggregate();
1067
+ if (!this.peekWord("OVER")) return { kind: "agg", item } satisfies Term;
1068
+ return { kind: "window", item: { ...item, partitionBy: this.over() } } satisfies Term;
1069
+ }
1070
+ if (upper === "DATE_TRUNC") return this.dateTrunc();
1071
+ if (upper === "EXTRACT") return this.extract();
1072
+ if (upper === "NOW") {
1073
+ this.take();
1074
+ this.expectPunct("(", "NOW takes no argument: NOW().");
1075
+ this.expectPunct(")", "NOW takes no argument: NOW().");
1076
+ return { kind: "date", at: this.now } satisfies Term;
1077
+ }
1078
+ if (upper in DATE_PART_WORDS) {
1079
+ this.take();
1080
+ this.take();
1081
+ const of = this.term(`${upper} needs a date column: ${upper}(column).`);
1082
+ this.expectPunct(")", `${upper}(...) is not closed.`);
1083
+ return datePartTerm(DATE_PART_WORDS[upper as keyof typeof DATE_PART_WORDS], of);
1084
+ }
1085
+ if (upper in FUNCTION_WORDS) {
1086
+ this.take();
1087
+ this.take();
1088
+ const args: Term[] = [];
1089
+ do {
1090
+ args.push(this.term(`${upper}() takes columns, literals or arithmetic.`));
1091
+ } while (this.optionalPunct(","));
1092
+ this.expectPunct(")", `${upper}(...) is not closed.`);
1093
+ const fn = FUNCTION_WORDS[upper as keyof typeof FUNCTION_WORDS];
1094
+ return { kind: "fn", fn, args } satisfies Term;
1095
+ }
1096
+ if (upper === "CAST") {
1097
+ throw new DatasetSqlError(
1098
+ "CAST is not needed: division is already exact; drop the cast.",
1099
+ );
1100
+ }
1101
+ if (RANK_WORDS.includes(upper)) throw new DatasetSqlError(WINDOW_PLACE);
1102
+ if (word.upper === "NULLIF") {
1103
+ throw new DatasetSqlError(
1104
+ "NULLIF() is not needed: a division by zero is already null, so write a / b.",
1105
+ );
1106
+ }
1107
+ throw new DatasetSqlError(`${word.value}() is not available. Functions: ${FUNCTIONS}.`);
1108
+ }
1109
+
1110
+ private groupBy() {
1111
+ if (!this.peekWord("GROUP")) return [] as Term[];
1112
+ this.take();
1113
+ this.expectWord("BY", "GROUP is followed by BY.");
1114
+ const items: Term[] = [];
1115
+ do {
1116
+ const term = this.term(
1117
+ "GROUP BY takes column names, select aliases or values such as DATE_TRUNC('unit', column).",
1118
+ );
1119
+ if (term.kind === "num") {
1120
+ throw new DatasetSqlError("GROUP BY names columns or aliases, not positions.");
1121
+ }
1122
+ items.push(term);
1123
+ } while (this.optionalPunct(","));
1124
+ return items;
1125
+ }
1126
+
1127
+ private orderBy() {
1128
+ if (!this.peekWord("ORDER")) return [] as SortList;
1129
+ this.take();
1130
+ this.expectWord("BY", "ORDER is followed by BY.");
1131
+ return this.sortList();
1132
+ }
1133
+
1134
+ private sortList() {
1135
+ const items: SortList = [];
1136
+ do {
1137
+ const term = this.term("ORDER BY takes a column, an alias or an aggregate.");
1138
+ if (term.kind === "num") {
1139
+ throw new DatasetSqlError("ORDER BY names columns or aliases, not positions.");
1140
+ }
1141
+ const dir = this.peekWord("DESC")
1142
+ ? (this.take(), "desc" as const)
1143
+ : (this.peekWord("ASC") && this.take(), "asc" as const);
1144
+ items.push({ term, dir });
1145
+ } while (this.optionalPunct(","));
1146
+ return items;
1147
+ }
1148
+
1149
+ private limit() {
1150
+ if (!this.peekWord("LIMIT")) return undefined;
1151
+ this.take();
1152
+ const count = this.take();
1153
+ if (count.kind !== "number" || !Number.isInteger(count.value) || count.value < 1) {
1154
+ throw new DatasetSqlError("LIMIT takes a positive whole number.");
1155
+ }
1156
+ return count.value;
1157
+ }
1158
+
1159
+ private offset(sorted: boolean) {
1160
+ if (!this.peekWord("OFFSET")) return undefined;
1161
+ this.take();
1162
+ const count = this.take();
1163
+ if (count.kind !== "number" || !Number.isInteger(count.value) || count.value < 0) {
1164
+ throw new DatasetSqlError("OFFSET takes a whole number.");
1165
+ }
1166
+ if (!sorted) {
1167
+ throw new DatasetSqlError(
1168
+ "OFFSET needs ORDER BY; without one the rows have no order to skip in.",
1169
+ );
1170
+ }
1171
+ return count.value;
1172
+ }
1173
+
1174
+ /** `a.b` as one reference; a bare keyword in reference position gets the caller's message. */
1175
+ private reference(message: string) {
1176
+ const token = this.peek();
1177
+ if (!isName(token)) throw new DatasetSqlError(message);
1178
+ this.take();
1179
+ let ref = token.value;
1180
+ while (this.peekPunct(".")) {
1181
+ this.take();
1182
+ const next = this.take();
1183
+ if (next.kind !== "word")
1184
+ throw new DatasetSqlError(`"${ref}." needs a column name after the dot.`);
1185
+ ref = `${ref}.${next.value}`;
1186
+ }
1187
+ return ref;
1188
+ }
1189
+
1190
+ private identifier(message: string) {
1191
+ const token = this.take();
1192
+ if (!isName(token)) throw new DatasetSqlError(message);
1193
+ return token.value;
1194
+ }
1195
+
1196
+ /** A string, number or boolean; `DATE '...'` and `TIMESTAMP '...'` are the string they wrap, CURRENT_DATE and NOW() the date they fold to. */
1197
+ private literal(message: string) {
1198
+ if (NOW_WORDS.some((word) => this.peekWord(word))) {
1199
+ const term = this.term(message);
1200
+ if (term.kind !== "date") throw new DatasetSqlError(message);
1201
+ return isoDate(term.at);
1202
+ }
1203
+ const token = this.take();
1204
+ if (token.kind === "word" && ["DATE", "TIMESTAMP"].includes(token.upper)) {
1205
+ const wrapped = this.take();
1206
+ if (wrapped.kind !== "string") {
1207
+ throw new DatasetSqlError(
1208
+ `${token.upper} takes a quoted value: ${token.upper} '2026-01-01'.`,
1209
+ );
1210
+ }
1211
+ return wrapped.value;
1212
+ }
1213
+ if (token.kind === "string") return token.value;
1214
+ if (token.kind === "number") return token.value;
1215
+ if (token.kind === "word" && (token.upper === "TRUE" || token.upper === "FALSE"))
1216
+ return token.upper === "TRUE";
1217
+ if (token.kind === "word" && token.upper === "NULL") {
1218
+ throw new DatasetSqlError("Compare with NULL using IS NULL or IS NOT NULL.");
1219
+ }
1220
+ throw new DatasetSqlError(message);
1221
+ }
1222
+
1223
+ private optionalAlias() {
1224
+ if (this.peekWord("AS")) {
1225
+ this.take();
1226
+ return this.identifier("AS needs a name.");
1227
+ }
1228
+ const token = this.peek();
1229
+ if (!isName(token)) return undefined;
1230
+ this.take();
1231
+ return token.value;
1232
+ }
1233
+
1234
+ private end() {
1235
+ const token = this.peek();
1236
+ if (token.kind === "end") return;
1237
+ if (token.kind === "word" && token.upper in REFUSED_CLAUSES) {
1238
+ throw new DatasetSqlError(REFUSED_CLAUSES[token.upper as keyof typeof REFUSED_CLAUSES]);
1239
+ }
1240
+ const shown = token.kind === "punct" ? token.value : String(token.value);
1241
+ throw new DatasetSqlError(`Unexpected "${shown}" after the statement.`);
1242
+ }
1243
+
1244
+ private peek(offset = 0) {
1245
+ return this.tokens[this.position + offset] ?? { kind: "end" };
1246
+ }
1247
+ private take() {
1248
+ const token = this.peek();
1249
+ this.position += 1;
1250
+ return token;
1251
+ }
1252
+ private peekWord(upper: string) {
1253
+ const token = this.peek();
1254
+ return token.kind === "word" && token.upper === upper;
1255
+ }
1256
+ private peekPunct(value: string, offset = 0) {
1257
+ const token = this.peek(offset);
1258
+ return token.kind === "punct" && token.value === value;
1259
+ }
1260
+ private optionalPunct(value: string) {
1261
+ if (!this.peekPunct(value)) return false;
1262
+ this.take();
1263
+ return true;
1264
+ }
1265
+ private expectWord(upper: string, message: string) {
1266
+ if (!this.peekWord(upper)) {
1267
+ const token = this.peek();
1268
+ if (token.kind === "word" && token.upper in REFUSED_CLAUSES) {
1269
+ throw new DatasetSqlError(
1270
+ REFUSED_CLAUSES[token.upper as keyof typeof REFUSED_CLAUSES],
1271
+ );
1272
+ }
1273
+ throw new DatasetSqlError(message);
1274
+ }
1275
+ this.take();
1276
+ }
1277
+ private expectPunct(value: string, message: string) {
1278
+ if (!this.optionalPunct(value)) throw new DatasetSqlError(message);
1279
+ }
1280
+ }
1281
+
1282
+ const ARITHMETIC = ["+", "-", "*", "/"];
1283
+
1284
+ /** `'%x%'` contains, `'x%'` starts with, `'%x'` ends with, `'x'` equals; a wildcard inside the text has no wire operator. */
1285
+ function likeFilter(shown: string, pattern: string) {
1286
+ const inner = pattern.replace(/^%/, "").replace(/%$/, "");
1287
+ if (inner.includes("%") || inner.includes("_")) {
1288
+ throw new DatasetSqlError(
1289
+ `LIKE supports '%text%' (contains), 'text%' (starts with) and '%text' (ends with); "${shown} LIKE '${pattern}'" is none of those.`,
1290
+ );
1291
+ }
1292
+ const starts = pattern.startsWith("%");
1293
+ const ends = pattern.endsWith("%");
1294
+ const op = starts && ends ? "contains" : ends ? "startsWith" : starts ? "endsWith" : "=";
1295
+ return { op, value: inner } satisfies Pick<DatasetCondition, "op" | "value">;
1296
+ }
1297
+
1298
+ const COMPARISONS = ["=", "!=", "<>", "<", "<=", ">", ">="];
1299
+ const CONDITION_WORDS = ["AND", "OR", "NOT", "IN", "IS", "LIKE", "BETWEEN"];
1300
+
1301
+ /** A token that can only appear inside a condition, never inside a value. */
1302
+ /** +1 on the opener, -1 on the closer, 0 otherwise; punctuation and words alike. */
1303
+ function nesting(token: Token, open: string, close: string) {
1304
+ const text = token.kind === "word" ? token.upper : token.kind === "punct" ? token.value : "";
1305
+ return text === open ? 1 : text === close ? -1 : 0;
1306
+ }
1307
+
1308
+ function isConditionWord(token: Token) {
1309
+ if (token.kind === "punct") return COMPARISONS.includes(token.value);
1310
+ return token.kind === "word" && CONDITION_WORDS.includes(token.upper);
1311
+ }
1312
+
1313
+ /** One predicate for a list: itself when alone, else an `all` group. */
1314
+ function grouped(predicates: Predicate[]) {
1315
+ if (predicates.length === 1) return predicates[0]!;
1316
+ return { kind: "all", of: predicates } satisfies Predicate;
1317
+ }
1318
+
1319
+ /**
1320
+ * `SUM(CASE WHEN p THEN 1 ELSE 0 END)` counts the rows p holds for and
1321
+ * `AVG(CASE WHEN p THEN total END)` averages them: one branch whose ELSE
1322
+ * changes nothing is the aggregate filtered by p. Any other CASE is
1323
+ * aggregated as the value it is. COUNT counts every non-null answer, so a
1324
+ * COUNT over a CASE with an ELSE would count the other rows too.
1325
+ */
1326
+ function conditional(word: string, fn: DatasetAggregate, term: Term & { kind: "case" }) {
1327
+ if (fn === "count" && term.else !== undefined) {
1328
+ throw new DatasetSqlError(
1329
+ `ELSE would count the other rows into ${word}(); drop the ELSE, or write ${word}(...) FILTER (WHERE condition).`,
1330
+ );
1331
+ }
1332
+ const plain = { fn, term, filter: [] as Predicate[] };
1333
+ const elseZero = term.else?.kind === "num" && term.else.value === 0;
1334
+ const harmlessElse = term.else === undefined || (elseZero && fn === "sum");
1335
+ const [branch] = term.branches;
1336
+ if (branch === undefined || term.branches.length !== 1 || !harmlessElse) return plain;
1337
+ const { then } = branch;
1338
+ const counting =
1339
+ (then.kind === "num" && then.value === 1) || (then.kind === "lit" && then.value === true);
1340
+ if (counting) {
1341
+ return fn === "count" || fn === "sum"
1342
+ ? { fn: "count" as const, term: undefined, filter: branch.when }
1343
+ : plain;
1344
+ }
1345
+ if (then.kind === "num") return plain;
1346
+ return { fn, term: then, filter: branch.when };
1347
+ }
1348
+
1349
+ function unqualified(ref: string, table: string | undefined) {
1350
+ return table !== undefined && ref.startsWith(`${table}.`) ? ref.slice(table.length + 1) : ref;
1351
+ }
1352
+
1353
+ /** The outer list of a derived table: `*` keeps every inner value, names keep those; the RANK() itself is never answered. */
1354
+ function narrowed(inner: SelectItem[], outer: SelectItem[], table: string | undefined) {
1355
+ const values = inner.filter((item) => item.kind !== "rankWindow");
1356
+ if (outer.some((item) => item.kind === "star")) return values;
1357
+ const windowNames = inner.flatMap((item) => (item.kind === "rankWindow" ? [item.alias] : []));
1358
+ const kept = outer.flatMap((item) => {
1359
+ const name =
1360
+ item.kind === "column" && item.alias === undefined
1361
+ ? unqualified(item.ref, table)
1362
+ : undefined;
1363
+ if (name !== undefined && windowNames.includes(name)) return [];
1364
+ const found =
1365
+ name === undefined ? undefined : values.find((value) => namesOf(value).includes(name));
1366
+ if (!found) {
1367
+ throw new DatasetSqlError(
1368
+ "The outer SELECT of a derived table lists names the inner SELECT answers, or *.",
1369
+ );
1370
+ }
1371
+ return [found];
1372
+ });
1373
+ const unnamed = values.flatMap((value) =>
1374
+ (value.kind === "aggregate" || value.kind === "window") && !kept.includes(value)
1375
+ ? [{ ...value, hidden: true as const }]
1376
+ : [],
1377
+ );
1378
+ return [...kept, ...unnamed];
1379
+ }
1380
+
1381
+ function namesOf(item: SelectItem) {
1382
+ if (item.kind === "star" || item.kind === "rankWindow") return [];
1383
+ if (item.alias !== undefined) return [item.alias];
1384
+ return item.kind === "column" ? [item.ref, lastSegment(item.ref)] : [];
1385
+ }
1386
+
1387
+ /** Every column reference a term names, in order, conditions inside a CASE included. */
1388
+ function termRefs(term: Term): string[] {
1389
+ switch (term.kind) {
1390
+ case "ref":
1391
+ return [term.ref];
1392
+ case "num":
1393
+ case "lit":
1394
+ case "date":
1395
+ case "interval":
1396
+ case "agg":
1397
+ case "window":
1398
+ return [];
1399
+ case "arith":
1400
+ return [...termRefs(term.left), ...termRefs(term.right)];
1401
+ case "dateTrunc":
1402
+ case "datePart":
1403
+ return termRefs(term.of);
1404
+ case "fn":
1405
+ return term.args.flatMap(termRefs);
1406
+ case "case":
1407
+ return [
1408
+ ...term.branches.flatMap((branch) => [
1409
+ ...branch.when.flatMap(predicateRefs),
1410
+ ...termRefs(branch.then),
1411
+ ]),
1412
+ ...(term.else === undefined ? [] : termRefs(term.else)),
1413
+ ];
1414
+ }
1415
+ }
1416
+
1417
+ function predicateRefs(predicate: Predicate): string[] {
1418
+ if (predicate.kind === "rank") return [];
1419
+ if (predicate.kind === "not") return predicateRefs(predicate.of);
1420
+ if (predicate.kind !== "cond") return predicate.of.flatMap(predicateRefs);
1421
+ const right = isTermValue(predicate.value) ? termRefs(predicate.value.term) : [];
1422
+ return [...termRefs(predicate.left), ...right];
1423
+ }
1424
+
1425
+ function isTermValue(
1426
+ value: Extract<Predicate, { kind: "cond" }>["value"],
1427
+ ): value is { term: Term } {
1428
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1429
+ }
1430
+
1431
+ /** Whether the term, or any value inside it (a window's operand and conditions included), passes `test`. */
1432
+ function termHas(term: Term, test: (term: Term) => boolean): boolean {
1433
+ if (test(term)) return true;
1434
+ const has = (inner: Term) => termHas(inner, test);
1435
+ switch (term.kind) {
1436
+ case "arith":
1437
+ return has(term.left) || has(term.right);
1438
+ case "dateTrunc":
1439
+ case "datePart":
1440
+ return has(term.of);
1441
+ case "fn":
1442
+ return term.args.some(has);
1443
+ case "case":
1444
+ return (
1445
+ term.branches.some(
1446
+ (branch) => has(branch.then) || branch.when.some((p) => predicateHas(p, test)),
1447
+ ) ||
1448
+ (term.else !== undefined && has(term.else))
1449
+ );
1450
+ case "window":
1451
+ return (
1452
+ (term.item.term !== undefined && has(term.item.term)) ||
1453
+ term.item.filter.some((p) => predicateHas(p, test)) ||
1454
+ term.item.partitionBy.some(has)
1455
+ );
1456
+ default:
1457
+ return false;
1458
+ }
1459
+ }
1460
+
1461
+ function predicateHas(predicate: Predicate, test: (term: Term) => boolean): boolean {
1462
+ if (predicate.kind === "rank") return false;
1463
+ if (predicate.kind === "not") return predicateHas(predicate.of, test);
1464
+ if (predicate.kind !== "cond") return predicate.of.some((p) => predicateHas(p, test));
1465
+ return (
1466
+ termHas(predicate.left, test) ||
1467
+ (isTermValue(predicate.value) && termHas(predicate.value.term, test))
1468
+ );
1469
+ }
1470
+
1471
+ function isAggregateCall(term: Term) {
1472
+ return term.kind === "agg";
1473
+ }
1474
+
1475
+ /** An aggregate call anywhere in the term, a window's operand included: what makes the query a grouped one. */
1476
+ function hasAggregate(term: Term) {
1477
+ return termHas(term, isAggregateCall);
1478
+ }
1479
+
1480
+ function hasWindow(term: Term) {
1481
+ return termHas(term, (inner) => inner.kind === "window");
1482
+ }
1483
+
1484
+ /** A term as written, for messages. */
1485
+ function describeTerm(term: Term): string {
1486
+ switch (term.kind) {
1487
+ case "ref":
1488
+ return term.ref;
1489
+ case "num":
1490
+ return String(term.value);
1491
+ case "lit":
1492
+ return typeof term.value === "string"
1493
+ ? `'${term.value}'`
1494
+ : String(term.value).toUpperCase();
1495
+ case "date":
1496
+ return `'${isoDate(term.at)}'`;
1497
+ case "interval":
1498
+ return "INTERVAL";
1499
+ case "agg":
1500
+ return `${term.item.fn.toUpperCase()}(${term.item.term ? describeTerm(term.item.term) : "*"})`;
1501
+ case "window": {
1502
+ const { partitionBy } = term.item;
1503
+ const over = partitionBy.length
1504
+ ? `PARTITION BY ${partitionBy.map(describeTerm).join(", ")}`
1505
+ : "";
1506
+ return `${describeTerm({ kind: "agg", item: term.item })} OVER (${over})`;
1507
+ }
1508
+ case "arith":
1509
+ return `${describeTerm(term.left)} ${term.op} ${describeTerm(term.right)}`;
1510
+ case "dateTrunc":
1511
+ return `DATE_TRUNC('${term.unit}', ${describeTerm(term.of)})`;
1512
+ case "datePart":
1513
+ return `${term.unit.toUpperCase()}(${describeTerm(term.of)})`;
1514
+ case "fn":
1515
+ return `${term.fn.toUpperCase()}(${term.args.map(describeTerm).join(", ")})`;
1516
+ case "case":
1517
+ return "CASE ... END";
1518
+ }
1519
+ }
1520
+ /**
1521
+ * The JOIN clause(s) that reach a relation, as `describeDataset` prints them
1522
+ * and refusals suggest them. Hosts always join left, so that is what is
1523
+ * printed. `alias` names the target when the dataset joins it more than once.
1524
+ */
1525
+ export function datasetJoinSql(from: string, relation: DatasetRelation, alias?: string) {
1526
+ const { through } = relation;
1527
+ const target = alias ?? relation.dataset;
1528
+ const as = alias ? ` AS ${alias}` : "";
1529
+ if (!through) {
1530
+ return `LEFT JOIN ${relation.dataset}${as} ON ${from}.${relation.on[0]} = ${target}.${relation.on[1]}`;
1531
+ }
1532
+ return [
1533
+ `LEFT JOIN ${through.dataset} ON ${from}.${relation.on[0]} = ${through.dataset}.${relation.on[1]}`,
1534
+ `LEFT JOIN ${relation.dataset}${as} ON ${through.dataset}.${through.on[0]} = ${target}.${through.on[1]}`,
1535
+ ].join(" ");
1536
+ }
1537
+
1538
+ /** Every join a dataset declares, one line each; a target reached by two relations is aliased by the relation name so both can appear in one statement. */
1539
+ export function datasetJoinLines(definition: DatasetDefinition) {
1540
+ const relations = Object.entries(definition.relations);
1541
+ const targets = relations.map(([, r]) => r.dataset);
1542
+ return relations.map(([name, relation]) => {
1543
+ const repeated = targets.filter((t) => t === relation.dataset).length > 1;
1544
+ return datasetJoinSql(definition.name, relation, repeated ? name : undefined);
1545
+ });
1546
+ }
1547
+
1548
+ /** A name that is no dataset; a link table of a many-to-many says which datasets to start from instead. */
1549
+ function unknownDataset(catalog: DatasetCatalog, name: string) {
1550
+ for (const definition of catalog.values()) {
1551
+ for (const relation of Object.values(definition.relations)) {
1552
+ if (relation.through?.dataset === name) {
1553
+ return `"${name}" is the link table between "${definition.name}" and "${relation.dataset}"; start FROM one of those.`;
1554
+ }
1555
+ }
1556
+ }
1557
+ return `Unknown dataset "${name}".`;
1558
+ }
1559
+
1560
+ /** A dataset the statement reads, under the relation path the wire query reaches it by (empty for FROM). */
1561
+ type Source = { path: string; dataset: DatasetDefinition };
1562
+ /** The link table of a many-to-many, joined but not readable; the next JOIN to the relation's target completes it. */
1563
+ type Link = {
1564
+ from: Source;
1565
+ relationName: string;
1566
+ relation: DatasetRelation;
1567
+ completed?: true;
1568
+ /** Joined with JOIN or INNER JOIN; the JOIN completing it must be the same kind. */
1569
+ inner: boolean;
1570
+ };
1571
+
1572
+ function isLink(entry: Source | Link): entry is Link {
1573
+ return "relation" in entry;
1574
+ }
1575
+
1576
+ /** The two sides of `a.x = b.y` as [qualifier, column]. */
1577
+ function keyOf(ref: string) {
1578
+ const parts = ref.split(".");
1579
+ if (parts.length !== 2) {
1580
+ throw new DatasetSqlError(`ON needs dataset.column on both sides; "${ref}" is not that.`);
1581
+ }
1582
+ return parts as [string, string];
1583
+ }
1584
+
1585
+ /**
1586
+ * The datasets a statement names and how each is reached: FROM is the
1587
+ * root, every JOIN is matched to a relation the parent declares by its
1588
+ * target and ON keys. Qualifiers are dataset names or aliases; a dataset
1589
+ * joined twice is only reachable through its aliases.
1590
+ */
1591
+ class Sources {
1592
+ private readonly byName = new Map<string, Source | Link | "ambiguous">();
1593
+ private readonly root: Source;
1594
+ /** Relation paths the wire query joins, in JOIN order. */
1595
+ public readonly paths: string[] = [];
1596
+ /**
1597
+ * What each INNER JOIN adds to WHERE. The wire joins keep rows without a
1598
+ * match, whose joined key is null; a matched row's key never is, so
1599
+ * requiring it drops exactly the rows an inner join would.
1600
+ */
1601
+ public readonly matched: DatasetCondition[] = [];
1602
+
1603
+ public constructor(
1604
+ private readonly catalog: DatasetCatalog,
1605
+ from: string,
1606
+ aliases: string[],
1607
+ ) {
1608
+ const dataset = catalog.get(from);
1609
+ if (!dataset) throw new DatasetSqlError(unknownDataset(catalog, from));
1610
+ this.root = { path: "", dataset };
1611
+ for (const name of [from, ...aliases]) this.name(name, this.root);
1612
+ }
1613
+
1614
+ /** An aliased JOIN is known by its alias only, as in SQL, so `JOIN employees m` leaves `employees` pointing at FROM. */
1615
+ public join({ dataset, alias, left, right, inner }: JoinClause) {
1616
+ const [leftQualifier, leftColumn] = keyOf(left);
1617
+ const [rightQualifier, rightColumn] = keyOf(right);
1618
+ const own = alias ?? dataset;
1619
+ const isNew = (qualifier: string) => qualifier === own;
1620
+ if (!isNew(leftQualifier) && !isNew(rightQualifier)) {
1621
+ throw new DatasetSqlError(
1622
+ `JOIN ${dataset} ... ON must compare a column of ${own} with a column of a dataset already in the query.`,
1623
+ );
1624
+ }
1625
+ const [parentQualifier, parentKey, targetKey] = isNew(rightQualifier)
1626
+ ? [leftQualifier, leftColumn, rightColumn]
1627
+ : [rightQualifier, rightColumn, leftColumn];
1628
+ const parent = this.byName.get(parentQualifier);
1629
+ if (parent === undefined || parent === "ambiguous") {
1630
+ throw new DatasetSqlError(
1631
+ `"${parentQualifier}" is not in the query before JOIN ${dataset}.`,
1632
+ );
1633
+ }
1634
+ const source = isLink(parent)
1635
+ ? this.completeLink(parent, dataset, parentKey, targetKey)
1636
+ : this.joinTo(parent, dataset, parentKey, targetKey, inner);
1637
+ // A link table and its target are one hop on the wire, which cannot keep a row whose link matched but whose target did not: the link's columns are never read.
1638
+ if (isLink(parent) && parent.inner !== inner) {
1639
+ throw new DatasetSqlError(
1640
+ `JOIN ${parent.relation.through!.dataset} and JOIN ${dataset} are one many-to-many join: write both as JOIN or both as LEFT JOIN.`,
1641
+ );
1642
+ }
1643
+ this.name(own, source);
1644
+ if ("path" in source && inner) {
1645
+ this.matched.push({ col: `${source.path}.${targetKey}`, op: "isNotNull" });
1646
+ }
1647
+ }
1648
+
1649
+ /** A link table joined without the JOIN to its target is a join to nothing; say what completes it. */
1650
+ public assertComplete() {
1651
+ for (const [qualifier, entry] of this.byName) {
1652
+ if (entry === "ambiguous" || !isLink(entry) || entry.completed) continue;
1653
+ throw new DatasetSqlError(
1654
+ `"${qualifier}" is only the link to "${entry.relation.dataset}"; write ${datasetJoinSql(entry.from.dataset.name, entry.relation)}.`,
1655
+ );
1656
+ }
1657
+ }
1658
+
1659
+ /** `qualifier.column` as the wire reference `relationPath.column`; a bare column is looked up on FROM first, then on the joined datasets. */
1660
+ public resolve(ref: string) {
1661
+ const dot = ref.indexOf(".");
1662
+ if (dot === -1) return this.bareColumn(ref);
1663
+ const qualifier = ref.slice(0, dot);
1664
+ const column = ref.slice(dot + 1);
1665
+ if (column.includes(".")) {
1666
+ throw new DatasetSqlError(
1667
+ `"${ref}": write one JOIN per dataset and reference dataset.column.`,
1668
+ );
1669
+ }
1670
+ const source = this.byName.get(qualifier);
1671
+ if (source === undefined) throw new DatasetSqlError(this.unknownQualifier(qualifier));
1672
+ if (source === "ambiguous") {
1673
+ throw new DatasetSqlError(
1674
+ `"${qualifier}" names more than one dataset in the query; give each JOIN its own alias.`,
1675
+ );
1676
+ }
1677
+ if (isLink(source)) {
1678
+ throw new DatasetSqlError(
1679
+ `"${qualifier}" is a link table; read its columns from the datasets it links.`,
1680
+ );
1681
+ }
1682
+ return source.path ? `${source.path}.${column}` : column;
1683
+ }
1684
+
1685
+ private name(qualifier: string, source: Source | Link) {
1686
+ this.byName.set(qualifier, this.byName.has(qualifier) ? "ambiguous" : source);
1687
+ }
1688
+
1689
+ /** FROM's own column names, what a plain row answers bare. */
1690
+ public get columnNames() {
1691
+ return this.root.dataset.columns.map((column) => column.name);
1692
+ }
1693
+
1694
+ /** Where a wire reference lands in the answered row: under the qualifier its dataset was named by, or bare for FROM's own columns. */
1695
+ public placeOf(wireRef: string) {
1696
+ const dot = wireRef.lastIndexOf(".");
1697
+ if (dot === -1) return [wireRef];
1698
+ const path = wireRef.slice(0, dot);
1699
+ for (const [qualifier, entry] of this.byName) {
1700
+ if (entry !== "ambiguous" && !isLink(entry) && entry.path === path) {
1701
+ return [qualifier, wireRef.slice(dot + 1)];
1702
+ }
1703
+ }
1704
+ return [wireRef];
1705
+ }
1706
+
1707
+ /** The relation whose target (or link table) and ON keys match; two relations to one dataset differ by their keys. */
1708
+ private joinTo(
1709
+ parent: Source,
1710
+ dataset: string,
1711
+ parentKey: string,
1712
+ targetKey: string,
1713
+ inner: boolean,
1714
+ ) {
1715
+ const candidates = Object.entries(parent.dataset.relations).filter(
1716
+ ([, r]) => (r.through?.dataset ?? r.dataset) === dataset,
1717
+ );
1718
+ if (candidates.length === 0) {
1719
+ throw new DatasetSqlError(
1720
+ `"${parent.dataset.name}" declares no join to "${dataset}". ${this.inverseOf(parent, dataset) ?? this.joinsOf(parent)}`,
1721
+ );
1722
+ }
1723
+ const found = candidates.find(([, r]) => r.on[0] === parentKey && r.on[1] === targetKey);
1724
+ if (!found) {
1725
+ const all = datasetJoinLines(parent.dataset);
1726
+ const names = Object.keys(parent.dataset.relations);
1727
+ const lines = candidates.map(([name]) => all[names.indexOf(name)]!);
1728
+ throw new DatasetSqlError(
1729
+ `"${parent.dataset.name}" reaches "${dataset}" as ${lines.join(", or ")}.`,
1730
+ );
1731
+ }
1732
+ const [relationName, relation] = found;
1733
+ if (!relation.through) return this.source(parent, relationName, relation.dataset);
1734
+ return { from: parent, relationName, relation, inner } satisfies Link;
1735
+ }
1736
+
1737
+ private completeLink(link: Link, dataset: string, linkKey: string, targetKey: string) {
1738
+ const { relation } = link;
1739
+ const through = relation.through!;
1740
+ const matches =
1741
+ relation.dataset === dataset &&
1742
+ through.on[0] === linkKey &&
1743
+ through.on[1] === targetKey;
1744
+ if (!matches) {
1745
+ throw new DatasetSqlError(
1746
+ `"${through.dataset}" is the link to "${relation.dataset}": ${datasetJoinSql(link.from.dataset.name, relation)}.`,
1747
+ );
1748
+ }
1749
+ link.completed = true;
1750
+ return this.source(link.from, link.relationName, dataset);
1751
+ }
1752
+
1753
+ private source(parent: Source, relationName: string, datasetName: string) {
1754
+ const dataset = this.catalog.get(datasetName);
1755
+ if (!dataset) throw new DatasetSqlError(`"${datasetName}" is not an exposed dataset.`);
1756
+ const path = parent.path ? `${parent.path}.${relationName}` : relationName;
1757
+ this.paths.push(path);
1758
+ return { path, dataset } satisfies Source;
1759
+ }
1760
+
1761
+ private bareColumn(column: string) {
1762
+ const has = (source: Source) => source.dataset.columns.some((c) => c.name === column);
1763
+ if (has(this.root)) return column;
1764
+ const owners = this.sources().filter((source) => source.path && has(source));
1765
+ if (owners.length === 1) return `${owners[0]!.path}.${column}`;
1766
+ if (owners.length > 1) {
1767
+ throw new DatasetSqlError(
1768
+ `"${column}" is a column of ${owners.map((o) => o.dataset.name).join(" and ")}; qualify it as dataset.column.`,
1769
+ );
1770
+ }
1771
+ const joined = this.paths.length ? " or of the joined datasets" : "";
1772
+ throw new DatasetSqlError(
1773
+ `"${column}" is not a column of ${this.root.dataset.name}${joined}.`,
1774
+ );
1775
+ }
1776
+
1777
+ /** Every readable dataset once, whatever it is named as. */
1778
+ private sources() {
1779
+ const seen = new Set<Source>();
1780
+ for (const entry of this.byName.values()) {
1781
+ if (entry !== "ambiguous" && !isLink(entry)) seen.add(entry);
1782
+ }
1783
+ return [...seen];
1784
+ }
1785
+
1786
+ private unknownQualifier(qualifier: string) {
1787
+ for (const source of this.sources()) {
1788
+ const relations = Object.entries(source.dataset.relations);
1789
+ const index = relations.findIndex(
1790
+ ([name, r]) => name === qualifier || r.dataset === qualifier,
1791
+ );
1792
+ if (index === -1) continue;
1793
+ const [, relation] = relations[index]!;
1794
+ const line = datasetJoinLines(source.dataset)[index]!;
1795
+ return this.byName.has(relation.dataset)
1796
+ ? `"${qualifier}" is the relation's name; reference ${relation.dataset}.column.`
1797
+ : `"${qualifier}" is not joined. Add ${line} and reference ${relation.dataset}.column.`;
1798
+ }
1799
+ return `"${qualifier}" is neither the dataset, a joined dataset nor an alias. ${this.joinsOf(this.root)}`;
1800
+ }
1801
+
1802
+ /** A one-to-many written from the parent side (`FROM plans JOIN planTasks`) is declared the other way round; say where to start. */
1803
+ private inverseOf(parent: Source, dataset: string) {
1804
+ const child = this.catalog.get(dataset);
1805
+ if (!child) return undefined;
1806
+ const index = Object.values(child.relations).findIndex(
1807
+ (r) => r.dataset === parent.dataset.name && !r.through,
1808
+ );
1809
+ if (index === -1) return undefined;
1810
+ return `"${dataset}" joins "${parent.dataset.name}", so start there: FROM ${dataset} ${datasetJoinLines(child)[index]}.`;
1811
+ }
1812
+
1813
+ private joinsOf(source: Source) {
1814
+ const lines = datasetJoinLines(source.dataset);
1815
+ return lines.length
1816
+ ? `Joins "${source.dataset.name}" declares: ${lines.join("; ")}.`
1817
+ : `"${source.dataset.name}" declares no joins.`;
1818
+ }
1819
+ }
1820
+
1821
+ type Statement = ReturnType<Parser["statement"]>;
1822
+ type Resolve = (ref: string) => string;
1823
+ type SelectEntry = string | DatasetNamedOperand;
1824
+ type GroupKey = NonNullable<DatasetQueryInput["groupBy"]>[number];
1825
+
1826
+ /**
1827
+ * Where a term is compiled: a row (columns only), the grouped row, where
1828
+ * names are aliases or group keys and an aggregate call finds or adds its
1829
+ * alias, or the finished row, where a window call finds or adds its alias too.
1830
+ */
1831
+ type Scope = {
1832
+ resolve: Resolve;
1833
+ aggregateAlias?: (item: AggregateCall, clause?: string) => string;
1834
+ windowAlias?: (item: WindowItem, clause?: string) => string;
1835
+ };
1836
+
1837
+ function toOperand(term: Term, scope: Scope): DatasetOperand {
1838
+ switch (term.kind) {
1839
+ case "ref":
1840
+ return scope.resolve(term.ref);
1841
+ case "num":
1842
+ return term.value;
1843
+ case "lit":
1844
+ return { value: term.value };
1845
+ case "date":
1846
+ return { value: isoDate(term.at) };
1847
+ case "interval":
1848
+ throw new DatasetSqlError(INTERVAL_PLACE);
1849
+ case "agg":
1850
+ if (!scope.aggregateAlias) {
1851
+ throw new DatasetSqlError(
1852
+ `${describeTerm(term)} belongs in SELECT or HAVING, not in a row condition.`,
1853
+ );
1854
+ }
1855
+ return scope.aggregateAlias(term.item);
1856
+ case "window":
1857
+ if (!scope.windowAlias) throw new DatasetSqlError(WINDOW_IN_CONDITION);
1858
+ return scope.windowAlias(term.item);
1859
+ case "arith":
1860
+ return {
1861
+ op: term.op,
1862
+ left: toOperand(term.left, scope),
1863
+ right: toOperand(term.right, scope),
1864
+ };
1865
+ case "dateTrunc":
1866
+ return { fn: "dateTrunc", unit: term.unit, of: toOperand(term.of, scope) };
1867
+ case "datePart":
1868
+ return { fn: "datePart", unit: term.unit, of: toOperand(term.of, scope) };
1869
+ case "fn":
1870
+ return { fn: term.fn, args: term.args.map((arg) => toOperand(arg, scope)) };
1871
+ case "case":
1872
+ return {
1873
+ case: term.branches.map((branch) => ({
1874
+ when: toFilter(grouped(branch.when), scope),
1875
+ then: toOperand(branch.then, scope),
1876
+ })),
1877
+ ...(term.else === undefined ? {} : { else: toOperand(term.else, scope) }),
1878
+ };
1879
+ }
1880
+ }
1881
+
1882
+ function toFilter(predicate: Predicate, scope: Scope): DatasetFilter {
1883
+ if (predicate.kind === "rank") {
1884
+ throw new DatasetSqlError(
1885
+ scope.windowAlias
1886
+ ? "RANK() OVER (...) <= n stands on its own in QUALIFY, not under NOT, OR or parentheses."
1887
+ : WINDOW_PLACE,
1888
+ );
1889
+ }
1890
+ if (predicate.kind === "not") return { not: toFilter(predicate.of, scope) };
1891
+ if (predicate.kind === "any")
1892
+ return { any: predicate.of.map((entry) => toFilter(entry, scope)) };
1893
+ if (predicate.kind === "all")
1894
+ return { all: predicate.of.map((entry) => toFilter(entry, scope)) };
1895
+ const col = toOperand(predicate.left, scope);
1896
+ if (typeof col === "number" || (typeof col === "object" && "value" in col)) {
1897
+ throw new DatasetSqlError(
1898
+ `A condition starts with a column, not the literal ${describeOperand(col)}.`,
1899
+ );
1900
+ }
1901
+ const value = isTermValue(predicate.value)
1902
+ ? asValue(toOperand(predicate.value.term, scope))
1903
+ : predicate.value;
1904
+ return {
1905
+ col,
1906
+ op: predicate.op,
1907
+ ...(value === undefined ? {} : { value }),
1908
+ } satisfies DatasetCondition;
1909
+ }
1910
+
1911
+ /** A compared value that is a literal stays one on the wire; a column or arithmetic is `{ col }`. */
1912
+ function asValue(operand: DatasetOperand) {
1913
+ if (typeof operand === "number") return operand;
1914
+ if (typeof operand === "object" && "value" in operand) return operand.value;
1915
+ return { col: operand };
1916
+ }
1917
+
1918
+ function same(left: unknown, right: unknown) {
1919
+ return JSON.stringify(left) === JSON.stringify(right);
1920
+ }
1921
+
1922
+ function isNamed(entry: SelectEntry | GroupKey): entry is DatasetNamedOperand {
1923
+ return typeof entry === "object";
1924
+ }
1925
+
1926
+ /** `SUM(total) AS revenue` becomes a wire aggregate; the alias (or the default one) is what ORDER BY and HAVING may name. */
1927
+ function toAggregate(item: AggregateCall, scope: Scope) {
1928
+ const col = item.term ? toOperand(item.term, scope) : undefined;
1929
+ if (typeof col === "number")
1930
+ throw new DatasetSqlError(`${item.fn.toUpperCase()}(${col}) has no meaning.`);
1931
+ const as = item.alias ?? defaultAlias(item.fn, col);
1932
+ const filter = item.filter.map((entry) => toFilter(entry, scope));
1933
+ return {
1934
+ fn: item.fn,
1935
+ ...(col === undefined ? {} : { col }),
1936
+ as,
1937
+ ...(filter.length ? { filter } : {}),
1938
+ } satisfies WireAggregate;
1939
+ }
1940
+
1941
+ function sameAggregate(left: WireAggregate, right: WireAggregate) {
1942
+ return (
1943
+ left.fn === right.fn &&
1944
+ same(left.col ?? null, right.col ?? null) &&
1945
+ same(left.filter ?? [], right.filter ?? [])
1946
+ );
1947
+ }
1948
+
1949
+ /** `AVG(total) OVER (PARTITION BY customerId)` becomes a wire window: the aggregate, plus the partition its rows share. */
1950
+ function toWindow(item: WindowItem, scope: Scope, named: DatasetNamedOperand[]) {
1951
+ const partitionBy = item.partitionBy.map((term) =>
1952
+ resolveSort(term, scope, named, "PARTITION BY"),
1953
+ );
1954
+ return { ...toAggregate(item, scope), partitionBy } satisfies WireWindow;
1955
+ }
1956
+
1957
+ function sameWindow(left: WireWindow, right: WireWindow) {
1958
+ return sameAggregate(left, right) && same(left.partitionBy, right.partitionBy);
1959
+ }
1960
+
1961
+ /**
1962
+ * Walks the SELECT list in order: aggregates become wire aggregates;
1963
+ * columns and values the entries a plain query selects and a grouped one
1964
+ * must group by; calculations over aggregates (or their aliases) the
1965
+ * compute items, compiled once the group keys are known. Every entry lands
1966
+ * in the row where the statement wrote it.
1967
+ */
1968
+ function collectSelect(items: SelectItem[], resolve: Resolve) {
1969
+ const list = {
1970
+ shape: Object.create(null) as Record<string, string[]>,
1971
+ aggregates: [] as WireAggregate[],
1972
+ entries: [] as SelectEntry[],
1973
+ compute: [] as { as: string; term: Term }[],
1974
+ /** Windows as written, compiled once the group keys are known since they name the row's own values. */
1975
+ windows: [] as (WindowItem & { alias: string })[],
1976
+ /** The term behind each named entry, for a grouped query to work out instead of group by. */
1977
+ terms: new Map<string, Term>(),
1978
+ aliasToKey: new Map<string, string>(),
1979
+ selected: new Set<string>(),
1980
+ star: items.some((item) => item.kind === "star"),
1981
+ };
1982
+ for (const item of items) {
1983
+ if (item.kind === "aggregate") collectAggregate(list, item, resolve);
1984
+ else if (item.kind === "window") collectWindow(list, item);
1985
+ else if (item.kind === "column") collectColumn(list, item, resolve);
1986
+ else if (item.kind === "expr") collectValue(list, item, resolve);
1987
+ }
1988
+ return list;
1989
+ }
1990
+
1991
+ type SelectList = ReturnType<typeof collectSelect>;
1992
+
1993
+ function collectWindow(list: SelectList, item: Extract<SelectItem, { kind: "window" }>) {
1994
+ const taken =
1995
+ list.selected.has(item.alias) || list.windows.some((other) => other.alias === item.alias);
1996
+ if (taken) throw new DatasetSqlError(`"${item.alias}" names two things; pick another alias.`);
1997
+ list.windows.push({ ...item.item, alias: item.alias });
1998
+ list.aliasToKey.set(item.alias, item.alias);
1999
+ if (!item.hidden) list.selected.add(item.alias);
2000
+ }
2001
+
2002
+ function collectAggregate(list: SelectList, item: AggregateItem, resolve: Resolve) {
2003
+ const aggregate = toAggregate(item, { resolve });
2004
+ if (list.aggregates.some((other) => other.as === aggregate.as)) {
2005
+ throw new DatasetSqlError(
2006
+ `Two aggregates would answer as "${aggregate.as}"; give each its own alias.`,
2007
+ );
2008
+ }
2009
+ list.aggregates.push(aggregate);
2010
+ list.aliasToKey.set(aggregate.as, aggregate.as);
2011
+ if (!item.hidden) list.selected.add(aggregate.as);
2012
+ }
2013
+
2014
+ function collectColumn(
2015
+ list: SelectList,
2016
+ item: Extract<SelectItem, { kind: "column" }>,
2017
+ resolve: Resolve,
2018
+ ) {
2019
+ const key = resolve(item.ref);
2020
+ list.entries.push(key);
2021
+ claim(list, key, item.alias ? [item.alias] : item.ref.split("."));
2022
+ if (item.alias) list.aliasToKey.set(item.alias, key);
2023
+ }
2024
+
2025
+ /** A row key is answered once: two select items landing on one key have nowhere to go. */
2026
+ function claim(list: SelectList, key: string, path: string[]) {
2027
+ if (list.shape[key] !== undefined) {
2028
+ throw new DatasetSqlError(`"${key}" names two things; pick another alias.`);
2029
+ }
2030
+ list.shape[key] = path;
2031
+ }
2032
+
2033
+ /**
2034
+ * A named value: over aggregates, their aliases or an earlier calculation it
2035
+ * is worked out per grouped row (compute); otherwise it is a select entry a
2036
+ * grouped query must group by.
2037
+ */
2038
+ function collectValue(
2039
+ list: SelectList,
2040
+ item: Extract<SelectItem, { kind: "expr" }>,
2041
+ resolve: Resolve,
2042
+ ) {
2043
+ const column = dateColumnOf(item.term);
2044
+ const name = item.alias ?? lastSegment(column ?? "");
2045
+ list.aliasToKey.set(name, name);
2046
+ const overAggregates =
2047
+ hasAggregate(item.term) ||
2048
+ hasWindow(item.term) ||
2049
+ termRefs(item.term).some(
2050
+ (ref) => list.selected.has(ref) || list.compute.some((entry) => entry.as === ref),
2051
+ );
2052
+ if (overAggregates) {
2053
+ list.compute.push({ as: name, term: item.term });
2054
+ return;
2055
+ }
2056
+ list.entries.push({ expr: toOperand(item.term, { resolve } satisfies Scope), as: name });
2057
+ list.terms.set(name, item.term);
2058
+ claim(list, name, (item.alias ?? column ?? name).split("."));
2059
+ }
2060
+
2061
+ /**
2062
+ * GROUP BY may name a column, a select alias, or repeat a selected value;
2063
+ * a bare column a selected date value is over takes that value as its key
2064
+ * (`SELECT DATE_TRUNC('month', placedAt) ... GROUP BY placedAt`). A value
2065
+ * nobody selected is keyed under the date column's own name, or refused.
2066
+ */
2067
+ function resolveGroupKeys(
2068
+ entries: SelectEntry[],
2069
+ groupBy: Term[],
2070
+ aliasToKey: Map<string, string>,
2071
+ scope: Scope,
2072
+ ) {
2073
+ const named = entries.filter(isNamed);
2074
+ const keys = new Map<string, GroupKey>();
2075
+ for (const group of groupBy) {
2076
+ let key: GroupKey;
2077
+ if (group.kind === "ref") {
2078
+ const name = aliasToKey.get(group.ref) ?? scope.resolve(group.ref);
2079
+ const lends = !entries.includes(name);
2080
+ key =
2081
+ named.find(
2082
+ (entry) =>
2083
+ entry.as === name || (lends && dateColumnOfOperand(entry.expr) === name),
2084
+ ) ?? name;
2085
+ } else {
2086
+ const expr = toOperand(group, scope);
2087
+ const twin = named.find((entry) => same(entry.expr, expr));
2088
+ const column = dateColumnOf(group);
2089
+ if (!twin && column === undefined) {
2090
+ throw new DatasetSqlError(
2091
+ `Name the value in SELECT (${describeTerm(group)} AS name) and GROUP BY name.`,
2092
+ );
2093
+ }
2094
+ key = twin ?? { expr, as: lastSegment(column ?? "") };
2095
+ }
2096
+ const taken = keys.get(groupKeyName(key));
2097
+ if (taken && !same(operandOf(taken), operandOf(key))) {
2098
+ throw new DatasetSqlError(
2099
+ `Two group keys would answer as "${groupKeyName(key)}"; name each value: ${describeTerm(group)} AS name.`,
2100
+ );
2101
+ }
2102
+ keys.set(groupKeyName(key), key);
2103
+ }
2104
+ return keys;
2105
+ }
2106
+
2107
+ function operandOf(key: GroupKey) {
2108
+ return typeof key === "string" ? key : key.expr;
2109
+ }
2110
+
2111
+ function dateColumnOfOperand(operand: DatasetOperand) {
2112
+ const dated = typeof operand === "object" && "of" in operand;
2113
+ return dated && typeof operand.of === "string" ? operand.of : undefined;
2114
+ }
2115
+
2116
+ /** A grouped query answers only group keys and aggregates; a selected column or value must be one of the keys. */
2117
+ function assertGrouped(entries: SelectEntry[], star: boolean, groupKeys: Map<string, GroupKey>) {
2118
+ const offending = star ? "*" : entries.map(groupKeyName).find((name) => !groupKeys.has(name));
2119
+ if (offending !== undefined) {
2120
+ throw new DatasetSqlError(
2121
+ `"${offending}" is selected beside an aggregate but not grouped: add it to GROUP BY, wrap it in an aggregate, or drop it.`,
2122
+ );
2123
+ }
2124
+ }
2125
+
2126
+ /**
2127
+ * Every name at the top of the answered row must be one thing: not two
2128
+ * select items, and not also a qualifier rows nest under (`COUNT(*) AS pets`
2129
+ * beside `pets.name` has nowhere to go). Returns the names the row uses.
2130
+ */
2131
+ function assertShapeFits(shape: Record<string, string[]>, flatNames: string[]) {
2132
+ const paths = Object.values(shape);
2133
+ const nested = new Set(paths.filter((path) => path.length > 1).map((path) => path[0]!));
2134
+ const flat = [
2135
+ ...flatNames,
2136
+ ...paths.filter((path) => path.length === 1).map((path) => path[0]!),
2137
+ ];
2138
+ const seen = new Set<string>();
2139
+ for (const name of flat) {
2140
+ if (seen.has(name)) {
2141
+ throw new DatasetSqlError(`"${name}" names two things; pick another alias.`);
2142
+ }
2143
+ seen.add(name);
2144
+ }
2145
+ const clash = flat.find((name) => nested.has(name));
2146
+ if (clash !== undefined) {
2147
+ throw new DatasetSqlError(
2148
+ `"${clash}" is both a dataset the row nests under and a top-level name; pick another alias.`,
2149
+ );
2150
+ }
2151
+ return seen;
2152
+ }
2153
+
2154
+ /**
2155
+ * HAVING, compute, rank and ORDER BY see the grouped row: a name is a select
2156
+ * alias, a group key or a computed alias; an aggregate call is the SELECT
2157
+ * aggregate it repeats, or, where adding is allowed, one added to the query
2158
+ * under a default alias.
2159
+ */
2160
+ function groupedScope(
2161
+ aliasToKey: Map<string, string>,
2162
+ aggregates: WireAggregate[],
2163
+ used: (as: string) => boolean,
2164
+ resolve: Resolve,
2165
+ addMissing: boolean,
2166
+ ) {
2167
+ return {
2168
+ resolve: (ref) => aliasToKey.get(ref) ?? resolve(ref),
2169
+ aggregateAlias(item, clause = "ORDER BY") {
2170
+ const wanted = toAggregate({ ...item, alias: "_" }, { resolve });
2171
+ const found = aggregates.find((aggregate) => sameAggregate(aggregate, wanted));
2172
+ if (found) return found.as;
2173
+ if (!addMissing) {
2174
+ throw new DatasetSqlError(
2175
+ `${clause} ${item.fn.toUpperCase()}(...) must also appear in SELECT; give it an alias and use that.`,
2176
+ );
2177
+ }
2178
+ const added = toAggregate(item, { resolve });
2179
+ added.as = freshAlias(added.as, used);
2180
+ aggregates.push(added);
2181
+ return added.as;
2182
+ },
2183
+ } satisfies Scope;
2184
+ }
2185
+
2186
+ /**
2187
+ * Unselected aggregates and windows take a default alias, which must not be
2188
+ * a name the row already uses (`used`): a filtered and an unfiltered COUNT(*)
2189
+ * would collide, and a column or calculation called `count` would be dropped with it.
2190
+ */
2191
+ function freshAlias(base: string, used: (as: string) => boolean) {
2192
+ let as = base;
2193
+ for (let n = 2; used(as); n++) as = `${base}_${n}`;
2194
+ return as;
2195
+ }
2196
+
2197
+ /**
2198
+ * The finished row: the grouped (or plain) row plus its windows, where a
2199
+ * window call is the SELECT window it repeats by shape, or, where adding is
2200
+ * allowed, one added to the query under a default alias. `scope` is the row
2201
+ * a window reads, so an added window is checked like a selected one.
2202
+ */
2203
+ function windowed(
2204
+ scope: ReturnType<typeof groupedScope>,
2205
+ windows: WireWindow[],
2206
+ used: (as: string) => boolean,
2207
+ named: DatasetNamedOperand[],
2208
+ addMissing: boolean,
2209
+ ) {
2210
+ return {
2211
+ ...scope,
2212
+ windowAlias(item, clause = "ORDER BY") {
2213
+ const wanted = toWindow({ ...item, alias: "_" }, scope, named);
2214
+ const found = windows.find((window) => sameWindow(window, wanted));
2215
+ if (found) return found.as;
2216
+ if (!addMissing) {
2217
+ throw new DatasetSqlError(
2218
+ `${clause} ${item.fn.toUpperCase()}(...) OVER (...) must also appear in SELECT; give it an alias and use that.`,
2219
+ );
2220
+ }
2221
+ const added = toWindow(item, scope, named);
2222
+ added.as = freshAlias(added.as, used);
2223
+ windows.push(added);
2224
+ return added.as;
2225
+ },
2226
+ } satisfies Required<Scope>;
2227
+ }
2228
+
2229
+ /** On a grouped query a name must be one the row answers; a plain row reads its columns as they are. */
2230
+ function inRow<S extends Scope>(
2231
+ scope: S,
2232
+ known: (key: string) => boolean,
2233
+ unknown: (ref: string) => string,
2234
+ ) {
2235
+ const resolve: Resolve = (ref) => {
2236
+ const key = scope.resolve(ref);
2237
+ if (known(key)) return key;
2238
+ throw new DatasetSqlError(unknown(ref));
2239
+ };
2240
+ return { ...scope, resolve };
2241
+ }
2242
+
2243
+ /** A sort or partition key: a name (alias, group key or column), an aggregate or window repeated from SELECT, or a selected value written again; the scope's refusal stands where it has no alias for the call. */
2244
+ function resolveSort(term: Term, scope: Scope, named: DatasetNamedOperand[], clause: string) {
2245
+ if (term.kind === "ref") return scope.resolve(term.ref);
2246
+ if (term.kind === "agg" && scope.aggregateAlias) return scope.aggregateAlias(term.item, clause);
2247
+ if (term.kind === "window" && scope.windowAlias) return scope.windowAlias(term.item, clause);
2248
+ const expr = toOperand(term, scope);
2249
+ const twin = named.find((entry) => same(entry.expr, expr));
2250
+ if (!twin) {
2251
+ throw new DatasetSqlError(
2252
+ `${clause} takes a column, an alias or an aggregate; name ${describeTerm(term)} in SELECT and use that.`,
2253
+ );
2254
+ }
2255
+ return twin.as;
2256
+ }
2257
+
2258
+ /** Turns the parsed statement into the wire query, resolving qualifiers, aliases and the grouped/ungrouped shape. */
2259
+ function compile(catalog: DatasetCatalog, statement: Statement) {
2260
+ const { from, aliases, joins, items, filter, groupBy, having, qualify, sort, limit, offset } =
2261
+ statement;
2262
+ const sources = new Sources(catalog, from, aliases);
2263
+ for (const join of joins) sources.join(join);
2264
+ sources.assertComplete();
2265
+ const resolve: Resolve = (ref) => sources.resolve(ref);
2266
+ const rowScope = { resolve } satisfies Scope;
2267
+ const list = collectSelect(items, resolve);
2268
+ const { shape, aggregates, compute, aliasToKey, selected, star } = list;
2269
+ const groupKeys = resolveGroupKeys(list.entries, groupBy, aliasToKey, rowScope);
2270
+ // An aggregate call anywhere, a window's operand included, groups the query; a window over columns leaves it plain.
2271
+ const aggregated =
2272
+ [
2273
+ ...compute.map((entry) => entry.term),
2274
+ ...list.windows.map((item) => ({ kind: "window", item }) satisfies Term),
2275
+ ].some(hasAggregate) || qualify.some((entry) => predicateHas(entry, isAggregateCall));
2276
+ const grouped = groupKeys.size > 0 || aggregates.length > 0 || having.length > 0 || aggregated;
2277
+ if (!grouped && !star && list.entries.length === 0 && list.windows.length) {
2278
+ throw new DatasetSqlError(
2279
+ "A window is answered on each row; SELECT the row's columns beside it, or write the aggregate without OVER (GROUP BY the partition for one row per group).",
2280
+ );
2281
+ }
2282
+ // A grouped query answers group keys; a selected value that is none is worked out from them per grouped row.
2283
+ const entries = list.entries.filter((entry) => {
2284
+ const term = typeof entry === "string" ? undefined : list.terms.get(entry.as);
2285
+ if (!grouped || term === undefined || groupKeys.has(groupKeyName(entry))) return true;
2286
+ compute.push({ as: groupKeyName(entry), term });
2287
+ delete shape[groupKeyName(entry)];
2288
+ return false;
2289
+ });
2290
+ if (grouped) assertGrouped(entries, star, groupKeys);
2291
+ // Group keys the SELECT list left out are answered too; they nest like the columns they are.
2292
+ for (const key of groupKeys.keys()) shape[key] ??= sources.placeOf(key);
2293
+ const row = finishRow(list, {
2294
+ grouped,
2295
+ groupKeys,
2296
+ entries,
2297
+ having,
2298
+ qualify,
2299
+ sort,
2300
+ resolve,
2301
+ columns: sources.columnNames,
2302
+ });
2303
+ // `*` is FROM's columns as the catalog lists them, then whatever else the SELECT names.
2304
+ const starred = star ? sources.columnNames : [];
2305
+ const select = [
2306
+ ...starred,
2307
+ ...entries.filter((entry) => typeof entry !== "string" || !starred.includes(entry)),
2308
+ ];
2309
+ const query = {
2310
+ from,
2311
+ join: sources.paths,
2312
+ filter: [...sources.matched, ...filter.map((entry) => toFilter(entry, rowScope))],
2313
+ ...(grouped
2314
+ ? { groupBy: [...groupKeys.values()], aggregate: aggregates }
2315
+ : { select: select.length ? select : undefined }),
2316
+ ...row.clauses,
2317
+ ...(limit === undefined ? {} : { limit }),
2318
+ ...(offset === undefined ? {} : { offset }),
2319
+ } satisfies DatasetQueryInput;
2320
+ const hidden = [...aggregates, ...row.windows]
2321
+ .map((entry) => entry.as)
2322
+ .filter((as) => !selected.has(as));
2323
+ return { query, shape, hidden } satisfies ParsedDatasetSql;
2324
+ }
2325
+
2326
+ /**
2327
+ * The clauses over the finished row, in the order the host works them out:
2328
+ * windows over the grouped or plain row, calculations over that and the
2329
+ * windows, HAVING (which sees no window), QUALIFY, the rank and ORDER BY.
2330
+ * On a grouped query every name must be one the row answers; a plain row
2331
+ * reads its columns as they are.
2332
+ */
2333
+ function finishRow(
2334
+ list: SelectList,
2335
+ row: {
2336
+ grouped: boolean;
2337
+ groupKeys: Map<string, GroupKey>;
2338
+ entries: SelectEntry[];
2339
+ having: Predicate[];
2340
+ qualify: Predicate[];
2341
+ sort: SortList;
2342
+ resolve: Resolve;
2343
+ /** The FROM dataset's column names, which a plain row answers under their own names. */
2344
+ columns: string[];
2345
+ },
2346
+ ) {
2347
+ const { shape, aggregates, compute, aliasToKey, selected } = list;
2348
+ const { grouped, groupKeys, entries, having, qualify, sort, resolve } = row;
2349
+ const computeNames = new Set(compute.map((entry) => entry.as));
2350
+ const rowNames = assertShapeFits(shape, [...selected, ...computeNames]);
2351
+ const windows: WireWindow[] = [];
2352
+ // A plain row reads its columns as they are, so a default alias must not shadow one either.
2353
+ const columnNames = new Set(grouped ? [] : row.columns);
2354
+ const used = (as: string) =>
2355
+ rowNames.has(as) ||
2356
+ columnNames.has(as) ||
2357
+ aggregates.some((aggregate) => aggregate.as === as) ||
2358
+ windows.some((window) => window.as === as);
2359
+ // What the grouped row answers so far; a plain row reads its columns as they are.
2360
+ const answered = (key: string) =>
2361
+ !grouped ||
2362
+ groupKeys.has(key) ||
2363
+ aggregates.some((aggregate) => aggregate.as === key) ||
2364
+ windows.some((window) => window.as === key);
2365
+ const named = [...groupKeys.values(), ...entries].filter(isNamed);
2366
+ const adding = groupedScope(aliasToKey, aggregates, used, resolve, true);
2367
+ // A window reads the row before any calculation and before every window: SQL nests none.
2368
+ const windowNames = new Set(list.windows.map((item) => item.alias));
2369
+ const windowScope = inRow(
2370
+ adding,
2371
+ (key) => answered(key) && !computeNames.has(key) && !windowNames.has(key),
2372
+ (ref) =>
2373
+ computeNames.has(ref)
2374
+ ? `"${ref}" is a calculation, worked out after the windows; put the arithmetic inside the window (SUM(n * 2) OVER ()).`
2375
+ : windowNames.has(ref)
2376
+ ? `"${ref}" is a window; a window cannot read another window. Aggregate the column inside this one instead.`
2377
+ : `"${ref}" in a window is a column outside the grouped row; aggregate it inside the window (AVG(SUM(${ref})) OVER (...)) or GROUP BY it.`,
2378
+ );
2379
+ for (const item of list.windows) windows.push(toWindow(item, windowScope, named));
2380
+ const finished = windowed(windowScope, windows, used, named, true);
2381
+ // The finished row a calculation or QUALIFY reads: the row, its windows and the calculations; window calls still resolve through `finished`.
2382
+ const finishedRow = (unknown: (ref: string) => string) =>
2383
+ inRow(
2384
+ { ...finished, resolve: adding.resolve },
2385
+ (key) => answered(key) || computeNames.has(key),
2386
+ unknown,
2387
+ );
2388
+ // A calculation reads the finished row: aggregates and windows (added if unselected), group keys and earlier calculations; a bare column is outside every aggregate.
2389
+ const wireCompute = compute.map(({ as, term }) => {
2390
+ const scope = finishedRow((ref) =>
2391
+ hasAggregate(term)
2392
+ ? `"${ref}" in ${describeTerm(term)} is a column outside an aggregate: put the arithmetic inside one (SUM(${ref} / other)), work between aggregates (SUM(${ref}) / COUNT(*)), or GROUP BY it.`
2393
+ : `"${as}" is selected beside an aggregate but not grouped: add it to GROUP BY, wrap it in an aggregate, or drop it.`,
2394
+ );
2395
+ return { as, expr: toOperand(term, scope) };
2396
+ });
2397
+ // HAVING sees the grouped row and the calculations over it, never a window or a calculation over one: those are worked out after it.
2398
+ const late = new Set(list.windows.map((item) => item.alias));
2399
+ for (const { as, term } of compute) {
2400
+ if (hasWindow(term) || termRefs(term).some((ref) => late.has(ref))) late.add(as);
2401
+ }
2402
+ const havingScope = inRow(
2403
+ adding,
2404
+ (key) => !late.has(key),
2405
+ (ref) => `"${ref}" is worked out after HAVING; filter on it in QUALIFY.`,
2406
+ );
2407
+ const wireHaving = having.map((entry) => toFilter(entry, havingScope));
2408
+ const ranks = qualify.flatMap((entry) => (entry.kind === "rank" ? [entry] : []));
2409
+ if (ranks.length > 1) {
2410
+ throw new DatasetSqlError("QUALIFY takes one RANK() OVER (...) <= n.");
2411
+ }
2412
+ // SQL ranks every row before QUALIFY drops any; the wire ranks the rows QUALIFY kept.
2413
+ // Both at once would answer rows SQL would not, so the mix is refused.
2414
+ if (ranks.length === 1 && qualify.length > 1) {
2415
+ throw new DatasetSqlError(
2416
+ "QUALIFY takes either RANK() OVER (...) <= n or other conditions, not both: SQL ranks the rows before QUALIFY filters them. Put the other conditions in WHERE or HAVING, or finish in code.",
2417
+ );
2418
+ }
2419
+ const qualifying = finishedRow(
2420
+ (ref) =>
2421
+ `"${ref}" in QUALIFY is a column outside the grouped row; QUALIFY names group keys, aggregates, calculations and windows.`,
2422
+ );
2423
+ const wireQualify = qualify.flatMap((entry) =>
2424
+ entry.kind === "rank" ? [] : [toFilter(entry, qualifying)],
2425
+ );
2426
+ const [rank] = ranks;
2427
+ const sorting = windowed(
2428
+ groupedScope(aliasToKey, aggregates, used, resolve, false),
2429
+ windows,
2430
+ used,
2431
+ named,
2432
+ false,
2433
+ );
2434
+ const sortNamed = [...named, ...wireCompute];
2435
+ const sortOf = (terms: SortList, clause: string) =>
2436
+ terms.map(({ term, dir }) => ({ col: resolveSort(term, sorting, sortNamed, clause), dir }));
2437
+ const clauses = {
2438
+ ...(windows.length ? { window: windows } : {}),
2439
+ ...(wireCompute.length ? { compute: wireCompute } : {}),
2440
+ ...(wireHaving.length ? { having: wireHaving } : {}),
2441
+ ...(wireQualify.length ? { qualify: wireQualify } : {}),
2442
+ ...(rank
2443
+ ? {
2444
+ rank: {
2445
+ partitionBy: rank.partitionBy.map((term) =>
2446
+ resolveSort(term, sorting, sortNamed, "PARTITION BY"),
2447
+ ),
2448
+ sort: sortOf(rank.sort, "OVER (... ORDER BY)"),
2449
+ limit: rank.limit,
2450
+ },
2451
+ }
2452
+ : {}),
2453
+ sort: sortOf(sort, "ORDER BY"),
2454
+ } satisfies Partial<DatasetQueryInput>;
2455
+ return { clauses, windows };
2456
+ }
2457
+
2458
+ /** `COUNT(*)` answers as `count`, `SUM(total)` as `sum_total`, `AVG(a / target)` as `avg_a_target`, when the statement gives no alias. */
2459
+ function defaultAlias(fn: DatasetAggregate, col: DatasetOperand | undefined) {
2460
+ const base = fn === "countDistinct" ? "count_distinct" : fn;
2461
+ if (col === undefined) return base;
2462
+ const words = describeOperand(col)
2463
+ .replace(/[^A-Za-z0-9]+/g, "_")
2464
+ .replace(/^_|_$/g, "");
2465
+ return `${base}_${words}`;
2466
+ }
2467
+
2468
+ /** What a parse needs beyond the statement: the moment CURRENT_DATE and NOW() name, and the IANA time zone whose calendar date that is (UTC when missing or unknown). */
2469
+ type ParseOptions = { now: Date; timeZone?: string };
2470
+
2471
+ /** The calendar date `now` falls on in `timeZone`, as the UTC midnight the date folding runs on. */
2472
+ function calendarDate(now: Date, timeZone: string | undefined) {
2473
+ const utc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
2474
+ if (timeZone === undefined) return utc;
2475
+ try {
2476
+ const parts = new Intl.DateTimeFormat("en-US", {
2477
+ timeZone,
2478
+ year: "numeric",
2479
+ month: "numeric",
2480
+ day: "numeric",
2481
+ }).formatToParts(now);
2482
+ const part = (type: "year" | "month" | "day") =>
2483
+ Number(parts.find((entry) => entry.type === type)?.value);
2484
+ return new Date(Date.UTC(part("year"), part("month") - 1, part("day")));
2485
+ } catch {
2486
+ return utc;
2487
+ }
2488
+ }
2489
+
2490
+ /** Parses one SELECT against the catalog into the wire query; throws {@link DatasetSqlError} with what to write instead. */
2491
+ export function parseDatasetSql(sql: string, catalog: DatasetCatalog, options: ParseOptions) {
2492
+ const trimmed = sql.trim();
2493
+ if (!trimmed) throw new DatasetSqlError("The query is empty.");
2494
+ const today = calendarDate(options.now, options.timeZone);
2495
+ return compile(catalog, new Parser(tokenize(trimmed), today).statement());
2496
+ }
2497
+
2498
+ /**
2499
+ * Rebuilds each answered row as the statement reads: unselected aggregates
2500
+ * dropped, and `pet.name` placed under `pets.name` as `{ pets: { name } }`.
2501
+ * Every level has no prototype: the path segments are names the statement
2502
+ * wrote, and `__proto__` must land as a key, not reach Object.prototype.
2503
+ */
2504
+ export function shapeDatasetRows(
2505
+ rows: ReadonlyArray<Record<string, unknown>>,
2506
+ parsed: Pick<ParsedDatasetSql, "shape" | "hidden">,
2507
+ ) {
2508
+ const { shape, hidden } = parsed;
2509
+ return rows.map((row) => {
2510
+ const shaped = Object.create(null) as Record<string, unknown>;
2511
+ for (const [key, value] of Object.entries(row)) {
2512
+ if (hidden.includes(key)) continue;
2513
+ const path = Object.hasOwn(shape, key) ? shape[key]! : [key];
2514
+ let target = shaped;
2515
+ for (const segment of path.slice(0, -1)) {
2516
+ target = (target[segment] ??= Object.create(null)) as Record<string, unknown>;
2517
+ }
2518
+ target[path.at(-1)!] = value;
2519
+ }
2520
+ return shaped;
2521
+ });
2522
+ }