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
7 changes: 6 additions & 1 deletion src/core/dot-layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@ export function dagreLayout(dagre, graph) {
const g = new dagre.graphlib.Graph();
g.setGraph({ rankdir: 'TB', nodesep: NODESEP, ranksep: RANKSEP, marginx: MARGIN, marginy: MARGIN });
g.setDefaultEdgeLabel(() => ({}));
for (const n of nodes) g.setNode(n.id, { width: nodeWidth(n.label), height: NODE_H });
// 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 });
}
for (const e of edges) g.setEdge(e.from, e.to);
dagre.layout(g);

Expand Down
107 changes: 107 additions & 0 deletions src/core/schema-cards.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Pure assembly + sizing of the rich node "cards" the fullscreen schema graph
// draws. No DOM, no fetch — the column/skip-index rows come from ch-client
// (loadSchemaCards) and the SVG drawing lives in src/ui/explain-graph.js. Kept
// pure so the geometry (which dagre needs *before* layout) is fully testable
// under happy-dom, which has no layout engine to measure rendered text.

import { formatRows, formatBytes } from './format.js';

// Card geometry — the single source of truth shared by cardSize() (which feeds
// dagre) and the SVG renderer (which places text at these offsets). HEADER_H
// covers the title + summary lines; each column/overflow/skip row is ROW_H tall.
export const CARD = {
ROW_H: 15,
HEADER_H: 36, // title + summary band; the divider sits at HEADER_H
TITLE_Y: 15, // title text baseline (within the header band)
SUMMARY_Y: 29, // summary text baseline (must stay < HEADER_H)
ROW_BASELINE: 11, // text baseline offset within each ROW_H column row
CHAR_W: 6.5, // monospace width estimate (mirrors dot-layout's CHAR_W intent)
PAD_X: 10,
BADGE_W: 26, // approx width of one role badge (PK/SK/PARTITION/SAMPLING)
MIN_W: 130,
MAX_COLS: 16,
};

// A ClickHouse UInt8 flag is 1/0, but JSON vs JSONStrings formats deliver it as
// a number or a string — treat both (and a real boolean) uniformly.
const isFlag = (v) => v === true || Number(v) === 1;

/** The key-role badges a column carries, in display order. */
export function columnRoles(col) {
const c = col || {};
const roles = [];
if (isFlag(c.is_in_primary_key)) roles.push('PK');
if (isFlag(c.is_in_sorting_key)) roles.push('SK');
if (isFlag(c.is_in_partition_key)) roles.push('PARTITION');
if (isFlag(c.is_in_sampling_key)) roles.push('SAMPLING');
return roles;
}

/**
* Build the display model for one node's card from its lineage row + columns +
* skip-indices. `node` carries `{ label, kind }`; `tableRow` is the system.tables
* row (engine/total_rows/total_bytes), `columns` the system.columns rows, and
* `skipIndices` the system.data_skipping_indices rows — any may be missing (an
* external/dictionary-source leaf has none), degrading to a header-only card.
*/
export function buildCardModel(node, tableRow, columns, skipIndices) {
const n = node || {};
const tr = tableRow || {};
const engine = tr.engine || n.kind || 'table';
const summary = engine + ' · ' + formatRows(tr.total_rows) + ' rows · ' + formatBytes(tr.total_bytes);
const allCols = columns || [];
const cols = allCols.slice(0, CARD.MAX_COLS).map((c) => ({
name: c.name, type: c.type, roles: columnRoles(c),
}));
const overflow = Math.max(0, allCols.length - CARD.MAX_COLS);
const idx = skipIndices || [];
const skipLine = idx.length
? 'idx: ' + idx.map((i) => i.name + ' (' + (i.type || '') + ')').join(', ')
: '';
return { title: n.label || n.id || '', kind: n.kind || 'table', summary, cols, overflow, skipLine };
}

/**
* The pixel size {w,h} of a card, computed purely from its model so dagre can lay
* it out. Height = header + one row per shown column (+ overflow + skip rows);
* width = the widest text line (monospace estimate) plus side padding, floored at
* MIN_W. `opts` overrides the CARD constants (used by tests).
*/
export function cardSize(model, opts = {}) {
const m = model || { title: '', summary: '', cols: [], overflow: 0, skipLine: '' };
const ROW_H = opts.rowH != null ? opts.rowH : CARD.ROW_H;
const HEADER_H = opts.headerH != null ? opts.headerH : CARD.HEADER_H;
const CHAR_W = opts.charW != null ? opts.charW : CARD.CHAR_W;
const PAD_X = opts.padX != null ? opts.padX : CARD.PAD_X;
const BADGE_W = opts.badgeW != null ? opts.badgeW : CARD.BADGE_W;
const rowCount = m.cols.length + (m.overflow ? 1 : 0) + (m.skipLine ? 1 : 0);
const h = HEADER_H + rowCount * ROW_H;
const textW = (str) => String(str).length * CHAR_W;
let maxLine = Math.max(textW(m.title), textW(m.summary));
for (const c of m.cols) {
maxLine = Math.max(maxLine, textW(c.name + ' ' + c.type) + c.roles.length * BADGE_W);
}
if (m.overflow) maxLine = Math.max(maxLine, textW('+' + m.overflow + ' more'));
if (m.skipLine) maxLine = Math.max(maxLine, textW(m.skipLine));
const w = Math.max(CARD.MIN_W, Math.round(maxLine + PAD_X * 2));
return { w, h };
}

/**
* Attach a `.card` model to every node of a lineage `graph` (from
* buildSchemaGraph), looking up each node's row/columns/skip-indices by `db.table`
* id. Pure: `data = { tables, columnsByKey, skipByKey }`. Returns a new graph
* `{ nodes, edges }` (edges passed through) for the rich renderer.
*/
export function buildCardGraph(graph, data) {
const g = graph || {};
const d = data || {};
const tablesByKey = new Map((d.tables || []).map((t) => [t.database + '.' + t.name, t]));
const colsByKey = d.columnsByKey || {};
const skipByKey = d.skipByKey || {};
const nodes = (g.nodes || []).map((n) => ({
...n,
card: buildCardModel(n, tablesByKey.get(n.id), colsByKey[n.id], skipByKey[n.id]),
}));
return { nodes, edges: g.edges || [] };
}
39 changes: 38 additions & 1 deletion src/net/ch-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,10 @@ export async function loadSchemaLineage(ctx, focus) {
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';
+ 'loading_dependencies_database, loading_dependencies_table, '
// Card metadata (ignored by the inline graph; used by the rich fullscreen cards).
+ 'toUInt64(ifNull(total_rows, 0)) AS total_rows, toUInt64(ifNull(total_bytes, 0)) AS total_bytes, '
+ 'partition_key, sorting_key, primary_key, sampling_key';
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 = ${sqlString(db)}`);
Expand All @@ -168,6 +171,40 @@ export async function loadColumns(ctx, db, table, sqlString) {
return (json.data || []).map((r) => ({ name: r.name, type: r.type, comment: r.comment || '' }));
}

/**
* Load the rich-card metadata (columns with key-role flags + skip indices) for a
* set of databases, keyed by `db.table`. Best-effort via tryQueryData: a missing
* system table or denied SELECT degrades to an empty map (cards then show just the
* engine/rows/bytes header — no badges/skip line), never a query error. Returns
* `{ columnsByKey, skipByKey }`.
*/
export async function loadSchemaCards(ctx, dbs) {
const columnsByKey = {};
const skipByKey = {};
const list = (dbs || []).map((d) => sqlString(d)).join(', ');
if (!list) return { columnsByKey, skipByKey };
// The two reads are independent — run them concurrently (one server round-trip
// of wall-clock instead of two).
const [colRows, idxRows] = await Promise.all([
tryQueryData(ctx,
'SELECT database, table, name, type, is_in_partition_key, is_in_sorting_key, '
+ 'is_in_primary_key, is_in_sampling_key, compression_codec, position '
+ 'FROM system.columns WHERE database IN (' + list + ') ORDER BY database, table, position FORMAT JSON'),
tryQueryData(ctx,
'SELECT database, table, name, type, expr FROM system.data_skipping_indices '
+ 'WHERE database IN (' + list + ') FORMAT JSON'),
]);
for (const r of colRows || []) {
const key = r.database + '.' + r.table;
(columnsByKey[key] = columnsByKey[key] || []).push(r);
}
for (const r of idxRows || []) {
const key = r.database + '.' + r.table;
(skipByKey[key] = skipByKey[key] || []).push(r);
}
return { columnsByKey, skipByKey };
}

// 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
19 changes: 19 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,25 @@ body {
.explain-graph .eg-edge--shard { stroke: #f97316; }
.explain-graph .eg-edge--buffer { stroke: #eab308; }
.explain-graph .eg-edge--merge { stroke: #64748b; }

/* -------- rich node cards (fullscreen schema graph) -------- */
/* The node rect keeps its kind colour (.eg-node--<kind>); these style the text
stacked on top: a bold title, a faint engine/rows/bytes header, a divider, and
one row per column with coloured key-role tags. */
.explain-graph .eg-card-title { fill: var(--fg); font-family: var(--mono); font-size: 11px; font-weight: 700; }
.explain-graph .eg-card-header { fill: var(--fg-faint); font-family: var(--mono); font-size: 9px; }
.explain-graph .eg-card-divider { stroke: var(--border); stroke-width: 1; }
.explain-graph .eg-col { font-family: var(--mono); font-size: 9px; }
.explain-graph .eg-col .eg-col-name { fill: var(--fg); }
.explain-graph .eg-col .eg-col-type { fill: var(--fg-faint); }
.explain-graph .eg-col-more { fill: var(--fg-faint); font-style: italic; }
.explain-graph .eg-skipidx { fill: #c297ff; font-family: var(--mono); font-size: 9px; }
.explain-graph .eg-badge { font-weight: 700; font-size: 8px; }
.explain-graph .eg-badge--pk { fill: #ff8f6b; }
.explain-graph .eg-badge--sk { fill: #6bb6ff; }
.explain-graph .eg-badge--partition { fill: #c297ff; }
.explain-graph .eg-badge--sampling { fill: #eab308; }

.schema-graph-view { position: relative; }
.schema-graph-legend {
position: absolute; top: 8px; left: 10px; pointer-events: none;
Expand Down
29 changes: 29 additions & 0 deletions src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ 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 { buildCardGraph } from '../core/schema-cards.js';
import { resolveTarget } from '../core/target.js';
import { toTSV, toCSV } from '../core/export.js';
import { newResult, applyStreamLine, parseErrorPos } from '../core/stream.js';
Expand All @@ -27,6 +28,7 @@ import { mountEditor, insertAtCursor, replaceEditor, SCHEMA_GRAPH_MIME } from '.
import { renderTabs, selectTab, newTab, closeTab, loadIntoNewTab } from './tabs.js';
import { renderSchema } from './schema.js';
import { renderResults } from './results.js';
import { openSchemaFullscreen } from './explain-graph.js';
import { renderSavedHistory } from './saved-history.js';
import { libraryControls, renderLibraryTitle } from './file-menu.js';
import { renderLogin } from './login.js';
Expand Down Expand Up @@ -534,6 +536,32 @@ export function createApp(env = {}) {
renderResults(app);
}

// Open the schema lineage fullscreen with RICH cards. Lazily fetches a separate
// enriched dataset (the inline pane stays compact and untouched): re-loads
// lineage + the per-table column / skip-index metadata (best-effort), attaches a
// card model to each node, then opens the overlay. Re-fetch (vs reusing the inline
// result) keeps the inline path's shape frozen and the card data off the hot path.
async function expandSchemaGraph(focus) {
if (!focus || !focus.db) return;
await ensureConfig();
if (!(await getToken())) { chCtx.onSignedOut(); return; }
let rows, cards;
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]),
]);
} 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 });
}

// Explain the current query without editing it: run it through the EXPLAIN
// views (the editor SQL is left untouched; run() wraps it as needed).
function explainQuery() { return run({ explain: true }); }
Expand Down Expand Up @@ -746,6 +774,7 @@ export function createApp(env = {}) {
explainQuery,
setExplainView,
showSchemaGraph,
expandSchemaGraph,
insertCreate,
openShortcuts: () => openShortcuts(app),
insertAtCursor: (text) => insertAtCursor(app, text),
Expand Down
Loading
Loading