@atscript/db-sql-tools 0.1.135 → 0.1.137

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,14 +432,20 @@ const AGG_FN_SQL = {
348
432
  min: "MIN",
349
433
  max: "MAX"
350
434
  };
351
- /** The SQL function for an aggregate name; throws `INVALID_QUERY` on an unsupported one. */
352
- function aggFnName(fn, path) {
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) {
353
442
  (0, _atscript_db_agg.assertAggregateFn)(fn, path);
354
- return AGG_FN_SQL[fn];
443
+ return fn === "countDistinct" ? `COUNT(DISTINCT ${arg})` : `${AGG_FN_SQL[fn]}(${arg})`;
355
444
  }
356
- /** The bare aggregate call, e.g. `SUM("amount")` / `COUNT(*)`. */
445
+ /** The bare aggregate call, e.g. `SUM("amount")` / `COUNT(*)` / `COUNT(DISTINCT "region")`. */
357
446
  function aggFnSql(dialect, expr) {
358
- return `${aggFnName(expr.$fn)}(${expr.$field === "*" ? "*" : dialect.quoteIdentifier(expr.$field)})`;
447
+ const field = expr.$field === "*" ? "*" : dialect.quoteIdentifier(expr.$field);
448
+ return renderAggCall(expr.$fn, field);
359
449
  }
360
450
  function buildAggExpr(dialect, expr) {
361
451
  return `${aggFnSql(dialect, expr)} AS ${dialect.quoteIdentifier((0, _atscript_db_agg.resolveAlias)(expr))}`;
@@ -416,7 +506,7 @@ function groupKeySql(dialect, controls, key) {
416
506
  * not allow a SELECT alias in HAVING (MySQL and SQLite tolerate it, so the
417
507
  * expression form keeps all three identical). A calendar-bucket alias renders
418
508
  * its bucket expression ({@link groupKeySql}), or its quoted alias when the
419
- * dialect sets `SqlDialect.bucketAliasInHaving` (why: see there). Other keys
509
+ * dialect sets `SqlDialect.bucketAliasInHaving` (`havingGroupRef`). Other keys
420
510
  * (grouped columns) render as plain columns.
421
511
  */
422
512
  function havingClause(dialect, controls) {
@@ -427,8 +517,8 @@ function havingClause(dialect, controls) {
427
517
  const fragment = (0, _uniqu_core.walkFilter)(having, createFilterVisitor(dialect, { columnRef: (field) => {
428
518
  const aggExpr = exprByAlias.get(field);
429
519
  if (aggExpr) return aggExpr;
430
- if (dialect.bucketAliasInHaving && controls.$select?.bucketByAlias(field)) return dialect.quoteIdentifier(field);
431
- return groupKeySql(dialect, controls, field);
520
+ const bucket = controls.$select?.bucketByAlias(field);
521
+ return bucket ? havingGroupRef(dialect, bucketSql(dialect, bucket), field) : dialect.quoteIdentifier(field);
432
522
  } }));
433
523
  if (!fragment || fragment.sql === EMPTY_AND.sql) return void 0;
434
524
  return {
@@ -509,6 +599,90 @@ function buildAggregateCount(dialect, table, where, controls) {
509
599
  });
510
600
  }
511
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
512
686
  //#region src/sql-builder.ts
513
687
  /**
514
688
  * Builds an INSERT statement.
@@ -706,50 +880,6 @@ function buildProjection(dialect, select) {
706
880
  }
707
881
  return sql || "*";
708
882
  }
709
- /** Builds the SQL expression for a single aggregate column. */
710
- function buildAggColExpr(dialect, c) {
711
- return `${aggFnName(c.aggFn, c.viewColumn)}(${c.aggField === "*" ? "*" : `${dialect.quoteIdentifier(c.sourceTable)}.${dialect.quoteIdentifier(c.sourceColumn)}`})`;
712
- }
713
- /**
714
- * Builds a CREATE VIEW statement from a view plan and column mappings.
715
- */
716
- function buildCreateView(dialect, viewName, plan, columns, resolveFieldRef) {
717
- const selectCols = columns.map((c) => {
718
- if (c.aggFn) return `${buildAggColExpr(dialect, c)} AS ${dialect.quoteIdentifier(c.viewColumn)}`;
719
- return `${dialect.quoteIdentifier(c.sourceTable)}.${dialect.quoteIdentifier(c.sourceColumn)} AS ${dialect.quoteIdentifier(c.viewColumn)}`;
720
- }).join(", ");
721
- let sql = `${dialect.createViewPrefix} ${dialect.quoteTable(viewName)} AS SELECT ${selectCols} FROM ${dialect.quoteIdentifier(plan.entryTable)}`;
722
- for (const join of plan.joins) {
723
- const onClause = queryNodeToSql(join.condition, resolveFieldRef);
724
- sql += ` JOIN ${dialect.quoteIdentifier(join.targetTable)} ON ${onClause}`;
725
- }
726
- if (plan.filter) {
727
- const whereClause = queryNodeToSql(plan.filter, resolveFieldRef);
728
- sql += ` WHERE ${whereClause}`;
729
- }
730
- if (columns.some((c) => c.aggFn)) {
731
- const dimensionCols = columns.filter((c) => !c.aggFn);
732
- if (dimensionCols.length > 0) {
733
- const groupByCols = dimensionCols.map((c) => `${dialect.quoteIdentifier(c.sourceTable)}.${dialect.quoteIdentifier(c.sourceColumn)}`).join(", ");
734
- sql += ` GROUP BY ${groupByCols}`;
735
- }
736
- if (plan.having) {
737
- const columnMap = /* @__PURE__ */ new Map();
738
- for (const c of columns) columnMap.set(c.viewColumn, c);
739
- const havingResolver = (ref) => {
740
- if (!ref.type) {
741
- const col = columnMap.get(ref.field);
742
- if (col?.aggFn) return buildAggColExpr(dialect, col);
743
- if (col) return `${dialect.quoteIdentifier(col.sourceTable)}.${dialect.quoteIdentifier(col.sourceColumn)}`;
744
- }
745
- return resolveFieldRef(ref);
746
- };
747
- const havingClause = queryNodeToSql(plan.having, havingResolver);
748
- sql += ` HAVING ${havingClause}`;
749
- }
750
- }
751
- return sql;
752
- }
753
883
  //#endregion
754
884
  //#region src/regex.ts
755
885
  /**
@@ -801,10 +931,12 @@ exports.finalizeParams = finalizeParams;
801
931
  exports.geoWindowFromControls = geoWindowFromControls;
802
932
  exports.groupKeySql = groupKeySql;
803
933
  exports.insertManyColumns = insertManyColumns;
934
+ exports.jsonDollarPath = jsonDollarPath;
804
935
  exports.normalizeGeoPointValue = normalizeGeoPointValue;
805
936
  exports.parseRegexString = parseRegexString;
806
937
  exports.queryNodeToSql = queryNodeToSql;
807
938
  exports.queryOpToSql = queryOpToSql;
939
+ exports.quotedJsonPathSegments = quotedJsonPathSegments;
808
940
  exports.refActionToSql = refActionToSql;
809
941
  exports.renameGeoDistance = renameGeoDistance;
810
942
  exports.replaceColumnsFor = replaceColumnsFor;
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
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
3
  import { TDbAggregateFn } from "@atscript/db/agg";
4
4
 
@@ -48,9 +48,10 @@ interface SqlDialect {
48
48
  */
49
49
  calendarBucket?(quotedCol: string, b: TResolvedBucket): string;
50
50
  /**
51
- * HAVING references a calendar bucket by its quoted SELECT alias instead of
52
- * re-rendering the bucket expression (the aggregate count query's inner
53
- * 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).
54
55
  *
55
56
  * MySQL needs this: the bucket expression reads the raw source column, which
56
57
  * is not itself in GROUP BY (only the expression is), so HAVING rejects it
@@ -61,6 +62,27 @@ interface SqlDialect {
61
62
  * Since 0.1.132.
62
63
  */
63
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;
64
86
  /** e.g. 'CREATE VIEW IF NOT EXISTS' or 'CREATE OR REPLACE VIEW' */
65
87
  createViewPrefix: string;
66
88
  /** Returns a parameter placeholder for the given 1-based index. When absent, '?' is used. */
@@ -71,6 +93,18 @@ interface SqlDialect {
71
93
  * (e.g. `$1, $2, ...` for PostgreSQL). No-op when `dialect.paramPlaceholder` is not set.
72
94
  */
73
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[];
74
108
  declare const EMPTY_AND: TSqlFragment;
75
109
  declare const EMPTY_OR: TSqlFragment;
76
110
  //#endregion
@@ -143,6 +177,17 @@ declare function normalizeGeoPointValue(value: unknown): [number, number] | unde
143
177
  /** Renames the internal distance alias to the public `$distance` field (in place). */
144
178
  declare function renameGeoDistance(row: Record<string, unknown>): Record<string, unknown>;
145
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
146
191
  //#region src/sql-builder.d.ts
147
192
  /**
148
193
  * Builds an INSERT statement.
@@ -227,14 +272,19 @@ declare function buildDelete(dialect: SqlDialect, table: string, where: TSqlFrag
227
272
  * Builds a column projection (SELECT clause fields).
228
273
  */
229
274
  declare function buildProjection(dialect: SqlDialect, select?: UniquSelect): string;
230
- /**
231
- * Builds a CREATE VIEW statement from a view plan and column mappings.
232
- */
233
- declare function buildCreateView(dialect: SqlDialect, viewName: string, plan: TViewPlan, columns: TViewColumnMapping[], resolveFieldRef: (ref: AtscriptQueryFieldRef) => string): string;
234
275
  //#endregion
235
276
  //#region src/common.d.ts
236
277
  /** Formats a string value as a SQL literal with single-quote escaping. */
237
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;
238
288
  /**
239
289
  * A calendar bucket's time zone as an inlined SQL string literal (`'Europe/Berlin'`).
240
290
  *
@@ -260,11 +310,22 @@ declare function defaultValueToSqlLiteral(designType: string, value: string): st
260
310
  declare const queryOpToSql: Record<string, string>;
261
311
  /**
262
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.
263
320
  */
264
321
  declare function queryNodeToSql(node: AtscriptQueryNode, resolveFieldRef: (ref: AtscriptQueryFieldRef) => string): string;
265
322
  //#endregion
266
323
  //#region src/agg.d.ts
267
- declare const AGG_FN_SQL: Readonly<Record<TDbAggregateFn, 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>>;
268
329
  /**
269
330
  * The SQL a `$groupBy` key renders as: a calendar-bucket alias renders the
270
331
  * bucket EXPRESSION (`dialect.calendarBucket`), anything else the quoted
@@ -307,4 +368,4 @@ declare function parseRegexString(value: unknown): {
307
368
  flags: string;
308
369
  };
309
370
  //#endregion
310
- 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,5 @@
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
3
  import { TDbAggregateFn } from "@atscript/db/agg";
4
4
 
5
5
  //#region src/dialect.d.ts
@@ -48,9 +48,10 @@ interface SqlDialect {
48
48
  */
49
49
  calendarBucket?(quotedCol: string, b: TResolvedBucket): string;
50
50
  /**
51
- * HAVING references a calendar bucket by its quoted SELECT alias instead of
52
- * re-rendering the bucket expression (the aggregate count query's inner
53
- * 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).
54
55
  *
55
56
  * MySQL needs this: the bucket expression reads the raw source column, which
56
57
  * is not itself in GROUP BY (only the expression is), so HAVING rejects it
@@ -61,6 +62,27 @@ interface SqlDialect {
61
62
  * Since 0.1.132.
62
63
  */
63
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;
64
86
  /** e.g. 'CREATE VIEW IF NOT EXISTS' or 'CREATE OR REPLACE VIEW' */
65
87
  createViewPrefix: string;
66
88
  /** Returns a parameter placeholder for the given 1-based index. When absent, '?' is used. */
@@ -71,6 +93,18 @@ interface SqlDialect {
71
93
  * (e.g. `$1, $2, ...` for PostgreSQL). No-op when `dialect.paramPlaceholder` is not set.
72
94
  */
73
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[];
74
108
  declare const EMPTY_AND: TSqlFragment;
75
109
  declare const EMPTY_OR: TSqlFragment;
76
110
  //#endregion
@@ -143,6 +177,17 @@ declare function normalizeGeoPointValue(value: unknown): [number, number] | unde
143
177
  /** Renames the internal distance alias to the public `$distance` field (in place). */
144
178
  declare function renameGeoDistance(row: Record<string, unknown>): Record<string, unknown>;
145
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
146
191
  //#region src/sql-builder.d.ts
147
192
  /**
148
193
  * Builds an INSERT statement.
@@ -227,14 +272,19 @@ declare function buildDelete(dialect: SqlDialect, table: string, where: TSqlFrag
227
272
  * Builds a column projection (SELECT clause fields).
228
273
  */
229
274
  declare function buildProjection(dialect: SqlDialect, select?: UniquSelect): string;
230
- /**
231
- * Builds a CREATE VIEW statement from a view plan and column mappings.
232
- */
233
- declare function buildCreateView(dialect: SqlDialect, viewName: string, plan: TViewPlan, columns: TViewColumnMapping[], resolveFieldRef: (ref: AtscriptQueryFieldRef) => string): string;
234
275
  //#endregion
235
276
  //#region src/common.d.ts
236
277
  /** Formats a string value as a SQL literal with single-quote escaping. */
237
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;
238
288
  /**
239
289
  * A calendar bucket's time zone as an inlined SQL string literal (`'Europe/Berlin'`).
240
290
  *
@@ -260,11 +310,22 @@ declare function defaultValueToSqlLiteral(designType: string, value: string): st
260
310
  declare const queryOpToSql: Record<string, string>;
261
311
  /**
262
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.
263
320
  */
264
321
  declare function queryNodeToSql(node: AtscriptQueryNode, resolveFieldRef: (ref: AtscriptQueryFieldRef) => string): string;
265
322
  //#endregion
266
323
  //#region src/agg.d.ts
267
- declare const AGG_FN_SQL: Readonly<Record<TDbAggregateFn, 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>>;
268
329
  /**
269
330
  * The SQL a `$groupBy` key renders as: a calendar-bucket alias renders the
270
331
  * bucket EXPRESSION (`dialect.calendarBucket`), anything else the quoted
@@ -307,4 +368,4 @@ declare function parseRegexString(value: unknown): {
307
368
  flags: string;
308
369
  };
309
370
  //#endregion
310
- 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,5 +1,5 @@
1
1
  import { BUCKET_UNITS, TIME_ZONE_NAME_RE, WEEK_STARTS, walkFilter } from "@uniqu/core";
2
- import { DbError } from "@atscript/db";
2
+ import { DbError, isFieldRef } from "@atscript/db";
3
3
  import { assertAggregateFn, resolveAlias } from "@atscript/db/agg";
4
4
  //#region src/dialect.ts
5
5
  /**
@@ -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,14 +431,20 @@ const AGG_FN_SQL = {
347
431
  min: "MIN",
348
432
  max: "MAX"
349
433
  };
350
- /** The SQL function for an aggregate name; throws `INVALID_QUERY` on an unsupported one. */
351
- function aggFnName(fn, path) {
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) {
352
441
  assertAggregateFn(fn, path);
353
- return AGG_FN_SQL[fn];
442
+ return fn === "countDistinct" ? `COUNT(DISTINCT ${arg})` : `${AGG_FN_SQL[fn]}(${arg})`;
354
443
  }
355
- /** The bare aggregate call, e.g. `SUM("amount")` / `COUNT(*)`. */
444
+ /** The bare aggregate call, e.g. `SUM("amount")` / `COUNT(*)` / `COUNT(DISTINCT "region")`. */
356
445
  function aggFnSql(dialect, expr) {
357
- return `${aggFnName(expr.$fn)}(${expr.$field === "*" ? "*" : dialect.quoteIdentifier(expr.$field)})`;
446
+ const field = expr.$field === "*" ? "*" : dialect.quoteIdentifier(expr.$field);
447
+ return renderAggCall(expr.$fn, field);
358
448
  }
359
449
  function buildAggExpr(dialect, expr) {
360
450
  return `${aggFnSql(dialect, expr)} AS ${dialect.quoteIdentifier(resolveAlias(expr))}`;
@@ -415,7 +505,7 @@ function groupKeySql(dialect, controls, key) {
415
505
  * not allow a SELECT alias in HAVING (MySQL and SQLite tolerate it, so the
416
506
  * expression form keeps all three identical). A calendar-bucket alias renders
417
507
  * its bucket expression ({@link groupKeySql}), or its quoted alias when the
418
- * dialect sets `SqlDialect.bucketAliasInHaving` (why: see there). Other keys
508
+ * dialect sets `SqlDialect.bucketAliasInHaving` (`havingGroupRef`). Other keys
419
509
  * (grouped columns) render as plain columns.
420
510
  */
421
511
  function havingClause(dialect, controls) {
@@ -426,8 +516,8 @@ function havingClause(dialect, controls) {
426
516
  const fragment = walkFilter(having, createFilterVisitor(dialect, { columnRef: (field) => {
427
517
  const aggExpr = exprByAlias.get(field);
428
518
  if (aggExpr) return aggExpr;
429
- if (dialect.bucketAliasInHaving && controls.$select?.bucketByAlias(field)) return dialect.quoteIdentifier(field);
430
- return groupKeySql(dialect, controls, field);
519
+ const bucket = controls.$select?.bucketByAlias(field);
520
+ return bucket ? havingGroupRef(dialect, bucketSql(dialect, bucket), field) : dialect.quoteIdentifier(field);
431
521
  } }));
432
522
  if (!fragment || fragment.sql === EMPTY_AND.sql) return void 0;
433
523
  return {
@@ -508,6 +598,90 @@ function buildAggregateCount(dialect, table, where, controls) {
508
598
  });
509
599
  }
510
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
511
685
  //#region src/sql-builder.ts
512
686
  /**
513
687
  * Builds an INSERT statement.
@@ -705,50 +879,6 @@ function buildProjection(dialect, select) {
705
879
  }
706
880
  return sql || "*";
707
881
  }
708
- /** Builds the SQL expression for a single aggregate column. */
709
- function buildAggColExpr(dialect, c) {
710
- return `${aggFnName(c.aggFn, c.viewColumn)}(${c.aggField === "*" ? "*" : `${dialect.quoteIdentifier(c.sourceTable)}.${dialect.quoteIdentifier(c.sourceColumn)}`})`;
711
- }
712
- /**
713
- * Builds a CREATE VIEW statement from a view plan and column mappings.
714
- */
715
- function buildCreateView(dialect, viewName, plan, columns, resolveFieldRef) {
716
- const selectCols = columns.map((c) => {
717
- if (c.aggFn) return `${buildAggColExpr(dialect, c)} AS ${dialect.quoteIdentifier(c.viewColumn)}`;
718
- return `${dialect.quoteIdentifier(c.sourceTable)}.${dialect.quoteIdentifier(c.sourceColumn)} AS ${dialect.quoteIdentifier(c.viewColumn)}`;
719
- }).join(", ");
720
- let sql = `${dialect.createViewPrefix} ${dialect.quoteTable(viewName)} AS SELECT ${selectCols} FROM ${dialect.quoteIdentifier(plan.entryTable)}`;
721
- for (const join of plan.joins) {
722
- const onClause = queryNodeToSql(join.condition, resolveFieldRef);
723
- sql += ` JOIN ${dialect.quoteIdentifier(join.targetTable)} ON ${onClause}`;
724
- }
725
- if (plan.filter) {
726
- const whereClause = queryNodeToSql(plan.filter, resolveFieldRef);
727
- sql += ` WHERE ${whereClause}`;
728
- }
729
- if (columns.some((c) => c.aggFn)) {
730
- const dimensionCols = columns.filter((c) => !c.aggFn);
731
- if (dimensionCols.length > 0) {
732
- const groupByCols = dimensionCols.map((c) => `${dialect.quoteIdentifier(c.sourceTable)}.${dialect.quoteIdentifier(c.sourceColumn)}`).join(", ");
733
- sql += ` GROUP BY ${groupByCols}`;
734
- }
735
- if (plan.having) {
736
- const columnMap = /* @__PURE__ */ new Map();
737
- for (const c of columns) columnMap.set(c.viewColumn, c);
738
- const havingResolver = (ref) => {
739
- if (!ref.type) {
740
- const col = columnMap.get(ref.field);
741
- if (col?.aggFn) return buildAggColExpr(dialect, col);
742
- if (col) return `${dialect.quoteIdentifier(col.sourceTable)}.${dialect.quoteIdentifier(col.sourceColumn)}`;
743
- }
744
- return resolveFieldRef(ref);
745
- };
746
- const havingClause = queryNodeToSql(plan.having, havingResolver);
747
- sql += ` HAVING ${havingClause}`;
748
- }
749
- }
750
- return sql;
751
- }
752
882
  //#endregion
753
883
  //#region src/regex.ts
754
884
  /**
@@ -775,4 +905,4 @@ function parseRegexString(value) {
775
905
  };
776
906
  }
777
907
  //#endregion
778
- 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.135",
3
+ "version": "0.1.137",
4
4
  "description": "Shared SQL builder utilities for @atscript database adapters.",
5
5
  "keywords": [
6
6
  "atscript",
@@ -42,7 +42,7 @@
42
42
  },
43
43
  "peerDependencies": {
44
44
  "@uniqu/core": "^0.1.11",
45
- "@atscript/db": "^0.1.135"
45
+ "@atscript/db": "^0.1.137"
46
46
  },
47
47
  "scripts": {
48
48
  "build": "vp pack",