Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 46 additions & 14 deletions src/core/dot-layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,37 +24,69 @@ 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));
// Keep edges between declared processors; drop self-loops (a Resize feedback
// 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 };
}
22 changes: 13 additions & 9 deletions src/core/schema-graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -228,16 +228,20 @@ 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.)

// Display label: inside the focused database the "<db>." 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 };
}

Expand Down
4 changes: 2 additions & 2 deletions src/ui/explain-graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
30 changes: 30 additions & 0 deletions tests/unit/dot-layout.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
});
});
23 changes: 19 additions & 4 deletions tests/unit/schema-graph.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,16 +181,31 @@ 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('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', () => {
Expand Down
Loading