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); });