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