diff --git a/frontend/src/viewers/SchemaLinkingViewer.test.tsx b/frontend/src/viewers/SchemaLinkingViewer.test.tsx
index 9ae5c0d3..8a706196 100644
--- a/frontend/src/viewers/SchemaLinkingViewer.test.tsx
+++ b/frontend/src/viewers/SchemaLinkingViewer.test.tsx
@@ -1,6 +1,6 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-import { SchemaLinkingViewer } from "./SchemaLinkingViewer";
+import { SchemaLinkingViewer, buildErDiagram } from "./SchemaLinkingViewer";
vi.mock("./mermaid", () => ({
renderMermaid: vi.fn().mockResolvedValue(''),
@@ -82,3 +82,18 @@ test("(d) ~10 promoted + ~50 excluded does NOT cap; graph is available", async (
// Grafico (mermaid) view is the default and renders the mocked svg.
await waitFor(() => expect(screen.getByTestId("mm")).toBeInTheDocument());
});
+
+test("(e) buildErDiagram emits entities, attributes and a relation", () => {
+ const def = buildErDiagram(
+ [
+ { kind: "table", name: "orders", decision: "promoted" },
+ { kind: "column", name: "orders.id", decision: "promoted" },
+ { kind: "table", name: "customers", decision: "promoted" },
+ ],
+ [{ from: "orders.customer_id", to: "customers.id" }],
+ );
+ expect(def.startsWith("erDiagram")).toBe(true);
+ expect(def).toContain("orders {");
+ expect(def).toContain("col id");
+ expect(def).toContain("orders }o--o{ customers : join");
+});
diff --git a/frontend/src/viewers/SchemaLinkingViewer.tsx b/frontend/src/viewers/SchemaLinkingViewer.tsx
index 32b3fc57..cc9c6b1c 100644
--- a/frontend/src/viewers/SchemaLinkingViewer.tsx
+++ b/frontend/src/viewers/SchemaLinkingViewer.tsx
@@ -33,24 +33,37 @@ function reasonFor(c: Candidate): string {
return "";
}
-function buildFlowchart(promoted: Candidate[], joins: Join[]): string {
- const nodeId = (name: string) => name.replace(/[^a-zA-Z0-9]/g, "_");
+export function buildErDiagram(promoted: Candidate[], joins: Join[]): string {
+ const sanitize = (name: string) => name.replace(/[^a-zA-Z0-9]/g, "_");
+ const tables = promoted.filter((c) => c.kind === "table");
+ const tableNames = new Set(tables.map((t) => t.name));
+ const columns = promoted.filter((c) => c.kind === "column");
- const lines: string[] = ["flowchart TB"];
+ const lines: string[] = ["erDiagram"];
- for (const c of promoted) {
- const id = nodeId(c.name);
- const reason = reasonFor(c);
- const tooltip = reason ? ` %% ${reason}` : "";
- const shape = c.kind === "table" ? `[${c.name}]` : `(${c.name})`;
- lines.push(` ${id}${shape}${tooltip}`);
+ for (const t of tables) {
+ const id = sanitize(t.name);
+ const cols = columns.filter((col) => col.name.startsWith(t.name + "."));
+ lines.push(` ${id} {`);
+ for (const col of cols) {
+ lines.push(` col ${sanitize(col.name.slice(t.name.length + 1))}`);
+ }
+ lines.push(` }`);
}
- const promotedNames = new Set(promoted.map((c) => c.name));
+ // A join endpoint may be "table" or "table.column"; resolve to its owning table.
+ // Draw a relationship only between two DISTINCT promoted tables, once per pair.
+ const owningTable = (ref: string) =>
+ ref.includes(".") ? ref.slice(0, ref.indexOf(".")) : ref;
+ const seen = new Set();
for (const j of joins) {
- if (promotedNames.has(j.from) && promotedNames.has(j.to)) {
- lines.push(` ${nodeId(j.from)} --> ${nodeId(j.to)}`);
- }
+ const a = owningTable(j.from);
+ const b = owningTable(j.to);
+ if (a === b || !tableNames.has(a) || !tableNames.has(b)) continue;
+ const key = [a, b].sort().join("::");
+ if (seen.has(key)) continue;
+ seen.add(key);
+ lines.push(` ${sanitize(a)} }o--o{ ${sanitize(b)} : join`);
}
return lines.join("\n");
@@ -89,7 +102,7 @@ export function SchemaLinkingViewer({ linking }: { linking: SchemaLinking }) {
useEffect(() => {
if (view !== "chart" || oversized) return;
let cancelled = false;
- const def = buildFlowchart(promoted, linking.joins);
+ const def = buildErDiagram(promoted, linking.joins);
renderMermaid(def).then((s) => {
if (!cancelled) setSvg(s);
});