@malloy-publisher/server 0.0.211 → 0.0.213
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/server.mjs +17 -4
- package/package.json +1 -1
- package/src/service/build_plan.spec.ts +68 -0
- package/src/service/build_plan.ts +34 -6
- package/src/service/db_utils.spec.ts +53 -1
- package/src/service/db_utils.ts +14 -1
- package/src/service/materialization_service.spec.ts +60 -0
- package/src/service/quoting.spec.ts +29 -0
- package/src/service/quoting.ts +4 -1
package/dist/server.mjs
CHANGED
|
@@ -230137,7 +230137,10 @@ async function listTablesForSchema(connection, schemaName, malloyConnection, tab
|
|
|
230137
230137
|
async function listTablesForBigQuery(connection, schemaName, malloyConnection, tableNames) {
|
|
230138
230138
|
try {
|
|
230139
230139
|
const bigquery = createBigQueryClient(connection);
|
|
230140
|
-
const
|
|
230140
|
+
const lastDot = schemaName.lastIndexOf(".");
|
|
230141
|
+
const dataset = lastDot === -1 ? bigquery.dataset(schemaName) : bigquery.dataset(schemaName.slice(lastDot + 1), {
|
|
230142
|
+
projectId: schemaName.slice(0, lastDot)
|
|
230143
|
+
});
|
|
230141
230144
|
const [tables] = await dataset.getTables();
|
|
230142
230145
|
let names = tables.map((table) => table.id).filter((id) => id !== undefined);
|
|
230143
230146
|
if (tableNames) {
|
|
@@ -239287,11 +239290,21 @@ function flattenDependsOn(node) {
|
|
|
239287
239290
|
return node.dependsOn.map((d) => d.sourceID);
|
|
239288
239291
|
}
|
|
239289
239292
|
function* iterGraphSources(graph, sources) {
|
|
239293
|
+
const seen = new Set;
|
|
239294
|
+
function* visit(node) {
|
|
239295
|
+
if (seen.has(node.sourceID))
|
|
239296
|
+
return;
|
|
239297
|
+
for (const dep of node.dependsOn) {
|
|
239298
|
+
yield* visit(dep);
|
|
239299
|
+
}
|
|
239300
|
+
seen.add(node.sourceID);
|
|
239301
|
+
const source = sources[node.sourceID];
|
|
239302
|
+
if (source)
|
|
239303
|
+
yield source;
|
|
239304
|
+
}
|
|
239290
239305
|
for (const level of graph.nodes) {
|
|
239291
239306
|
for (const node of level) {
|
|
239292
|
-
|
|
239293
|
-
if (source)
|
|
239294
|
-
yield source;
|
|
239307
|
+
yield* visit(node);
|
|
239295
239308
|
}
|
|
239296
239309
|
}
|
|
239297
239310
|
}
|
package/package.json
CHANGED
|
@@ -45,6 +45,74 @@ describe("iterGraphSources", () => {
|
|
|
45
45
|
);
|
|
46
46
|
expect(names).toEqual(["a", "b"]);
|
|
47
47
|
});
|
|
48
|
+
|
|
49
|
+
it("walks each root's nested dependsOn tree, deps before dependents", () => {
|
|
50
|
+
// root -> mid -> leaf, with only `root` at the graph's node level — this
|
|
51
|
+
// mirrors malloy getBuildPlan(): terminal persist sources are the nodes,
|
|
52
|
+
// and every transitive persist dependency is nested in dependsOn. All
|
|
53
|
+
// three must be yielded (so all get built), leaf-first so a downstream
|
|
54
|
+
// build reads its upstream's freshly materialized table.
|
|
55
|
+
const root = fakeSource({ name: "root", buildId: "br" });
|
|
56
|
+
const mid = fakeSource({ name: "mid", buildId: "bm" });
|
|
57
|
+
const leaf = fakeSource({ name: "leaf", buildId: "bl" });
|
|
58
|
+
const graph = {
|
|
59
|
+
connectionName: "duckdb",
|
|
60
|
+
nodes: [
|
|
61
|
+
[
|
|
62
|
+
{
|
|
63
|
+
sourceID: "root@m",
|
|
64
|
+
dependsOn: [
|
|
65
|
+
{
|
|
66
|
+
sourceID: "mid@m",
|
|
67
|
+
dependsOn: [{ sourceID: "leaf@m", dependsOn: [] }],
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
],
|
|
73
|
+
} as unknown as MalloyBuildGraph;
|
|
74
|
+
|
|
75
|
+
const names = [
|
|
76
|
+
...iterGraphSources(graph, {
|
|
77
|
+
"root@m": root,
|
|
78
|
+
"mid@m": mid,
|
|
79
|
+
"leaf@m": leaf,
|
|
80
|
+
}),
|
|
81
|
+
].map((s) => s.name);
|
|
82
|
+
expect(names).toEqual(["leaf", "mid", "root"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("deduplicates a shared (diamond) dependency across roots", () => {
|
|
86
|
+
// r1 and r2 both depend on `shared`; it must be yielded exactly once and
|
|
87
|
+
// before both dependents.
|
|
88
|
+
const r1 = fakeSource({ name: "r1", buildId: "b1" });
|
|
89
|
+
const r2 = fakeSource({ name: "r2", buildId: "b2" });
|
|
90
|
+
const shared = fakeSource({ name: "shared", buildId: "bs" });
|
|
91
|
+
const graph = {
|
|
92
|
+
connectionName: "duckdb",
|
|
93
|
+
nodes: [
|
|
94
|
+
[
|
|
95
|
+
{
|
|
96
|
+
sourceID: "r1@m",
|
|
97
|
+
dependsOn: [{ sourceID: "shared@m", dependsOn: [] }],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
sourceID: "r2@m",
|
|
101
|
+
dependsOn: [{ sourceID: "shared@m", dependsOn: [] }],
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
],
|
|
105
|
+
} as unknown as MalloyBuildGraph;
|
|
106
|
+
|
|
107
|
+
const names = [
|
|
108
|
+
...iterGraphSources(graph, {
|
|
109
|
+
"r1@m": r1,
|
|
110
|
+
"r2@m": r2,
|
|
111
|
+
"shared@m": shared,
|
|
112
|
+
}),
|
|
113
|
+
].map((s) => s.name);
|
|
114
|
+
expect(names).toEqual(["shared", "r1", "r2"]);
|
|
115
|
+
});
|
|
48
116
|
});
|
|
49
117
|
|
|
50
118
|
describe("deriveAnnotationFields", () => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
AtomicField,
|
|
3
3
|
BuildGraph as MalloyBuildGraph,
|
|
4
|
+
BuildNode,
|
|
4
5
|
MalloyConfig,
|
|
5
6
|
Connection as MalloyConnection,
|
|
6
7
|
PersistSource,
|
|
@@ -94,19 +95,46 @@ export function flattenDependsOn(node: {
|
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
/**
|
|
97
|
-
* Yield
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* planning and build loops.
|
|
98
|
+
* Yield every persist source of one graph in dependency order — each source's
|
|
99
|
+
* persist dependencies before the source itself — resolving each node's
|
|
100
|
+
* sourceID against the sources map and skipping nodes whose source is absent.
|
|
101
|
+
* Centralizes the graph→source walk shared by the planning and build loops.
|
|
102
|
+
*
|
|
103
|
+
* <p>Malloy's {@code getBuildPlan()} puts only the <em>root</em> persist sources
|
|
104
|
+
* (the terminals nothing else consumes) in {@code graph.nodes}; every transitive
|
|
105
|
+
* persist dependency is nested under a root in its recursive {@code dependsOn}
|
|
106
|
+
* tree (and present in {@code sources}). Walking only {@code graph.nodes} —
|
|
107
|
+
* which this used to do — therefore silently skips every intermediate persist
|
|
108
|
+
* source, so it never gets materialized (it only got a table by coincidence when
|
|
109
|
+
* it shared a buildId with a root). We post-order DFS the {@code dependsOn} tree
|
|
110
|
+
* so dependencies are built first (a downstream build can then read its upstream
|
|
111
|
+
* source's freshly materialized table), deduplicating shared (diamond)
|
|
112
|
+
* dependencies by sourceID so each is yielded once. This mirrors the canonical
|
|
113
|
+
* malloy-cli reference build loop (its {@code flattenBuildNodes} +
|
|
114
|
+
* dedup-by-sourceID; see malloydata/malloy-cli src/malloy/build_graph.ts).
|
|
101
115
|
*/
|
|
102
116
|
export function* iterGraphSources(
|
|
103
117
|
graph: MalloyBuildGraph,
|
|
104
118
|
sources: Record<string, PersistSource>,
|
|
105
119
|
): Iterable<PersistSource> {
|
|
120
|
+
const seen = new Set<string>();
|
|
121
|
+
|
|
122
|
+
function* visit(node: BuildNode): Iterable<PersistSource> {
|
|
123
|
+
if (seen.has(node.sourceID)) return;
|
|
124
|
+
// Dependencies first: a source built later in the run resolves its
|
|
125
|
+
// upstream references against the tables built earlier (see the Manifest
|
|
126
|
+
// threading in executeInstructedBuild).
|
|
127
|
+
for (const dep of node.dependsOn) {
|
|
128
|
+
yield* visit(dep);
|
|
129
|
+
}
|
|
130
|
+
seen.add(node.sourceID);
|
|
131
|
+
const source = sources[node.sourceID];
|
|
132
|
+
if (source) yield source;
|
|
133
|
+
}
|
|
134
|
+
|
|
106
135
|
for (const level of graph.nodes) {
|
|
107
136
|
for (const node of level) {
|
|
108
|
-
|
|
109
|
-
if (source) yield source;
|
|
137
|
+
yield* visit(node);
|
|
110
138
|
}
|
|
111
139
|
}
|
|
112
140
|
}
|
|
@@ -7,8 +7,16 @@ mock.module("@azure/identity", () => ({
|
|
|
7
7
|
mock.module("@azure/storage-blob", () => ({
|
|
8
8
|
ContainerClient: class {},
|
|
9
9
|
}));
|
|
10
|
+
// Records bigquery.dataset() calls so tests can assert how a (possibly
|
|
11
|
+
// project-qualified) schema name is split into datasetId + projectId.
|
|
12
|
+
const bqDatasetCalls: Array<{ id: string; options?: unknown }> = [];
|
|
10
13
|
mock.module("@google-cloud/bigquery", () => ({
|
|
11
|
-
BigQuery: class {
|
|
14
|
+
BigQuery: class {
|
|
15
|
+
dataset(id: string, options?: unknown) {
|
|
16
|
+
bqDatasetCalls.push({ id, options });
|
|
17
|
+
return { getTables: async () => [[]] };
|
|
18
|
+
}
|
|
19
|
+
},
|
|
12
20
|
}));
|
|
13
21
|
|
|
14
22
|
import { Connection } from "@malloydata/malloy";
|
|
@@ -1031,3 +1039,47 @@ describe("publisher proxy introspection", () => {
|
|
|
1031
1039
|
);
|
|
1032
1040
|
});
|
|
1033
1041
|
});
|
|
1042
|
+
|
|
1043
|
+
// ---------------------------------------------------------------------------
|
|
1044
|
+
// listTablesForSchema — bigquery (project-qualified dataset)
|
|
1045
|
+
// ---------------------------------------------------------------------------
|
|
1046
|
+
describe("listTablesForSchema bigquery", () => {
|
|
1047
|
+
const bqConnection = {
|
|
1048
|
+
name: "bq",
|
|
1049
|
+
type: "bigquery",
|
|
1050
|
+
bigqueryConnection: {
|
|
1051
|
+
defaultProjectId: "default-proj",
|
|
1052
|
+
serviceAccountKeyJson: JSON.stringify({ project_id: "default-proj" }),
|
|
1053
|
+
},
|
|
1054
|
+
} as unknown as ApiConnection;
|
|
1055
|
+
const malloyConn = {} as unknown as Connection;
|
|
1056
|
+
|
|
1057
|
+
it("splits a project-qualified schema into a bare dataset id + projectId", async () => {
|
|
1058
|
+
bqDatasetCalls.length = 0;
|
|
1059
|
+
await listTablesForSchema(bqConnection, "myproj.myds", malloyConn);
|
|
1060
|
+
expect(bqDatasetCalls).toHaveLength(1);
|
|
1061
|
+
expect(bqDatasetCalls[0]?.id).toBe("myds");
|
|
1062
|
+
expect(bqDatasetCalls[0]?.options).toEqual({ projectId: "myproj" });
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
it("splits on the last dot so domain-scoped project ids survive", async () => {
|
|
1066
|
+
bqDatasetCalls.length = 0;
|
|
1067
|
+
await listTablesForSchema(
|
|
1068
|
+
bqConnection,
|
|
1069
|
+
"domain.com:proj.myds",
|
|
1070
|
+
malloyConn,
|
|
1071
|
+
);
|
|
1072
|
+
expect(bqDatasetCalls[0]?.id).toBe("myds");
|
|
1073
|
+
expect(bqDatasetCalls[0]?.options).toEqual({
|
|
1074
|
+
projectId: "domain.com:proj",
|
|
1075
|
+
});
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
it("passes a bare dataset name through unqualified", async () => {
|
|
1079
|
+
bqDatasetCalls.length = 0;
|
|
1080
|
+
await listTablesForSchema(bqConnection, "myds", malloyConn);
|
|
1081
|
+
expect(bqDatasetCalls).toHaveLength(1);
|
|
1082
|
+
expect(bqDatasetCalls[0]?.id).toBe("myds");
|
|
1083
|
+
expect(bqDatasetCalls[0]?.options).toBeUndefined();
|
|
1084
|
+
});
|
|
1085
|
+
});
|
package/src/service/db_utils.ts
CHANGED
|
@@ -1077,7 +1077,20 @@ async function listTablesForBigQuery(
|
|
|
1077
1077
|
): Promise<ApiTable[]> {
|
|
1078
1078
|
try {
|
|
1079
1079
|
const bigquery = createBigQueryClient(connection);
|
|
1080
|
-
|
|
1080
|
+
// A 3-segment table reference ("project.dataset.table") reaches here with a
|
|
1081
|
+
// project-qualified schema ("project.dataset"). bigquery.dataset() takes a
|
|
1082
|
+
// BARE dataset id plus an optional projectId, so passing "project.dataset" as
|
|
1083
|
+
// the id resolves to a non-existent dataset in the client's default project —
|
|
1084
|
+
// the dataset's tables never list, so the orphan sweep can't see (and reclaim)
|
|
1085
|
+
// those tables. Split the project off the last dot. (Domain-scoped project ids
|
|
1086
|
+
// like "domain.com:project" keep their dots/colon since we split on the last.)
|
|
1087
|
+
const lastDot = schemaName.lastIndexOf(".");
|
|
1088
|
+
const dataset =
|
|
1089
|
+
lastDot === -1
|
|
1090
|
+
? bigquery.dataset(schemaName)
|
|
1091
|
+
: bigquery.dataset(schemaName.slice(lastDot + 1), {
|
|
1092
|
+
projectId: schemaName.slice(0, lastDot),
|
|
1093
|
+
});
|
|
1081
1094
|
const [tables] = await dataset.getTables();
|
|
1082
1095
|
|
|
1083
1096
|
let names = tables
|
|
@@ -718,6 +718,66 @@ describe("executeInstructedBuild", () => {
|
|
|
718
718
|
expect(entries["carried0"].physicalTableName).toBe("carried_tbl");
|
|
719
719
|
});
|
|
720
720
|
|
|
721
|
+
it("builds an intermediate persist source nested under a root node", async () => {
|
|
722
|
+
// Mirrors malloy getBuildPlan(): only the terminal `root` is a graph
|
|
723
|
+
// node; its persist dependency `mid` is nested in dependsOn (not a node).
|
|
724
|
+
// Before the iterGraphSources fix, `mid` was silently never built even
|
|
725
|
+
// though the caller instructed it. Both must now build, `mid` first.
|
|
726
|
+
const runSQL = sinon.stub().resolves();
|
|
727
|
+
const connection = { runSQL } as unknown as MalloyConnection;
|
|
728
|
+
const root = fakeSource({ name: "root", buildId: "brootaaaaaaaaaa" });
|
|
729
|
+
const mid = fakeSource({ name: "mid", buildId: "bmidbbbbbbbbbbb" });
|
|
730
|
+
const compiled = {
|
|
731
|
+
graphs: [
|
|
732
|
+
{
|
|
733
|
+
connectionName: "duckdb",
|
|
734
|
+
nodes: [
|
|
735
|
+
[
|
|
736
|
+
{
|
|
737
|
+
sourceID: "root",
|
|
738
|
+
dependsOn: [{ sourceID: "mid", dependsOn: [] }],
|
|
739
|
+
},
|
|
740
|
+
],
|
|
741
|
+
],
|
|
742
|
+
},
|
|
743
|
+
],
|
|
744
|
+
sources: { root, mid },
|
|
745
|
+
connectionDigests: { duckdb: "dig" },
|
|
746
|
+
connections: new Map([["duckdb", connection]]),
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
const entries = await callExecute(
|
|
750
|
+
compiled,
|
|
751
|
+
[
|
|
752
|
+
{
|
|
753
|
+
buildId: "brootaaaaaaaaaa",
|
|
754
|
+
materializedTableId: "mt-r",
|
|
755
|
+
physicalTableName: "root_v1",
|
|
756
|
+
realization: "COPY",
|
|
757
|
+
},
|
|
758
|
+
{
|
|
759
|
+
buildId: "bmidbbbbbbbbbbb",
|
|
760
|
+
materializedTableId: "mt-m",
|
|
761
|
+
physicalTableName: "mid_v1",
|
|
762
|
+
realization: "COPY",
|
|
763
|
+
},
|
|
764
|
+
],
|
|
765
|
+
{},
|
|
766
|
+
);
|
|
767
|
+
|
|
768
|
+
expect(entries["bmidbbbbbbbbbbb"].physicalTableName).toBe("mid_v1");
|
|
769
|
+
expect(entries["brootaaaaaaaaaa"].physicalTableName).toBe("root_v1");
|
|
770
|
+
// The dependency's CREATE precedes its dependent's (dependency order).
|
|
771
|
+
const creates = runSQL
|
|
772
|
+
.getCalls()
|
|
773
|
+
.map((c) => c.args[0] as string)
|
|
774
|
+
.filter((s) => s.startsWith("CREATE TABLE"));
|
|
775
|
+
const midIdx = creates.findIndex((s) => s.includes("mid_v1"));
|
|
776
|
+
const rootIdx = creates.findIndex((s) => s.includes("root_v1"));
|
|
777
|
+
expect(midIdx).toBeGreaterThanOrEqual(0);
|
|
778
|
+
expect(rootIdx).toBeGreaterThan(midIdx);
|
|
779
|
+
});
|
|
780
|
+
|
|
721
781
|
it("throws when an instructed graph's connection is missing", async () => {
|
|
722
782
|
const s1 = fakeSource({ name: "s1", buildId: "b1aaaaaaaaaaaaaa" });
|
|
723
783
|
const compiled = compiledWith({ s1 }, [["s1"]], new Map());
|
|
@@ -30,6 +30,35 @@ describe("quoteIdentifier", () => {
|
|
|
30
30
|
});
|
|
31
31
|
});
|
|
32
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
|
+
|
|
33
62
|
describe("quoteTablePath", () => {
|
|
34
63
|
it("quotes each segment of a container path independently", () => {
|
|
35
64
|
expect(
|
package/src/service/quoting.ts
CHANGED
|
@@ -10,7 +10,10 @@ export function bareTableName(tableName: string): string {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
// Dialects whose identifier quote character is a backtick; everything else uses
|
|
13
|
-
// the SQL-standard double quote. Keyed by Malloy `dialectName`.
|
|
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.
|
|
14
17
|
const BACKTICK_DIALECTS = new Set(["standardsql", "mysql", "databricks"]);
|
|
15
18
|
|
|
16
19
|
/**
|