@atscript/db-sql-tools 0.1.134 → 0.1.136

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
@@ -15,6 +15,37 @@ function finalizeParams(dialect, fragment) {
15
15
  params: fragment.params
16
16
  };
17
17
  }
18
+ /**
19
+ * How HAVING references a grouped NON-column expression (a calendar bucket, a
20
+ * view's JSON-extracted dimension): its quoted SELECT `alias` on dialects that
21
+ * set {@link SqlDialect.bucketAliasInHaving}, else the expression itself.
22
+ */
23
+ function havingGroupRef(dialect, expr, alias) {
24
+ return dialect.bucketAliasInHaving ? dialect.quoteIdentifier(alias) : expr;
25
+ }
26
+ /**
27
+ * Each JSON path segment wrapped in double quotes (`"a"`), for the dialects'
28
+ * {@link SqlDialect.jsonExtract} path literals (`'$."a"."b"'`, `'{"a","b"}'`).
29
+ *
30
+ * A segment containing `"`, `\` or a control character has no quoting that
31
+ * reads the same on every dialect (SQLite's quoted path labels have no escape
32
+ * syntax), so it is rejected rather than escaped.
33
+ *
34
+ * @since 0.1.136
35
+ * @throws for an empty path or a segment that is empty or holds such a character.
36
+ */
37
+ function quotedJsonPathSegments(path) {
38
+ if (path.length === 0) throw new Error("JSON extraction needs a path below the JSON column");
39
+ return path.map((seg) => {
40
+ let ok = seg.length > 0;
41
+ for (let i = 0; ok && i < seg.length; i++) {
42
+ const code = seg.charCodeAt(i);
43
+ ok = code >= 32 && code !== 34 && code !== 92;
44
+ }
45
+ if (!ok) throw new Error(`JSON path segment ${JSON.stringify(seg)} can't be extracted — segments must be non-empty and free of '"', '\\' and control characters`);
46
+ return `"${seg}"`;
47
+ });
48
+ }
18
49
  const EMPTY_AND = {
19
50
  sql: "1=1",
20
51
  params: []
@@ -255,6 +286,17 @@ function sqlStringLiteral(value) {
255
286
  return `'${value.replace(/'/g, "''")}'`;
256
287
  }
257
288
  /**
289
+ * The SQL string literal of a `$`-rooted JSON path with every segment quoted
290
+ * (`'$."a"."b"'`) — the path argument of SQLite's and MySQL's `json_extract` /
291
+ * `json_type`.
292
+ *
293
+ * @since 0.1.136
294
+ * @throws as {@link quotedJsonPathSegments}.
295
+ */
296
+ function jsonDollarPath(path) {
297
+ return sqlStringLiteral(`$.${quotedJsonPathSegments(path).join(".")}`);
298
+ }
299
+ /**
258
300
  * A calendar bucket's time zone as an inlined SQL string literal (`'Europe/Berlin'`).
259
301
  *
260
302
  * The zone is already canonical (the core normalizer ran uniqu's
@@ -324,23 +366,65 @@ const queryOpToSql = {
324
366
  $lt: "<",
325
367
  $lte: "<="
326
368
  };
369
+ /** An inlined SQL literal for a view-predicate value (DDL — no parameters). */
370
+ function predicateLiteral(value) {
371
+ if (value === null || value === void 0) return "NULL";
372
+ if (typeof value === "string") return sqlStringLiteral(value);
373
+ if (typeof value === "number") {
374
+ if (!Number.isFinite(value)) throw new Error(`Non-finite number ${value} in a view predicate`);
375
+ return String(value);
376
+ }
377
+ if (typeof value === "boolean") return value ? "true" : "false";
378
+ throw new Error(`Unsupported literal ${JSON.stringify(value)} in a view predicate`);
379
+ }
327
380
  /**
328
381
  * Renders an AtscriptQueryNode tree to raw SQL (no parameters -- for DDL use only).
382
+ *
383
+ * Operators: `=`/`!=`/`<`/`<=`/`>`/`>=` (a `null` operand becomes `IS [NOT] NULL`),
384
+ * `in` / `not in` (inlined literals; an empty `in` is `0=1`, an empty
385
+ * `not in` `1=1`), `exists` → `IS NOT NULL`, `not exists` → `IS NULL`.
386
+ *
387
+ * @throws for `matches` (`$regex`) and any other operator — a view predicate
388
+ * that cannot be rendered fails at sync instead of rendering wrong SQL.
329
389
  */
330
390
  function queryNodeToSql(node, resolveFieldRef) {
331
- if ("$and" in node) return node.$and.map((n) => queryNodeToSql(n, resolveFieldRef)).join(" AND ");
332
- if ("$or" in node) return `(${node.$or.map((n) => queryNodeToSql(n, resolveFieldRef)).join(" OR ")})`;
391
+ if ("$and" in node) {
392
+ const children = node.$and;
393
+ if (children.length === 0) return EMPTY_AND.sql;
394
+ return children.map((n) => queryNodeToSql(n, resolveFieldRef)).join(" AND ");
395
+ }
396
+ if ("$or" in node) {
397
+ const children = node.$or;
398
+ if (children.length === 0) return EMPTY_OR.sql;
399
+ return `(${children.map((n) => queryNodeToSql(n, resolveFieldRef)).join(" OR ")})`;
400
+ }
333
401
  if ("$not" in node) return `NOT (${queryNodeToSql(node.$not, resolveFieldRef)})`;
334
402
  const comp = node;
335
403
  const leftSql = resolveFieldRef(comp.left);
336
- const sqlOp = queryOpToSql[comp.op] || "=";
337
- if (comp.right && typeof comp.right === "object" && "field" in comp.right) return `${leftSql} ${sqlOp} ${resolveFieldRef(comp.right)}`;
404
+ switch (comp.op) {
405
+ case "$exists": return comp.right === false ? `${leftSql} IS NULL` : `${leftSql} IS NOT NULL`;
406
+ case "$in":
407
+ case "$nin": {
408
+ const values = Array.isArray(comp.right) ? comp.right : [comp.right];
409
+ if (values.length === 0) return comp.op === "$in" ? EMPTY_OR.sql : EMPTY_AND.sql;
410
+ const list = values.map((v) => predicateLiteral(v)).join(", ");
411
+ return `${leftSql} ${comp.op === "$in" ? "IN" : "NOT IN"} (${list})`;
412
+ }
413
+ case "$regex": throw new Error("matches is not supported in view predicates");
414
+ default:
415
+ }
416
+ const sqlOp = queryOpToSql[comp.op];
417
+ if (!sqlOp) throw new Error(`Operator "${comp.op}" is not supported in view predicates`);
418
+ if ((0, _atscript_db.isFieldRef)(comp.right)) return `${leftSql} ${sqlOp} ${resolveFieldRef(comp.right)}`;
338
419
  if (comp.right === null || comp.right === void 0) return comp.op === "$ne" ? `${leftSql} IS NOT NULL` : `${leftSql} IS NULL`;
339
- if (typeof comp.right === "string") return `${leftSql} ${sqlOp} '${comp.right.replace(/'/g, "''")}'`;
340
- return `${leftSql} ${sqlOp} ${comp.right}`;
420
+ return `${leftSql} ${sqlOp} ${predicateLiteral(comp.right)}`;
341
421
  }
342
422
  //#endregion
343
423
  //#region src/agg.ts
424
+ /**
425
+ * SQL function name of each single-name aggregate. `countDistinct` is not a
426
+ * name but a form (`COUNT(DISTINCT x)`) — see {@link renderAggCall}.
427
+ */
344
428
  const AGG_FN_SQL = {
345
429
  sum: "SUM",
346
430
  avg: "AVG",
@@ -348,9 +432,20 @@ const AGG_FN_SQL = {
348
432
  min: "MIN",
349
433
  max: "MAX"
350
434
  };
351
- /** The bare aggregate call, e.g. `SUM("amount")` / `COUNT(*)`. */
435
+ /**
436
+ * Renders one aggregate call over an already-rendered argument (`*`, a quoted
437
+ * column, a `CASE` expression): `SUM(x)`, `COUNT(DISTINCT x)`, …
438
+ * Re-asserts the name first (`INVALID_QUERY` on an unknown one), so nothing
439
+ * unchecked reaches SQL.
440
+ */
441
+ function renderAggCall(fn, arg, path) {
442
+ (0, _atscript_db_agg.assertAggregateFn)(fn, path);
443
+ return fn === "countDistinct" ? `COUNT(DISTINCT ${arg})` : `${AGG_FN_SQL[fn]}(${arg})`;
444
+ }
445
+ /** The bare aggregate call, e.g. `SUM("amount")` / `COUNT(*)` / `COUNT(DISTINCT "region")`. */
352
446
  function aggFnSql(dialect, expr) {
353
- return `${AGG_FN_SQL[expr.$fn] ?? expr.$fn.toUpperCase()}(${expr.$field === "*" ? "*" : dialect.quoteIdentifier(expr.$field)})`;
447
+ const field = expr.$field === "*" ? "*" : dialect.quoteIdentifier(expr.$field);
448
+ return renderAggCall(expr.$fn, field);
354
449
  }
355
450
  function buildAggExpr(dialect, expr) {
356
451
  return `${aggFnSql(dialect, expr)} AS ${dialect.quoteIdentifier((0, _atscript_db_agg.resolveAlias)(expr))}`;
@@ -411,7 +506,7 @@ function groupKeySql(dialect, controls, key) {
411
506
  * not allow a SELECT alias in HAVING (MySQL and SQLite tolerate it, so the
412
507
  * expression form keeps all three identical). A calendar-bucket alias renders
413
508
  * its bucket expression ({@link groupKeySql}), or its quoted alias when the
414
- * dialect sets `SqlDialect.bucketAliasInHaving` (why: see there). Other keys
509
+ * dialect sets `SqlDialect.bucketAliasInHaving` (`havingGroupRef`). Other keys
415
510
  * (grouped columns) render as plain columns.
416
511
  */
417
512
  function havingClause(dialect, controls) {
@@ -422,8 +517,8 @@ function havingClause(dialect, controls) {
422
517
  const fragment = (0, _uniqu_core.walkFilter)(having, createFilterVisitor(dialect, { columnRef: (field) => {
423
518
  const aggExpr = exprByAlias.get(field);
424
519
  if (aggExpr) return aggExpr;
425
- if (dialect.bucketAliasInHaving && controls.$select?.bucketByAlias(field)) return dialect.quoteIdentifier(field);
426
- return groupKeySql(dialect, controls, field);
520
+ const bucket = controls.$select?.bucketByAlias(field);
521
+ return bucket ? havingGroupRef(dialect, bucketSql(dialect, bucket), field) : dialect.quoteIdentifier(field);
427
522
  } }));
428
523
  if (!fragment || fragment.sql === EMPTY_AND.sql) return void 0;
429
524
  return {
@@ -504,6 +599,90 @@ function buildAggregateCount(dialect, table, where, controls) {
504
599
  });
505
600
  }
506
601
  //#endregion
602
+ //#region src/view-builder.ts
603
+ /**
604
+ * The SQL expression a view column reads: `"table"."column"` — the physical
605
+ * source column resolved by `AtscriptDbView.getViewColumnMappings()` — or,
606
+ * for a primitive leaf inside a JSON column (`mapping.json`), the dialect's
607
+ * typed {@link SqlDialect.jsonExtract} over that column. Used for SELECT
608
+ * columns, GROUP BY / HAVING dimensions and as an aggregate's source.
609
+ *
610
+ * @throws when the column reads a JSON leaf and the dialect has no `jsonExtract`.
611
+ */
612
+ function viewSourceExpr(dialect, mapping) {
613
+ const col = `${dialect.quoteIdentifier(mapping.sourceTable)}.${dialect.quoteIdentifier(mapping.sourceColumn)}`;
614
+ if (!mapping.json) return col;
615
+ if (!dialect.jsonExtract) throw new Error(`View column "${mapping.viewColumn}": JSON extraction is not supported by this adapter`);
616
+ return dialect.jsonExtract(col, mapping.json.path, mapping.json.type);
617
+ }
618
+ /**
619
+ * The SQL expression of one aggregate view column, e.g. `SUM("orders"."amount")`,
620
+ * `COUNT(*)` or `COUNT(DISTINCT "orders"."customer_id")`, over
621
+ * {@link viewSourceExpr}. The mapping's aggregate rules (`*` only for count, …)
622
+ * are validated where the core builds it.
623
+ *
624
+ * A conditional aggregate (`aggFilter`, rendered with the view's predicate
625
+ * resolver) aggregates `CASE WHEN <predicate> THEN <src> END` — NULL for the
626
+ * rows the predicate rejects, which every aggregate skips:
627
+ * - `COUNT(*)` → `COUNT(CASE WHEN p THEN 1 END)`;
628
+ * - `countDistinct` → `COUNT(DISTINCT CASE WHEN p THEN src END)`;
629
+ * - `sum` → `COALESCE(SUM(CASE WHEN p THEN src END), 0)` — a group with no
630
+ * matching row sums to 0, not NULL (MongoDB's `$sum` answer too);
631
+ * - `avg` / `min` / `max` stay NULL when no row matches.
632
+ */
633
+ function viewAggExpr(dialect, c, resolveFieldRef) {
634
+ const star = c.aggField === "*";
635
+ const src = star ? "*" : viewSourceExpr(dialect, c);
636
+ if (!c.aggFilter) return renderAggCall(c.aggFn, src, c.viewColumn);
637
+ const predicate = queryNodeToSql(c.aggFilter, resolveFieldRef);
638
+ const call = renderAggCall(c.aggFn, `CASE WHEN ${predicate} THEN ${star ? "1" : src} END`, c.viewColumn);
639
+ return c.aggFn === "sum" ? `COALESCE(${call}, 0)` : call;
640
+ }
641
+ /**
642
+ * Builds a CREATE VIEW statement from a view plan and column mappings.
643
+ *
644
+ * Joins render in declaration order — `JOIN` (inner, the default) or
645
+ * `LEFT JOIN` for `kind: "left"`. Column mappings carry PHYSICAL source
646
+ * names; `resolveFieldRef` renders predicate refs (join ON, WHERE, HAVING
647
+ * fallbacks) as `"table"."column"`.
648
+ */
649
+ function buildCreateView(dialect, viewName, plan, columns, resolveFieldRef) {
650
+ const selectCols = columns.map((c) => {
651
+ return `${c.aggFn ? viewAggExpr(dialect, c, resolveFieldRef) : viewSourceExpr(dialect, c)} AS ${dialect.quoteIdentifier(c.viewColumn)}`;
652
+ }).join(", ");
653
+ let sql = `${dialect.createViewPrefix} ${dialect.quoteTable(viewName)} AS SELECT ${selectCols} FROM ${dialect.quoteIdentifier(plan.entryTable)}`;
654
+ for (const join of plan.joins) {
655
+ const onClause = queryNodeToSql(join.condition, resolveFieldRef);
656
+ const keyword = join.kind === "left" ? "LEFT JOIN" : "JOIN";
657
+ sql += ` ${keyword} ${dialect.quoteIdentifier(join.targetTable)} ON ${onClause}`;
658
+ }
659
+ if (plan.filter) {
660
+ const whereClause = queryNodeToSql(plan.filter, resolveFieldRef);
661
+ sql += ` WHERE ${whereClause}`;
662
+ }
663
+ if (columns.some((c) => c.aggFn)) {
664
+ const dimensionCols = columns.filter((c) => !c.aggFn);
665
+ if (dimensionCols.length > 0) {
666
+ const groupByCols = dimensionCols.map((c) => viewSourceExpr(dialect, c)).join(", ");
667
+ sql += ` GROUP BY ${groupByCols}`;
668
+ }
669
+ if (plan.having) {
670
+ const columnByPath = /* @__PURE__ */ new Map();
671
+ for (const c of columns) columnByPath.set(c.viewPath, c);
672
+ const havingResolver = (ref) => {
673
+ const col = ref.type ? void 0 : columnByPath.get(ref.field);
674
+ if (!col) return resolveFieldRef(ref);
675
+ if (col.aggFn) return viewAggExpr(dialect, col, resolveFieldRef);
676
+ const expr = viewSourceExpr(dialect, col);
677
+ return col.json ? havingGroupRef(dialect, expr, col.viewColumn) : expr;
678
+ };
679
+ const havingClause = queryNodeToSql(plan.having, havingResolver);
680
+ sql += ` HAVING ${havingClause}`;
681
+ }
682
+ }
683
+ return sql;
684
+ }
685
+ //#endregion
507
686
  //#region src/sql-builder.ts
508
687
  /**
509
688
  * Builds an INSERT statement.
@@ -701,50 +880,6 @@ function buildProjection(dialect, select) {
701
880
  }
702
881
  return sql || "*";
703
882
  }
704
- /** Builds the SQL expression for a single aggregate column. */
705
- function buildAggColExpr(dialect, c) {
706
- return `${AGG_FN_SQL[c.aggFn] ?? c.aggFn.toUpperCase()}(${c.aggField === "*" ? "*" : `${dialect.quoteIdentifier(c.sourceTable)}.${dialect.quoteIdentifier(c.sourceColumn)}`})`;
707
- }
708
- /**
709
- * Builds a CREATE VIEW statement from a view plan and column mappings.
710
- */
711
- function buildCreateView(dialect, viewName, plan, columns, resolveFieldRef) {
712
- const selectCols = columns.map((c) => {
713
- if (c.aggFn) return `${buildAggColExpr(dialect, c)} AS ${dialect.quoteIdentifier(c.viewColumn)}`;
714
- return `${dialect.quoteIdentifier(c.sourceTable)}.${dialect.quoteIdentifier(c.sourceColumn)} AS ${dialect.quoteIdentifier(c.viewColumn)}`;
715
- }).join(", ");
716
- let sql = `${dialect.createViewPrefix} ${dialect.quoteTable(viewName)} AS SELECT ${selectCols} FROM ${dialect.quoteIdentifier(plan.entryTable)}`;
717
- for (const join of plan.joins) {
718
- const onClause = queryNodeToSql(join.condition, resolveFieldRef);
719
- sql += ` JOIN ${dialect.quoteIdentifier(join.targetTable)} ON ${onClause}`;
720
- }
721
- if (plan.filter) {
722
- const whereClause = queryNodeToSql(plan.filter, resolveFieldRef);
723
- sql += ` WHERE ${whereClause}`;
724
- }
725
- if (columns.some((c) => c.aggFn)) {
726
- const dimensionCols = columns.filter((c) => !c.aggFn);
727
- if (dimensionCols.length > 0) {
728
- const groupByCols = dimensionCols.map((c) => `${dialect.quoteIdentifier(c.sourceTable)}.${dialect.quoteIdentifier(c.sourceColumn)}`).join(", ");
729
- sql += ` GROUP BY ${groupByCols}`;
730
- }
731
- if (plan.having) {
732
- const columnMap = /* @__PURE__ */ new Map();
733
- for (const c of columns) columnMap.set(c.viewColumn, c);
734
- const havingResolver = (ref) => {
735
- if (!ref.type) {
736
- const col = columnMap.get(ref.field);
737
- if (col?.aggFn) return buildAggColExpr(dialect, col);
738
- if (col) return `${dialect.quoteIdentifier(col.sourceTable)}.${dialect.quoteIdentifier(col.sourceColumn)}`;
739
- }
740
- return resolveFieldRef(ref);
741
- };
742
- const havingClause = queryNodeToSql(plan.having, havingResolver);
743
- sql += ` HAVING ${havingClause}`;
744
- }
745
- }
746
- return sql;
747
- }
748
883
  //#endregion
749
884
  //#region src/regex.ts
750
885
  /**
@@ -796,10 +931,12 @@ exports.finalizeParams = finalizeParams;
796
931
  exports.geoWindowFromControls = geoWindowFromControls;
797
932
  exports.groupKeySql = groupKeySql;
798
933
  exports.insertManyColumns = insertManyColumns;
934
+ exports.jsonDollarPath = jsonDollarPath;
799
935
  exports.normalizeGeoPointValue = normalizeGeoPointValue;
800
936
  exports.parseRegexString = parseRegexString;
801
937
  exports.queryNodeToSql = queryNodeToSql;
802
938
  exports.queryOpToSql = queryOpToSql;
939
+ exports.quotedJsonPathSegments = quotedJsonPathSegments;
803
940
  exports.refActionToSql = refActionToSql;
804
941
  exports.renameGeoDistance = renameGeoDistance;
805
942
  exports.replaceColumnsFor = replaceColumnsFor;
package/dist/index.d.cts CHANGED
@@ -1,5 +1,6 @@
1
- import { AtscriptQueryFieldRef, AtscriptQueryNode, DbControls, TDbDefaultFn, TDbFieldMeta, TDbReferentialAction, TFieldOps, TResolvedBucket, TViewColumnMapping, TViewPlan, UniquSelect } from "@atscript/db";
1
+ import { AtscriptQueryFieldRef, AtscriptQueryNode, DbControls, TDbDefaultFn, TDbFieldMeta, TDbReferentialAction, TFieldOps, TResolvedBucket, TViewColumnMapping, TViewJsonType, TViewPlan, UniquSelect } from "@atscript/db";
2
2
  import { FilterExpr, FilterVisitor } from "@uniqu/core";
3
+ import { TDbAggregateFn } from "@atscript/db/agg";
3
4
 
4
5
  //#region src/dialect.d.ts
5
6
  interface TSqlFragment {
@@ -47,9 +48,10 @@ interface SqlDialect {
47
48
  */
48
49
  calendarBucket?(quotedCol: string, b: TResolvedBucket): string;
49
50
  /**
50
- * HAVING references a calendar bucket by its quoted SELECT alias instead of
51
- * re-rendering the bucket expression (the aggregate count query's inner
52
- * SELECT lists `<bucket expr> AS alias`, so the alias exists there too).
51
+ * HAVING references a calendar bucket — and a view's JSON-extracted
52
+ * dimension ({@link jsonExtract}) — by its quoted SELECT alias instead of
53
+ * re-rendering the expression (the aggregate count query's inner SELECT
54
+ * lists `<bucket expr> AS alias`, so the alias exists there too).
53
55
  *
54
56
  * MySQL needs this: the bucket expression reads the raw source column, which
55
57
  * is not itself in GROUP BY (only the expression is), so HAVING rejects it
@@ -60,6 +62,27 @@ interface SqlDialect {
60
62
  * Since 0.1.132.
61
63
  */
62
64
  bucketAliasInHaving?: boolean;
65
+ /**
66
+ * Typed extraction of one primitive leaf inside a JSON column — how a view
67
+ * field that references a descendant of a `@db.json` column reads it.
68
+ *
69
+ * Semantics (identical on every dialect): the result is the declared
70
+ * primitive, or NULL when the path is missing, the value is JSON `null`, or
71
+ * the value has another JSON type — never a coercion (the JSON string
72
+ * `"5"` is NULL for a `number` leaf). Numbers come back as doubles (integers
73
+ * beyond 2^53 lose precision); booleans as the dialect's boolean
74
+ * representation (0/1 where booleans are integers).
75
+ *
76
+ * `quotedCol` is already quoted (`"table"."column"`); `path` holds the
77
+ * segments below it. The expression must be PARAMETER-FREE — it renders in
78
+ * CREATE VIEW DDL, repeated in SELECT, GROUP BY, HAVING and aggregate
79
+ * arguments. Quote every segment via {@link quotedJsonPathSegments}.
80
+ * Dialects without JSON extraction omit this; view sync then fails with
81
+ * `JSON extraction is not supported by this adapter`.
82
+ *
83
+ * @since 0.1.136
84
+ */
85
+ jsonExtract?(quotedCol: string, path: readonly string[], type: TViewJsonType): string;
63
86
  /** e.g. 'CREATE VIEW IF NOT EXISTS' or 'CREATE OR REPLACE VIEW' */
64
87
  createViewPrefix: string;
65
88
  /** Returns a parameter placeholder for the given 1-based index. When absent, '?' is used. */
@@ -70,6 +93,18 @@ interface SqlDialect {
70
93
  * (e.g. `$1, $2, ...` for PostgreSQL). No-op when `dialect.paramPlaceholder` is not set.
71
94
  */
72
95
  declare function finalizeParams(dialect: SqlDialect, fragment: TSqlFragment): TSqlFragment;
96
+ /**
97
+ * Each JSON path segment wrapped in double quotes (`"a"`), for the dialects'
98
+ * {@link SqlDialect.jsonExtract} path literals (`'$."a"."b"'`, `'{"a","b"}'`).
99
+ *
100
+ * A segment containing `"`, `\` or a control character has no quoting that
101
+ * reads the same on every dialect (SQLite's quoted path labels have no escape
102
+ * syntax), so it is rejected rather than escaped.
103
+ *
104
+ * @since 0.1.136
105
+ * @throws for an empty path or a segment that is empty or holds such a character.
106
+ */
107
+ declare function quotedJsonPathSegments(path: readonly string[]): string[];
73
108
  declare const EMPTY_AND: TSqlFragment;
74
109
  declare const EMPTY_OR: TSqlFragment;
75
110
  //#endregion
@@ -142,6 +177,17 @@ declare function normalizeGeoPointValue(value: unknown): [number, number] | unde
142
177
  /** Renames the internal distance alias to the public `$distance` field (in place). */
143
178
  declare function renameGeoDistance(row: Record<string, unknown>): Record<string, unknown>;
144
179
  //#endregion
180
+ //#region src/view-builder.d.ts
181
+ /**
182
+ * Builds a CREATE VIEW statement from a view plan and column mappings.
183
+ *
184
+ * Joins render in declaration order — `JOIN` (inner, the default) or
185
+ * `LEFT JOIN` for `kind: "left"`. Column mappings carry PHYSICAL source
186
+ * names; `resolveFieldRef` renders predicate refs (join ON, WHERE, HAVING
187
+ * fallbacks) as `"table"."column"`.
188
+ */
189
+ declare function buildCreateView(dialect: SqlDialect, viewName: string, plan: TViewPlan, columns: TViewColumnMapping[], resolveFieldRef: (ref: AtscriptQueryFieldRef) => string): string;
190
+ //#endregion
145
191
  //#region src/sql-builder.d.ts
146
192
  /**
147
193
  * Builds an INSERT statement.
@@ -226,14 +272,19 @@ declare function buildDelete(dialect: SqlDialect, table: string, where: TSqlFrag
226
272
  * Builds a column projection (SELECT clause fields).
227
273
  */
228
274
  declare function buildProjection(dialect: SqlDialect, select?: UniquSelect): string;
229
- /**
230
- * Builds a CREATE VIEW statement from a view plan and column mappings.
231
- */
232
- declare function buildCreateView(dialect: SqlDialect, viewName: string, plan: TViewPlan, columns: TViewColumnMapping[], resolveFieldRef: (ref: AtscriptQueryFieldRef) => string): string;
233
275
  //#endregion
234
276
  //#region src/common.d.ts
235
277
  /** Formats a string value as a SQL literal with single-quote escaping. */
236
278
  declare function sqlStringLiteral(value: string): string;
279
+ /**
280
+ * The SQL string literal of a `$`-rooted JSON path with every segment quoted
281
+ * (`'$."a"."b"'`) — the path argument of SQLite's and MySQL's `json_extract` /
282
+ * `json_type`.
283
+ *
284
+ * @since 0.1.136
285
+ * @throws as {@link quotedJsonPathSegments}.
286
+ */
287
+ declare function jsonDollarPath(path: readonly string[]): string;
237
288
  /**
238
289
  * A calendar bucket's time zone as an inlined SQL string literal (`'Europe/Berlin'`).
239
290
  *
@@ -259,11 +310,22 @@ declare function defaultValueToSqlLiteral(designType: string, value: string): st
259
310
  declare const queryOpToSql: Record<string, string>;
260
311
  /**
261
312
  * Renders an AtscriptQueryNode tree to raw SQL (no parameters -- for DDL use only).
313
+ *
314
+ * Operators: `=`/`!=`/`<`/`<=`/`>`/`>=` (a `null` operand becomes `IS [NOT] NULL`),
315
+ * `in` / `not in` (inlined literals; an empty `in` is `0=1`, an empty
316
+ * `not in` `1=1`), `exists` → `IS NOT NULL`, `not exists` → `IS NULL`.
317
+ *
318
+ * @throws for `matches` (`$regex`) and any other operator — a view predicate
319
+ * that cannot be rendered fails at sync instead of rendering wrong SQL.
262
320
  */
263
321
  declare function queryNodeToSql(node: AtscriptQueryNode, resolveFieldRef: (ref: AtscriptQueryFieldRef) => string): string;
264
322
  //#endregion
265
323
  //#region src/agg.d.ts
266
- declare const AGG_FN_SQL: Record<string, string>;
324
+ /**
325
+ * SQL function name of each single-name aggregate. `countDistinct` is not a
326
+ * name but a form (`COUNT(DISTINCT x)`) — see {@link renderAggCall}.
327
+ */
328
+ declare const AGG_FN_SQL: Readonly<Record<Exclude<TDbAggregateFn, "countDistinct">, string>>;
267
329
  /**
268
330
  * The SQL a `$groupBy` key renders as: a calendar-bucket alias renders the
269
331
  * bucket EXPRESSION (`dialect.calendarBucket`), anything else the quoted
@@ -306,4 +368,4 @@ declare function parseRegexString(value: unknown): {
306
368
  flags: string;
307
369
  };
308
370
  //#endregion
309
- export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, SQL_DEFAULT, type SqlDialect, type TFilterVisitorOptions, type TGeoCircle, type TGeoWindow, type TReplaceColumn, type TSqlFragment, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildProjection, buildSelect, buildUpdate, buildWhere, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, fillReplacePayload, finalizeParams, geoWindowFromControls, groupKeySql, insertManyColumns, normalizeGeoPointValue, parseRegexString, queryNodeToSql, queryOpToSql, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, toSqlValue };
371
+ export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, SQL_DEFAULT, type SqlDialect, type TFilterVisitorOptions, type TGeoCircle, type TGeoWindow, type TReplaceColumn, type TSqlFragment, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildProjection, buildSelect, buildUpdate, buildWhere, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, fillReplacePayload, finalizeParams, geoWindowFromControls, groupKeySql, insertManyColumns, jsonDollarPath, normalizeGeoPointValue, parseRegexString, queryNodeToSql, queryOpToSql, quotedJsonPathSegments, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, toSqlValue };
package/dist/index.d.mts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { FilterExpr, FilterVisitor } from "@uniqu/core";
2
- import { AtscriptQueryFieldRef, AtscriptQueryNode, DbControls, TDbDefaultFn, TDbFieldMeta, TDbReferentialAction, TFieldOps, TResolvedBucket, TViewColumnMapping, TViewPlan, UniquSelect } from "@atscript/db";
2
+ import { AtscriptQueryFieldRef, AtscriptQueryNode, DbControls, TDbDefaultFn, TDbFieldMeta, TDbReferentialAction, TFieldOps, TResolvedBucket, TViewColumnMapping, TViewJsonType, TViewPlan, UniquSelect } from "@atscript/db";
3
+ import { TDbAggregateFn } from "@atscript/db/agg";
3
4
 
4
5
  //#region src/dialect.d.ts
5
6
  interface TSqlFragment {
@@ -47,9 +48,10 @@ interface SqlDialect {
47
48
  */
48
49
  calendarBucket?(quotedCol: string, b: TResolvedBucket): string;
49
50
  /**
50
- * HAVING references a calendar bucket by its quoted SELECT alias instead of
51
- * re-rendering the bucket expression (the aggregate count query's inner
52
- * SELECT lists `<bucket expr> AS alias`, so the alias exists there too).
51
+ * HAVING references a calendar bucket — and a view's JSON-extracted
52
+ * dimension ({@link jsonExtract}) — by its quoted SELECT alias instead of
53
+ * re-rendering the expression (the aggregate count query's inner SELECT
54
+ * lists `<bucket expr> AS alias`, so the alias exists there too).
53
55
  *
54
56
  * MySQL needs this: the bucket expression reads the raw source column, which
55
57
  * is not itself in GROUP BY (only the expression is), so HAVING rejects it
@@ -60,6 +62,27 @@ interface SqlDialect {
60
62
  * Since 0.1.132.
61
63
  */
62
64
  bucketAliasInHaving?: boolean;
65
+ /**
66
+ * Typed extraction of one primitive leaf inside a JSON column — how a view
67
+ * field that references a descendant of a `@db.json` column reads it.
68
+ *
69
+ * Semantics (identical on every dialect): the result is the declared
70
+ * primitive, or NULL when the path is missing, the value is JSON `null`, or
71
+ * the value has another JSON type — never a coercion (the JSON string
72
+ * `"5"` is NULL for a `number` leaf). Numbers come back as doubles (integers
73
+ * beyond 2^53 lose precision); booleans as the dialect's boolean
74
+ * representation (0/1 where booleans are integers).
75
+ *
76
+ * `quotedCol` is already quoted (`"table"."column"`); `path` holds the
77
+ * segments below it. The expression must be PARAMETER-FREE — it renders in
78
+ * CREATE VIEW DDL, repeated in SELECT, GROUP BY, HAVING and aggregate
79
+ * arguments. Quote every segment via {@link quotedJsonPathSegments}.
80
+ * Dialects without JSON extraction omit this; view sync then fails with
81
+ * `JSON extraction is not supported by this adapter`.
82
+ *
83
+ * @since 0.1.136
84
+ */
85
+ jsonExtract?(quotedCol: string, path: readonly string[], type: TViewJsonType): string;
63
86
  /** e.g. 'CREATE VIEW IF NOT EXISTS' or 'CREATE OR REPLACE VIEW' */
64
87
  createViewPrefix: string;
65
88
  /** Returns a parameter placeholder for the given 1-based index. When absent, '?' is used. */
@@ -70,6 +93,18 @@ interface SqlDialect {
70
93
  * (e.g. `$1, $2, ...` for PostgreSQL). No-op when `dialect.paramPlaceholder` is not set.
71
94
  */
72
95
  declare function finalizeParams(dialect: SqlDialect, fragment: TSqlFragment): TSqlFragment;
96
+ /**
97
+ * Each JSON path segment wrapped in double quotes (`"a"`), for the dialects'
98
+ * {@link SqlDialect.jsonExtract} path literals (`'$."a"."b"'`, `'{"a","b"}'`).
99
+ *
100
+ * A segment containing `"`, `\` or a control character has no quoting that
101
+ * reads the same on every dialect (SQLite's quoted path labels have no escape
102
+ * syntax), so it is rejected rather than escaped.
103
+ *
104
+ * @since 0.1.136
105
+ * @throws for an empty path or a segment that is empty or holds such a character.
106
+ */
107
+ declare function quotedJsonPathSegments(path: readonly string[]): string[];
73
108
  declare const EMPTY_AND: TSqlFragment;
74
109
  declare const EMPTY_OR: TSqlFragment;
75
110
  //#endregion
@@ -142,6 +177,17 @@ declare function normalizeGeoPointValue(value: unknown): [number, number] | unde
142
177
  /** Renames the internal distance alias to the public `$distance` field (in place). */
143
178
  declare function renameGeoDistance(row: Record<string, unknown>): Record<string, unknown>;
144
179
  //#endregion
180
+ //#region src/view-builder.d.ts
181
+ /**
182
+ * Builds a CREATE VIEW statement from a view plan and column mappings.
183
+ *
184
+ * Joins render in declaration order — `JOIN` (inner, the default) or
185
+ * `LEFT JOIN` for `kind: "left"`. Column mappings carry PHYSICAL source
186
+ * names; `resolveFieldRef` renders predicate refs (join ON, WHERE, HAVING
187
+ * fallbacks) as `"table"."column"`.
188
+ */
189
+ declare function buildCreateView(dialect: SqlDialect, viewName: string, plan: TViewPlan, columns: TViewColumnMapping[], resolveFieldRef: (ref: AtscriptQueryFieldRef) => string): string;
190
+ //#endregion
145
191
  //#region src/sql-builder.d.ts
146
192
  /**
147
193
  * Builds an INSERT statement.
@@ -226,14 +272,19 @@ declare function buildDelete(dialect: SqlDialect, table: string, where: TSqlFrag
226
272
  * Builds a column projection (SELECT clause fields).
227
273
  */
228
274
  declare function buildProjection(dialect: SqlDialect, select?: UniquSelect): string;
229
- /**
230
- * Builds a CREATE VIEW statement from a view plan and column mappings.
231
- */
232
- declare function buildCreateView(dialect: SqlDialect, viewName: string, plan: TViewPlan, columns: TViewColumnMapping[], resolveFieldRef: (ref: AtscriptQueryFieldRef) => string): string;
233
275
  //#endregion
234
276
  //#region src/common.d.ts
235
277
  /** Formats a string value as a SQL literal with single-quote escaping. */
236
278
  declare function sqlStringLiteral(value: string): string;
279
+ /**
280
+ * The SQL string literal of a `$`-rooted JSON path with every segment quoted
281
+ * (`'$."a"."b"'`) — the path argument of SQLite's and MySQL's `json_extract` /
282
+ * `json_type`.
283
+ *
284
+ * @since 0.1.136
285
+ * @throws as {@link quotedJsonPathSegments}.
286
+ */
287
+ declare function jsonDollarPath(path: readonly string[]): string;
237
288
  /**
238
289
  * A calendar bucket's time zone as an inlined SQL string literal (`'Europe/Berlin'`).
239
290
  *
@@ -259,11 +310,22 @@ declare function defaultValueToSqlLiteral(designType: string, value: string): st
259
310
  declare const queryOpToSql: Record<string, string>;
260
311
  /**
261
312
  * Renders an AtscriptQueryNode tree to raw SQL (no parameters -- for DDL use only).
313
+ *
314
+ * Operators: `=`/`!=`/`<`/`<=`/`>`/`>=` (a `null` operand becomes `IS [NOT] NULL`),
315
+ * `in` / `not in` (inlined literals; an empty `in` is `0=1`, an empty
316
+ * `not in` `1=1`), `exists` → `IS NOT NULL`, `not exists` → `IS NULL`.
317
+ *
318
+ * @throws for `matches` (`$regex`) and any other operator — a view predicate
319
+ * that cannot be rendered fails at sync instead of rendering wrong SQL.
262
320
  */
263
321
  declare function queryNodeToSql(node: AtscriptQueryNode, resolveFieldRef: (ref: AtscriptQueryFieldRef) => string): string;
264
322
  //#endregion
265
323
  //#region src/agg.d.ts
266
- declare const AGG_FN_SQL: Record<string, string>;
324
+ /**
325
+ * SQL function name of each single-name aggregate. `countDistinct` is not a
326
+ * name but a form (`COUNT(DISTINCT x)`) — see {@link renderAggCall}.
327
+ */
328
+ declare const AGG_FN_SQL: Readonly<Record<Exclude<TDbAggregateFn, "countDistinct">, string>>;
267
329
  /**
268
330
  * The SQL a `$groupBy` key renders as: a calendar-bucket alias renders the
269
331
  * bucket EXPRESSION (`dialect.calendarBucket`), anything else the quoted
@@ -306,4 +368,4 @@ declare function parseRegexString(value: unknown): {
306
368
  flags: string;
307
369
  };
308
370
  //#endregion
309
- export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, SQL_DEFAULT, type SqlDialect, type TFilterVisitorOptions, type TGeoCircle, type TGeoWindow, type TReplaceColumn, type TSqlFragment, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildProjection, buildSelect, buildUpdate, buildWhere, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, fillReplacePayload, finalizeParams, geoWindowFromControls, groupKeySql, insertManyColumns, normalizeGeoPointValue, parseRegexString, queryNodeToSql, queryOpToSql, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, toSqlValue };
371
+ export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, SQL_DEFAULT, type SqlDialect, type TFilterVisitorOptions, type TGeoCircle, type TGeoWindow, type TReplaceColumn, type TSqlFragment, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildProjection, buildSelect, buildUpdate, buildWhere, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, fillReplacePayload, finalizeParams, geoWindowFromControls, groupKeySql, insertManyColumns, jsonDollarPath, normalizeGeoPointValue, parseRegexString, queryNodeToSql, queryOpToSql, quotedJsonPathSegments, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, toSqlValue };
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { BUCKET_UNITS, TIME_ZONE_NAME_RE, WEEK_STARTS, walkFilter } from "@uniqu/core";
2
- import { DbError } from "@atscript/db";
3
- import { resolveAlias } from "@atscript/db/agg";
2
+ import { DbError, isFieldRef } from "@atscript/db";
3
+ import { assertAggregateFn, resolveAlias } from "@atscript/db/agg";
4
4
  //#region src/dialect.ts
5
5
  /**
6
6
  * Replaces positional `?` placeholders with dialect-specific numbered placeholders
@@ -14,6 +14,37 @@ function finalizeParams(dialect, fragment) {
14
14
  params: fragment.params
15
15
  };
16
16
  }
17
+ /**
18
+ * How HAVING references a grouped NON-column expression (a calendar bucket, a
19
+ * view's JSON-extracted dimension): its quoted SELECT `alias` on dialects that
20
+ * set {@link SqlDialect.bucketAliasInHaving}, else the expression itself.
21
+ */
22
+ function havingGroupRef(dialect, expr, alias) {
23
+ return dialect.bucketAliasInHaving ? dialect.quoteIdentifier(alias) : expr;
24
+ }
25
+ /**
26
+ * Each JSON path segment wrapped in double quotes (`"a"`), for the dialects'
27
+ * {@link SqlDialect.jsonExtract} path literals (`'$."a"."b"'`, `'{"a","b"}'`).
28
+ *
29
+ * A segment containing `"`, `\` or a control character has no quoting that
30
+ * reads the same on every dialect (SQLite's quoted path labels have no escape
31
+ * syntax), so it is rejected rather than escaped.
32
+ *
33
+ * @since 0.1.136
34
+ * @throws for an empty path or a segment that is empty or holds such a character.
35
+ */
36
+ function quotedJsonPathSegments(path) {
37
+ if (path.length === 0) throw new Error("JSON extraction needs a path below the JSON column");
38
+ return path.map((seg) => {
39
+ let ok = seg.length > 0;
40
+ for (let i = 0; ok && i < seg.length; i++) {
41
+ const code = seg.charCodeAt(i);
42
+ ok = code >= 32 && code !== 34 && code !== 92;
43
+ }
44
+ if (!ok) throw new Error(`JSON path segment ${JSON.stringify(seg)} can't be extracted — segments must be non-empty and free of '"', '\\' and control characters`);
45
+ return `"${seg}"`;
46
+ });
47
+ }
17
48
  const EMPTY_AND = {
18
49
  sql: "1=1",
19
50
  params: []
@@ -254,6 +285,17 @@ function sqlStringLiteral(value) {
254
285
  return `'${value.replace(/'/g, "''")}'`;
255
286
  }
256
287
  /**
288
+ * The SQL string literal of a `$`-rooted JSON path with every segment quoted
289
+ * (`'$."a"."b"'`) — the path argument of SQLite's and MySQL's `json_extract` /
290
+ * `json_type`.
291
+ *
292
+ * @since 0.1.136
293
+ * @throws as {@link quotedJsonPathSegments}.
294
+ */
295
+ function jsonDollarPath(path) {
296
+ return sqlStringLiteral(`$.${quotedJsonPathSegments(path).join(".")}`);
297
+ }
298
+ /**
257
299
  * A calendar bucket's time zone as an inlined SQL string literal (`'Europe/Berlin'`).
258
300
  *
259
301
  * The zone is already canonical (the core normalizer ran uniqu's
@@ -323,23 +365,65 @@ const queryOpToSql = {
323
365
  $lt: "<",
324
366
  $lte: "<="
325
367
  };
368
+ /** An inlined SQL literal for a view-predicate value (DDL — no parameters). */
369
+ function predicateLiteral(value) {
370
+ if (value === null || value === void 0) return "NULL";
371
+ if (typeof value === "string") return sqlStringLiteral(value);
372
+ if (typeof value === "number") {
373
+ if (!Number.isFinite(value)) throw new Error(`Non-finite number ${value} in a view predicate`);
374
+ return String(value);
375
+ }
376
+ if (typeof value === "boolean") return value ? "true" : "false";
377
+ throw new Error(`Unsupported literal ${JSON.stringify(value)} in a view predicate`);
378
+ }
326
379
  /**
327
380
  * Renders an AtscriptQueryNode tree to raw SQL (no parameters -- for DDL use only).
381
+ *
382
+ * Operators: `=`/`!=`/`<`/`<=`/`>`/`>=` (a `null` operand becomes `IS [NOT] NULL`),
383
+ * `in` / `not in` (inlined literals; an empty `in` is `0=1`, an empty
384
+ * `not in` `1=1`), `exists` → `IS NOT NULL`, `not exists` → `IS NULL`.
385
+ *
386
+ * @throws for `matches` (`$regex`) and any other operator — a view predicate
387
+ * that cannot be rendered fails at sync instead of rendering wrong SQL.
328
388
  */
329
389
  function queryNodeToSql(node, resolveFieldRef) {
330
- if ("$and" in node) return node.$and.map((n) => queryNodeToSql(n, resolveFieldRef)).join(" AND ");
331
- if ("$or" in node) return `(${node.$or.map((n) => queryNodeToSql(n, resolveFieldRef)).join(" OR ")})`;
390
+ if ("$and" in node) {
391
+ const children = node.$and;
392
+ if (children.length === 0) return EMPTY_AND.sql;
393
+ return children.map((n) => queryNodeToSql(n, resolveFieldRef)).join(" AND ");
394
+ }
395
+ if ("$or" in node) {
396
+ const children = node.$or;
397
+ if (children.length === 0) return EMPTY_OR.sql;
398
+ return `(${children.map((n) => queryNodeToSql(n, resolveFieldRef)).join(" OR ")})`;
399
+ }
332
400
  if ("$not" in node) return `NOT (${queryNodeToSql(node.$not, resolveFieldRef)})`;
333
401
  const comp = node;
334
402
  const leftSql = resolveFieldRef(comp.left);
335
- const sqlOp = queryOpToSql[comp.op] || "=";
336
- if (comp.right && typeof comp.right === "object" && "field" in comp.right) return `${leftSql} ${sqlOp} ${resolveFieldRef(comp.right)}`;
403
+ switch (comp.op) {
404
+ case "$exists": return comp.right === false ? `${leftSql} IS NULL` : `${leftSql} IS NOT NULL`;
405
+ case "$in":
406
+ case "$nin": {
407
+ const values = Array.isArray(comp.right) ? comp.right : [comp.right];
408
+ if (values.length === 0) return comp.op === "$in" ? EMPTY_OR.sql : EMPTY_AND.sql;
409
+ const list = values.map((v) => predicateLiteral(v)).join(", ");
410
+ return `${leftSql} ${comp.op === "$in" ? "IN" : "NOT IN"} (${list})`;
411
+ }
412
+ case "$regex": throw new Error("matches is not supported in view predicates");
413
+ default:
414
+ }
415
+ const sqlOp = queryOpToSql[comp.op];
416
+ if (!sqlOp) throw new Error(`Operator "${comp.op}" is not supported in view predicates`);
417
+ if (isFieldRef(comp.right)) return `${leftSql} ${sqlOp} ${resolveFieldRef(comp.right)}`;
337
418
  if (comp.right === null || comp.right === void 0) return comp.op === "$ne" ? `${leftSql} IS NOT NULL` : `${leftSql} IS NULL`;
338
- if (typeof comp.right === "string") return `${leftSql} ${sqlOp} '${comp.right.replace(/'/g, "''")}'`;
339
- return `${leftSql} ${sqlOp} ${comp.right}`;
419
+ return `${leftSql} ${sqlOp} ${predicateLiteral(comp.right)}`;
340
420
  }
341
421
  //#endregion
342
422
  //#region src/agg.ts
423
+ /**
424
+ * SQL function name of each single-name aggregate. `countDistinct` is not a
425
+ * name but a form (`COUNT(DISTINCT x)`) — see {@link renderAggCall}.
426
+ */
343
427
  const AGG_FN_SQL = {
344
428
  sum: "SUM",
345
429
  avg: "AVG",
@@ -347,9 +431,20 @@ const AGG_FN_SQL = {
347
431
  min: "MIN",
348
432
  max: "MAX"
349
433
  };
350
- /** The bare aggregate call, e.g. `SUM("amount")` / `COUNT(*)`. */
434
+ /**
435
+ * Renders one aggregate call over an already-rendered argument (`*`, a quoted
436
+ * column, a `CASE` expression): `SUM(x)`, `COUNT(DISTINCT x)`, …
437
+ * Re-asserts the name first (`INVALID_QUERY` on an unknown one), so nothing
438
+ * unchecked reaches SQL.
439
+ */
440
+ function renderAggCall(fn, arg, path) {
441
+ assertAggregateFn(fn, path);
442
+ return fn === "countDistinct" ? `COUNT(DISTINCT ${arg})` : `${AGG_FN_SQL[fn]}(${arg})`;
443
+ }
444
+ /** The bare aggregate call, e.g. `SUM("amount")` / `COUNT(*)` / `COUNT(DISTINCT "region")`. */
351
445
  function aggFnSql(dialect, expr) {
352
- return `${AGG_FN_SQL[expr.$fn] ?? expr.$fn.toUpperCase()}(${expr.$field === "*" ? "*" : dialect.quoteIdentifier(expr.$field)})`;
446
+ const field = expr.$field === "*" ? "*" : dialect.quoteIdentifier(expr.$field);
447
+ return renderAggCall(expr.$fn, field);
353
448
  }
354
449
  function buildAggExpr(dialect, expr) {
355
450
  return `${aggFnSql(dialect, expr)} AS ${dialect.quoteIdentifier(resolveAlias(expr))}`;
@@ -410,7 +505,7 @@ function groupKeySql(dialect, controls, key) {
410
505
  * not allow a SELECT alias in HAVING (MySQL and SQLite tolerate it, so the
411
506
  * expression form keeps all three identical). A calendar-bucket alias renders
412
507
  * its bucket expression ({@link groupKeySql}), or its quoted alias when the
413
- * dialect sets `SqlDialect.bucketAliasInHaving` (why: see there). Other keys
508
+ * dialect sets `SqlDialect.bucketAliasInHaving` (`havingGroupRef`). Other keys
414
509
  * (grouped columns) render as plain columns.
415
510
  */
416
511
  function havingClause(dialect, controls) {
@@ -421,8 +516,8 @@ function havingClause(dialect, controls) {
421
516
  const fragment = walkFilter(having, createFilterVisitor(dialect, { columnRef: (field) => {
422
517
  const aggExpr = exprByAlias.get(field);
423
518
  if (aggExpr) return aggExpr;
424
- if (dialect.bucketAliasInHaving && controls.$select?.bucketByAlias(field)) return dialect.quoteIdentifier(field);
425
- return groupKeySql(dialect, controls, field);
519
+ const bucket = controls.$select?.bucketByAlias(field);
520
+ return bucket ? havingGroupRef(dialect, bucketSql(dialect, bucket), field) : dialect.quoteIdentifier(field);
426
521
  } }));
427
522
  if (!fragment || fragment.sql === EMPTY_AND.sql) return void 0;
428
523
  return {
@@ -503,6 +598,90 @@ function buildAggregateCount(dialect, table, where, controls) {
503
598
  });
504
599
  }
505
600
  //#endregion
601
+ //#region src/view-builder.ts
602
+ /**
603
+ * The SQL expression a view column reads: `"table"."column"` — the physical
604
+ * source column resolved by `AtscriptDbView.getViewColumnMappings()` — or,
605
+ * for a primitive leaf inside a JSON column (`mapping.json`), the dialect's
606
+ * typed {@link SqlDialect.jsonExtract} over that column. Used for SELECT
607
+ * columns, GROUP BY / HAVING dimensions and as an aggregate's source.
608
+ *
609
+ * @throws when the column reads a JSON leaf and the dialect has no `jsonExtract`.
610
+ */
611
+ function viewSourceExpr(dialect, mapping) {
612
+ const col = `${dialect.quoteIdentifier(mapping.sourceTable)}.${dialect.quoteIdentifier(mapping.sourceColumn)}`;
613
+ if (!mapping.json) return col;
614
+ if (!dialect.jsonExtract) throw new Error(`View column "${mapping.viewColumn}": JSON extraction is not supported by this adapter`);
615
+ return dialect.jsonExtract(col, mapping.json.path, mapping.json.type);
616
+ }
617
+ /**
618
+ * The SQL expression of one aggregate view column, e.g. `SUM("orders"."amount")`,
619
+ * `COUNT(*)` or `COUNT(DISTINCT "orders"."customer_id")`, over
620
+ * {@link viewSourceExpr}. The mapping's aggregate rules (`*` only for count, …)
621
+ * are validated where the core builds it.
622
+ *
623
+ * A conditional aggregate (`aggFilter`, rendered with the view's predicate
624
+ * resolver) aggregates `CASE WHEN <predicate> THEN <src> END` — NULL for the
625
+ * rows the predicate rejects, which every aggregate skips:
626
+ * - `COUNT(*)` → `COUNT(CASE WHEN p THEN 1 END)`;
627
+ * - `countDistinct` → `COUNT(DISTINCT CASE WHEN p THEN src END)`;
628
+ * - `sum` → `COALESCE(SUM(CASE WHEN p THEN src END), 0)` — a group with no
629
+ * matching row sums to 0, not NULL (MongoDB's `$sum` answer too);
630
+ * - `avg` / `min` / `max` stay NULL when no row matches.
631
+ */
632
+ function viewAggExpr(dialect, c, resolveFieldRef) {
633
+ const star = c.aggField === "*";
634
+ const src = star ? "*" : viewSourceExpr(dialect, c);
635
+ if (!c.aggFilter) return renderAggCall(c.aggFn, src, c.viewColumn);
636
+ const predicate = queryNodeToSql(c.aggFilter, resolveFieldRef);
637
+ const call = renderAggCall(c.aggFn, `CASE WHEN ${predicate} THEN ${star ? "1" : src} END`, c.viewColumn);
638
+ return c.aggFn === "sum" ? `COALESCE(${call}, 0)` : call;
639
+ }
640
+ /**
641
+ * Builds a CREATE VIEW statement from a view plan and column mappings.
642
+ *
643
+ * Joins render in declaration order — `JOIN` (inner, the default) or
644
+ * `LEFT JOIN` for `kind: "left"`. Column mappings carry PHYSICAL source
645
+ * names; `resolveFieldRef` renders predicate refs (join ON, WHERE, HAVING
646
+ * fallbacks) as `"table"."column"`.
647
+ */
648
+ function buildCreateView(dialect, viewName, plan, columns, resolveFieldRef) {
649
+ const selectCols = columns.map((c) => {
650
+ return `${c.aggFn ? viewAggExpr(dialect, c, resolveFieldRef) : viewSourceExpr(dialect, c)} AS ${dialect.quoteIdentifier(c.viewColumn)}`;
651
+ }).join(", ");
652
+ let sql = `${dialect.createViewPrefix} ${dialect.quoteTable(viewName)} AS SELECT ${selectCols} FROM ${dialect.quoteIdentifier(plan.entryTable)}`;
653
+ for (const join of plan.joins) {
654
+ const onClause = queryNodeToSql(join.condition, resolveFieldRef);
655
+ const keyword = join.kind === "left" ? "LEFT JOIN" : "JOIN";
656
+ sql += ` ${keyword} ${dialect.quoteIdentifier(join.targetTable)} ON ${onClause}`;
657
+ }
658
+ if (plan.filter) {
659
+ const whereClause = queryNodeToSql(plan.filter, resolveFieldRef);
660
+ sql += ` WHERE ${whereClause}`;
661
+ }
662
+ if (columns.some((c) => c.aggFn)) {
663
+ const dimensionCols = columns.filter((c) => !c.aggFn);
664
+ if (dimensionCols.length > 0) {
665
+ const groupByCols = dimensionCols.map((c) => viewSourceExpr(dialect, c)).join(", ");
666
+ sql += ` GROUP BY ${groupByCols}`;
667
+ }
668
+ if (plan.having) {
669
+ const columnByPath = /* @__PURE__ */ new Map();
670
+ for (const c of columns) columnByPath.set(c.viewPath, c);
671
+ const havingResolver = (ref) => {
672
+ const col = ref.type ? void 0 : columnByPath.get(ref.field);
673
+ if (!col) return resolveFieldRef(ref);
674
+ if (col.aggFn) return viewAggExpr(dialect, col, resolveFieldRef);
675
+ const expr = viewSourceExpr(dialect, col);
676
+ return col.json ? havingGroupRef(dialect, expr, col.viewColumn) : expr;
677
+ };
678
+ const havingClause = queryNodeToSql(plan.having, havingResolver);
679
+ sql += ` HAVING ${havingClause}`;
680
+ }
681
+ }
682
+ return sql;
683
+ }
684
+ //#endregion
506
685
  //#region src/sql-builder.ts
507
686
  /**
508
687
  * Builds an INSERT statement.
@@ -700,50 +879,6 @@ function buildProjection(dialect, select) {
700
879
  }
701
880
  return sql || "*";
702
881
  }
703
- /** Builds the SQL expression for a single aggregate column. */
704
- function buildAggColExpr(dialect, c) {
705
- return `${AGG_FN_SQL[c.aggFn] ?? c.aggFn.toUpperCase()}(${c.aggField === "*" ? "*" : `${dialect.quoteIdentifier(c.sourceTable)}.${dialect.quoteIdentifier(c.sourceColumn)}`})`;
706
- }
707
- /**
708
- * Builds a CREATE VIEW statement from a view plan and column mappings.
709
- */
710
- function buildCreateView(dialect, viewName, plan, columns, resolveFieldRef) {
711
- const selectCols = columns.map((c) => {
712
- if (c.aggFn) return `${buildAggColExpr(dialect, c)} AS ${dialect.quoteIdentifier(c.viewColumn)}`;
713
- return `${dialect.quoteIdentifier(c.sourceTable)}.${dialect.quoteIdentifier(c.sourceColumn)} AS ${dialect.quoteIdentifier(c.viewColumn)}`;
714
- }).join(", ");
715
- let sql = `${dialect.createViewPrefix} ${dialect.quoteTable(viewName)} AS SELECT ${selectCols} FROM ${dialect.quoteIdentifier(plan.entryTable)}`;
716
- for (const join of plan.joins) {
717
- const onClause = queryNodeToSql(join.condition, resolveFieldRef);
718
- sql += ` JOIN ${dialect.quoteIdentifier(join.targetTable)} ON ${onClause}`;
719
- }
720
- if (plan.filter) {
721
- const whereClause = queryNodeToSql(plan.filter, resolveFieldRef);
722
- sql += ` WHERE ${whereClause}`;
723
- }
724
- if (columns.some((c) => c.aggFn)) {
725
- const dimensionCols = columns.filter((c) => !c.aggFn);
726
- if (dimensionCols.length > 0) {
727
- const groupByCols = dimensionCols.map((c) => `${dialect.quoteIdentifier(c.sourceTable)}.${dialect.quoteIdentifier(c.sourceColumn)}`).join(", ");
728
- sql += ` GROUP BY ${groupByCols}`;
729
- }
730
- if (plan.having) {
731
- const columnMap = /* @__PURE__ */ new Map();
732
- for (const c of columns) columnMap.set(c.viewColumn, c);
733
- const havingResolver = (ref) => {
734
- if (!ref.type) {
735
- const col = columnMap.get(ref.field);
736
- if (col?.aggFn) return buildAggColExpr(dialect, col);
737
- if (col) return `${dialect.quoteIdentifier(col.sourceTable)}.${dialect.quoteIdentifier(col.sourceColumn)}`;
738
- }
739
- return resolveFieldRef(ref);
740
- };
741
- const havingClause = queryNodeToSql(plan.having, havingResolver);
742
- sql += ` HAVING ${havingClause}`;
743
- }
744
- }
745
- return sql;
746
- }
747
882
  //#endregion
748
883
  //#region src/regex.ts
749
884
  /**
@@ -770,4 +905,4 @@ function parseRegexString(value) {
770
905
  };
771
906
  }
772
907
  //#endregion
773
- export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, SQL_DEFAULT, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildProjection, buildSelect, buildUpdate, buildWhere, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, fillReplacePayload, finalizeParams, geoWindowFromControls, groupKeySql, insertManyColumns, normalizeGeoPointValue, parseRegexString, queryNodeToSql, queryOpToSql, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, toSqlValue };
908
+ export { AGG_FN_SQL, EMPTY_AND, EMPTY_OR, GEO_DISTANCE_ALIAS, SQL_DEFAULT, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildProjection, buildSelect, buildUpdate, buildWhere, createFilterVisitor, defaultValueForType, defaultValueToSqlLiteral, fillReplacePayload, finalizeParams, geoWindowFromControls, groupKeySql, insertManyColumns, jsonDollarPath, normalizeGeoPointValue, parseRegexString, queryNodeToSql, queryOpToSql, quotedJsonPathSegments, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlStringLiteral, sqlTimeZoneLiteral, toSqlValue };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-sql-tools",
3
- "version": "0.1.134",
3
+ "version": "0.1.136",
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.10",
40
+ "@uniqu/core": "^0.1.11",
41
41
  "unplugin-atscript": "^0.1.92"
42
42
  },
43
43
  "peerDependencies": {
44
- "@uniqu/core": "^0.1.10",
45
- "@atscript/db": "^0.1.134"
44
+ "@uniqu/core": "^0.1.11",
45
+ "@atscript/db": "^0.1.136"
46
46
  },
47
47
  "scripts": {
48
48
  "build": "vp pack",