From e43e47571a10d202ed2f755c602d0b20c70ce1d5 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Fri, 26 Jun 2026 07:37:52 +0200 Subject: [PATCH] fix(schema): clear empty-state for a DB with no lineage (was invisible strip) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: dragging db `target_all` on github.demo (201 tables, all URL engine, zero relationships) produced "empty output". Diagnosed live: buildSchemaGraph returned 201 nodes / 0 edges, dagre laid them in a ~127000px-wide × 58px single row, and fit-to-view shrank every node to sub-pixel — a blank-looking pane. Root cause + fixes (from a high-effort review of the schema-graph feature, #41): - schema-graph.js: whole-DB lineage now ALWAYS keeps only connected (degree>0) nodes — a lineage view shows lineage. The old `else if (edges.length)` kept all tables when there were no edges, which is exactly the degenerate dump. A DB with no relationships now yields an empty graph. - explain-graph.js: the empty-state was keyed on nodeCount===0 (unreachable for a real DB). renderSchemaGraph/openSchemaFullscreen now show an informative message via schemaEmptyMessage ("No object relationships in — its N tables aren't linked by …" / " has no lineage relationships"). app.js carries tableCount for the message. - results.js: hide Expand when there's nothing to draw. - attachPanZoom: minW floor `min(width/8, 600)` so a wide graph can still be zoomed to a legible scale (was width/8 ≈ unusable on huge graphs). Also from the review: - ch-client.js: loadSchemaLineage used a local SQL quoter that dropped backslash-escaping — use core/format.js sqlString. - schema-graph.js: index system.dictionaries by db.name (was O(D) find per dict); drop the dead `void uuid` in the inner-label pass. - explain-graph.js: single `placeholder()` helper (was triplicated markup). - README + tests: the doc and a test ("keeps all tables when a DB has no lineage") previously certified the buggy behavior — both updated; new tests cover the no-relationship DB (50 URL tables → empty), the empty-state messages, and the hidden Expand. Verified live on github.demo: target_all now shows the message, not a strip. 883 tests pass; per-file coverage gate holds. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QGBS74oUsXarGkCRQKEFLu --- README.md | 10 ++++++---- src/core/schema-graph.js | 21 +++++++++++--------- src/net/ch-client.js | 6 +++--- src/ui/app.js | 3 ++- src/ui/explain-graph.js | 34 ++++++++++++++++++++++---------- src/ui/results.js | 5 +++-- tests/unit/explain-graph.test.js | 12 +++++++++++ tests/unit/results.test.js | 10 ++++++++++ tests/unit/schema-graph.test.js | 14 ++++++++----- 9 files changed, 81 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 3899d5f..fcd9ebf 100644 --- a/README.md +++ b/README.md @@ -110,10 +110,12 @@ target), regular views (`reads` their sources), dictionaries (`dict` from a sour table), and `Distributed`/`Buffer`/`Merge` engines pointing at their backing tables. Nodes are coloured by kind (table / view / materialized view / dictionary / distributed / external) with a legend; edges are coloured and labelled by -relationship. Drag a **database** → the whole-DB lineage (isolated tables with no -relationships are dropped so the lineage is the focus); drag a **table** → its -1-hop neighbourhood. **Click any node** to re-centre on it, and **Expand** for a -fullscreen pan/zoom view (same controls as the pipeline graph). +relationship. Drag a **database** → the whole-DB lineage (it shows only the tables +that participate in a relationship; a database whose tables aren't linked by any +view/MV/dictionary/Distributed engine shows a "no object relationships" message +rather than a wall of disconnected boxes); drag a **table** → its 1-hop +neighbourhood. **Click any node** to run `SHOW CREATE` for it into the editor; +**⌘/Ctrl-drag** to pan; **Expand** for a fullscreen pan/zoom view. Discovery is **structured-first, parse-fallback**, because the helpful `system.tables` columns are build-dependent: it prefers `dependencies_table` / diff --git a/src/core/schema-graph.js b/src/core/schema-graph.js index efdc958..ea40517 100644 --- a/src/core/schema-graph.js +++ b/src/core/schema-graph.js @@ -132,10 +132,9 @@ export function buildSchemaGraph(rows, focus) { node(id, objectKind(t.engine)); } // friendlier labels for inner storage tables - for (const [uuid, id] of innerByUuid) { + for (const id of innerByUuid.values()) { const n = nodes.get(id); if (n) n.label = '·inner'; - void uuid; } const edges = []; @@ -192,12 +191,14 @@ export function buildSchemaGraph(rows, focus) { } } - // dictionaries: prefer loading_dependencies (structured) else parse source/CREATE + // dictionaries: prefer loading_dependencies (structured) else parse source/CREATE. + // Index system.dictionaries by db.name once (O(1) source lookup, not O(D) per dict). + const dictByid = new Map(dicts.map((d) => [d.database + '.' + d.name, d])); for (const t of tables) { - if (nodes.get(rowId(t)).kind !== 'dictionary') continue; const id = rowId(t); + if (nodes.get(id).kind !== 'dictionary') continue; const ld = zip(t.loading_dependencies_database, t.loading_dependencies_table); - const d = dicts.find((x) => x.database === t.database && x.name === t.name); + const d = dictByid.get(id); if (ld.length) { for (const src of ld) { node(src, byId.has(src) ? nodes.get(src).kind : 'table'); addEdge(src, id, 'dict'); } } else { @@ -218,10 +219,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: drop isolated (degree-0) tables so the relationships are - // the focus — but only when there ARE relationships, so a DB with no lineage - // still shows its tables rather than an empty pane. + } else { + // Whole-DB lineage: keep only tables that participate in a relationship — a + // lineage view always shows lineage. When nothing is connected (e.g. a DB of + // unrelated URL/MergeTree tables) this yields an empty graph, and the renderer + // shows a "no relationships" message rather than dumping every table as a row + // of disconnected boxes (which dagre lays out into an unreadable wide strip). 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)); diff --git a/src/net/ch-client.js b/src/net/ch-client.js index 9bd784b..844cef6 100644 --- a/src/net/ch-client.js +++ b/src/net/ch-client.js @@ -9,6 +9,7 @@ import { parseExceptionText, isAuthExpiredBody, authDeniedMessage } from '../core/stream.js'; import { parseAstTables } from '../core/schema-graph.js'; +import { sqlString } from '../core/format.js'; /** Build a ClickHouse HTTP URL with query-string options. Pure. */ export function chUrl(origin, opts = {}) { @@ -138,14 +139,13 @@ export async function loadSchema(ctx) { * `create_table_query` in `buildSchemaGraph`. Returns `{ tables, dictionaries }`. */ export async function loadSchemaLineage(ctx, focus) { - const q = (s) => "'" + String(s).replace(/'/g, "''") + "'"; const db = (focus && focus.db) || ''; const cols = 'database, name, engine, engine_full, create_table_query, as_select, ' + 'toString(uuid) AS uuid, dependencies_database, dependencies_table, ' + 'loading_dependencies_database, loading_dependencies_table'; - const tablesJson = await queryJson(ctx, `SELECT ${cols} FROM system.tables WHERE database = ${q(db)} ORDER BY name`); + const tablesJson = await queryJson(ctx, `SELECT ${cols} FROM system.tables WHERE database = ${sqlString(db)} ORDER BY name`); const tables = tablesJson.data || []; - const dictsJson = await queryJson(ctx, `SELECT database, name, source FROM system.dictionaries WHERE database = ${q(db)}`); + const dictsJson = await queryJson(ctx, `SELECT database, name, source FROM system.dictionaries WHERE database = ${sqlString(db)}`); const dictionaries = dictsJson.data || []; // Robust source extraction for views/MVs: let ClickHouse parse the SELECT. await Promise.all(tables.map(async (t) => { diff --git a/src/ui/app.js b/src/ui/app.js index ed5294d..1cbe6e2 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -525,7 +525,8 @@ export function createApp(env = {}) { try { const rows = await ch.loadSchemaLineage(chCtx, focus); const g = buildSchemaGraph(rows, focus); - tab.result.schemaGraph = { focus, nodes: g.nodes, edges: g.edges }; + // tableCount lets the renderer explain an empty result ("N tables, none linked"). + tab.result.schemaGraph = { focus, nodes: g.nodes, edges: g.edges, tableCount: (rows.tables || []).length }; } catch (e) { tab.result = newResult('Table'); tab.result.error = String((e && e.message) || e); diff --git a/src/ui/explain-graph.js b/src/ui/explain-graph.js index e44d3f1..3726c4d 100644 --- a/src/ui/explain-graph.js +++ b/src/ui/explain-graph.js @@ -14,6 +14,21 @@ import { fitBox, zoomBox, panBox, viewBoxStr } from '../core/panzoom.js'; const ZOOM_STEP = 1.2; // per wheel notch / button press +/** A centred message shown in place of a graph (no nodes / nothing to draw). */ +const placeholder = (msg) => h('div', { class: 'placeholder' }, h('div', null, msg)); + +/** + * Empty-state copy for a schema graph that has no relationships to draw — explains + * WHY (so a relationless DB doesn't look like a failure) and what to try instead. + */ +function schemaEmptyMessage(graph) { + const f = (graph && graph.focus) || {}; + if (f.kind === 'table') return f.db + '.' + f.table + ' has no lineage relationships.'; + const n = graph && graph.tableCount; + return 'No object relationships in ' + f.db + + (n ? ' — its ' + n + ' table' + (n === 1 ? '' : 's') + " aren't linked by a view, materialized view, dictionary, or Distributed/Buffer/Merge engine." : '.'); +} + /** * Wire pan/zoom onto a container holding the graph `svg` (sized to fill it). The * viewBox starts fitted to the `dims` graph. Returns `{ fit, zoomIn, zoomOut }` @@ -28,7 +43,9 @@ function attachPanZoom(container, svg, dims, opts = {}) { svg.setAttribute('width', '100%'); svg.setAttribute('height', '100%'); svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); - const minW = dims.width / 8; + // Smallest viewBox (most zoomed-in). Cap at an absolute pixel floor so a very + // wide graph can still be zoomed to a legible node, not just to width/8. + const minW = Math.min(dims.width / 8, 600); const maxW = dims.width * 3; let vb = fitBox(dims.width, dims.height); const apply = () => svg.setAttribute('viewBox', viewBoxStr(vb)); @@ -136,9 +153,7 @@ export function buildSchemaSvg(graph, dagre, onNode) { */ export function renderExplainGraph(app, r) { const built = buildPipelineSvg(r.rawText || '', app.Dagre); - if (!built.nodeCount) { - return h('div', { class: 'placeholder' }, h('div', null, 'No pipeline graph to display.')); - } + if (!built.nodeCount) return placeholder('No pipeline graph to display.'); const view = h('div', { class: 'explain-graph-view', tabindex: '0' }, built.svg); attachPanZoom(view, built.svg, built); return view; @@ -162,7 +177,7 @@ function schemaLegend() { * — shared by the pipeline and schema graphs. `extra` is an optional overlay node * (e.g. the schema legend). */ -function openGraphFullscreen(app, title, build, extra) { +function openGraphFullscreen(app, title, build, extra, emptyMsg) { const doc = (app && app.document) || document; const built = build(); const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } }; @@ -172,7 +187,7 @@ function openGraphFullscreen(app, title, build, extra) { const bar = h('div', { class: 'graph-overlay-bar' }, h('span', { class: 'graph-overlay-title' }, title)); const canvas = h('div', { class: 'graph-overlay-canvas' }); if (!built.nodeCount) { - canvas.appendChild(h('div', { class: 'placeholder' }, h('div', null, 'Nothing to display.'))); + canvas.appendChild(placeholder(emptyMsg || 'Nothing to display.')); } else { canvas.appendChild(built.svg); if (extra) canvas.appendChild(extra); @@ -207,7 +222,7 @@ const schemaClick = (app) => (n) => { /** Fullscreen schema-lineage graph. */ export function openSchemaFullscreen(app, graph) { - return openGraphFullscreen(app, 'Schema', () => buildSchemaSvg(graph, app && app.Dagre, schemaClick(app)), schemaLegend()); + return openGraphFullscreen(app, 'Schema', () => buildSchemaSvg(graph, app && app.Dagre, schemaClick(app)), schemaLegend(), schemaEmptyMessage(graph)); } /** @@ -217,9 +232,8 @@ export function openSchemaFullscreen(app, graph) { */ export function renderSchemaGraph(app, r) { const built = buildSchemaSvg(r.schemaGraph, app.Dagre, schemaClick(app)); - if (!built.nodeCount) { - return h('div', { class: 'placeholder' }, h('div', null, 'No objects to graph.')); - } + // No connected objects → explain why instead of drawing nothing / a wide strip. + if (!built.nodeCount) return placeholder(schemaEmptyMessage(r.schemaGraph)); const view = h('div', { class: 'explain-graph-view schema-graph-view', tabindex: '0' }, built.svg, schemaLegend()); attachPanZoom(view, built.svg, built, { modifierPan: true }); return view; diff --git a/src/ui/results.js b/src/ui/results.js index 5e6e472..507b969 100644 --- a/src/ui/results.js +++ b/src/ui/results.js @@ -157,8 +157,9 @@ function buildToolbar(app, r) { const title = f.kind === 'table' ? f.db + '.' + f.table : f.db; toolbar.appendChild(h('div', { class: 'result-view-tabs' }, h('span', { class: 'res-graph-title' }, 'Schema · ' + title))); toolbar.appendChild(h('div', { style: { flex: '1' } })); - // Expand is meaningless until the graph has loaded. - if (!r.schemaGraph.loading) { + // Expand is meaningless until the graph has loaded, or when there's nothing + // to draw (no connected objects → the pane shows a message, not a graph). + if (!r.schemaGraph.loading && r.schemaGraph.nodes.length) { toolbar.appendChild(h('button', { class: 'res-act', title: 'Open the graph fullscreen (pan & zoom)', onclick: () => openSchemaFullscreen(app, r.schemaGraph), diff --git a/tests/unit/explain-graph.test.js b/tests/unit/explain-graph.test.js index d70b11c..ffd3e4c 100644 --- a/tests/unit/explain-graph.test.js +++ b/tests/unit/explain-graph.test.js @@ -225,6 +225,18 @@ describe('schema lineage graph', () => { expect(el.className).toBe('placeholder'); }); + it('explains an empty whole-DB result with the table count (no-relationships message)', () => { + const el = renderSchemaGraph(APP, { schemaGraph: { focus: { kind: 'db', db: 'target_all' }, nodes: [], edges: [], tableCount: 201 } }); + expect(el.className).toBe('placeholder'); + expect(el.textContent).toMatch(/No object relationships in target_all/); + expect(el.textContent).toMatch(/201 tables/); + }); + + it('explains an empty table-focus result', () => { + const el = renderSchemaGraph(APP, { schemaGraph: { focus: { kind: 'table', db: 'd', table: 'lonely' }, nodes: [], edges: [] } }); + expect(el.textContent).toMatch(/d\.lonely has no lineage relationships/); + }); + it('openSchemaFullscreen mounts an overlay with the legend and closes', () => { const overlay = openSchemaFullscreen({ document, Dagre: dagre, actions: { showSchemaGraph: vi.fn() } }, GRAPH); expect(document.body.contains(overlay)).toBe(true); diff --git a/tests/unit/results.test.js b/tests/unit/results.test.js index aa5a851..fe765dc 100644 --- a/tests/unit/results.test.js +++ b/tests/unit/results.test.js @@ -606,4 +606,14 @@ describe('schema lineage result', () => { expect(region.querySelector('.res-graph-title').textContent).toBe('Schema · lin'); expect([...region.querySelectorAll('.res-act')].find((b) => /Expand/.test(b.textContent))).toBeFalsy(); }); + it('a no-relationship DB shows the message and no Expand button', () => { + const r = newResult('Table'); + r.schemaGraph = { focus: { kind: 'db', db: 'target_all' }, nodes: [], edges: [], tableCount: 201 }; + const app = appWithResult(r); + renderResults(app); + const region = app.dom.resultsRegion; + expect(region.querySelector('svg.explain-graph')).toBeNull(); + expect(region.querySelector('.placeholder').textContent).toMatch(/No object relationships in target_all/); + expect([...region.querySelectorAll('.res-act')].find((b) => /Expand/.test(b.textContent))).toBeFalsy(); + }); }); diff --git a/tests/unit/schema-graph.test.js b/tests/unit/schema-graph.test.js index 347f126..0745a78 100644 --- a/tests/unit/schema-graph.test.js +++ b/tests/unit/schema-graph.test.js @@ -192,11 +192,15 @@ describe('buildSchemaGraph', () => { expect(ids.has('lin.orphan')).toBe(false); }); - it('keeps all tables when a DB has no lineage at all', () => { - const rows = { tables: [T('lin', 'a', 'MergeTree'), T('lin', 'b', 'MergeTree')], dictionaries: [] }; - const ids = new Set(buildSchemaGraph(rows, { kind: 'db', db: 'lin' }).nodes.map((n) => n.id)); - expect(ids.has('lin.a')).toBe(true); - expect(ids.has('lin.b')).toBe(true); + it('returns an EMPTY graph for a DB with no relationships (lineage-only; the UI shows a message)', () => { + // Regression for the target_all bug: a DB of unrelated tables (e.g. all URL + // engine) must NOT dump every table as a disconnected node — it returns no + // nodes so the renderer can explain "no relationships" instead of laying out + // a giant unreadable strip. + const tables = Array.from({ length: 50 }, (_, i) => T('lin', 'url_' + i, 'URL')); + const g = buildSchemaGraph({ tables, dictionaries: [] }, { kind: 'db', db: 'lin' }); + expect(g.nodes).toEqual([]); + expect(g.edges).toEqual([]); }); it('never throws on a malformed Merge regex (keeps the no-throw contract)', () => {