@malloy-publisher/server 0.0.211 → 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/server.mjs CHANGED
@@ -239287,11 +239287,21 @@ function flattenDependsOn(node) {
239287
239287
  return node.dependsOn.map((d) => d.sourceID);
239288
239288
  }
239289
239289
  function* iterGraphSources(graph, sources) {
239290
+ const seen = new Set;
239291
+ function* visit(node) {
239292
+ if (seen.has(node.sourceID))
239293
+ return;
239294
+ for (const dep of node.dependsOn) {
239295
+ yield* visit(dep);
239296
+ }
239297
+ seen.add(node.sourceID);
239298
+ const source = sources[node.sourceID];
239299
+ if (source)
239300
+ yield source;
239301
+ }
239290
239302
  for (const level of graph.nodes) {
239291
239303
  for (const node of level) {
239292
- const source = sources[node.sourceID];
239293
- if (source)
239294
- yield source;
239304
+ yield* visit(node);
239295
239305
  }
239296
239306
  }
239297
239307
  }
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.212",
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
  }
@@ -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
  /**