Skip to content
Closed
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
7 changes: 4 additions & 3 deletions src/core/dot-layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,10 @@ export function dagreLayout(dagre, graph) {

const outNodes = nodes.map((n) => {
const dn = g.node(n.id);
// `kind`/`db`/`name` (node) and `label` (edge) pass through for the schema
// graph's colouring + click-to-SHOW-CREATE (so the UI need not re-split the id).
return { id: n.id, label: n.label, kind: n.kind, db: n.db, name: n.name, x: dn.x - dn.width / 2, y: dn.y - dn.height / 2, w: dn.width, h: dn.height };
// `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 };
});
const outEdges = edges.map((e) => ({
from: e.from, to: e.to, kind: e.kind, label: e.label,
Expand Down
85 changes: 72 additions & 13 deletions src/core/schema-graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,11 @@ export function buildSchemaGraph(rows, focus) {
const byId = new Map(); // id → table row, for lookups
const innerByUuid = new Map(); // implicit-MV inner storage, keyed by owner uuid

const node = (id, kind) => {
if (!nodes.has(id)) {
const dot = id.indexOf('.');
nodes.set(id, { id, label: id, kind, db: id.slice(0, dot), name: id.slice(dot + 1) });
}
// Every creation passes explicit db/name (callers build the id via joinId/rowId,
// so they always know the parts) — keeping a dotted *database* correct, not just a
// dotted table. The db/name args are ignored when the node already exists.
const node = (id, kind, db, name) => {
if (!nodes.has(id)) nodes.set(id, { id, label: id, kind, db, name });
return nodes.get(id);
};
// external (non-CH dictionary source) leaf
Expand All @@ -129,7 +129,7 @@ export function buildSchemaGraph(rows, focus) {
const uuid = t.name.replace(/^\.inner(_id)?\./, '');
innerByUuid.set(uuid, id);
}
node(id, objectKind(t.engine));
node(id, objectKind(t.engine), t.database, t.name);
}
// friendlier labels for inner storage tables
for (const id of innerByUuid.values()) {
Expand All @@ -147,15 +147,18 @@ export function buildSchemaGraph(rows, focus) {
seen.add(k);
edges.push({ from, to, kind });
};
const zip = (dbs, names) => (names || []).map((nm, i) => joinId((dbs && dbs[i]) || '', nm));
const zip = (dbs, names) => (names || []).map((nm, i) => {
const d = (dbs && dbs[i]) || '';
return { id: joinId(d, nm), db: d, name: nm };
});

for (const t of tables) {
const id = rowId(t);
const kind = nodes.get(id).kind;
// source → MV/View (structured dependents on the source side)
for (const dep of zip(t.dependencies_database, t.dependencies_table)) {
node(dep, byId.has(dep) ? nodes.get(dep).kind : 'table');
addEdge(id, dep, 'feeds');
node(dep.id, byId.has(dep.id) ? nodes.get(dep.id).kind : 'table', dep.db, dep.name);
addEdge(id, dep.id, 'feeds');
}
// fallback: EXPLAIN AST sources of a view/MV → source → this object. EXPLAIN
// AST prints names unquoted, qualified-or-bare — so resolve against the known
Expand All @@ -172,12 +175,18 @@ export function buildSchemaGraph(rows, focus) {
if (kind === 'mv') {
const target = parseMvTarget(t.create_table_query);
const targetId = target ? joinId(target.db || t.database, target.table) : innerByUuid.get(String(t.uuid || ''));
if (targetId) { node(targetId, byId.has(targetId) ? nodes.get(targetId).kind : 'table'); addEdge(id, targetId, 'writes'); }
if (targetId) {
// For an implicit (.inner) target the node already exists with correct
// parts (created in the first pass), so db/name here are only used for the
// explicit-TO case.
node(targetId, byId.has(targetId) ? nodes.get(targetId).kind : 'table', target ? (target.db || t.database) : undefined, target ? target.table : undefined);
addEdge(id, targetId, 'writes');
}
} else if (kind === 'distributed' || kind === 'buffer' || kind === 'merge') {
const ref = parseEngineRef(t.engine, t.engine_full);
if (ref && ref.table) {
const refId = joinId(ref.db || t.database, ref.table);
node(refId, byId.has(refId) ? nodes.get(refId).kind : 'table');
node(refId, byId.has(refId) ? nodes.get(refId).kind : 'table', ref.db || t.database, ref.table);
addEdge(refId, id, ref.kind === 'buffer' ? 'buffer' : 'shard');
} else if (ref && ref.regex) {
let rx = null;
Expand All @@ -200,10 +209,10 @@ export function buildSchemaGraph(rows, focus) {
const ld = zip(t.loading_dependencies_database, t.loading_dependencies_table);
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'); }
for (const src of ld) { node(src.id, byId.has(src.id) ? nodes.get(src.id).kind : 'table', src.db, src.name); addEdge(src.id, id, 'dict'); }
} else {
const s = parseDictSource(d && d.source, t.create_table_query);
if (s && s.table) { const sid = joinId(s.db || t.database, s.table); node(sid, 'table'); addEdge(sid, id, 'dict'); }
if (s && s.table) { const sid = joinId(s.db || t.database, s.table); node(sid, 'table', s.db || t.database, s.table); addEdge(sid, id, 'dict'); }
else if (s && s.external) addEdge(external(s.external), id, 'dict');
}
}
Expand Down Expand Up @@ -231,3 +240,53 @@ export function buildSchemaGraph(rows, focus) {
}
return { nodes: outNodes, edges: outEdges };
}

/**
* The databases referenced by `graph`'s nodes that aren't in `loadedDbs` — the
* next databases to fetch to extend a transitive cross-DB lineage. External
* (non-CH `ext:`) leaves carry an empty db and are skipped. Pure.
*/
export function externalDbs(graph, loadedDbs) {
const loaded = new Set(loadedDbs || []);
const out = new Set();
for (const n of (graph && graph.nodes) || []) {
if (n.db && !loaded.has(n.db)) out.add(n.db);
}
return [...out];
}

/**
* Transitive closure of `graph` around every node in `seedDb`: an undirected BFS
* over the edges in BOTH directions across database boundaries, until the frontier
* empties or `cap` nodes are reached (then `truncated`). Returns `{ nodes, edges,
* truncated }` with each node tagged `external = (n.db !== seedDb)` and only the
* reached nodes/edges kept. All of `seedDb` is seeded unconditionally; the cap
* bounds only the cross-DB expansion (a pathologically interconnected cluster
* can't freeze the view). Pure — the loader decides which DBs to fetch.
*/
export function expandLineage(graph, seedDb, opts = {}) {
const cap = opts.cap != null ? opts.cap : 600;
const allNodes = (graph && graph.nodes) || [];
const edges = (graph && graph.edges) || [];
const byId = new Map(allNodes.map((n) => [n.id, n]));
const adj = new Map();
const link = (a, b) => { const l = adj.get(a); if (l) l.push(b); else adj.set(a, [b]); };
for (const e of edges) {
if (!byId.has(e.from) || !byId.has(e.to)) continue;
link(e.from, e.to); link(e.to, e.from);
}
const visited = new Set();
const queue = [];
for (const n of allNodes) if (n.db === seedDb) { visited.add(n.id); queue.push(n.id); }
let truncated = false;
while (queue.length && !truncated) {
for (const nb of adj.get(queue.shift()) || []) {
if (visited.has(nb)) continue;
if (visited.size >= cap) { truncated = true; break; }
visited.add(nb); queue.push(nb);
}
}
const nodes = [...visited].map((id) => ({ ...byId.get(id), external: byId.get(id).db !== seedDb }));
const outEdges = edges.filter((e) => visited.has(e.from) && visited.has(e.to));
return { nodes, edges: outEdges, truncated };
}
66 changes: 65 additions & 1 deletion src/net/ch-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
// so the whole module is unit-testable with plain stubs.

import { parseExceptionText, isAuthExpiredBody, authDeniedMessage } from '../core/stream.js';
import { parseAstTables } from '../core/schema-graph.js';
import { parseAstTables, buildSchemaGraph, externalDbs } from '../core/schema-graph.js';
import { sqlString } from '../core/format.js';

/** Build a ClickHouse HTTP URL with query-string options. Pure. */
Expand Down Expand Up @@ -205,6 +205,70 @@ export async function loadSchemaCards(ctx, dbs) {
return { columnsByKey, skipByKey };
}

/**
* Load lineage rows transitively across database boundaries: start at `focus.db`,
* then BFS into every database referenced by the graph built so far, merging rows,
* until no new database is referenced or a cap is hit. `opts.dbCap` bounds the
* number of databases fetched and `opts.nodeCap` the graph size — either tripping
* sets `truncated` (the caller shows a banner). Returns `{ rows, truncated }`;
* `rows` is the merged `{ tables, dictionaries }` for buildSchemaGraph + expandLineage.
*/
export async function loadLineageTransitive(ctx, focus, opts = {}) {
const nodeCap = opts.nodeCap != null ? opts.nodeCap : 600;
const dbCap = opts.dbCap != null ? opts.dbCap : 8;
const seed = (focus && focus.db) || '';
const loaded = new Set();
let frontier = seed ? [seed] : [];
let tables = [];
let dictionaries = [];
let truncated = false;
while (frontier.length) {
if (loaded.size >= dbCap) { truncated = true; break; }
// Load the whole frontier concurrently (bounded by the remaining db budget),
// rebuild the graph once per round, then take its newly-referenced dbs as the
// next frontier. Far fewer round-trips than fetching one db at a time.
const batch = frontier.slice(0, dbCap - loaded.size);
batch.forEach((db) => loaded.add(db));
const parts = await Promise.all(batch.map((db) => loadSchemaLineage(ctx, { db })));
for (const part of parts) {
tables = tables.concat(part.tables);
dictionaries = dictionaries.concat(part.dictionaries);
}
const graph = buildSchemaGraph({ tables, dictionaries });
if (graph.nodes.length >= nodeCap) { truncated = true; break; }
frontier = externalDbs(graph, loaded);
}
return { rows: { tables, dictionaries }, truncated };
}

/**
* Per-table detail for the node detail pane: full columns (with key-role flags +
* compression sizes), per-partition part/row/byte sums, and the DDL. All reads are
* best-effort via tryQueryData (a denied/missing system table degrades to empty,
* never an error). Returns `{ columns, partitions, ddl }`.
*/
export async function loadTableDetail(ctx, db, table) {
const byCol = 'database = ' + sqlString(db) + ' AND table = ' + sqlString(table);
const byName = 'database = ' + sqlString(db) + ' AND name = ' + sqlString(table);
const [columns, partitions, ddlRows] = await Promise.all([
tryQueryData(ctx,
'SELECT name, type, compression_codec AS codec, '
+ 'is_in_partition_key, is_in_sorting_key, is_in_primary_key, is_in_sampling_key, '
+ 'toUInt64(data_compressed_bytes) AS compressed, toUInt64(data_uncompressed_bytes) AS uncompressed, '
+ 'toUInt64(marks_bytes) AS marks, position '
+ 'FROM system.columns WHERE ' + byCol + ' ORDER BY position FORMAT JSON'),
tryQueryData(ctx,
'SELECT partition, count() AS parts, sum(rows) AS rows, sum(bytes_on_disk) AS bytes '
+ 'FROM system.parts WHERE ' + byCol + ' AND active GROUP BY partition ORDER BY partition FORMAT JSON'),
tryQueryData(ctx, 'SELECT create_table_query AS ddl FROM system.tables WHERE ' + byName + ' FORMAT JSON'),
]);
return {
columns: columns || [],
partitions: partitions || [],
ddl: (ddlRows && ddlRows[0] && ddlRows[0].ddl) || '',
};
}

// Run a query for its `data` rows, returning null on ANY error. Editor
// reference data is best-effort: a missing system table on older ClickHouse (or
// a denied SELECT) must degrade gracefully, never surface as a query error.
Expand Down
40 changes: 40 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,9 @@ body {
.explain-graph .eg-badge--sk { fill: #6bb6ff; }
.explain-graph .eg-badge--partition { fill: #c297ff; }
.explain-graph .eg-badge--sampling { fill: #eab308; }
/* external (other-DB) cards pulled in transitively: dashed border + dimmed fill,
composed on top of the kind colour. */
.explain-graph .eg-node--ext { stroke-dasharray: 4 3; fill-opacity: 0.4; }

.schema-graph-view { position: relative; }
.schema-graph-legend {
Expand Down Expand Up @@ -729,6 +732,43 @@ body {
}
.graph-overlay-canvas.grabbing { cursor: grabbing; }
.graph-overlay-canvas > svg.explain-graph { width: 100%; height: 100%; }
.graph-overlay-note { font-size: 11px; color: #eab308; }

/* ------------ schema node detail pane (fullscreen graph) ------------ */
.schema-detail {
flex: 0 0 280px; min-height: 90px; position: relative;
border-top: 1px solid var(--border); background: var(--bg-modal); overflow: auto;
}
.schema-detail-handle {
position: sticky; top: 0; height: 7px; cursor: row-resize; background: var(--border);
}
.schema-detail-handle:hover { background: var(--fg-faint); }
.schema-detail-close {
position: absolute; top: 10px; right: 10px; z-index: 1;
display: flex; align-items: center; justify-content: center;
width: 24px; height: 24px; border: none; border-radius: 6px;
background: transparent; color: var(--fg-mute); cursor: pointer;
}
.schema-detail-close:hover { background: var(--bg-hover); color: var(--fg); }
.schema-detail-body { padding: 10px 14px; }
.schema-detail-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.schema-detail-head b { font-size: 13px; word-break: break-all; }
.schema-detail-kind { font-size: 11px; color: var(--fg-faint); }
.schema-detail h4 {
font-size: 11px; text-transform: uppercase; letter-spacing: .04em;
color: var(--fg-faint); margin: 12px 0 6px;
}
.schema-detail-cols { width: 100%; border-collapse: collapse; font-size: 11px; }
.schema-detail-cols th, .schema-detail-cols td {
text-align: left; padding: 3px 8px; border-bottom: 1px solid var(--border); white-space: nowrap;
}
.schema-detail-cols th { color: var(--fg-faint); font-weight: 600; }
.schema-detail-cols th.num, .schema-detail-cols td.num { text-align: right; }
.schema-detail-roles { color: #ff8f6b; font-weight: 600; }
.schema-detail-ddl {
background: var(--bg-table); border: 1px solid var(--border); border-radius: 6px;
padding: 10px; overflow: auto; font: 11px/1.45 var(--mono); white-space: pre-wrap;
}

/* ------------ chart view ------------ */
/* `.res-body` is display:block, so height:100% (not flex:1) is what lets the
Expand Down
37 changes: 27 additions & 10 deletions src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { saveJSON, saveStr } from '../core/storage.js';
import { decodeJwtPayload, isTokenExpired } from '../core/jwt.js';
import { sqlString, inferQueryName, shortVersion, userShortName, withStatementBreak, detectSqlFormat } from '../core/format.js';
import { EXPLAIN_VIEWS, parseExplain, detectExplainView, buildExplainQuery } from '../core/explain.js';
import { buildSchemaGraph } from '../core/schema-graph.js';
import { buildSchemaGraph, expandLineage } from '../core/schema-graph.js';
import { buildCardGraph } from '../core/schema-cards.js';
import { resolveTarget } from '../core/target.js';
import { toTSV, toCSV } from '../core/export.js';
Expand All @@ -29,6 +29,7 @@ import { renderTabs, selectTab, newTab, closeTab, loadIntoNewTab } from './tabs.
import { renderSchema } from './schema.js';
import { renderResults } from './results.js';
import { openSchemaFullscreen } from './explain-graph.js';
import { openDetailPane } from './schema-detail.js';
import { renderSavedHistory } from './saved-history.js';
import { libraryControls, renderLibraryTitle } from './file-menu.js';
import { renderLogin } from './login.js';
Expand Down Expand Up @@ -545,21 +546,36 @@ export function createApp(env = {}) {
if (!focus || !focus.db) return;
await ensureConfig();
if (!(await getToken())) { chCtx.onSignedOut(); return; }
let rows, cards;
let lineage;
try {
// The lineage rows and the card metadata are independent — load concurrently.
[rows, cards] = await Promise.all([
ch.loadSchemaLineage(chCtx, focus),
ch.loadSchemaCards(chCtx, [focus.db]),
]);
// Walk lineage transitively across DB boundaries (soft-capped) — pulls in
// objects an other database references, instead of dead-ending at the edge.
lineage = await ch.loadLineageTransitive(chCtx, focus);
} catch {
// The inline graph is still on screen; tell the user the expand didn't load.
flashToast('Could not load the schema graph', { document: doc });
return;
}
const g = buildSchemaGraph(rows, focus);
const cardGraph = buildCardGraph(g, { tables: rows.tables, columnsByKey: cards.columnsByKey, skipByKey: cards.skipByKey });
openSchemaFullscreen(app, { nodes: cardGraph.nodes, edges: cardGraph.edges, focus, tableCount: (rows.tables || []).length });
const g = buildSchemaGraph(lineage.rows, focus);
const ex = expandLineage(g, focus.db); // closure around focus.db, tags external nodes
// Card metadata for every database the expansion reached (external nodes too).
const dbs = [...new Set(ex.nodes.map((n) => n.db).filter(Boolean))];
const cards = await ch.loadSchemaCards(chCtx, dbs);
const cardGraph = buildCardGraph({ nodes: ex.nodes, edges: ex.edges },
{ tables: lineage.rows.tables, columnsByKey: cards.columnsByKey, skipByKey: cards.skipByKey });
openSchemaFullscreen(app, {
nodes: cardGraph.nodes, edges: cardGraph.edges, focus,
tableCount: (lineage.rows.tables || []).length,
truncated: lineage.truncated || ex.truncated,
});
}

// Open the detail pane for a clicked fullscreen node: lazily load the table's full
// columns / partitions / DDL (best-effort) and mount the pane in the overlay.
async function openNodeDetail(node) {
if (!node || !node.db || !node.name) return;
const detail = await ch.loadTableDetail(chCtx, node.db, node.name);
openDetailPane(app, node, detail);
}

// Explain the current query without editing it: run it through the EXPLAIN
Expand Down Expand Up @@ -775,6 +791,7 @@ export function createApp(env = {}) {
setExplainView,
showSchemaGraph,
expandSchemaGraph,
openNodeDetail,
insertCreate,
openShortcuts: () => openShortcuts(app),
insertAtCursor: (text) => insertAtCursor(app, text),
Expand Down
Loading
Loading