From b436f7d6f7721cefd8fbabffc9bffb955e4facac Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sun, 28 Jun 2026 18:16:54 +0200 Subject: [PATCH 1/2] fix(schema): draw every table in a whole-DB graph, linked or not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The data-flow graph dropped tables that didn't participate in any view/MV/dictionary/Distributed relationship whenever the database had *some* lineage — so a database of mostly-standalone tables showed only the few linked boxes and hid the rest. Reverses that pruning: a whole-DB view now keeps every object as a node, drawing lineage edges between the linked ones and standalone cards for the rest (matching the no-relationships case, which already did this). Table-focus (1-hop neighbourhood) is unchanged — it's a deliberately scoped view. Cross-DB scoping in the full view is still handled by expandLineage, which seeds the focus DB and BFS-walks only the connected nodes of other databases, so this doesn't pull unrelated tables in from neighbouring databases. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019kE9qbgBNBrfNgwg9fRsMJ --- src/core/schema-graph.js | 14 +++++--------- tests/unit/schema-graph.test.js | 12 ++++++++---- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/core/schema-graph.js b/src/core/schema-graph.js index f26d5d2..1d9158f 100644 --- a/src/core/schema-graph.js +++ b/src/core/schema-graph.js @@ -228,16 +228,12 @@ export function buildSchemaGraph(rows, focus) { for (const e of edges) { if (e.from === center) keep.add(e.to); if (e.to === center) keep.add(e.from); } outNodes = outNodes.filter((n) => keep.has(n.id)); outEdges = edges.filter((e) => keep.has(e.from) && keep.has(e.to)); - } else if (edges.length) { - // Whole-DB lineage WITH relationships: keep only the tables that participate — - // a lineage view shows lineage, not a wall of unrelated boxes alongside it. - const linked = new Set(); - for (const e of edges) { linked.add(e.from); linked.add(e.to); } - outNodes = outNodes.filter((n) => linked.has(n.id)); } - // Whole-DB lineage with NO relationships (e.g. a DB of unrelated URL/MergeTree - // tables): fall through keeping every table as a standalone node, so the database - // still renders its tables rather than showing an empty "no relationships" screen. + // Whole-DB lineage: keep EVERY table as a node, linked or not. A database view + // should show all of its objects — with lineage edges drawn where they exist — + // rather than hiding the unlinked tables behind the relationships. (Cross-DB + // scoping in the full view is handled afterwards by expandLineage, which seeds + // the focus DB and BFS-walks only the connected nodes of other databases.) return { nodes: outNodes, edges: outEdges }; } diff --git a/tests/unit/schema-graph.test.js b/tests/unit/schema-graph.test.js index 04524bd..9289084 100644 --- a/tests/unit/schema-graph.test.js +++ b/tests/unit/schema-graph.test.js @@ -181,16 +181,20 @@ describe('buildSchemaGraph', () => { expect(ids.has('lin.events_daily')).toBe(true); // events_mv → events_daily (outgoing) }); - it('drops isolated tables from a whole-DB graph when there is lineage', () => { + it('keeps isolated tables in a whole-DB graph even when there is lineage', () => { + // A whole-DB view shows ALL objects: the lineage chain AND the unlinked tables + // alongside it (the lineage edges are still drawn between the linked ones). const rows = { tables: [ T('lin', 'src', 'MergeTree'), T('lin', 'mv', 'MaterializedView', { astTables: ['lin.src'], create_table_query: 'CREATE MATERIALIZED VIEW lin.mv TO lin.dst AS SELECT 1 FROM lin.src' }), T('lin', 'dst', 'MergeTree'), - T('lin', 'orphan', 'MergeTree'), // no relationships → pruned + T('lin', 'orphan', 'MergeTree'), // unlinked — still drawn ], dictionaries: [] }; - const ids = new Set(buildSchemaGraph(rows, { kind: 'db', db: 'lin' }).nodes.map((n) => n.id)); + const g = buildSchemaGraph(rows, { kind: 'db', db: 'lin' }); + const ids = new Set(g.nodes.map((n) => n.id)); expect(ids.has('lin.src')).toBe(true); - expect(ids.has('lin.orphan')).toBe(false); + expect(ids.has('lin.orphan')).toBe(true); // isolated, but kept + expect(eset(g).has('lin.src>lin.mv:feeds')).toBe(true); // lineage edges still present }); it('keeps every table as a standalone node when a whole-DB graph has no relationships', () => { From 3018827d1ba996bb613791d4fe19e70e8b8ab53a Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sun, 28 Jun 2026 18:25:28 +0200 Subject: [PATCH 2/2] fix(schema): pack unlinked tables below the lineage + drop redundant db prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two UX refinements on the whole-DB graph: - Lay the connected lineage out first and grid the edge-less "single" tables beneath it (new `isolatedLast` option on dagreLayout, used by both the inline preview and the full view), instead of dagre ranking the orphans across the top. - Drop the redundant "." prefix from a node's label when it's in the focused database; nodes from another database keep their qualified id so the cross-DB origin stays visible. Applied once in buildSchemaGraph, so both the inline short view and the full-view cards pick it up. ids/edges (and click-to-SHOW-CREATE) are unaffected — only the display label changes. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019kE9qbgBNBrfNgwg9fRsMJ --- src/core/dot-layout.js | 60 +++++++++++++++++++++++++-------- src/core/schema-graph.js | 8 +++++ src/ui/explain-graph.js | 4 +-- tests/unit/dot-layout.test.js | 30 +++++++++++++++++ tests/unit/schema-graph.test.js | 11 ++++++ 5 files changed, 97 insertions(+), 16 deletions(-) diff --git a/src/core/dot-layout.js b/src/core/dot-layout.js index 17721f3..76b2ac1 100644 --- a/src/core/dot-layout.js +++ b/src/core/dot-layout.js @@ -24,7 +24,7 @@ export function nodeWidth(label) { * @param dagre the injected dagre module (`{ graphlib, layout }`) * @param graph parsed `{ nodes:[{id,label}], edges:[{from,to}] }` */ -export function dagreLayout(dagre, graph) { +export function dagreLayout(dagre, graph, opts = {}) { const nodes = graph.nodes || []; if (!nodes.length) return { nodes: [], edges: [], width: 0, height: 0 }; const ids = new Set(nodes.map((n) => n.id)); @@ -32,29 +32,61 @@ export function dagreLayout(dagre, graph) { // would just loop onto its own box). const edges = (graph.edges || []).filter((e) => ids.has(e.from) && ids.has(e.to) && e.from !== e.to); - const g = new dagre.graphlib.Graph(); - g.setGraph({ rankdir: 'TB', nodesep: NODESEP, ranksep: RANKSEP, marginx: MARGIN, marginy: MARGIN }); - g.setDefaultEdgeLabel(() => ({})); + // Schema views opt into `isolatedLast`: lay out only the connected nodes with + // dagre and pack the edge-less "single" tables into a grid *below* the lineage, + // so a whole-DB graph reads as "relationships first, loose tables after" rather + // than dagre ranking the orphans across the top. Other callers (the pipeline + // graph) keep every node in the dagre pass. + const connected = new Set(); + for (const e of edges) { connected.add(e.from); connected.add(e.to); } + const singles = opts.isolatedLast ? nodes.filter((n) => !connected.has(n.id)) : []; + const ranked = opts.isolatedLast ? nodes.filter((n) => connected.has(n.id)) : nodes; + // Honor a node's explicit size when it carries one (the rich schema cards // pre-compute w/h from their content via cardSize); otherwise fall back to the // label-based width + fixed height (pipeline + inline schema boxes). - for (const n of nodes) { - g.setNode(n.id, { width: n.w != null ? n.w : nodeWidth(n.label), height: n.h != null ? n.h : NODE_H }); - } + const sizeOf = (n) => ({ width: n.w != null ? n.w : nodeWidth(n.label), height: n.h != null ? n.h : NODE_H }); + // `kind`/`db`/`name`/`external` (node) and `label` (edge) pass through for the + // schema graph's colouring, external-dimming + click-to-SHOW-CREATE (so the UI + // need not re-split the id or keep a side-channel for these). + const carry = (n) => ({ id: n.id, label: n.label, kind: n.kind, db: n.db, name: n.name, external: n.external }); + + const g = new dagre.graphlib.Graph(); + g.setGraph({ rankdir: 'TB', nodesep: NODESEP, ranksep: RANKSEP, marginx: MARGIN, marginy: MARGIN }); + g.setDefaultEdgeLabel(() => ({})); + for (const n of ranked) g.setNode(n.id, sizeOf(n)); for (const e of edges) g.setEdge(e.from, e.to); - dagre.layout(g); + if (ranked.length) dagre.layout(g); - const outNodes = nodes.map((n) => { + const outNodes = ranked.map((n) => { const dn = g.node(n.id); - // `kind`/`db`/`name`/`external` (node) and `label` (edge) pass through for the - // schema graph's colouring, external-dimming + click-to-SHOW-CREATE (so the UI - // need not re-split the id or keep a side-channel for these). - return { id: n.id, label: n.label, kind: n.kind, db: n.db, name: n.name, external: n.external, x: dn.x - dn.width / 2, y: dn.y - dn.height / 2, w: dn.width, h: dn.height }; + return { ...carry(n), x: dn.x - dn.width / 2, y: dn.y - dn.height / 2, w: dn.width, h: dn.height }; }); const outEdges = edges.map((e) => ({ from: e.from, to: e.to, kind: e.kind, label: e.label, points: g.edge(e.from, e.to).points.map((p) => ({ x: p.x, y: p.y })), })); const gg = g.graph(); - return { nodes: outNodes, edges: outEdges, width: gg.width, height: gg.height }; + let width = ranked.length ? gg.width : 0; + let height = ranked.length ? gg.height : 0; + + // Grid-pack the singles beneath the connected layout: a roughly-square block of + // uniform cells (widest/tallest single), left-aligned at the margin, sitting one + // ranksep below the lineage (or at the top when there's no lineage at all). + if (singles.length) { + const cells = singles.map(sizeOf); + const colW = Math.max(...cells.map((c) => c.width)); + const rowH = Math.max(...cells.map((c) => c.height)); + const cols = Math.max(1, Math.ceil(Math.sqrt(singles.length))); + const top = height ? height + RANKSEP : MARGIN; + singles.forEach((n, i) => { + const col = i % cols, row = Math.floor(i / cols); + outNodes.push({ ...carry(n), x: MARGIN + col * (colW + NODESEP), y: top + row * (rowH + NODESEP), w: cells[i].width, h: cells[i].height }); + }); + const usedCols = Math.min(cols, singles.length); + const rows = Math.ceil(singles.length / cols); + width = Math.max(width, MARGIN * 2 + usedCols * colW + (usedCols - 1) * NODESEP); + height = top + rows * rowH + (rows - 1) * NODESEP + MARGIN; + } + return { nodes: outNodes, edges: outEdges, width, height }; } diff --git a/src/core/schema-graph.js b/src/core/schema-graph.js index 1d9158f..487eb45 100644 --- a/src/core/schema-graph.js +++ b/src/core/schema-graph.js @@ -234,6 +234,14 @@ export function buildSchemaGraph(rows, focus) { // rather than hiding the unlinked tables behind the relationships. (Cross-DB // scoping in the full view is handled afterwards by expandLineage, which seeds // the focus DB and BFS-walks only the connected nodes of other databases.) + + // Display label: inside the focused database the "." prefix is redundant, so + // show just the table name; a node from another database keeps its qualified id + // so its cross-DB origin stays visible. Only the untouched id is rewritten — the + // friendly ·inner and external-source labels are left alone — and ids/edges + // (which key everything, incl. click-to-SHOW-CREATE) are unaffected. + const curDb = focus && focus.db; + if (curDb) for (const n of outNodes) { if (n.db === curDb && n.label === n.id) n.label = n.name; } return { nodes: outNodes, edges: outEdges }; } diff --git a/src/ui/explain-graph.js b/src/ui/explain-graph.js index 92cc05f..abd4d6e 100644 --- a/src/ui/explain-graph.js +++ b/src/ui/explain-graph.js @@ -180,7 +180,7 @@ export function buildPipelineSvg(rawText, dagre) { /** Build the schema-lineage SVG from a `{nodes,edges}` graph (kind-coloured). */ export function buildSchemaSvg(graph, dagre, onNode) { - return renderGraphSvg(dagreLayout(dagre, graph || { nodes: [], edges: [] }), { + return renderGraphSvg(dagreLayout(dagre, graph || { nodes: [], edges: [] }, { isolatedLast: true }), { nodeClass: (n) => 'eg-node eg-node--' + (n.kind || 'table'), edgeClass: (e) => 'eg-edge eg-edge--' + (e.kind || 'feeds'), edgeLabel: (e) => e.kind, @@ -248,7 +248,7 @@ export function buildRichSchemaSvg(graph, dagre, onNode) { }); // `external` rides through dagreLayout (like kind/db/name), so the node class can // read it off the laid node — no side-channel needed. - const laid = dagreLayout(dagre, { nodes: sized, edges: g.edges || [] }); + const laid = dagreLayout(dagre, { nodes: sized, edges: g.edges || [] }, { isolatedLast: true }); // Overlay any manually-moved positions remembered for this result, then // straighten the edges touching a moved node so they still connect on first draw. const positions = g.savedPositions; diff --git a/tests/unit/dot-layout.test.js b/tests/unit/dot-layout.test.js index ad796bb..4c194a7 100644 --- a/tests/unit/dot-layout.test.js +++ b/tests/unit/dot-layout.test.js @@ -73,4 +73,34 @@ describe('dagreLayout', () => { expect(g.nodes).toHaveLength(2); expect(g.edges).toEqual([{ from: 'a', to: 'b', points: expect.any(Array) }]); }); + + describe('isolatedLast (schema views)', () => { + it('packs edge-less nodes into a grid below the connected lineage', () => { + const g = dagreLayout(dagre, { + nodes: [ + { id: 'a', label: 'A' }, { id: 'b', label: 'B' }, // a → b lineage + { id: 's1', label: 'S1' }, { id: 's2', label: 'S2' }, { id: 's3', label: 'S3' }, // singles + ], + edges: [{ from: 'a', to: 'b' }], + }, { isolatedLast: true }); + const by = Object.fromEntries(g.nodes.map((n) => [n.id, n])); + expect(g.nodes).toHaveLength(5); // every node kept + expect(g.edges).toHaveLength(1); // lineage edge preserved + const lineageBottom = Math.max(by.a.y + by.a.h, by.b.y + by.b.h); + for (const id of ['s1', 's2', 's3']) expect(by[id].y).toBeGreaterThanOrEqual(lineageBottom); // all below + expect(by.a.y).toBeLessThan(by.b.y); // lineage still laid out top→bottom + }); + + it('grids all nodes from the top when none are connected (no lineage)', () => { + const g = dagreLayout(dagre, { + nodes: [{ id: 'x', label: 'X' }, { id: 'y', label: 'Y' }, { id: 'z', label: 'Z' }], + edges: [], + }, { isolatedLast: true }); + expect(g.nodes).toHaveLength(3); + expect(g.edges).toEqual([]); + expect(g.width).toBeGreaterThan(0); + expect(g.height).toBeGreaterThan(0); + expect(Math.min(...g.nodes.map((n) => n.y))).toBeLessThanOrEqual(12); // top row at the margin + }); + }); }); diff --git a/tests/unit/schema-graph.test.js b/tests/unit/schema-graph.test.js index 9289084..8d42c7f 100644 --- a/tests/unit/schema-graph.test.js +++ b/tests/unit/schema-graph.test.js @@ -197,6 +197,17 @@ describe('buildSchemaGraph', () => { expect(eset(g).has('lin.src>lin.mv:feeds')).toBe(true); // lineage edges still present }); + it('strips the focus-db prefix from same-db node labels but keeps it cross-db', () => { + const rows = { tables: [ + T('lin', 'events', 'MergeTree'), // same db, isolated + T('lin', 'dist', 'Distributed', { engine_full: "Distributed('cl', 'other', 'remote_tbl')" }), + ], dictionaries: [] }; + const byId = Object.fromEntries(buildSchemaGraph(rows, { kind: 'db', db: 'lin' }).nodes.map((n) => [n.id, n])); + expect(byId['lin.events'].label).toBe('events'); // same db → bare table name + expect(byId['lin.dist'].label).toBe('dist'); // same db → bare table name + expect(byId['other.remote_tbl'].label).toBe('other.remote_tbl'); // another db → stays qualified + }); + it('keeps every table as a standalone node when a whole-DB graph has no relationships', () => { // A DB of unrelated tables (e.g. all URL engine) still renders its tables — // showing the objects beats an empty "no relationships" screen, even though