@atscript/db-sql-tools 0.1.147 → 0.1.149

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,6 +81,67 @@ 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`.
@@ -428,15 +515,23 @@ const AGG_FN_SQL = {
428
515
  */
429
516
  function renderAggCall(fn, arg, path) {
430
517
  (0, _atscript_db_agg.assertAggregateFn)(fn, path);
431
- 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})`;
432
520
  }
433
- /** The bare aggregate call, e.g. `SUM("amount")` / `COUNT(*)` / `COUNT(DISTINCT "region")`. */
434
- function aggFnSql(dialect, expr) {
435
- const field = expr.$field === "*" ? "*" : dialect.quoteIdentifier(expr.$field);
436
- 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);
437
529
  }
438
- function buildAggExpr(dialect, expr) {
439
- 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);
440
535
  }
441
536
  const BUCKET_UNIT_SET = new Set(_uniqu_core.BUCKET_UNITS);
442
537
  const WEEK_START_SET = new Set(_uniqu_core.WEEK_STARTS);
@@ -484,25 +579,52 @@ function groupKeySql(dialect, controls, key) {
484
579
  const bucket = controls.$select?.bucketByAlias(key);
485
580
  return bucket ? bucketSql(dialect, bucket) : dialect.quoteIdentifier(key);
486
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
+ }
487
607
  /**
488
608
  * ` HAVING <predicate>` (leading space) + params for `controls.$having`, or
489
609
  * `undefined` when there is nothing to render. Shared by the row and the
490
610
  * count builders so both filter the same group set.
491
611
  *
492
- * A key that names an aggregate alias (`$as`, else `fn_field`) renders the
493
- * aggregate expression itself — `SUM("amount") > ?` — because PostgreSQL does
494
- * not allow a SELECT alias in HAVING (MySQL and SQLite tolerate it, so the
495
- * expression form keeps all three identical). A calendar-bucket alias renders
496
- * its bucket expression ({@link groupKeySql}), or its quoted alias when the
497
- * dialect sets `SqlDialect.bucketAliasInHaving` (`havingGroupRef`). Other keys
498
- * (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.
499
620
  */
500
- function havingClause(dialect, controls) {
621
+ function havingClause(dialect, controls, aliasSql) {
501
622
  const having = controls.$having;
502
623
  if (!having) return void 0;
503
- const exprByAlias = /* @__PURE__ */ new Map();
504
- 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();
505
626
  const fragment = (0, _uniqu_core.walkFilter)(having, createFilterVisitor(dialect, { columnRef: (field) => {
627
+ refs.add(field);
506
628
  const aggExpr = exprByAlias.get(field);
507
629
  if (aggExpr) return aggExpr;
508
630
  const bucket = controls.$select?.bucketByAlias(field);
@@ -511,43 +633,103 @@ function havingClause(dialect, controls) {
511
633
  if (!fragment || fragment.sql === EMPTY_AND.sql) return void 0;
512
634
  return {
513
635
  sql: ` HAVING ${fragment.sql}`,
514
- params: fragment.params
636
+ params: fragment.params,
637
+ refs
515
638
  };
516
639
  }
517
640
  /** `<bucket expr> AS "alias"` for every calendar bucket in `$select`. */
518
641
  function bucketSelectParts(dialect, controls) {
519
642
  return (controls.$select?.buckets ?? []).map((bucket) => `${bucketSql(dialect, bucket)} AS ${dialect.quoteIdentifier(bucket.alias)}`);
520
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
+ }
521
703
  /**
522
704
  * Builds a SELECT ... GROUP BY statement with aggregate functions.
523
705
  *
524
706
  * SELECT lists the plain grouped columns, then `<bucket expr> AS "alias"`
525
- * 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
526
710
  * parameter-free, so the bind parameters are exactly those of the same query
527
711
  * without buckets (WHERE, HAVING, LIMIT, OFFSET).
528
712
  */
529
713
  function buildAggregateSelect(dialect, table, where, controls) {
714
+ const select = controls.$select;
530
715
  const selectParts = [];
531
- const plainFields = controls.$select?.asArray;
532
- if (plainFields) for (const f of plainFields) selectParts.push(dialect.quoteIdentifier(f));
716
+ for (const f of select?.asArray ?? []) selectParts.push(dialect.quoteIdentifier(f));
533
717
  selectParts.push(...bucketSelectParts(dialect, controls));
534
- const aggregates = controls.$select?.aggregates;
535
- if (aggregates) for (const expr of aggregates) selectParts.push(buildAggExpr(dialect, expr));
536
- 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)}`;
537
724
  const params = [...where.params];
538
- const groupBy = controls.$groupBy;
539
- if (groupBy?.length) {
540
- const groupCols = groupBy.map((key) => groupKeySql(dialect, controls, key)).join(", ");
541
- sql += ` GROUP BY ${groupCols}`;
542
- }
543
- const having = havingClause(dialect, controls);
725
+ const having = havingClause(dialect, controls, aliasSql);
544
726
  if (having) {
545
727
  sql += having.sql;
546
728
  params.push(...having.params);
547
729
  }
548
730
  if (controls.$sort) {
549
731
  const orderParts = [];
550
- 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));
551
733
  if (orderParts.length > 0) sql += ` ORDER BY ${orderParts.join(", ")}`;
552
734
  }
553
735
  if (controls.$limit !== void 0) {
@@ -568,21 +750,30 @@ function buildAggregateSelect(dialect, table, where, controls) {
568
750
  * Builds a COUNT query for the number of distinct groups — the groups that
569
751
  * survive `$having` when one is given (the same predicate the row query
570
752
  * renders, so `$count` agrees with the row set). Returns `{ count: N }` when
571
- * 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.
572
756
  */
573
757
  function buildAggregateCount(dialect, table, where, controls) {
574
758
  const groupFields = controls.$groupBy;
575
759
  const having = havingClause(dialect, controls);
576
760
  const countCol = `COUNT(*) AS ${dialect.quoteIdentifier("count")}`;
577
- if (!groupFields?.length && !having) return finalizeParams(dialect, {
578
- sql: `SELECT ${countCol} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}`,
579
- params: where.params
580
- });
581
- 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);
582
773
  let inner = "COUNT(*)";
583
774
  if (groupBy) inner = bucketSelectParts(dialect, controls).join(", ") || "1";
584
775
  return finalizeParams(dialect, {
585
- 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")}`,
586
777
  params: [...where.params, ...having?.params ?? []]
587
778
  });
588
779
  }
@@ -648,30 +839,16 @@ function viewAggExpr(dialect, c, resolveFieldRef) {
648
839
  function viewComputeExpr(dialect, c, byPath, resolveFieldRef, grouped = false, cache = /* @__PURE__ */ new Map()) {
649
840
  const cached = cache.get(c.viewPath);
650
841
  if (cached !== void 0) return cached;
651
- const cast = dialect.castDouble?.bind(dialect);
652
- if (!cast) throw new Error(`View column "${c.viewColumn}": computed view columns are not supported by this adapter`);
653
- const render = (e) => {
654
- if (typeof e === "number") {
655
- if (!Number.isFinite(e)) throw new Error(`View column "${c.viewColumn}": non-finite literal in @db.compute`);
656
- return cast(String(e));
657
- }
658
- if ("field" in e) {
659
- const leaf = byPath.get(e.field);
660
- if (!leaf) throw new Error(`View column "${c.viewColumn}": "${e.field}" is not a column of the view`);
661
- if (leaf.expr !== void 0) return viewComputeExpr(dialect, leaf, byPath, resolveFieldRef, grouped, cache);
662
- if (leaf.aggFn) return cast(viewAggExpr(dialect, leaf, resolveFieldRef));
663
- const source = viewSourceExpr(dialect, leaf);
664
- return cast(grouped && leaf.json ? `MIN(${source})` : source);
665
- }
666
- const args = e.args.map(render);
667
- switch (e.op) {
668
- case "neg": return `(-${args[0]})`;
669
- case "coalesce": return `COALESCE(${args.join(", ")})`;
670
- case "/": return `(${args[0]} / NULLIF(${args[1]}, 0))`;
671
- default: return `(${args[0]} ${e.op} ${args[1]})`;
672
- }
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;
673
850
  };
674
- const sql = render(c.expr);
851
+ const sql = renderArith(dialect, c.expr, leaf, fail);
675
852
  cache.set(c.viewPath, sql);
676
853
  return sql;
677
854
  }
@@ -695,10 +872,7 @@ function firstRowOn(dialect, join, target, condition, resolveFieldRef) {
695
872
  type: join.targetType,
696
873
  field: first.key
697
874
  });
698
- return `${key} = (SELECT ${key} FROM ${target} WHERE ${condition} ORDER BY ${first.order.map(({ ref, desc }) => {
699
- const nulls = dialect.nullsSortLargest ? desc ? " NULLS LAST" : " NULLS FIRST" : "";
700
- return `${resolveFieldRef(ref)} ${desc ? "DESC" : "ASC"}${nulls}`;
701
- }).join(", ")} LIMIT 1)`;
875
+ return `${key} = (SELECT ${key} FROM ${target} WHERE ${condition} ORDER BY ${first.order.map(({ ref, desc }) => orderKeySql(dialect, resolveFieldRef(ref), desc)).join(", ")} LIMIT 1)`;
702
876
  }
703
877
  /**
704
878
  * Builds a CREATE VIEW statement from a view plan and column mappings.
@@ -779,6 +953,21 @@ function insertManyColumns(rows) {
779
953
  return [...columns];
780
954
  }
781
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
+ /**
782
971
  * Builds a multi-row `INSERT … VALUES (…), (…)` statement over `columns`
783
972
  * (default: {@link insertManyColumns} of `rows`). A row lacking a column gets
784
973
  * `DEFAULT` — exactly what a single-row INSERT omitting it stores. Callers
@@ -1262,6 +1451,7 @@ exports.PARTITION_ROW_NUMBER_ALIAS = PARTITION_ROW_NUMBER_ALIAS;
1262
1451
  exports.SEARCH_SOURCE_ALIAS = SEARCH_SOURCE_ALIAS;
1263
1452
  exports.SQL_DEFAULT = SQL_DEFAULT;
1264
1453
  exports.VECTOR_DISTANCE_ALIAS = VECTOR_DISTANCE_ALIAS;
1454
+ exports.arithOverflowError = arithOverflowError;
1265
1455
  exports.buildAggregateCount = buildAggregateCount;
1266
1456
  exports.buildAggregateSelect = buildAggregateSelect;
1267
1457
  exports.buildCreateView = buildCreateView;
@@ -1277,6 +1467,7 @@ exports.buildUpdate = buildUpdate;
1277
1467
  exports.buildVectorSearchCount = buildVectorSearchCount;
1278
1468
  exports.buildVectorSearchSelect = buildVectorSearchSelect;
1279
1469
  exports.buildWhere = buildWhere;
1470
+ exports.chunkInsertRows = chunkInsertRows;
1280
1471
  exports.createFilterVisitor = createFilterVisitor;
1281
1472
  exports.defaultValueForType = defaultValueForType;
1282
1473
  exports.defaultValueToSqlLiteral = defaultValueToSqlLiteral;
@@ -1288,13 +1479,17 @@ exports.geoWindowFromControls = geoWindowFromControls;
1288
1479
  exports.groupKeySql = groupKeySql;
1289
1480
  exports.insertManyColumns = insertManyColumns;
1290
1481
  exports.jsonDollarPath = jsonDollarPath;
1482
+ exports.mapQueryErrors = mapQueryErrors;
1291
1483
  exports.normalizeGeoPointValue = normalizeGeoPointValue;
1484
+ exports.numericOutOfRangeError = numericOutOfRangeError;
1485
+ exports.orderKeySql = orderKeySql;
1292
1486
  exports.parseRegexString = parseRegexString;
1293
1487
  exports.queryNodeToSql = queryNodeToSql;
1294
1488
  exports.queryOpToSql = queryOpToSql;
1295
1489
  exports.quotedJsonPathSegments = quotedJsonPathSegments;
1296
1490
  exports.refActionToSql = refActionToSql;
1297
1491
  exports.renameGeoDistance = renameGeoDistance;
1492
+ exports.renderArith = renderArith;
1298
1493
  exports.replaceColumnsFor = replaceColumnsFor;
1299
1494
  exports.sqlStringLiteral = sqlStringLiteral;
1300
1495
  exports.sqlTimeZoneLiteral = sqlTimeZoneLiteral;
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { AtscriptQueryFieldRef, AtscriptQueryNode, DbControls, TDbDefaultFn, TDbFieldMeta, TDbForeignKey, TDbReferentialAction, TFieldOps, TResolvedBucket, TViewColumnMapping, TViewJsonType, TViewPlan, UniquSelect } from "@atscript/db";
1
+ import { AtscriptExprNode, AtscriptQueryFieldRef, AtscriptQueryNode, DbControls, DbError, TDbDefaultFn, TDbFieldMeta, TDbForeignKey, TDbReferentialAction, TFieldOps, TResolvedBucket, TViewColumnMapping, TViewJsonType, TViewPlan, UniquSelect } from "@atscript/db";
2
2
  import { FilterExpr, FilterVisitor } from "@uniqu/core";
3
3
  import { TDbAggregateFn } from "@atscript/db/agg";
4
4
 
@@ -95,6 +95,38 @@ interface SqlDialect {
95
95
  * @since 0.1.147
96
96
  */
97
97
  castDouble?(expr: string): string;
98
+ /**
99
+ * The aggregate functions that stand in for `MIN` / `MAX` over a BOOLEAN
100
+ * column on an engine that has no `MIN(boolean)` (PostgreSQL: `BOOL_AND` /
101
+ * `BOOL_OR`). Absent: `MIN` / `MAX` apply to booleans as to any column.
102
+ * @since 0.1.148
103
+ */
104
+ booleanAggregates?: {
105
+ min: string;
106
+ max: string;
107
+ };
108
+ /**
109
+ * Renders the "any value of the group" pick over an already rendered column
110
+ * — what a `first` / `last` derived column (constant within its group)
111
+ * aggregates through — for the column's source `field` (`undefined` when
112
+ * unknown). The dialect picks the cheapest aggregate its engine has for the
113
+ * column's type: a streaming `MIN` where one exists, a type-agnostic form
114
+ * only where it does not (PostgreSQL: `MIN` for ordered types, `BOOL_AND`
115
+ * for a boolean, `(ARRAY_AGG(x))[1]` for uuid, bytea, point, json, …).
116
+ * Absent: `MIN(x)`.
117
+ * @since 0.1.148
118
+ */
119
+ anyValue?(expr: string, field: TDbFieldMeta | undefined): string;
120
+ /**
121
+ * Maps a driver error of a grouped query to the `DbError` it means — a
122
+ * numeric overflow (`arithOverflowError` when the query has arithmetic
123
+ * expressions, else `numericOutOfRangeError`), an unknown calendar-bucket
124
+ * zone — or `undefined` to let it propagate unchanged. `arithmetic` is
125
+ * whether the query selects any arithmetic expression. Run by
126
+ * {@link mapQueryErrors}.
127
+ * @since 0.1.148
128
+ */
129
+ mapQueryError?(error: unknown, arithmetic: boolean): Error | undefined;
98
130
  /**
99
131
  * `true` when the database sorts NULL as the LARGEST value (PostgreSQL):
100
132
  * first-row join order keys then render `ASC NULLS FIRST` /
@@ -113,6 +145,21 @@ interface SqlDialect {
113
145
  * (e.g. `$1, $2, ...` for PostgreSQL). No-op when `dialect.paramPlaceholder` is not set.
114
146
  */
115
147
  declare function finalizeParams(dialect: SqlDialect, fragment: TSqlFragment): TSqlFragment;
148
+ /**
149
+ * Runs `fn`, rethrowing a driver error as the `DbError`
150
+ * {@link SqlDialect.mapQueryError} maps it to (any other error unchanged).
151
+ * `controls` of the query tell whether it has arithmetic expressions.
152
+ * @since 0.1.148
153
+ */
154
+ declare function mapQueryErrors<R>(dialect: SqlDialect, fn: () => Promise<R>, controls?: DbControls): Promise<R>;
155
+ /**
156
+ * One `ORDER BY` key with the uniform "NULL is the smallest value" ordering:
157
+ * `<expr> ASC` / `<expr> DESC`, plus `NULLS FIRST` / `NULLS LAST` on a
158
+ * dialect where NULL sorts largest ({@link SqlDialect.nullsSortLargest}).
159
+ * Shared by first-row joins and `first` / `last` aggregates.
160
+ * @since 0.1.148
161
+ */
162
+ declare function orderKeySql(dialect: SqlDialect, expr: string, desc: boolean): string;
116
163
  /**
117
164
  * Each JSON path segment wrapped in double quotes (`"a"`), for the dialects'
118
165
  * {@link SqlDialect.jsonExtract} path literals (`'$."a"."b"'`, `'{"a","b"}'`).
@@ -128,6 +175,34 @@ declare function quotedJsonPathSegments(path: readonly string[]): string[];
128
175
  declare const EMPTY_AND: TSqlFragment;
129
176
  declare const EMPTY_OR: TSqlFragment;
130
177
  //#endregion
178
+ //#region src/arith.d.ts
179
+ /** Why {@link renderArith} cannot render an expression. */
180
+ type TArithFailure = "no-cast" | "non-finite";
181
+ /**
182
+ * Renders a computed expression tree (`@db.compute` on a view, or a query-time
183
+ * arithmetic `$select` entry) as SQL, in IEEE double on every dialect: each
184
+ * literal is cast with {@link SqlDialect.castDouble}; `+ - *` render as
185
+ * `(l op r)`, `/` as `(l / NULLIF(r, 0))` (division by zero is NULL), unary
186
+ * minus as `(-x)` and `coalesce` as `COALESCE(…)`. `leaf` renders a field
187
+ * reference as raw SQL, which is cast to double here — or, as `{ double }`,
188
+ * SQL that already is a double (a nested computed column) and stays as is.
189
+ *
190
+ * The one renderer of both paths, so declared and query-time arithmetic
191
+ * cannot diverge.
192
+ *
193
+ * @param fail - the error to throw for a failure (default `DbError`:
194
+ * `AGG_EXPR_NOT_SUPPORTED` without `castDouble`, `INVALID_QUERY` for a
195
+ * non-finite literal).
196
+ * @since 0.1.148
197
+ */
198
+ declare function renderArith(dialect: SqlDialect, node: AtscriptExprNode, leaf: (field: string) => string | {
199
+ double: string;
200
+ }, fail?: (reason: TArithFailure) => Error): string;
201
+ /** The error of a numeric overflow that is no arithmetic expression's (`INVALID_QUERY`, `path` `""`). */
202
+ declare function numericOutOfRangeError(): DbError;
203
+ /** The error of a double overflow in aggregate arithmetic (`INVALID_QUERY`, `path` `$select`). */
204
+ declare function arithOverflowError(): DbError;
205
+ //#endregion
131
206
  //#region src/filter-builder.d.ts
132
207
  interface TFilterVisitorOptions {
133
208
  /**
@@ -326,6 +401,15 @@ declare function buildInsert(dialect: SqlDialect, table: string, data: Record<st
326
401
  * some).
327
402
  */
328
403
  declare function insertManyColumns(rows: readonly Record<string, unknown>[]): string[];
404
+ /**
405
+ * Splits `rows` into batches that stay under the driver's bind-parameter limit
406
+ * (PostgreSQL ~65535, MySQL packet size): `maxParams` (default 60000) divided
407
+ * by the column count. Returns the shared column union and the batches.
408
+ */
409
+ declare function chunkInsertRows(rows: readonly Record<string, unknown>[], maxParams?: number): {
410
+ columns: string[];
411
+ batches: Record<string, unknown>[][];
412
+ };
329
413
  /**
330
414
  * Builds a multi-row `INSERT … VALUES (…), (…)` statement over `columns`
331
415
  * (default: {@link insertManyColumns} of `rows`). A row lacking a column gets
@@ -495,7 +579,7 @@ declare function queryNodeToSql(node: AtscriptQueryNode, resolveFieldRef: (ref:
495
579
  * SQL function name of each single-name aggregate. `countDistinct` is not a
496
580
  * name but a form (`COUNT(DISTINCT x)`) — see {@link renderAggCall}.
497
581
  */
498
- declare const AGG_FN_SQL: Readonly<Record<Exclude<TDbAggregateFn, "countDistinct">, string>>;
582
+ declare const AGG_FN_SQL: Readonly<Record<Exclude<TDbAggregateFn, "countDistinct" | "first" | "last">, string>>;
499
583
  /**
500
584
  * The SQL a `$groupBy` key renders as: a calendar-bucket alias renders the
501
585
  * bucket EXPRESSION (`dialect.calendarBucket`), anything else the quoted
@@ -512,7 +596,9 @@ declare function groupKeySql(dialect: SqlDialect, controls: DbControls, key: str
512
596
  * Builds a SELECT ... GROUP BY statement with aggregate functions.
513
597
  *
514
598
  * SELECT lists the plain grouped columns, then `<bucket expr> AS "alias"`
515
- * per calendar bucket, then the aggregates. Bucket expressions are
599
+ * per calendar bucket, then every computed alias (aggregates, row-level
600
+ * expression aggregates, `first` / `last`, group-level expressions — the
601
+ * order of `UniquSelect.computedAliases`). Bucket expressions are
516
602
  * parameter-free, so the bind parameters are exactly those of the same query
517
603
  * without buckets (WHERE, HAVING, LIMIT, OFFSET).
518
604
  */
@@ -521,7 +607,9 @@ declare function buildAggregateSelect(dialect: SqlDialect, table: string, where:
521
607
  * Builds a COUNT query for the number of distinct groups — the groups that
522
608
  * survive `$having` when one is given (the same predicate the row query
523
609
  * renders, so `$count` agrees with the row set). Returns `{ count: N }` when
524
- * executed.
610
+ * executed. The rows come straight from the table, unless `$having` reads a
611
+ * `first` / `last` value (or an expression over one): only then the window
612
+ * derived table is built.
525
613
  */
526
614
  declare function buildAggregateCount(dialect: SqlDialect, table: string, where: TSqlFragment, controls: DbControls): TSqlFragment;
527
615
  //#endregion
@@ -538,4 +626,4 @@ declare function parseRegexString(value: unknown): {
538
626
  flags: string;
539
627
  };
540
628
  //#endregion
541
- export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, PARTITION_ROW_NUMBER_ALIAS, SEARCH_SOURCE_ALIAS, SQL_DEFAULT, type SqlDialect, type TFilterVisitorOptions, type TGeoCircle, type TGeoSearchControls, type TGeoWindow, type TReplaceColumn, type TSqlFragment, VECTOR_DISTANCE_ALIAS, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildPartitionedSelect, buildProjection, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, finalizeParams, foreignKeySql, geoWindowFromControls, groupKeySql, insertManyColumns, jsonDollarPath, normalizeGeoPointValue, parseRegexString, queryNodeToSql, queryOpToSql, quotedJsonPathSegments, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue, vectorDistanceSource };
629
+ export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, PARTITION_ROW_NUMBER_ALIAS, SEARCH_SOURCE_ALIAS, SQL_DEFAULT, type SqlDialect, type TArithFailure, type TFilterVisitorOptions, type TGeoCircle, type TGeoSearchControls, type TGeoWindow, type TReplaceColumn, type TSqlFragment, 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 };
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
+ import { AtscriptExprNode, AtscriptQueryFieldRef, AtscriptQueryNode, DbControls, DbError, TDbDefaultFn, TDbFieldMeta, TDbForeignKey, TDbReferentialAction, TFieldOps, TResolvedBucket, TViewColumnMapping, TViewJsonType, TViewPlan, UniquSelect } from "@atscript/db";
1
2
  import { FilterExpr, FilterVisitor } from "@uniqu/core";
2
- import { AtscriptQueryFieldRef, AtscriptQueryNode, DbControls, TDbDefaultFn, TDbFieldMeta, TDbForeignKey, TDbReferentialAction, TFieldOps, TResolvedBucket, TViewColumnMapping, TViewJsonType, TViewPlan, UniquSelect } from "@atscript/db";
3
3
  import { TDbAggregateFn } from "@atscript/db/agg";
4
4
 
5
5
  //#region src/dialect.d.ts
@@ -95,6 +95,38 @@ interface SqlDialect {
95
95
  * @since 0.1.147
96
96
  */
97
97
  castDouble?(expr: string): string;
98
+ /**
99
+ * The aggregate functions that stand in for `MIN` / `MAX` over a BOOLEAN
100
+ * column on an engine that has no `MIN(boolean)` (PostgreSQL: `BOOL_AND` /
101
+ * `BOOL_OR`). Absent: `MIN` / `MAX` apply to booleans as to any column.
102
+ * @since 0.1.148
103
+ */
104
+ booleanAggregates?: {
105
+ min: string;
106
+ max: string;
107
+ };
108
+ /**
109
+ * Renders the "any value of the group" pick over an already rendered column
110
+ * — what a `first` / `last` derived column (constant within its group)
111
+ * aggregates through — for the column's source `field` (`undefined` when
112
+ * unknown). The dialect picks the cheapest aggregate its engine has for the
113
+ * column's type: a streaming `MIN` where one exists, a type-agnostic form
114
+ * only where it does not (PostgreSQL: `MIN` for ordered types, `BOOL_AND`
115
+ * for a boolean, `(ARRAY_AGG(x))[1]` for uuid, bytea, point, json, …).
116
+ * Absent: `MIN(x)`.
117
+ * @since 0.1.148
118
+ */
119
+ anyValue?(expr: string, field: TDbFieldMeta | undefined): string;
120
+ /**
121
+ * Maps a driver error of a grouped query to the `DbError` it means — a
122
+ * numeric overflow (`arithOverflowError` when the query has arithmetic
123
+ * expressions, else `numericOutOfRangeError`), an unknown calendar-bucket
124
+ * zone — or `undefined` to let it propagate unchanged. `arithmetic` is
125
+ * whether the query selects any arithmetic expression. Run by
126
+ * {@link mapQueryErrors}.
127
+ * @since 0.1.148
128
+ */
129
+ mapQueryError?(error: unknown, arithmetic: boolean): Error | undefined;
98
130
  /**
99
131
  * `true` when the database sorts NULL as the LARGEST value (PostgreSQL):
100
132
  * first-row join order keys then render `ASC NULLS FIRST` /
@@ -113,6 +145,21 @@ interface SqlDialect {
113
145
  * (e.g. `$1, $2, ...` for PostgreSQL). No-op when `dialect.paramPlaceholder` is not set.
114
146
  */
115
147
  declare function finalizeParams(dialect: SqlDialect, fragment: TSqlFragment): TSqlFragment;
148
+ /**
149
+ * Runs `fn`, rethrowing a driver error as the `DbError`
150
+ * {@link SqlDialect.mapQueryError} maps it to (any other error unchanged).
151
+ * `controls` of the query tell whether it has arithmetic expressions.
152
+ * @since 0.1.148
153
+ */
154
+ declare function mapQueryErrors<R>(dialect: SqlDialect, fn: () => Promise<R>, controls?: DbControls): Promise<R>;
155
+ /**
156
+ * One `ORDER BY` key with the uniform "NULL is the smallest value" ordering:
157
+ * `<expr> ASC` / `<expr> DESC`, plus `NULLS FIRST` / `NULLS LAST` on a
158
+ * dialect where NULL sorts largest ({@link SqlDialect.nullsSortLargest}).
159
+ * Shared by first-row joins and `first` / `last` aggregates.
160
+ * @since 0.1.148
161
+ */
162
+ declare function orderKeySql(dialect: SqlDialect, expr: string, desc: boolean): string;
116
163
  /**
117
164
  * Each JSON path segment wrapped in double quotes (`"a"`), for the dialects'
118
165
  * {@link SqlDialect.jsonExtract} path literals (`'$."a"."b"'`, `'{"a","b"}'`).
@@ -128,6 +175,34 @@ declare function quotedJsonPathSegments(path: readonly string[]): string[];
128
175
  declare const EMPTY_AND: TSqlFragment;
129
176
  declare const EMPTY_OR: TSqlFragment;
130
177
  //#endregion
178
+ //#region src/arith.d.ts
179
+ /** Why {@link renderArith} cannot render an expression. */
180
+ type TArithFailure = "no-cast" | "non-finite";
181
+ /**
182
+ * Renders a computed expression tree (`@db.compute` on a view, or a query-time
183
+ * arithmetic `$select` entry) as SQL, in IEEE double on every dialect: each
184
+ * literal is cast with {@link SqlDialect.castDouble}; `+ - *` render as
185
+ * `(l op r)`, `/` as `(l / NULLIF(r, 0))` (division by zero is NULL), unary
186
+ * minus as `(-x)` and `coalesce` as `COALESCE(…)`. `leaf` renders a field
187
+ * reference as raw SQL, which is cast to double here — or, as `{ double }`,
188
+ * SQL that already is a double (a nested computed column) and stays as is.
189
+ *
190
+ * The one renderer of both paths, so declared and query-time arithmetic
191
+ * cannot diverge.
192
+ *
193
+ * @param fail - the error to throw for a failure (default `DbError`:
194
+ * `AGG_EXPR_NOT_SUPPORTED` without `castDouble`, `INVALID_QUERY` for a
195
+ * non-finite literal).
196
+ * @since 0.1.148
197
+ */
198
+ declare function renderArith(dialect: SqlDialect, node: AtscriptExprNode, leaf: (field: string) => string | {
199
+ double: string;
200
+ }, fail?: (reason: TArithFailure) => Error): string;
201
+ /** The error of a numeric overflow that is no arithmetic expression's (`INVALID_QUERY`, `path` `""`). */
202
+ declare function numericOutOfRangeError(): DbError;
203
+ /** The error of a double overflow in aggregate arithmetic (`INVALID_QUERY`, `path` `$select`). */
204
+ declare function arithOverflowError(): DbError;
205
+ //#endregion
131
206
  //#region src/filter-builder.d.ts
132
207
  interface TFilterVisitorOptions {
133
208
  /**
@@ -326,6 +401,15 @@ declare function buildInsert(dialect: SqlDialect, table: string, data: Record<st
326
401
  * some).
327
402
  */
328
403
  declare function insertManyColumns(rows: readonly Record<string, unknown>[]): string[];
404
+ /**
405
+ * Splits `rows` into batches that stay under the driver's bind-parameter limit
406
+ * (PostgreSQL ~65535, MySQL packet size): `maxParams` (default 60000) divided
407
+ * by the column count. Returns the shared column union and the batches.
408
+ */
409
+ declare function chunkInsertRows(rows: readonly Record<string, unknown>[], maxParams?: number): {
410
+ columns: string[];
411
+ batches: Record<string, unknown>[][];
412
+ };
329
413
  /**
330
414
  * Builds a multi-row `INSERT … VALUES (…), (…)` statement over `columns`
331
415
  * (default: {@link insertManyColumns} of `rows`). A row lacking a column gets
@@ -495,7 +579,7 @@ declare function queryNodeToSql(node: AtscriptQueryNode, resolveFieldRef: (ref:
495
579
  * SQL function name of each single-name aggregate. `countDistinct` is not a
496
580
  * name but a form (`COUNT(DISTINCT x)`) — see {@link renderAggCall}.
497
581
  */
498
- declare const AGG_FN_SQL: Readonly<Record<Exclude<TDbAggregateFn, "countDistinct">, string>>;
582
+ declare const AGG_FN_SQL: Readonly<Record<Exclude<TDbAggregateFn, "countDistinct" | "first" | "last">, string>>;
499
583
  /**
500
584
  * The SQL a `$groupBy` key renders as: a calendar-bucket alias renders the
501
585
  * bucket EXPRESSION (`dialect.calendarBucket`), anything else the quoted
@@ -512,7 +596,9 @@ declare function groupKeySql(dialect: SqlDialect, controls: DbControls, key: str
512
596
  * Builds a SELECT ... GROUP BY statement with aggregate functions.
513
597
  *
514
598
  * SELECT lists the plain grouped columns, then `<bucket expr> AS "alias"`
515
- * per calendar bucket, then the aggregates. Bucket expressions are
599
+ * per calendar bucket, then every computed alias (aggregates, row-level
600
+ * expression aggregates, `first` / `last`, group-level expressions — the
601
+ * order of `UniquSelect.computedAliases`). Bucket expressions are
516
602
  * parameter-free, so the bind parameters are exactly those of the same query
517
603
  * without buckets (WHERE, HAVING, LIMIT, OFFSET).
518
604
  */
@@ -521,7 +607,9 @@ declare function buildAggregateSelect(dialect: SqlDialect, table: string, where:
521
607
  * Builds a COUNT query for the number of distinct groups — the groups that
522
608
  * survive `$having` when one is given (the same predicate the row query
523
609
  * renders, so `$count` agrees with the row set). Returns `{ count: N }` when
524
- * executed.
610
+ * executed. The rows come straight from the table, unless `$having` reads a
611
+ * `first` / `last` value (or an expression over one): only then the window
612
+ * derived table is built.
525
613
  */
526
614
  declare function buildAggregateCount(dialect: SqlDialect, table: string, where: TSqlFragment, controls: DbControls): TSqlFragment;
527
615
  //#endregion
@@ -538,4 +626,4 @@ declare function parseRegexString(value: unknown): {
538
626
  flags: string;
539
627
  };
540
628
  //#endregion
541
- export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, PARTITION_ROW_NUMBER_ALIAS, SEARCH_SOURCE_ALIAS, SQL_DEFAULT, type SqlDialect, type TFilterVisitorOptions, type TGeoCircle, type TGeoSearchControls, type TGeoWindow, type TReplaceColumn, type TSqlFragment, VECTOR_DISTANCE_ALIAS, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildPartitionedSelect, buildProjection, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, finalizeParams, foreignKeySql, geoWindowFromControls, groupKeySql, insertManyColumns, jsonDollarPath, normalizeGeoPointValue, parseRegexString, queryNodeToSql, queryOpToSql, quotedJsonPathSegments, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue, vectorDistanceSource };
629
+ export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, PARTITION_ROW_NUMBER_ALIAS, SEARCH_SOURCE_ALIAS, SQL_DEFAULT, type SqlDialect, type TArithFailure, type TFilterVisitorOptions, type TGeoCircle, type TGeoSearchControls, type TGeoWindow, type TReplaceColumn, type TSqlFragment, 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 };
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, containsRelationFilter, fkColumns, isFieldRef, isResolvedRelationFilter } 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,6 +80,67 @@ 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`.
@@ -427,15 +514,23 @@ const AGG_FN_SQL = {
427
514
  */
428
515
  function renderAggCall(fn, arg, path) {
429
516
  assertAggregateFn(fn, path);
430
- 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})`;
431
519
  }
432
- /** The bare aggregate call, e.g. `SUM("amount")` / `COUNT(*)` / `COUNT(DISTINCT "region")`. */
433
- function aggFnSql(dialect, expr) {
434
- const field = expr.$field === "*" ? "*" : dialect.quoteIdentifier(expr.$field);
435
- 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);
436
528
  }
437
- function buildAggExpr(dialect, expr) {
438
- 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);
439
534
  }
440
535
  const BUCKET_UNIT_SET = new Set(BUCKET_UNITS);
441
536
  const WEEK_START_SET = new Set(WEEK_STARTS);
@@ -483,25 +578,52 @@ function groupKeySql(dialect, controls, key) {
483
578
  const bucket = controls.$select?.bucketByAlias(key);
484
579
  return bucket ? bucketSql(dialect, bucket) : dialect.quoteIdentifier(key);
485
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
+ }
486
606
  /**
487
607
  * ` HAVING <predicate>` (leading space) + params for `controls.$having`, or
488
608
  * `undefined` when there is nothing to render. Shared by the row and the
489
609
  * count builders so both filter the same group set.
490
610
  *
491
- * A key that names an aggregate alias (`$as`, else `fn_field`) renders the
492
- * aggregate expression itself — `SUM("amount") > ?` — because PostgreSQL does
493
- * not allow a SELECT alias in HAVING (MySQL and SQLite tolerate it, so the
494
- * expression form keeps all three identical). A calendar-bucket alias renders
495
- * its bucket expression ({@link groupKeySql}), or its quoted alias when the
496
- * dialect sets `SqlDialect.bucketAliasInHaving` (`havingGroupRef`). Other keys
497
- * (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.
498
619
  */
499
- function havingClause(dialect, controls) {
620
+ function havingClause(dialect, controls, aliasSql) {
500
621
  const having = controls.$having;
501
622
  if (!having) return void 0;
502
- const exprByAlias = /* @__PURE__ */ new Map();
503
- 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();
504
625
  const fragment = walkFilter(having, createFilterVisitor(dialect, { columnRef: (field) => {
626
+ refs.add(field);
505
627
  const aggExpr = exprByAlias.get(field);
506
628
  if (aggExpr) return aggExpr;
507
629
  const bucket = controls.$select?.bucketByAlias(field);
@@ -510,43 +632,103 @@ function havingClause(dialect, controls) {
510
632
  if (!fragment || fragment.sql === EMPTY_AND.sql) return void 0;
511
633
  return {
512
634
  sql: ` HAVING ${fragment.sql}`,
513
- params: fragment.params
635
+ params: fragment.params,
636
+ refs
514
637
  };
515
638
  }
516
639
  /** `<bucket expr> AS "alias"` for every calendar bucket in `$select`. */
517
640
  function bucketSelectParts(dialect, controls) {
518
641
  return (controls.$select?.buckets ?? []).map((bucket) => `${bucketSql(dialect, bucket)} AS ${dialect.quoteIdentifier(bucket.alias)}`);
519
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
+ }
520
702
  /**
521
703
  * Builds a SELECT ... GROUP BY statement with aggregate functions.
522
704
  *
523
705
  * SELECT lists the plain grouped columns, then `<bucket expr> AS "alias"`
524
- * 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
525
709
  * parameter-free, so the bind parameters are exactly those of the same query
526
710
  * without buckets (WHERE, HAVING, LIMIT, OFFSET).
527
711
  */
528
712
  function buildAggregateSelect(dialect, table, where, controls) {
713
+ const select = controls.$select;
529
714
  const selectParts = [];
530
- const plainFields = controls.$select?.asArray;
531
- if (plainFields) for (const f of plainFields) selectParts.push(dialect.quoteIdentifier(f));
715
+ for (const f of select?.asArray ?? []) selectParts.push(dialect.quoteIdentifier(f));
532
716
  selectParts.push(...bucketSelectParts(dialect, controls));
533
- const aggregates = controls.$select?.aggregates;
534
- if (aggregates) for (const expr of aggregates) selectParts.push(buildAggExpr(dialect, expr));
535
- 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)}`;
536
723
  const params = [...where.params];
537
- const groupBy = controls.$groupBy;
538
- if (groupBy?.length) {
539
- const groupCols = groupBy.map((key) => groupKeySql(dialect, controls, key)).join(", ");
540
- sql += ` GROUP BY ${groupCols}`;
541
- }
542
- const having = havingClause(dialect, controls);
724
+ const having = havingClause(dialect, controls, aliasSql);
543
725
  if (having) {
544
726
  sql += having.sql;
545
727
  params.push(...having.params);
546
728
  }
547
729
  if (controls.$sort) {
548
730
  const orderParts = [];
549
- 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));
550
732
  if (orderParts.length > 0) sql += ` ORDER BY ${orderParts.join(", ")}`;
551
733
  }
552
734
  if (controls.$limit !== void 0) {
@@ -567,21 +749,30 @@ function buildAggregateSelect(dialect, table, where, controls) {
567
749
  * Builds a COUNT query for the number of distinct groups — the groups that
568
750
  * survive `$having` when one is given (the same predicate the row query
569
751
  * renders, so `$count` agrees with the row set). Returns `{ count: N }` when
570
- * 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.
571
755
  */
572
756
  function buildAggregateCount(dialect, table, where, controls) {
573
757
  const groupFields = controls.$groupBy;
574
758
  const having = havingClause(dialect, controls);
575
759
  const countCol = `COUNT(*) AS ${dialect.quoteIdentifier("count")}`;
576
- if (!groupFields?.length && !having) return finalizeParams(dialect, {
577
- sql: `SELECT ${countCol} FROM ${dialect.quoteTable(table)} WHERE ${where.sql}`,
578
- params: where.params
579
- });
580
- 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);
581
772
  let inner = "COUNT(*)";
582
773
  if (groupBy) inner = bucketSelectParts(dialect, controls).join(", ") || "1";
583
774
  return finalizeParams(dialect, {
584
- 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")}`,
585
776
  params: [...where.params, ...having?.params ?? []]
586
777
  });
587
778
  }
@@ -647,30 +838,16 @@ function viewAggExpr(dialect, c, resolveFieldRef) {
647
838
  function viewComputeExpr(dialect, c, byPath, resolveFieldRef, grouped = false, cache = /* @__PURE__ */ new Map()) {
648
839
  const cached = cache.get(c.viewPath);
649
840
  if (cached !== void 0) return cached;
650
- const cast = dialect.castDouble?.bind(dialect);
651
- if (!cast) throw new Error(`View column "${c.viewColumn}": computed view columns are not supported by this adapter`);
652
- const render = (e) => {
653
- if (typeof e === "number") {
654
- if (!Number.isFinite(e)) throw new Error(`View column "${c.viewColumn}": non-finite literal in @db.compute`);
655
- return cast(String(e));
656
- }
657
- if ("field" in e) {
658
- const leaf = byPath.get(e.field);
659
- if (!leaf) throw new Error(`View column "${c.viewColumn}": "${e.field}" is not a column of the view`);
660
- if (leaf.expr !== void 0) return viewComputeExpr(dialect, leaf, byPath, resolveFieldRef, grouped, cache);
661
- if (leaf.aggFn) return cast(viewAggExpr(dialect, leaf, resolveFieldRef));
662
- const source = viewSourceExpr(dialect, leaf);
663
- return cast(grouped && leaf.json ? `MIN(${source})` : source);
664
- }
665
- const args = e.args.map(render);
666
- switch (e.op) {
667
- case "neg": return `(-${args[0]})`;
668
- case "coalesce": return `COALESCE(${args.join(", ")})`;
669
- case "/": return `(${args[0]} / NULLIF(${args[1]}, 0))`;
670
- default: return `(${args[0]} ${e.op} ${args[1]})`;
671
- }
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;
672
849
  };
673
- const sql = render(c.expr);
850
+ const sql = renderArith(dialect, c.expr, leaf, fail);
674
851
  cache.set(c.viewPath, sql);
675
852
  return sql;
676
853
  }
@@ -694,10 +871,7 @@ function firstRowOn(dialect, join, target, condition, resolveFieldRef) {
694
871
  type: join.targetType,
695
872
  field: first.key
696
873
  });
697
- return `${key} = (SELECT ${key} FROM ${target} WHERE ${condition} ORDER BY ${first.order.map(({ ref, desc }) => {
698
- const nulls = dialect.nullsSortLargest ? desc ? " NULLS LAST" : " NULLS FIRST" : "";
699
- return `${resolveFieldRef(ref)} ${desc ? "DESC" : "ASC"}${nulls}`;
700
- }).join(", ")} LIMIT 1)`;
874
+ return `${key} = (SELECT ${key} FROM ${target} WHERE ${condition} ORDER BY ${first.order.map(({ ref, desc }) => orderKeySql(dialect, resolveFieldRef(ref), desc)).join(", ")} LIMIT 1)`;
701
875
  }
702
876
  /**
703
877
  * Builds a CREATE VIEW statement from a view plan and column mappings.
@@ -778,6 +952,21 @@ function insertManyColumns(rows) {
778
952
  return [...columns];
779
953
  }
780
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
+ /**
781
970
  * Builds a multi-row `INSERT … VALUES (…), (…)` statement over `columns`
782
971
  * (default: {@link insertManyColumns} of `rows`). A row lacking a column gets
783
972
  * `DEFAULT` — exactly what a single-row INSERT omitting it stores. Callers
@@ -1253,4 +1442,4 @@ function parseRegexString(value) {
1253
1442
  };
1254
1443
  }
1255
1444
  //#endregion
1256
- export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, PARTITION_ROW_NUMBER_ALIAS, SEARCH_SOURCE_ALIAS, SQL_DEFAULT, VECTOR_DISTANCE_ALIAS, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildPartitionedSelect, buildProjection, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, finalizeParams, foreignKeySql, geoWindowFromControls, groupKeySql, insertManyColumns, jsonDollarPath, normalizeGeoPointValue, parseRegexString, queryNodeToSql, queryOpToSql, quotedJsonPathSegments, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, stripPartitionRowNumber, 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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-sql-tools",
3
- "version": "0.1.147",
3
+ "version": "0.1.149",
4
4
  "description": "Shared SQL builder utilities for @atscript database adapters.",
5
5
  "keywords": [
6
6
  "atscript",
@@ -37,12 +37,12 @@
37
37
  "access": "public"
38
38
  },
39
39
  "devDependencies": {
40
- "@uniqu/core": "^0.1.12",
41
- "unplugin-atscript": "^0.1.99"
40
+ "@uniqu/core": "^0.1.13",
41
+ "unplugin-atscript": "^0.1.100"
42
42
  },
43
43
  "peerDependencies": {
44
- "@uniqu/core": "^0.1.12",
45
- "@atscript/db": "^0.1.147"
44
+ "@uniqu/core": "^0.1.13",
45
+ "@atscript/db": "^0.1.149"
46
46
  },
47
47
  "scripts": {
48
48
  "build": "vp pack",