@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.
- package/dist/app/api-doc.yaml +20 -3
- package/dist/app/assets/{EnvironmentPage-BRMCY9d8.js → EnvironmentPage-CuO6sty5.js} +1 -1
- package/dist/app/assets/HomePage-u3vxROIF.js +1 -0
- package/dist/app/assets/{MainPage-sZdUjUcu.js → MainPage-Bt2sYLbr.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-C_jQQUCJ.js → MaterializationsPage-B1rj61zs.js} +1 -1
- package/dist/app/assets/{ModelPage-Bh62OIEq.js → ModelPage-BpvONvSR.js} +1 -1
- package/dist/app/assets/{PackagePage-DJW4xjLp.js → PackagePage-DHNcwboW.js} +1 -1
- package/dist/app/assets/{RouteError-Vi5yGs_F.js → RouteError-D1vhLJvr.js} +1 -1
- package/dist/app/assets/{WorkbookPage-BvJMi21d.js → WorkbookPage-CZmud-yI.js} +1 -1
- package/dist/app/assets/{core-BbW0t3RQ.es-L-mZcOk3.js → core-XtBSnW2U.es-DE88sVsZ.js} +1 -1
- package/dist/app/assets/{index-CCcHdeew.js → index-BQdOB6m9.js} +1 -1
- package/dist/app/assets/{index-DuqTjxM_.js → index-BlnQtDZj.js} +137 -137
- package/dist/app/assets/{index-CNFX-CGL.js → index-CYf2akGH.js} +1 -1
- package/dist/app/assets/{index.umd-GgEb4WfT.js → index.umd-BdQ0R4hx.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +3 -0
- package/dist/server.mjs +160 -140
- package/package.json +1 -1
- package/src/materialization_metrics.spec.ts +84 -0
- package/src/materialization_metrics.ts +78 -119
- package/src/service/build_plan.spec.ts +152 -0
- package/src/service/build_plan.ts +81 -2
- package/src/service/materialization_service.spec.ts +186 -8
- package/src/service/materialization_service.ts +96 -83
- package/src/service/materialization_test_fixtures.ts +2 -0
- package/src/service/package.ts +14 -2
- package/src/service/persist_annotation_validation.spec.ts +77 -0
- package/src/service/persist_annotation_validation.ts +54 -0
- package/src/service/quoting.spec.ts +79 -0
- package/src/service/quoting.ts +40 -16
- package/src/utils.ts +5 -0
- 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
|
+
});
|
package/src/service/quoting.ts
CHANGED
|
@@ -1,21 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
10
|
-
schemaPrefix: string;
|
|
11
|
-
bareName: string;
|
|
12
|
-
} {
|
|
7
|
+
export function bareTableName(tableName: string): string {
|
|
13
8
|
const lastDot = tableName.lastIndexOf(".");
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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};
|