@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 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 dataset = bigquery.dataset(schemaName);
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
- const source = sources[node.sourceID];
239293
- if (source)
239294
- yield source;
239307
+ yield* visit(node);
239295
239308
  }
239296
239309
  }
239297
239310
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@malloy-publisher/server",
3
3
  "description": "Malloy Publisher Server",
4
- "version": "0.0.211",
4
+ "version": "0.0.213",
5
5
  "main": "dist/server.mjs",
6
6
  "bin": {
7
7
  "malloy-publisher": "dist/server.mjs"
@@ -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 each dependency-ordered persist source of one graph, resolving the
98
- * node's sourceID against the sources map and skipping nodes whose source is
99
- * absent. Centralizes the graph→levels→nodes→source walk shared by the
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
- const source = sources[node.sourceID];
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
+ });
@@ -1077,7 +1077,20 @@ async function listTablesForBigQuery(
1077
1077
  ): Promise<ApiTable[]> {
1078
1078
  try {
1079
1079
  const bigquery = createBigQueryClient(connection);
1080
- const dataset = bigquery.dataset(schemaName);
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(
@@ -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
  /**