@malloy-publisher/server 0.0.210 → 0.0.212

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.
Files changed (32) hide show
  1. package/dist/app/api-doc.yaml +20 -3
  2. package/dist/app/assets/{EnvironmentPage-BRMCY9d8.js → EnvironmentPage-CuO6sty5.js} +1 -1
  3. package/dist/app/assets/HomePage-u3vxROIF.js +1 -0
  4. package/dist/app/assets/{MainPage-sZdUjUcu.js → MainPage-Bt2sYLbr.js} +2 -2
  5. package/dist/app/assets/{MaterializationsPage-C_jQQUCJ.js → MaterializationsPage-B1rj61zs.js} +1 -1
  6. package/dist/app/assets/{ModelPage-Bh62OIEq.js → ModelPage-BpvONvSR.js} +1 -1
  7. package/dist/app/assets/{PackagePage-DJW4xjLp.js → PackagePage-DHNcwboW.js} +1 -1
  8. package/dist/app/assets/{RouteError-Vi5yGs_F.js → RouteError-D1vhLJvr.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-BvJMi21d.js → WorkbookPage-CZmud-yI.js} +1 -1
  10. package/dist/app/assets/{core-BbW0t3RQ.es-L-mZcOk3.js → core-XtBSnW2U.es-DE88sVsZ.js} +1 -1
  11. package/dist/app/assets/{index-CCcHdeew.js → index-BQdOB6m9.js} +1 -1
  12. package/dist/app/assets/{index-DuqTjxM_.js → index-BlnQtDZj.js} +137 -137
  13. package/dist/app/assets/{index-CNFX-CGL.js → index-CYf2akGH.js} +1 -1
  14. package/dist/app/assets/{index.umd-GgEb4WfT.js → index.umd-BdQ0R4hx.js} +1 -1
  15. package/dist/app/index.html +1 -1
  16. package/dist/package_load_worker.mjs +3 -0
  17. package/dist/server.mjs +160 -140
  18. package/package.json +1 -1
  19. package/src/materialization_metrics.spec.ts +84 -0
  20. package/src/materialization_metrics.ts +78 -119
  21. package/src/service/build_plan.spec.ts +152 -0
  22. package/src/service/build_plan.ts +81 -2
  23. package/src/service/materialization_service.spec.ts +186 -8
  24. package/src/service/materialization_service.ts +96 -83
  25. package/src/service/materialization_test_fixtures.ts +2 -0
  26. package/src/service/package.ts +14 -2
  27. package/src/service/persist_annotation_validation.spec.ts +77 -0
  28. package/src/service/persist_annotation_validation.ts +54 -0
  29. package/src/service/quoting.spec.ts +79 -0
  30. package/src/service/quoting.ts +40 -16
  31. package/src/utils.ts +5 -0
  32. package/dist/app/assets/HomePage-DQzgkiMI.js +0 -1
@@ -0,0 +1,54 @@
1
+ import { ModelCompilationError } from "../errors";
2
+
3
+ /** A line whose first non-whitespace content is a `#@ persist` directive. */
4
+ const PERSIST_LINE_PATTERN = /^\s*#@\s+persist\b/;
5
+ /**
6
+ * A `name=` key whose value is NOT immediately opened by a single or double
7
+ * quote — i.e. a bare `name=engaged_events` (whitespace around `=` tolerated).
8
+ * `name="..."` and `name='...'` pass. The `\bname` word boundary requires a
9
+ * standalone key, so neighbours like `tablename=` / `realization_name=` are
10
+ * never mistaken for it.
11
+ */
12
+ const UNQUOTED_NAME_PATTERN = /\bname\s*=\s*(?!["'])/;
13
+
14
+ /**
15
+ * Reject `#@ persist name=<value>` annotations whose name is unquoted.
16
+ *
17
+ * The persist build plan requires a quoted name — a dialect-style table path
18
+ * (`name="engaged_events"`, or `name="my_dataset.engaged_events"`). A bare
19
+ * value is dropped from the build plan, so the source publishes and serves but
20
+ * is never materialized, with no error anywhere. Throwing a
21
+ * `ModelCompilationError` (HTTP 424) fails the publish/load with a clear,
22
+ * actionable message — the same hard-stop `Model.validateRenderTags` applies to
23
+ * a misconfigured render tag.
24
+ *
25
+ * Scans the raw model source line-by-line rather than the compiled annotation
26
+ * objects: the check must fire regardless of how the compiler attaches the
27
+ * annotation, and the raw text is the ground truth for whether the author
28
+ * quoted the value (the tag parser discards quote information once parsed).
29
+ *
30
+ * @throws {ModelCompilationError} listing every offending annotation.
31
+ */
32
+ export function assertPersistNamesQuoted(
33
+ modelSource: string,
34
+ modelPath: string,
35
+ ): void {
36
+ const offenders: string[] = [];
37
+ for (const rawLine of modelSource.split("\n")) {
38
+ if (!PERSIST_LINE_PATTERN.test(rawLine)) continue;
39
+ if (UNQUOTED_NAME_PATTERN.test(rawLine)) {
40
+ offenders.push(rawLine.trim());
41
+ }
42
+ }
43
+ if (offenders.length > 0) {
44
+ throw new ModelCompilationError({
45
+ message:
46
+ `${modelPath}: persist annotation name must be quoted. Write a quoted ` +
47
+ `value like name="engaged_events" (or a dialect table path such as ` +
48
+ `name="my_dataset.engaged_events"), not a bare value — an unquoted ` +
49
+ `persist name is dropped from the build plan, so the source would ` +
50
+ `publish but never materialize. Offending annotation(s): ` +
51
+ `${offenders.join("; ")}.`,
52
+ });
53
+ }
54
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { bareTableName, quoteIdentifier, quoteTablePath } from "./quoting";
3
+
4
+ describe("bareTableName", () => {
5
+ it("returns the segment after the last dot for a qualified name", () => {
6
+ expect(bareTableName("my_schema.my_table")).toBe("my_table");
7
+ });
8
+
9
+ it("returns the name unchanged when it is unqualified", () => {
10
+ expect(bareTableName("my_table")).toBe("my_table");
11
+ });
12
+ });
13
+
14
+ describe("quoteIdentifier", () => {
15
+ it("backticks for backtick dialects (BigQuery / MySQL / Databricks)", () => {
16
+ expect(quoteIdentifier("my-table", "standardsql")).toBe("`my-table`");
17
+ expect(quoteIdentifier("t", "mysql")).toBe("`t`");
18
+ expect(quoteIdentifier("t", "databricks")).toBe("`t`");
19
+ });
20
+
21
+ it("double-quotes for everything else (Postgres / DuckDB / Snowflake)", () => {
22
+ expect(quoteIdentifier("my-table", "postgres")).toBe('"my-table"');
23
+ expect(quoteIdentifier("t", "duckdb")).toBe('"t"');
24
+ expect(quoteIdentifier("t", "snowflake")).toBe('"t"');
25
+ });
26
+
27
+ it("escapes an embedded quote char by doubling it", () => {
28
+ expect(quoteIdentifier("a`b", "standardsql")).toBe("`a``b`");
29
+ expect(quoteIdentifier('a"b', "postgres")).toBe('"a""b"');
30
+ });
31
+ });
32
+
33
+ // Cross-repo conformance (PR ms2data/service#5272 review item 1). The publisher
34
+ // keys identifier quoting off the Malloy `dialectName`; the control plane keys
35
+ // the SAME fact off the publisher *connection type* in
36
+ // `PhysicalTableName.BACKTICK_TYPES` ({bigquery, mysql, databricks}). The two
37
+ // must stay byte-compatible — a name the publisher CREATEs with backticks the CP
38
+ // must DROP with backticks — but they live in different repos under different key
39
+ // vocabularies (`standardsql` the dialect vs `bigquery` the connection type), so
40
+ // drift produces malformed DDL with no compile error. This table pins the full
41
+ // connection-type → dialect → quote-char correspondence; if it changes, update
42
+ // `PhysicalTableName.BACKTICK_TYPES` in the control plane in lockstep.
43
+ describe("dialect/connection-type quoting conformance", () => {
44
+ const QUOTING = [
45
+ { connectionType: "bigquery", dialect: "standardsql", quote: "`" },
46
+ { connectionType: "mysql", dialect: "mysql", quote: "`" },
47
+ { connectionType: "databricks", dialect: "databricks", quote: "`" },
48
+ { connectionType: "postgres", dialect: "postgres", quote: '"' },
49
+ { connectionType: "snowflake", dialect: "snowflake", quote: '"' },
50
+ { connectionType: "trino", dialect: "trino", quote: '"' },
51
+ { connectionType: "duckdb", dialect: "duckdb", quote: '"' },
52
+ { connectionType: "motherduck", dialect: "duckdb", quote: '"' },
53
+ ] as const;
54
+
55
+ for (const { connectionType, dialect, quote } of QUOTING) {
56
+ it(`${connectionType} (${dialect}) quotes with ${quote}`, () => {
57
+ expect(quoteIdentifier("x", dialect)).toBe(`${quote}x${quote}`);
58
+ });
59
+ }
60
+ });
61
+
62
+ describe("quoteTablePath", () => {
63
+ it("quotes each segment of a container path independently", () => {
64
+ expect(
65
+ quoteTablePath(
66
+ "my-proj.mydataset.engaged_events_ab12_v0",
67
+ "standardsql",
68
+ ),
69
+ ).toBe("`my-proj`.`mydataset`.`engaged_events_ab12_v0`");
70
+ expect(quoteTablePath("mydataset.t_ab12_v0", "postgres")).toBe(
71
+ '"mydataset"."t_ab12_v0"',
72
+ );
73
+ });
74
+
75
+ it("quotes a bare (unqualified) name", () => {
76
+ expect(quoteTablePath("t_ab12_v0", "standardsql")).toBe("`t_ab12_v0`");
77
+ expect(quoteTablePath("t_ab12_v0", "postgres")).toBe('"t_ab12_v0"');
78
+ });
79
+ });
@@ -1,21 +1,45 @@
1
1
  /**
2
- * Split a possibly schema-qualified table name into its schema prefix
3
- * (including the trailing dot) and the bare table name.
4
- *
5
- * Examples:
6
- * "my_schema.my_table" -> { schemaPrefix: "my_schema.", bareName: "my_table" }
7
- * "my_table" -> { schemaPrefix: "", bareName: "my_table" }
2
+ * The bare (unqualified) name of a possibly container-qualified table path:
3
+ * the segment after the last dot, e.g. `my_schema.my_table` -> `my_table` and
4
+ * `my_table` -> `my_table`. Used as the RENAME target, which names a table
5
+ * within its existing schema rather than re-stating the full path.
8
6
  */
9
- export function splitTablePath(tableName: string): {
10
- schemaPrefix: string;
11
- bareName: string;
12
- } {
7
+ export function bareTableName(tableName: string): string {
13
8
  const lastDot = tableName.lastIndexOf(".");
14
- if (lastDot >= 0) {
15
- return {
16
- schemaPrefix: tableName.substring(0, lastDot + 1),
17
- bareName: tableName.substring(lastDot + 1),
18
- };
9
+ return lastDot >= 0 ? tableName.substring(lastDot + 1) : tableName;
10
+ }
11
+
12
+ // Dialects whose identifier quote character is a backtick; everything else uses
13
+ // the SQL-standard double quote. Keyed by Malloy `dialectName`. The control
14
+ // plane encodes the same fact keyed by connection type
15
+ // (`PhysicalTableName.BACKTICK_TYPES` = {bigquery, mysql, databricks}); the two
16
+ // must stay byte-compatible. See the conformance table in quoting.spec.ts.
17
+ const BACKTICK_DIALECTS = new Set(["standardsql", "mysql", "databricks"]);
18
+
19
+ /**
20
+ * Quote a single SQL identifier for {@code dialect}, escaping any embedded quote
21
+ * character by doubling it.
22
+ */
23
+ export function quoteIdentifier(identifier: string, dialect: string): string {
24
+ if (BACKTICK_DIALECTS.has(dialect)) {
25
+ return "`" + identifier.replace(/`/g, "``") + "`";
19
26
  }
20
- return { schemaPrefix: "", bareName: tableName };
27
+ return '"' + identifier.replace(/"/g, '""') + '"';
28
+ }
29
+
30
+ /**
31
+ * Dialect-quote a (possibly container-qualified) table path so it can be inlined
32
+ * into DDL. Each dot segment is quoted independently and rejoined with dots, so
33
+ * a path like {@code my-proj.mydataset.engaged_events_v0} becomes
34
+ * `` `my-proj`.`mydataset`.`engaged_events_v0` `` on BigQuery or
35
+ * {@code "my-proj"."mydataset"."engaged_events_v0"} on Postgres — handling
36
+ * container hierarchies and quote-requiring names (e.g. hyphenated BigQuery
37
+ * project ids) uniformly. The control plane provides the logical (unquoted) path
38
+ * in `physicalTableName`; quoting for the warehouse is the publisher's job.
39
+ */
40
+ export function quoteTablePath(tableName: string, dialect: string): string {
41
+ return tableName
42
+ .split(".")
43
+ .map((segment) => quoteIdentifier(segment, dialect))
44
+ .join(".");
21
45
  }
package/src/utils.ts CHANGED
@@ -22,3 +22,8 @@ export const URL_READER: URLReader = {
22
22
  export function ignoreDotfiles(file: string): boolean {
23
23
  return path.basename(file).startsWith(".");
24
24
  }
25
+
26
+ /** The message of a thrown value, stringifying non-Error throws. */
27
+ export function errMessage(err: unknown): string {
28
+ return err instanceof Error ? err.message : String(err);
29
+ }
@@ -1 +0,0 @@
1
- import{$ as s,j as t,s as o}from"./index-DuqTjxM_.js";function e(){const n=s();return t.jsx(o,{onClickEnvironment:n})}export{e as default};