@atscript/db-sql-tools 0.1.146 → 0.1.148

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
+ import { DbError, aggregateExpressionsNotSupported, containsRelationFilter, fkColumns, isFieldRef, isResolvedRelationFilter } from "@atscript/db";
1
2
  import { BUCKET_UNITS, TIME_ZONE_NAME_RE, WEEK_STARTS, walkFilter } from "@uniqu/core";
2
- import { DbError, isFieldRef } from "@atscript/db";
3
3
  import { assertAggregateFn, resolveAlias } from "@atscript/db/agg";
4
4
  //#region src/dialect.ts
5
5
  /**
@@ -23,6 +23,32 @@ function havingGroupRef(dialect, expr, alias) {
23
23
  return dialect.bucketAliasInHaving ? dialect.quoteIdentifier(alias) : expr;
24
24
  }
25
25
  /**
26
+ * Runs `fn`, rethrowing a driver error as the `DbError`
27
+ * {@link SqlDialect.mapQueryError} maps it to (any other error unchanged).
28
+ * `controls` of the query tell whether it has arithmetic expressions.
29
+ * @since 0.1.148
30
+ */
31
+ async function mapQueryErrors(dialect, fn, controls) {
32
+ try {
33
+ return await fn();
34
+ } catch (error) {
35
+ const select = controls?.$select;
36
+ const arithmetic = !!(select?.exprAggregates?.length || select?.exprs?.length);
37
+ throw dialect.mapQueryError?.(error, arithmetic) ?? error;
38
+ }
39
+ }
40
+ /**
41
+ * One `ORDER BY` key with the uniform "NULL is the smallest value" ordering:
42
+ * `<expr> ASC` / `<expr> DESC`, plus `NULLS FIRST` / `NULLS LAST` on a
43
+ * dialect where NULL sorts largest ({@link SqlDialect.nullsSortLargest}).
44
+ * Shared by first-row joins and `first` / `last` aggregates.
45
+ * @since 0.1.148
46
+ */
47
+ function orderKeySql(dialect, expr, desc) {
48
+ const nulls = dialect.nullsSortLargest ? desc ? " NULLS LAST" : " NULLS FIRST" : "";
49
+ return `${expr} ${desc ? "DESC" : "ASC"}${nulls}`;
50
+ }
51
+ /**
26
52
  * Each JSON path segment wrapped in double quotes (`"a"`), for the dialects'
27
53
  * {@link SqlDialect.jsonExtract} path literals (`'$."a"."b"'`, `'{"a","b"}'`).
28
54
  *
@@ -54,12 +80,75 @@ const EMPTY_OR = {
54
80
  params: []
55
81
  };
56
82
  //#endregion
83
+ //#region src/arith.ts
84
+ /**
85
+ * Renders a computed expression tree (`@db.compute` on a view, or a query-time
86
+ * arithmetic `$select` entry) as SQL, in IEEE double on every dialect: each
87
+ * literal is cast with {@link SqlDialect.castDouble}; `+ - *` render as
88
+ * `(l op r)`, `/` as `(l / NULLIF(r, 0))` (division by zero is NULL), unary
89
+ * minus as `(-x)` and `coalesce` as `COALESCE(…)`. `leaf` renders a field
90
+ * reference as raw SQL, which is cast to double here — or, as `{ double }`,
91
+ * SQL that already is a double (a nested computed column) and stays as is.
92
+ *
93
+ * The one renderer of both paths, so declared and query-time arithmetic
94
+ * cannot diverge.
95
+ *
96
+ * @param fail - the error to throw for a failure (default `DbError`:
97
+ * `AGG_EXPR_NOT_SUPPORTED` without `castDouble`, `INVALID_QUERY` for a
98
+ * non-finite literal).
99
+ * @since 0.1.148
100
+ */
101
+ function renderArith(dialect, node, leaf, fail = defaultFailure) {
102
+ const cast = dialect.castDouble?.bind(dialect);
103
+ if (!cast) throw fail("no-cast");
104
+ const render = (e) => {
105
+ if (typeof e === "number") {
106
+ if (!Number.isFinite(e)) throw fail("non-finite");
107
+ return cast(String(e));
108
+ }
109
+ if ("field" in e) {
110
+ const sql = leaf(e.field);
111
+ return typeof sql === "string" ? cast(sql) : sql.double;
112
+ }
113
+ const args = e.args.map(render);
114
+ switch (e.op) {
115
+ case "neg": return `(-${args[0]})`;
116
+ case "coalesce": return `COALESCE(${args.join(", ")})`;
117
+ case "/": return `(${args[0]} / NULLIF(${args[1]}, 0))`;
118
+ default: return `(${args[0]} ${e.op} ${args[1]})`;
119
+ }
120
+ };
121
+ return render(node);
122
+ }
123
+ function defaultFailure(reason) {
124
+ return reason === "no-cast" ? aggregateExpressionsNotSupported() : new DbError("INVALID_QUERY", [{
125
+ path: "$select",
126
+ message: "Expression literal is not finite"
127
+ }]);
128
+ }
129
+ /** The error of a numeric overflow that is no arithmetic expression's (`INVALID_QUERY`, `path` `""`). */
130
+ function numericOutOfRangeError() {
131
+ return new DbError("INVALID_QUERY", [{
132
+ path: "",
133
+ message: "Numeric value out of range"
134
+ }]);
135
+ }
136
+ /** The error of a double overflow in aggregate arithmetic (`INVALID_QUERY`, `path` `$select`). */
137
+ function arithOverflowError() {
138
+ return new DbError("INVALID_QUERY", [{
139
+ path: "$select",
140
+ message: "Arithmetic overflow"
141
+ }]);
142
+ }
143
+ //#endregion
57
144
  //#region src/filter-builder.ts
58
145
  /**
59
146
  * Creates a dialect-specific filter visitor for `walkFilter`.
60
147
  */
61
148
  function createFilterVisitor(dialect, options) {
62
149
  const columnRef = options?.columnRef ?? ((field) => dialect.quoteIdentifier(field));
150
+ const qualifier = options?.qualifier;
151
+ const aliasSeq = options?.aliasSeq ?? { n: 0 };
63
152
  return {
64
153
  comparison(field, op, value) {
65
154
  if (op === "$geoWithin") {
@@ -152,9 +241,75 @@ function createFilterVisitor(dialect, options) {
152
241
  sql: `NOT (${child.sql})`,
153
242
  params: child.params
154
243
  };
244
+ },
245
+ relation(field, op, operand) {
246
+ if (!isResolvedRelationFilter(operand)) throw new DbError("REL_FILTER_NOT_SUPPORTED", [{
247
+ path: field,
248
+ message: `Relational predicate "${op}" on "${field}" reached the SQL renderer unresolved`
249
+ }]);
250
+ return renderRelation(dialect, op, operand, qualifier ?? dialect.quoteTable(operand.source.table), aliasSeq);
155
251
  }
156
252
  };
157
253
  }
254
+ /**
255
+ * Renders one resolved relational predicate as a correlated `EXISTS`:
256
+ *
257
+ * ```sql
258
+ * -- to / from
259
+ * EXISTS (SELECT 1 FROM <target> AS "_rf1"
260
+ * WHERE "_rf1"."<pair.target>" = <outer>."<pair.source>" [AND …] [AND <inner>])
261
+ * -- via
262
+ * EXISTS (SELECT 1 FROM <junction> AS "_rf1" JOIN <target> AS "_rf2"
263
+ * ON "_rf2"."<toTarget.target>" = "_rf1"."<toTarget.junction>" [AND …]
264
+ * WHERE "_rf1"."<toSource.junction>" = <outer>."<toSource.source>" [AND …]
265
+ * [AND <junction filter on _rf1>] [AND <inner on _rf2>])
266
+ * ```
267
+ *
268
+ * `$none` is `NOT EXISTS (…)`. Outer columns are always qualified (a bare
269
+ * column would bind to the subquery's table on a self relation or a
270
+ * same-named column). A NULL foreign-key component never satisfies `=`, so
271
+ * such a row has no related row (`$some` false, `$none` true).
272
+ */
273
+ function renderRelation(dialect, op, node, outer, aliasSeq) {
274
+ const q = (name) => dialect.quoteIdentifier(name);
275
+ const nextAlias = () => q(`_rf${++aliasSeq.n}`);
276
+ const conditions = [];
277
+ const params = [];
278
+ const addFilter = (filter, alias) => {
279
+ if (!filter || Object.keys(filter).length === 0) return;
280
+ const fragment = walkFilter(filter, createFilterVisitor(dialect, {
281
+ columnRef: (field) => `${alias}.${q(field)}`,
282
+ qualifier: alias,
283
+ aliasSeq
284
+ }));
285
+ if (fragment) {
286
+ conditions.push(fragment.sql);
287
+ params.push(...fragment.params);
288
+ }
289
+ };
290
+ let from;
291
+ if (node.kind === "via") {
292
+ const junction = node.junction;
293
+ if (!junction) throw new Error(`Relational predicate on "${node.nav}": a "via" relation needs a junction`);
294
+ const j = nextAlias();
295
+ const t = nextAlias();
296
+ const on = junction.toTarget.map((p) => `${t}.${q(p.target)} = ${j}.${q(p.junction)}`);
297
+ from = `${dialect.quoteTable(junction.table)} AS ${j} JOIN ${dialect.quoteTable(node.target.table)} AS ${t} ON ${on.join(" AND ")}`;
298
+ for (const p of junction.toSource) conditions.push(`${j}.${q(p.junction)} = ${outer}.${q(p.source)}`);
299
+ addFilter(junction.filter, j);
300
+ addFilter(node.filter, t);
301
+ } else {
302
+ const t = nextAlias();
303
+ from = `${dialect.quoteTable(node.target.table)} AS ${t}`;
304
+ for (const p of node.pairs) conditions.push(`${t}.${q(p.target)} = ${outer}.${q(p.source)}`);
305
+ addFilter(node.filter, t);
306
+ }
307
+ const where = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
308
+ return {
309
+ sql: `${op === "$none" ? "NOT " : ""}EXISTS (SELECT 1 FROM ${from}${where})`,
310
+ params
311
+ };
312
+ }
158
313
  const visitorCache = /* @__PURE__ */ new WeakMap();
159
314
  function getVisitor(dialect) {
160
315
  let visitor = visitorCache.get(dialect);
@@ -166,10 +321,23 @@ function getVisitor(dialect) {
166
321
  }
167
322
  /**
168
323
  * Translates a filter expression into a parameterized SQL WHERE clause.
324
+ *
325
+ * Relational predicates (`{ nav: { $some | $none: … } }`, resolved by the
326
+ * core into `ResolvedRelationFilter` operands) render as correlated
327
+ * `[NOT] EXISTS (…)` subqueries; `opts.qualifier` names the outer table or
328
+ * alias they correlate to (default: the source table — see
329
+ * {@link TFilterVisitorOptions.qualifier}). `opts.columnRef` overrides how
330
+ * the filter's own columns render (e.g. `t."col"` for an aliased FROM).
331
+ * Placeholders stay positional `?` in textual order.
332
+ *
333
+ * @param opts - since 0.1.147
169
334
  */
170
- function buildWhere(dialect, filter) {
335
+ function buildWhere(dialect, filter, opts) {
171
336
  if (!filter || Object.keys(filter).length === 0) return EMPTY_AND;
172
- return walkFilter(filter, getVisitor(dialect)) ?? EMPTY_AND;
337
+ return walkFilter(filter, opts || containsRelationFilter(filter) ? createFilterVisitor(dialect, {
338
+ ...opts,
339
+ aliasSeq: opts?.aliasSeq ?? { n: 0 }
340
+ }) : getVisitor(dialect)) ?? EMPTY_AND;
173
341
  }
174
342
  //#endregion
175
343
  //#region src/common.ts
@@ -214,6 +382,20 @@ function toSqlValue(value) {
214
382
  if (typeof value === "boolean") return value ? 1 : 0;
215
383
  return value;
216
384
  }
385
+ /**
386
+ * `FOREIGN KEY (…) REFERENCES <target> (…)[ ON DELETE …][ ON UPDATE …]` of a
387
+ * foreign key over its physical columns ({@link fkColumns}); the target is
388
+ * schema-qualified when it declares `@db.schema`. `quote` quotes one
389
+ * identifier.
390
+ */
391
+ function foreignKeySql(quote, fk) {
392
+ const { fields, targetFields } = fkColumns(fk);
393
+ const target = fk.targetSchema ? `${quote(fk.targetSchema)}.${quote(fk.targetTable)}` : quote(fk.targetTable);
394
+ let sql = `FOREIGN KEY (${fields.map(quote).join(", ")}) REFERENCES ${target} (${targetFields.map(quote).join(", ")})`;
395
+ if (fk.onDelete) sql += ` ON DELETE ${refActionToSql(fk.onDelete)}`;
396
+ if (fk.onUpdate) sql += ` ON UPDATE ${refActionToSql(fk.onUpdate)}`;
397
+ return sql;
398
+ }
217
399
  function refActionToSql(action) {
218
400
  switch (action) {
219
401
  case "cascade": return "CASCADE";
@@ -332,15 +514,23 @@ const AGG_FN_SQL = {
332
514
  */
333
515
  function renderAggCall(fn, arg, path) {
334
516
  assertAggregateFn(fn, path);
335
- return fn === "countDistinct" ? `COUNT(DISTINCT ${arg})` : `${AGG_FN_SQL[fn]}(${arg})`;
517
+ if (fn === "countDistinct") return `COUNT(DISTINCT ${arg})`;
518
+ return `${AGG_FN_SQL[fn]}(${arg})`;
336
519
  }
337
- /** The bare aggregate call, e.g. `SUM("amount")` / `COUNT(*)` / `COUNT(DISTINCT "region")`. */
338
- function aggFnSql(dialect, expr) {
339
- const field = expr.$field === "*" ? "*" : dialect.quoteIdentifier(expr.$field);
340
- return renderAggCall(expr.$fn, field);
520
+ /**
521
+ * `MIN` / `MAX` of a column the engine may not aggregate directly: a boolean
522
+ * on a dialect with {@link SqlDialect.booleanAggregates} (PostgreSQL has no
523
+ * `MIN(boolean)`) renders its stand-in.
524
+ */
525
+ function pickSql(dialect, select, fn, column, sql) {
526
+ const bool = select?.sources.get(column)?.designType === "boolean" ? dialect.booleanAggregates?.[fn] : void 0;
527
+ return bool ? `${bool}(${sql})` : renderAggCall(fn, sql);
341
528
  }
342
- function buildAggExpr(dialect, expr) {
343
- return `${aggFnSql(dialect, expr)} AS ${dialect.quoteIdentifier(resolveAlias(expr))}`;
529
+ /** The bare aggregate call, e.g. `SUM("amount")` / `COUNT(*)` / `COUNT(DISTINCT "region")`. */
530
+ function aggFnSql(dialect, select, expr) {
531
+ if (expr.$field === "*") return renderAggCall(expr.$fn, "*");
532
+ const field = dialect.quoteIdentifier(expr.$field);
533
+ return expr.$fn === "min" || expr.$fn === "max" ? pickSql(dialect, select, expr.$fn, expr.$field, field) : renderAggCall(expr.$fn, field);
344
534
  }
345
535
  const BUCKET_UNIT_SET = new Set(BUCKET_UNITS);
346
536
  const WEEK_START_SET = new Set(WEEK_STARTS);
@@ -388,25 +578,52 @@ function groupKeySql(dialect, controls, key) {
388
578
  const bucket = controls.$select?.bucketByAlias(key);
389
579
  return bucket ? bucketSql(dialect, bucket) : dialect.quoteIdentifier(key);
390
580
  }
581
+ /** Alias of the derived table `first` / `last` aggregates read from. */
582
+ const ROWS_ALIAS = "__as_rows";
583
+ /** The derived-table column of the `i`-th `first` / `last` entry. */
584
+ const firstLastColumn = (i) => `__as_fl${i}`;
585
+ /**
586
+ * The SQL each computed alias stands for where an alias is not usable
587
+ * (HAVING; other expressions): an aggregate's call, a `first` / `last`
588
+ * derived column (aggregated: constant within its group), a row-level
589
+ * expression aggregate's call, and every group-level expression rendered over
590
+ * those (in dependency order, operands cast to double). A grouped column
591
+ * renders as its quoted name.
592
+ */
593
+ function aliasSqlMap(dialect, controls) {
594
+ const map = /* @__PURE__ */ new Map();
595
+ const select = controls.$select;
596
+ const quote = (name) => dialect.quoteIdentifier(name);
597
+ for (const expr of select?.aggregates ?? []) map.set(resolveAlias(expr), aggFnSql(dialect, select, expr));
598
+ for (const e of select?.exprAggregates ?? []) map.set(e.alias, renderAggCall(e.fn, renderArith(dialect, e.expr, quote)));
599
+ (select?.firstLast ?? []).forEach((fl, i) => {
600
+ const col = quote(firstLastColumn(i));
601
+ map.set(fl.alias, dialect.anyValue ? dialect.anyValue(col, select?.sources.get(fl.column)) : renderAggCall("min", col));
602
+ });
603
+ for (const e of select?.exprs ?? []) map.set(e.alias, renderArith(dialect, e.expr, (name) => map.get(name) ?? quote(name)));
604
+ return map;
605
+ }
391
606
  /**
392
607
  * ` HAVING <predicate>` (leading space) + params for `controls.$having`, or
393
608
  * `undefined` when there is nothing to render. Shared by the row and the
394
609
  * count builders so both filter the same group set.
395
610
  *
396
- * A key that names an aggregate alias (`$as`, else `fn_field`) renders the
397
- * aggregate expression itself — `SUM("amount") > ?` — because PostgreSQL does
398
- * not allow a SELECT alias in HAVING (MySQL and SQLite tolerate it, so the
399
- * expression form keeps all three identical). A calendar-bucket alias renders
400
- * its bucket expression ({@link groupKeySql}), or its quoted alias when the
401
- * dialect sets `SqlDialect.bucketAliasInHaving` (`havingGroupRef`). Other keys
402
- * (grouped columns) render as plain columns.
611
+ * A key that names a computed alias (`$as`, else `fn_field`) renders what the
612
+ * alias stands for — `SUM("amount") > ?`, an expression's arithmetic — because
613
+ * PostgreSQL does not allow a SELECT alias in HAVING (MySQL and SQLite
614
+ * tolerate it, so the expression form keeps all three identical). A
615
+ * calendar-bucket alias renders its bucket expression ({@link groupKeySql}),
616
+ * or its quoted alias when the dialect sets `SqlDialect.bucketAliasInHaving`
617
+ * (`havingGroupRef`). Other keys (grouped columns) render as plain columns.
618
+ * `aliasSql` is the query's {@link aliasSqlMap} when the caller has built it.
403
619
  */
404
- function havingClause(dialect, controls) {
620
+ function havingClause(dialect, controls, aliasSql) {
405
621
  const having = controls.$having;
406
622
  if (!having) return void 0;
407
- const exprByAlias = /* @__PURE__ */ new Map();
408
- for (const expr of controls.$select?.aggregates ?? []) exprByAlias.set(resolveAlias(expr), aggFnSql(dialect, expr));
623
+ const exprByAlias = aliasSql ?? aliasSqlMap(dialect, controls);
624
+ const refs = /* @__PURE__ */ new Set();
409
625
  const fragment = walkFilter(having, createFilterVisitor(dialect, { columnRef: (field) => {
626
+ refs.add(field);
410
627
  const aggExpr = exprByAlias.get(field);
411
628
  if (aggExpr) return aggExpr;
412
629
  const bucket = controls.$select?.bucketByAlias(field);
@@ -415,43 +632,103 @@ function havingClause(dialect, controls) {
415
632
  if (!fragment || fragment.sql === EMPTY_AND.sql) return void 0;
416
633
  return {
417
634
  sql: ` HAVING ${fragment.sql}`,
418
- params: fragment.params
635
+ params: fragment.params,
636
+ refs
419
637
  };
420
638
  }
421
639
  /** `<bucket expr> AS "alias"` for every calendar bucket in `$select`. */
422
640
  function bucketSelectParts(dialect, controls) {
423
641
  return (controls.$select?.buckets ?? []).map((bucket) => `${bucketSql(dialect, bucket)} AS ${dialect.quoteIdentifier(bucket.alias)}`);
424
642
  }
643
+ /** ` GROUP BY <keys>` (leading space), or `""` for the whole table as one group. */
644
+ function groupByClause(dialect, controls) {
645
+ const keys = controls.$groupBy ?? [];
646
+ return keys.length ? ` GROUP BY ${keys.map((key) => groupKeySql(dialect, controls, key)).join(", ")}` : "";
647
+ }
648
+ /** Whether any of the HAVING keys is a `first` / `last` alias, or an expression over one. */
649
+ function readsFirstLast(select, refs) {
650
+ if (!select?.firstLast) return false;
651
+ const firstLast = new Set(select.firstLast.map((fl) => fl.alias));
652
+ const names = new Map((select.exprs ?? []).map((e) => [e.alias, e.names]));
653
+ const visit = (name, seen) => {
654
+ if (firstLast.has(name)) return true;
655
+ const deps = names.get(name);
656
+ if (!deps || seen.has(name)) return false;
657
+ seen.add(name);
658
+ return deps.some((dep) => visit(dep, seen));
659
+ };
660
+ return [...refs].some((key) => visit(key, /* @__PURE__ */ new Set()));
661
+ }
662
+ /**
663
+ * The columns the outer query reads from the derived table of a `first` /
664
+ * `last` aggregate: group keys (a bucket's source for its alias), plain
665
+ * `$select` fields, aggregate fields and the leaves of row-level expressions.
666
+ * The `$rowOrder` columns and the `first` / `last` sources stay inside the
667
+ * window; a group-level expression, `$having` and `$sort` read aliases or
668
+ * group keys only.
669
+ */
670
+ function rowColumns(controls) {
671
+ const select = controls.$select;
672
+ const columns = /* @__PURE__ */ new Set();
673
+ for (const key of controls.$groupBy ?? []) if (!select?.bucketByAlias(key)) columns.add(key);
674
+ for (const bucket of select?.buckets ?? []) columns.add(bucket.field);
675
+ for (const field of select?.asArray ?? []) columns.add(field);
676
+ for (const expr of select?.aggregates ?? []) if (expr.$field !== "*") columns.add(expr.$field);
677
+ for (const e of select?.exprAggregates ?? []) for (const name of e.names) columns.add(name);
678
+ return [...columns];
679
+ }
680
+ /**
681
+ * The row source of an aggregate: `FROM <table> WHERE <where>`; with `first` /
682
+ * `last` (and `withRows`) a derived table instead — the table's rows that
683
+ * pass the WHERE, only the columns {@link rowColumns} lists, plus one
684
+ * `FIRST_VALUE(col) OVER (PARTITION BY <group keys> ORDER BY <rowOrder>)`
685
+ * column per entry (`last` over the reversed order). Each group then reads its
686
+ * representative row's value as an aggregate (constant within the group). The
687
+ * WHERE moves inside unchanged, so the bind parameters keep their order.
688
+ */
689
+ function aggSource(dialect, table, where, controls, withRows) {
690
+ const quotedTable = dialect.quoteTable(table);
691
+ const firstLast = controls.$select?.firstLast;
692
+ const rowOrder = controls.$select?.rowOrder;
693
+ if (!withRows || !firstLast?.length || !rowOrder?.length) return `FROM ${quotedTable} WHERE ${where.sql}`;
694
+ const groupBy = controls.$groupBy;
695
+ const partition = groupBy?.length ? `PARTITION BY ${groupBy.map((key) => groupKeySql(dialect, controls, key)).join(", ")} ` : "";
696
+ const order = (reverse) => rowOrder.map((k) => orderKeySql(dialect, dialect.quoteIdentifier(k.column), k.desc !== reverse)).join(", ");
697
+ return `FROM (SELECT ${[...rowColumns(controls).map((column) => dialect.quoteIdentifier(column)), ...firstLast.map((fl, i) => {
698
+ const col = dialect.quoteIdentifier(firstLastColumn(i));
699
+ return `FIRST_VALUE(${dialect.quoteIdentifier(fl.column)}) OVER (${partition}ORDER BY ${order(fl.fn === "last")}) AS ${col}`;
700
+ })].join(", ")} FROM ${quotedTable} WHERE ${where.sql}) AS ${dialect.quoteIdentifier(ROWS_ALIAS)}`;
701
+ }
425
702
  /**
426
703
  * Builds a SELECT ... GROUP BY statement with aggregate functions.
427
704
  *
428
705
  * SELECT lists the plain grouped columns, then `<bucket expr> AS "alias"`
429
- * per calendar bucket, then the aggregates. Bucket expressions are
706
+ * per calendar bucket, then every computed alias (aggregates, row-level
707
+ * expression aggregates, `first` / `last`, group-level expressions — the
708
+ * order of `UniquSelect.computedAliases`). Bucket expressions are
430
709
  * parameter-free, so the bind parameters are exactly those of the same query
431
710
  * without buckets (WHERE, HAVING, LIMIT, OFFSET).
432
711
  */
433
712
  function buildAggregateSelect(dialect, table, where, controls) {
713
+ const select = controls.$select;
434
714
  const selectParts = [];
435
- const plainFields = controls.$select?.asArray;
436
- if (plainFields) for (const f of plainFields) selectParts.push(dialect.quoteIdentifier(f));
715
+ for (const f of select?.asArray ?? []) selectParts.push(dialect.quoteIdentifier(f));
437
716
  selectParts.push(...bucketSelectParts(dialect, controls));
438
- const aggregates = controls.$select?.aggregates;
439
- if (aggregates) for (const expr of aggregates) selectParts.push(buildAggExpr(dialect, expr));
440
- let sql = `SELECT ${selectParts.length > 0 ? selectParts.join(", ") : "*"} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}`;
717
+ let aliasSql;
718
+ if (select?.exprAggregates || select?.firstLast || select?.exprs) {
719
+ aliasSql = aliasSqlMap(dialect, controls);
720
+ for (const alias of select.computedAliases) selectParts.push(`${aliasSql.get(alias)} AS ${dialect.quoteIdentifier(alias)}`);
721
+ } else for (const expr of select?.aggregates ?? []) selectParts.push(`${aggFnSql(dialect, select, expr)} AS ${dialect.quoteIdentifier(resolveAlias(expr))}`);
722
+ let sql = `SELECT ${selectParts.length > 0 ? selectParts.join(", ") : "*"} ${aggSource(dialect, table, where, controls, true)}${groupByClause(dialect, controls)}`;
441
723
  const params = [...where.params];
442
- const groupBy = controls.$groupBy;
443
- if (groupBy?.length) {
444
- const groupCols = groupBy.map((key) => groupKeySql(dialect, controls, key)).join(", ");
445
- sql += ` GROUP BY ${groupCols}`;
446
- }
447
- const having = havingClause(dialect, controls);
724
+ const having = havingClause(dialect, controls, aliasSql);
448
725
  if (having) {
449
726
  sql += having.sql;
450
727
  params.push(...having.params);
451
728
  }
452
729
  if (controls.$sort) {
453
730
  const orderParts = [];
454
- for (const [col, dir] of Object.entries(controls.$sort)) orderParts.push(`${dialect.quoteIdentifier(col)} ${dir === -1 ? "DESC" : "ASC"}`);
731
+ for (const [col, dir] of Object.entries(controls.$sort)) orderParts.push(orderKeySql(dialect, dialect.quoteIdentifier(col), dir === -1));
455
732
  if (orderParts.length > 0) sql += ` ORDER BY ${orderParts.join(", ")}`;
456
733
  }
457
734
  if (controls.$limit !== void 0) {
@@ -472,21 +749,30 @@ function buildAggregateSelect(dialect, table, where, controls) {
472
749
  * Builds a COUNT query for the number of distinct groups — the groups that
473
750
  * survive `$having` when one is given (the same predicate the row query
474
751
  * renders, so `$count` agrees with the row set). Returns `{ count: N }` when
475
- * executed.
752
+ * executed. The rows come straight from the table, unless `$having` reads a
753
+ * `first` / `last` value (or an expression over one): only then the window
754
+ * derived table is built.
476
755
  */
477
756
  function buildAggregateCount(dialect, table, where, controls) {
478
757
  const groupFields = controls.$groupBy;
479
758
  const having = havingClause(dialect, controls);
480
759
  const countCol = `COUNT(*) AS ${dialect.quoteIdentifier("count")}`;
481
- if (!groupFields?.length && !having) return finalizeParams(dialect, {
482
- sql: `SELECT ${countCol} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}`,
483
- params: where.params
484
- });
485
- const groupBy = groupFields?.length ? ` GROUP BY ${groupFields.map((key) => groupKeySql(dialect, controls, key)).join(", ")}` : "";
760
+ if (!groupFields?.length && !having) {
761
+ if ((controls.$select?.computedAliases.length ?? 0) > 0) return finalizeParams(dialect, {
762
+ sql: `SELECT 1 AS ${dialect.quoteIdentifier("count")}`,
763
+ params: []
764
+ });
765
+ return finalizeParams(dialect, {
766
+ sql: `SELECT ${countCol} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}`,
767
+ params: where.params
768
+ });
769
+ }
770
+ const from = aggSource(dialect, table, where, controls, !!having && readsFirstLast(controls.$select, having.refs));
771
+ const groupBy = groupByClause(dialect, controls);
486
772
  let inner = "COUNT(*)";
487
773
  if (groupBy) inner = bucketSelectParts(dialect, controls).join(", ") || "1";
488
774
  return finalizeParams(dialect, {
489
- sql: `SELECT ${countCol} FROM (SELECT ${inner} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}${groupBy}${having?.sql ?? ""}) AS ${dialect.quoteIdentifier("_groups")}`,
775
+ sql: `SELECT ${countCol} FROM (SELECT ${inner} ${from}${groupBy}${having?.sql ?? ""}) AS ${dialect.quoteIdentifier("_groups")}`,
490
776
  params: [...where.params, ...having?.params ?? []]
491
777
  });
492
778
  }
@@ -531,6 +817,63 @@ function viewAggExpr(dialect, c, resolveFieldRef) {
531
817
  return c.aggFn === "sum" ? `COALESCE(${call}, 0)` : call;
532
818
  }
533
819
  /**
820
+ * The SQL expression of a computed view column (`@db.compute`), evaluated in
821
+ * IEEE double on every dialect: each field and literal leaf is cast with
822
+ * {@link SqlDialect.castDouble} (a computed leaf is already a double and is
823
+ * inlined); `+ - *` render as `(l op r)`, `/` as `(l / NULLIF(r, 0))` —
824
+ * division by zero is NULL — unary minus as `(-x)`, `coalesce` as
825
+ * `COALESCE(…)`. A leaf reads its column like the SELECT list does: an
826
+ * aggregate's call ({@link viewAggExpr}) or a dimension's source
827
+ * ({@link viewSourceExpr}). `byPath` holds the view's mappings by `viewPath`.
828
+ *
829
+ * In a `grouped` view a JSON-extracted dimension leaf reads `MIN(<extract>)`:
830
+ * the extraction is a GROUP BY key, so every row of a group holds the same
831
+ * value, but MySQL's ONLY_FULL_GROUP_BY rejects an expression over the raw
832
+ * JSON column (which is not itself grouped) — the aggregate form is accepted
833
+ * everywhere and yields that same value.
834
+ *
835
+ * @throws when the dialect has no `castDouble`, or a leaf names no column.
836
+ * @since 0.1.147
837
+ */
838
+ function viewComputeExpr(dialect, c, byPath, resolveFieldRef, grouped = false, cache = /* @__PURE__ */ new Map()) {
839
+ const cached = cache.get(c.viewPath);
840
+ if (cached !== void 0) return cached;
841
+ const fail = (reason) => /* @__PURE__ */ new Error(reason === "no-cast" ? `View column "${c.viewColumn}": computed view columns are not supported by this adapter` : `View column "${c.viewColumn}": non-finite literal in @db.compute`);
842
+ const leaf = (field) => {
843
+ const mapping = byPath.get(field);
844
+ if (!mapping) throw new Error(`View column "${c.viewColumn}": "${field}" is not a column of the view`);
845
+ if (mapping.expr !== void 0) return { double: viewComputeExpr(dialect, mapping, byPath, resolveFieldRef, grouped, cache) };
846
+ if (mapping.aggFn) return viewAggExpr(dialect, mapping, resolveFieldRef);
847
+ const source = viewSourceExpr(dialect, mapping);
848
+ return grouped && mapping.json ? `MIN(${source})` : source;
849
+ };
850
+ const sql = renderArith(dialect, c.expr, leaf, fail);
851
+ cache.set(c.viewPath, sql);
852
+ return sql;
853
+ }
854
+ /**
855
+ * The `ON` clause of a first-row join (`join.first`): the joined row is the
856
+ * one whose primary key equals the key of the FIRST matching target row —
857
+ * a correlated scalar subquery
858
+ * `"T"."id" = (SELECT "T"."id" FROM "table" AS "T" WHERE <condition> ORDER BY … LIMIT 1)`.
859
+ * The inner `FROM` repeats the join's own target / alias text, so the
860
+ * condition's refs to the joined scope bind to the inner row (SQL name
861
+ * shadowing) while refs to the entry and earlier joins stay correlated to
862
+ * the outer query. The ordering ends with the primary key, so the pick is
863
+ * deterministic; NULL sorts first in `asc` (`nullsSortLargest` dialects
864
+ * render `NULLS FIRST` / `NULLS LAST`). No window functions — SQLite, MySQL 8
865
+ * and PostgreSQL render it alike.
866
+ * @since 0.1.147
867
+ */
868
+ function firstRowOn(dialect, join, target, condition, resolveFieldRef) {
869
+ const first = join.first;
870
+ const key = resolveFieldRef({
871
+ type: join.targetType,
872
+ field: first.key
873
+ });
874
+ return `${key} = (SELECT ${key} FROM ${target} WHERE ${condition} ORDER BY ${first.order.map(({ ref, desc }) => orderKeySql(dialect, resolveFieldRef(ref), desc)).join(", ")} LIMIT 1)`;
875
+ }
876
+ /**
534
877
  * Builds a CREATE VIEW statement from a view plan and column mappings.
535
878
  *
536
879
  * Joins render in declaration order — `JOIN` (inner, the default) or
@@ -542,35 +885,41 @@ function viewAggExpr(dialect, c, resolveFieldRef) {
542
885
  * join target may be views.
543
886
  */
544
887
  function buildCreateView(dialect, viewName, plan, columns, resolveFieldRef) {
545
- const selectCols = columns.map((c) => {
546
- return `${c.aggFn ? viewAggExpr(dialect, c, resolveFieldRef) : viewSourceExpr(dialect, c)} AS ${dialect.quoteIdentifier(c.viewColumn)}`;
547
- }).join(", ");
888
+ const columnByPath = new Map(columns.map((c) => [c.viewPath, c]));
889
+ const hasAggregates = columns.some((c) => c.aggFn);
890
+ const computedSql = /* @__PURE__ */ new Map();
891
+ /** A column's SELECT expression: computed, aggregate or plain source. */
892
+ const columnExpr = (c) => c.expr !== void 0 ? viewComputeExpr(dialect, c, columnByPath, resolveFieldRef, hasAggregates, computedSql) : c.aggFn ? viewAggExpr(dialect, c, resolveFieldRef) : viewSourceExpr(dialect, c);
893
+ const selectCols = columns.map((c) => `${columnExpr(c)} AS ${dialect.quoteIdentifier(c.viewColumn)}`).join(", ");
548
894
  let sql = `${dialect.createViewPrefix} ${dialect.quoteTable(viewName)} AS SELECT ${selectCols} FROM ${dialect.quoteIdentifier(plan.entryTable)}`;
549
895
  for (const join of plan.joins) {
550
- const onClause = queryNodeToSql(join.condition, resolveFieldRef);
896
+ const condition = queryNodeToSql(join.condition, resolveFieldRef);
551
897
  const keyword = join.kind === "left" ? "LEFT JOIN" : "JOIN";
552
898
  const target = join.scope === join.targetTable ? dialect.quoteIdentifier(join.targetTable) : `${dialect.quoteIdentifier(join.targetTable)} AS ${dialect.quoteIdentifier(join.scope)}`;
899
+ const onClause = join.first ? firstRowOn(dialect, join, target, condition, resolveFieldRef) : condition;
553
900
  sql += ` ${keyword} ${target} ON ${onClause}`;
554
901
  }
555
902
  if (plan.filter) {
556
903
  const whereClause = queryNodeToSql(plan.filter, resolveFieldRef);
557
904
  sql += ` WHERE ${whereClause}`;
558
905
  }
559
- if (columns.some((c) => c.aggFn)) {
560
- const dimensionCols = columns.filter((c) => !c.aggFn);
906
+ if (hasAggregates) {
907
+ const dimensionCols = columns.filter((c) => !c.aggFn && c.expr === void 0);
561
908
  if (dimensionCols.length > 0) {
562
909
  const groupByCols = dimensionCols.map((c) => viewSourceExpr(dialect, c)).join(", ");
563
910
  sql += ` GROUP BY ${groupByCols}`;
564
911
  }
565
912
  if (plan.having) {
566
- const columnByPath = /* @__PURE__ */ new Map();
567
- for (const c of columns) columnByPath.set(c.viewPath, c);
913
+ let grouped;
914
+ const groupedColumns = () => grouped ??= new Set(dimensionCols.filter((c) => !c.json).map((c) => c.sourceColumn.toLowerCase()));
568
915
  const havingResolver = (ref) => {
569
916
  const col = ref.type ? void 0 : columnByPath.get(ref.field);
570
917
  if (!col) return resolveFieldRef(ref);
918
+ if (col.expr !== void 0) return columnExpr(col);
571
919
  if (col.aggFn) return viewAggExpr(dialect, col, resolveFieldRef);
572
920
  const expr = viewSourceExpr(dialect, col);
573
- return col.json ? havingGroupRef(dialect, expr, col.viewColumn) : expr;
921
+ if (!col.json) return expr;
922
+ return dialect.bucketAliasInHaving && groupedColumns().has(col.viewColumn.toLowerCase()) ? `MIN(${expr})` : havingGroupRef(dialect, expr, col.viewColumn);
574
923
  };
575
924
  const havingClause = queryNodeToSql(plan.having, havingResolver);
576
925
  sql += ` HAVING ${havingClause}`;
@@ -603,6 +952,21 @@ function insertManyColumns(rows) {
603
952
  return [...columns];
604
953
  }
605
954
  /**
955
+ * Splits `rows` into batches that stay under the driver's bind-parameter limit
956
+ * (PostgreSQL ~65535, MySQL packet size): `maxParams` (default 60000) divided
957
+ * by the column count. Returns the shared column union and the batches.
958
+ */
959
+ function chunkInsertRows(rows, maxParams = 6e4) {
960
+ const columns = insertManyColumns(rows);
961
+ const size = columns.length > 0 ? Math.max(1, Math.floor(maxParams / columns.length)) : rows.length;
962
+ const batches = [];
963
+ for (let offset = 0; offset < rows.length; offset += size) batches.push(rows.slice(offset, offset + size));
964
+ return {
965
+ columns,
966
+ batches
967
+ };
968
+ }
969
+ /**
606
970
  * Builds a multi-row `INSERT … VALUES (…), (…)` statement over `columns`
607
971
  * (default: {@link insertManyColumns} of `rows`). A row lacking a column gets
608
972
  * `DEFAULT` — exactly what a single-row INSERT omitting it stores. Callers
@@ -631,11 +995,8 @@ function buildInsertMany(dialect, table, rows, columns = insertManyColumns(rows)
631
995
  function buildSelect(dialect, table, where, controls) {
632
996
  let sql = `SELECT ${buildProjection(dialect, controls?.$select)} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}`;
633
997
  const params = [...where.params];
634
- if (controls?.$sort) {
635
- const orderParts = [];
636
- for (const [col, dir] of Object.entries(controls.$sort)) orderParts.push(`${dialect.quoteIdentifier(col)} ${dir === -1 ? "DESC" : "ASC"}`);
637
- if (orderParts.length > 0) sql += ` ORDER BY ${orderParts.join(", ")}`;
638
- }
998
+ const orderBy = orderByList(dialect, controls?.$sort);
999
+ if (orderBy) sql += ` ORDER BY ${orderBy}`;
639
1000
  if (controls?.$limit !== void 0) {
640
1001
  sql += ` LIMIT ?`;
641
1002
  params.push(controls.$limit);
@@ -650,6 +1011,53 @@ function buildSelect(dialect, table, where, controls) {
650
1011
  params
651
1012
  });
652
1013
  }
1014
+ /** `"col" ASC, "other" DESC` of a physical `$sort` — `""` when it orders nothing. */
1015
+ function orderByList(dialect, sort) {
1016
+ if (!sort) return "";
1017
+ const parts = [];
1018
+ for (const [col, dir] of Object.entries(sort)) parts.push(`${dialect.quoteIdentifier(col)} ${dir === -1 ? "DESC" : "ASC"}`);
1019
+ return parts.join(", ");
1020
+ }
1021
+ /**
1022
+ * The row-number column {@link buildPartitionedSelect} adds to every row;
1023
+ * {@link stripPartitionRowNumber} removes it from the result rows.
1024
+ * @since 0.1.147
1025
+ */
1026
+ const PARTITION_ROW_NUMBER_ALIAS = "__atscript_rn";
1027
+ /**
1028
+ * Builds a SELECT whose `$skip` / `$limit` apply to each partition — the rows
1029
+ * sharing the values of the `partitionBy` columns (physical names) — instead
1030
+ * of to the whole result: a `ROW_NUMBER() OVER (PARTITION BY … ORDER BY
1031
+ * <$sort>)` window in a derived table, filtered on the row number. The rows
1032
+ * of each partition come out in `$sort` order (partitions interleave); every
1033
+ * row carries {@link PARTITION_ROW_NUMBER_ALIAS}. Window functions need
1034
+ * SQLite ≥ 3.25, MySQL ≥ 8.0 or MariaDB ≥ 10.2.
1035
+ * @since 0.1.147
1036
+ */
1037
+ function buildPartitionedSelect(dialect, table, where, controls, partitionBy) {
1038
+ const rn = dialect.quoteIdentifier(PARTITION_ROW_NUMBER_ALIAS);
1039
+ const orderBy = orderByList(dialect, controls.$sort);
1040
+ const window = `PARTITION BY ${partitionBy.map((col) => dialect.quoteIdentifier(col)).join(", ")}` + (orderBy ? ` ORDER BY ${orderBy}` : "");
1041
+ const inner = `SELECT ${buildProjection(dialect, controls.$select)}, ROW_NUMBER() OVER (${window}) AS ${rn} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}`;
1042
+ const skip = controls.$skip ?? 0;
1043
+ const limit = controls.$limit;
1044
+ const params = [...where.params, skip];
1045
+ let sql = `SELECT * FROM (${inner}) AS ${dialect.quoteIdentifier("__atscript_p")} WHERE ${rn} > ?`;
1046
+ if (limit !== void 0 && limit !== null) {
1047
+ sql += ` AND ${rn} <= ?`;
1048
+ params.push(skip + limit);
1049
+ }
1050
+ sql += ` ORDER BY ${rn}`;
1051
+ return finalizeParams(dialect, {
1052
+ sql,
1053
+ params
1054
+ });
1055
+ }
1056
+ /** Removes {@link PARTITION_ROW_NUMBER_ALIAS} from rows read by {@link buildPartitionedSelect}. */
1057
+ function stripPartitionRowNumber(rows) {
1058
+ for (const row of rows) delete row[PARTITION_ROW_NUMBER_ALIAS];
1059
+ return rows;
1060
+ }
653
1061
  /**
654
1062
  * Marker value for {@link buildUpdate}: the column is assigned its DDL
655
1063
  * `DEFAULT` (`SET "col" = DEFAULT`, no bound parameter). Produced by
@@ -809,6 +1217,13 @@ function buildProjection(dialect, select, qualifier) {
809
1217
  */
810
1218
  const GEO_DISTANCE_ALIAS = "__atscript_distance";
811
1219
  /**
1220
+ * Alias of the searched table inside the geo and vector search statements —
1221
+ * a filter rendered into them correlates its relational predicates to it
1222
+ * (`buildWhere(filter, { qualifier: dialect.quoteTable(SEARCH_SOURCE_ALIAS) })`).
1223
+ * @since 0.1.147
1224
+ */
1225
+ const SEARCH_SOURCE_ALIAS = "t";
1226
+ /**
812
1227
  * Builds a distance-ranked geo search SELECT:
813
1228
  *
814
1229
  * ```sql
@@ -1027,4 +1442,4 @@ function parseRegexString(value) {
1027
1442
  };
1028
1443
  }
1029
1444
  //#endregion
1030
- export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, SQL_DEFAULT, VECTOR_DISTANCE_ALIAS, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildProjection, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, finalizeParams, geoWindowFromControls, groupKeySql, insertManyColumns, jsonDollarPath, normalizeGeoPointValue, parseRegexString, queryNodeToSql, queryOpToSql, quotedJsonPathSegments, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, toSqlValue, vectorDistanceSource };
1445
+ export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, PARTITION_ROW_NUMBER_ALIAS, SEARCH_SOURCE_ALIAS, SQL_DEFAULT, VECTOR_DISTANCE_ALIAS, arithOverflowError, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildPartitionedSelect, buildProjection, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere, chunkInsertRows, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, finalizeParams, foreignKeySql, geoWindowFromControls, groupKeySql, insertManyColumns, jsonDollarPath, mapQueryErrors, normalizeGeoPointValue, numericOutOfRangeError, orderKeySql, parseRegexString, queryNodeToSql, queryOpToSql, quotedJsonPathSegments, refActionToSql, renameGeoDistance, renderArith, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue, vectorDistanceSource };