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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` /
Expand Down
21 changes: 12 additions & 9 deletions src/core/schema-graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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 {
Expand All @@ -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));
Expand Down
6 changes: 3 additions & 3 deletions src/net/ch-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}) {
Expand Down Expand Up @@ -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) => {
Expand Down
3 changes: 2 additions & 1 deletion src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
34 changes: 24 additions & 10 deletions src/ui/explain-graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }`
Expand All @@ -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));
Expand Down Expand Up @@ -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;
Expand All @@ -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(); } };
Expand All @@ -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);
Expand Down Expand Up @@ -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));
}

/**
Expand All @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions src/ui/results.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
12 changes: 12 additions & 0 deletions tests/unit/explain-graph.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions tests/unit/results.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
14 changes: 9 additions & 5 deletions tests/unit/schema-graph.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
Loading