From 49522199daac944daaa1f37ca54566b704a42624 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Fri, 26 Jun 2026 14:58:41 +0200 Subject: [PATCH] feat(schema): rich node cards on the fullscreen lineage graph (#47, PR1) Enrich the FULL-SCREEN schema-lineage graph with per-table cards while leaving the inline (compact) data-pane graph untouched. This is PR1 of issue #47 (parity with the system-audit generator); PR2 adds the transitive cross-DB walk + node detail pane. Implemented on the existing dagre + inline-SVG renderer rather than adopting the generator's Cytoscape stack, to stay within CLAUDE.md's two-runtime-dep budget and the 100%-per-file coverage model. - src/core/schema-cards.js (new, pure): buildCardModel / cardSize / columnRoles / buildCardGraph. Deterministic geometry (no DOM measurement) so dagre can size cards and happy-dom can test them. - src/net/ch-client.js: lineage query also selects total_rows/total_bytes and partition/sorting/primary/sampling keys; new loadSchemaCards() pulls system.columns (key-role flags) + system.data_skipping_indices via the tryQueryData best-effort seam (graceful on denial / old ClickHouse). - src/core/dot-layout.js: dagreLayout honors explicit node w/h (cards), else falls back to label-width + fixed height (pipeline/inline). - src/ui/explain-graph.js: rich card SVG (title, engine/rows/bytes header, divider, columns with PK/SK/PARTITION/SAMPLING badges, +N more, skip-index line); openSchemaFullscreen now draws cards. Shared graphSvgWithEdges scaffold across the plain + rich renderers. - src/ui/app.js + results.js: new expandSchemaGraph(focus) action loads the enriched dataset (lineage + cards in parallel) and opens the overlay; Expand button rewired to it, with a toast on load failure. - src/styles.css: card / badge / divider classes. Tests: new schema-cards.test.js + extended ch-client/dot-layout/explain-graph/ app/results specs. 906 tests pass; per-file coverage gate green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XmDqY6Bxf5b1fyQE8XwA2u --- src/core/dot-layout.js | 7 +- src/core/schema-cards.js | 107 +++++++++++++++++++++++++++++++ src/net/ch-client.js | 39 ++++++++++- src/styles.css | 19 ++++++ src/ui/app.js | 29 +++++++++ src/ui/explain-graph.js | 92 ++++++++++++++++++++++++-- src/ui/results.js | 6 +- tests/helpers/fake-app.js | 1 + tests/unit/app.test.js | 32 +++++++++ tests/unit/ch-client.test.js | 48 +++++++++++++- tests/unit/dot-layout.test.js | 12 ++++ tests/unit/explain-graph.test.js | 67 ++++++++++++++++++- tests/unit/results.test.js | 6 +- tests/unit/schema-cards.test.js | 99 ++++++++++++++++++++++++++++ 14 files changed, 547 insertions(+), 17 deletions(-) create mode 100644 src/core/schema-cards.js create mode 100644 tests/unit/schema-cards.test.js diff --git a/src/core/dot-layout.js b/src/core/dot-layout.js index 4616643..e8dd1c9 100644 --- a/src/core/dot-layout.js +++ b/src/core/dot-layout.js @@ -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); diff --git a/src/core/schema-cards.js b/src/core/schema-cards.js new file mode 100644 index 0000000..9b6751c --- /dev/null +++ b/src/core/schema-cards.js @@ -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 || [] }; +} diff --git a/src/net/ch-client.js b/src/net/ch-client.js index 844cef6..de82551 100644 --- a/src/net/ch-client.js +++ b/src/net/ch-client.js @@ -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)}`); @@ -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. diff --git a/src/styles.css b/src/styles.css index 38128d6..94689bf 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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--); 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; diff --git a/src/ui/app.js b/src/ui/app.js index 1cbe6e2..564ad64 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -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'; @@ -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'; @@ -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 }); } @@ -746,6 +774,7 @@ export function createApp(env = {}) { explainQuery, setExplainView, showSchemaGraph, + expandSchemaGraph, insertCreate, openShortcuts: () => openShortcuts(app), insertAtCursor: (text) => insertAtCursor(app, text), diff --git a/src/ui/explain-graph.js b/src/ui/explain-graph.js index 3726c4d..d900637 100644 --- a/src/ui/explain-graph.js +++ b/src/ui/explain-graph.js @@ -9,6 +9,7 @@ import { h, s } from './dom.js'; import { Icon } from './icons.js'; import { parseDot } from '../core/dot.js'; import { dagreLayout } from '../core/dot-layout.js'; +import { buildCardModel, cardSize, CARD } from '../core/schema-cards.js'; import { qualifyIdent } from '../core/format.js'; import { fitBox, zoomBox, panBox, viewBoxStr } from '../core/panzoom.js'; @@ -96,11 +97,13 @@ function attachPanZoom(container, svg, dims, opts = {}) { * Returns `{ svg, width, height, nodeCount }`. DOT-agnostic — reused by both the * pipeline graph (DOT) and the schema graph (system.* rows). */ -function renderGraphSvg(g, opts = {}) { - const nodeClass = opts.nodeClass || (() => 'eg-node'); - const edgeClass = opts.edgeClass || (() => 'eg-edge'); +// Build the shell + arrowhead + routed edges (with optional +// mid-edge labels). Node drawing is the caller's job — plain labelled boxes +// (renderGraphSvg) or rich cards (renderRichGraphSvg) — so the edge/marker code +// lives in one place. Empty-graph safe: returns a bare with no defs. +function graphSvgWithEdges(g, edgeClass, edgeLabel) { const svg = s('svg', { class: 'explain-graph', viewBox: `0 0 ${g.width} ${g.height}` }); - if (!g.nodes.length) return { svg, width: g.width, height: g.height, nodeCount: 0 }; + if (!g.nodes.length) return svg; svg.appendChild(s('defs', null, s('marker', { id: 'eg-arrow', viewBox: '0 0 10 10', refX: '9', refY: '5', @@ -109,12 +112,19 @@ function renderGraphSvg(g, opts = {}) { for (const e of g.edges) { const d = 'M' + e.points.map((p) => p.x + ' ' + p.y).join(' L'); svg.appendChild(s('path', { class: edgeClass(e), d, 'marker-end': 'url(#eg-arrow)' })); - const lbl = opts.edgeLabel && opts.edgeLabel(e); + const lbl = edgeLabel && edgeLabel(e); if (lbl) { const mid = e.points[Math.floor(e.points.length / 2)]; svg.appendChild(s('text', { class: 'eg-edge-label', x: mid.x, y: mid.y - 3, 'text-anchor': 'middle' }, lbl)); } } + return svg; +} + +function renderGraphSvg(g, opts = {}) { + const nodeClass = opts.nodeClass || (() => 'eg-node'); + const svg = graphSvgWithEdges(g, opts.edgeClass || (() => 'eg-edge'), opts.edgeLabel); + if (!g.nodes.length) return { svg, width: g.width, height: g.height, nodeCount: 0 }; for (const n of g.nodes) { const rect = s('rect', { class: nodeClass(n), x: n.x, y: n.y, width: n.w, height: n.h, rx: '4' }); const text = s('text', { @@ -146,6 +156,74 @@ export function buildSchemaSvg(graph, dagre, onNode) { }); } +// Draw one node as a rich card: a kind-coloured background rect with a title + +// engine/rows/bytes summary header, then a row per column (with key-role badges), +// an overflow row, and a skip-index row — all placed at the deterministic offsets +// cardSize() used to size the node, so no DOM measurement is needed. `model` is +// always supplied by renderRichGraphSvg (a header-only model for a card-less node). +function renderCardNode(n, model, nodeClass, onNode) { + const g = s('g', { class: 'eg-card' }); + const rect = s('rect', { class: nodeClass(n), x: n.x, y: n.y, width: n.w, height: n.h, rx: '5' }); + g.appendChild(rect); + const left = n.x + CARD.PAD_X; + g.appendChild(s('text', { class: 'eg-card-title', x: left, y: n.y + CARD.TITLE_Y }, model.title)); + g.appendChild(s('text', { class: 'eg-card-header', x: left, y: n.y + CARD.SUMMARY_Y }, model.summary)); + const divY = n.y + CARD.HEADER_H; + g.appendChild(s('line', { class: 'eg-card-divider', x1: n.x, y1: divY, x2: n.x + n.w, y2: divY })); + let row = 0; + const rowY = () => divY + row * CARD.ROW_H + CARD.ROW_BASELINE; + for (const c of model.cols) { + const t = s('text', { class: 'eg-col', x: left, y: rowY() }, + s('tspan', { class: 'eg-col-name' }, c.name), + s('tspan', { class: 'eg-col-type', dx: '6' }, c.type)); + for (const role of c.roles) t.appendChild(s('tspan', { class: 'eg-badge eg-badge--' + role.toLowerCase(), dx: '6' }, role)); + g.appendChild(t); + row++; + } + if (model.overflow) { g.appendChild(s('text', { class: 'eg-col eg-col-more', x: left, y: rowY() }, '+' + model.overflow + ' more')); row++; } + if (model.skipLine) g.appendChild(s('text', { class: 'eg-skipidx', x: left, y: rowY() }, model.skipLine)); + if (onNode) { + rect.setAttribute('cursor', 'pointer'); + g.addEventListener('click', (e) => { e.stopPropagation(); onNode(n); }); + } + return g; +} + +// Like renderGraphSvg, but draws each node as a rich card (looked up by id in +// `opts.cardById`) instead of a single labelled box, reusing the same edge/marker +// scaffold. `opts` always carries cardById/nodeClass/edgeClass/edgeLabel (onNode optional). +function renderRichGraphSvg(g, opts) { + const svg = graphSvgWithEdges(g, opts.edgeClass, opts.edgeLabel); + if (!g.nodes.length) return { svg, width: g.width, height: g.height, nodeCount: 0 }; + for (const n of g.nodes) svg.appendChild(renderCardNode(n, opts.cardById.get(n.id), opts.nodeClass, opts.onNode)); + return { svg, width: g.width, height: g.height, nodeCount: g.nodes.length }; +} + +/** + * Build the rich schema-lineage SVG: size each node from its `.card` model (the + * model is attached by buildCardGraph; a node without one degrades to a header-only + * card), lay out with dagre (honoring the card w/h), then draw cards. Used by the + * fullscreen overlay; the inline pane keeps the compact buildSchemaSvg. + */ +export function buildRichSchemaSvg(graph, dagre, onNode) { + const g = graph || { nodes: [], edges: [] }; + const cardById = new Map(); + const sized = (g.nodes || []).map((n) => { + const model = n.card || buildCardModel(n); + cardById.set(n.id, model); + const { w, h } = cardSize(model); + return { ...n, w, h }; + }); + const laid = dagreLayout(dagre, { nodes: sized, edges: g.edges || [] }); + return renderRichGraphSvg(laid, { + cardById, + nodeClass: (n) => 'eg-node eg-node--' + (n.kind || 'table'), + edgeClass: (e) => 'eg-edge eg-edge--' + (e.kind || 'feeds'), + edgeLabel: (e) => e.kind, + onNode, + }); +} + /** * Render `r.rawText` as the inline pipeline graph: fitted to the pane, with the * shared drag/wheel pan-zoom. Falls back to a placeholder when the DOT has no @@ -220,9 +298,9 @@ const schemaClick = (app) => (n) => { app.actions.insertCreate(qualifyIdent(n.db, n.name)); }; -/** Fullscreen schema-lineage graph. */ +/** Fullscreen schema-lineage graph — rich cards (engine/rows/bytes + columns). */ export function openSchemaFullscreen(app, graph) { - return openGraphFullscreen(app, 'Schema', () => buildSchemaSvg(graph, app && app.Dagre, schemaClick(app)), schemaLegend(), schemaEmptyMessage(graph)); + return openGraphFullscreen(app, 'Schema', () => buildRichSchemaSvg(graph, app && app.Dagre, schemaClick(app)), schemaLegend(), schemaEmptyMessage(graph)); } /** diff --git a/src/ui/results.js b/src/ui/results.js index 507b969..4eee976 100644 --- a/src/ui/results.js +++ b/src/ui/results.js @@ -9,7 +9,7 @@ import { looksLikeHtml, prettyValue } from '../core/cell.js'; import { sortRows } from '../core/sort.js'; import { autoChart, schemaKey, chartFieldOptions, chartColors, chartJsConfig, chartCfgValid, normalizeChartCfg, unzoomChartEvent, CHART_ROW_CAP } from '../core/chart-data.js'; import { EXPLAIN_VIEWS } from '../core/explain.js'; -import { renderExplainGraph, openPipelineFullscreen, renderSchemaGraph, openSchemaFullscreen } from './explain-graph.js'; +import { renderExplainGraph, openPipelineFullscreen, renderSchemaGraph } from './explain-graph.js'; // View id → tab glyph for the EXPLAIN view strip (kept here so core/explain.js // stays DOM-free). Pipeline reuses the node-graph share glyph. @@ -161,8 +161,8 @@ function buildToolbar(app, r) { // 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), + class: 'res-act', title: 'Open the graph fullscreen with rich cards (pan & zoom)', + onclick: () => app.actions.expandSchemaGraph(r.schemaGraph.focus), }, Icon.expand(), h('span', null, 'Expand'))); } return toolbar; diff --git a/tests/helpers/fake-app.js b/tests/helpers/fake-app.js index df86592..b6ca40c 100644 --- a/tests/helpers/fake-app.js +++ b/tests/helpers/fake-app.js @@ -74,6 +74,7 @@ export function makeApp(over = {}) { explainQuery: vi.fn(), setExplainView: vi.fn(), showSchemaGraph: vi.fn(), + expandSchemaGraph: vi.fn(), insertCreate: vi.fn(), openShortcuts: vi.fn(), insertAtCursor: vi.fn(), diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index e4917d6..fdfffc4 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -1202,6 +1202,38 @@ describe('schema lineage graph (drag a db/table onto the results pane)', () => { await app.actions.showSchemaGraph({ kind: 'db', db: 'lin' }); expect(app.activeTab().result.error).toContain('nope'); }); + + it('expandSchemaGraph loads the enriched dataset and opens a rich-card fullscreen overlay', async () => { + const routes = [ + ...lineageRoutes, + [(u, sql) => /system\.columns/.test(sql), resp({ json: { data: [ + { database: 'lin', table: 'events', name: 'id', type: 'UInt64', is_in_primary_key: 1, position: 1 }, + ] } })], + [(u, sql) => /data_skipping_indices/.test(sql), resp({ json: { data: [] } })], + ]; + const { app } = appForRun(routes); + await app.actions.expandSchemaGraph({ kind: 'db', db: 'lin' }); + const overlay = document.body.querySelector('.graph-overlay'); + expect(overlay).not.toBeNull(); + expect(overlay.querySelector('g.eg-card')).not.toBeNull(); + expect(overlay.querySelector('text.eg-card-header').textContent).toMatch(/rows/); + overlay.remove(); + }); + + it('expandSchemaGraph guards: no db, signed-out, and a lineage failure open no overlay', async () => { + // no focus.db → early return + const { app } = appForRun(lineageRoutes); + await app.actions.expandSchemaGraph({ kind: 'db' }); + expect(document.body.querySelector('.graph-overlay')).toBeNull(); + // signed out (empty session → null token) → onSignedOut + return + const { app: app2 } = appForRun(lineageRoutes, { sessionStorage: memSession({}) }); + await app2.actions.expandSchemaGraph({ kind: 'db', db: 'lin' }); + expect(document.body.querySelector('.graph-overlay')).toBeNull(); + // lineage load fails → caught, no overlay (the inline graph would still be on screen) + const { app: app3 } = appForRun([[(u, sql) => /system\.tables/.test(sql), resp({ ok: false, status: 500, text: 'boom' })]]); + await app3.actions.expandSchemaGraph({ kind: 'db', db: 'lin' }); + expect(document.body.querySelector('.graph-overlay')).toBeNull(); + }); }); describe('schema graph drop edge cases', () => { diff --git a/tests/unit/ch-client.test.js b/tests/unit/ch-client.test.js index 09c331c..5e1c1ca 100644 --- a/tests/unit/ch-client.test.js +++ b/tests/unit/ch-client.test.js @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { - chUrl, authedFetch, queryJson, loadServerVersion, loadSchema, loadColumns, loadReferenceData, loadEntityDoc, runQuery, killQuery, loadSchemaLineage, + chUrl, authedFetch, queryJson, loadServerVersion, loadSchema, loadColumns, loadReferenceData, loadEntityDoc, runQuery, killQuery, loadSchemaLineage, loadSchemaCards, } from '../../src/net/ch-client.js'; import { sqlString } from '../../src/core/format.js'; @@ -359,4 +359,50 @@ describe('loadSchemaLineage', () => { const out = await loadSchemaLineage(ctx, { kind: 'db', db: 'lin' }); expect(out.tables[0].astTables).toBeUndefined(); }); + it('includes the card metadata columns in the scoped tables query', async () => { + const seen = []; + const ctx = ctxWith((url, init) => { + seen.push(init.body); + if (/system\.dictionaries/.test(init.body)) return jsonResp({ data: [] }); + return jsonResp({ data: [{ database: 'lin', name: 't', engine: 'MergeTree', as_select: '' }] }); + }); + await loadSchemaLineage(ctx, { kind: 'db', db: 'lin' }); + const tablesSql = seen.find((s) => /FROM system\.tables/.test(s)); + expect(tablesSql).toMatch(/total_rows/); + expect(tablesSql).toMatch(/total_bytes/); + expect(tablesSql).toMatch(/partition_key/); + expect(tablesSql).toMatch(/sampling_key/); + }); +}); + +describe('loadSchemaCards', () => { + it('keys columns + skip indices by db.table and scopes via IN (…)', async () => { + const seen = []; + const ctx = ctxWith((url, init) => { + const sql = init.body; seen.push(sql); + if (/system\.data_skipping_indices/.test(sql)) { + return jsonResp({ data: [{ database: 'lin', table: 'events', name: 'idx_d', type: 'minmax', expr: 'd' }] }); + } + return jsonResp({ data: [ + { database: 'lin', table: 'events', name: 'id', type: 'UInt64', is_in_primary_key: 1, position: 1 }, + { database: 'lin', table: 'events', name: 'd', type: 'Date', is_in_partition_key: 1, position: 2 }, + { database: 'lin', table: 'other', name: 'x', type: 'String', position: 1 }, + ] }); + }); + const out = await loadSchemaCards(ctx, ['lin']); + expect(out.columnsByKey['lin.events']).toHaveLength(2); + expect(out.columnsByKey['lin.other']).toHaveLength(1); + expect(out.skipByKey['lin.events']).toEqual([{ database: 'lin', table: 'events', name: 'idx_d', type: 'minmax', expr: 'd' }]); + expect(seen.some((s) => /system\.columns/.test(s) && /database IN \('lin'\)/.test(s))).toBe(true); + expect(seen.some((s) => /data_skipping_indices/.test(s) && /database IN \('lin'\)/.test(s))).toBe(true); + }); + it('degrades to empty maps when the system tables are denied (no throw)', async () => { + const ctx = ctxWith(() => jsonResp('Code: 497 ACCESS_DENIED', false, 500)); + expect(await loadSchemaCards(ctx, ['lin', 'other'])).toEqual({ columnsByKey: {}, skipByKey: {} }); + }); + it('issues no query for an empty database list', async () => { + const ctx = ctxWith(() => { throw new Error('should not fetch'); }); + expect(await loadSchemaCards(ctx, [])).toEqual({ columnsByKey: {}, skipByKey: {} }); + expect(ctx.fetch).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/dot-layout.test.js b/tests/unit/dot-layout.test.js index 0cd6a2e..c938d02 100644 --- a/tests/unit/dot-layout.test.js +++ b/tests/unit/dot-layout.test.js @@ -52,6 +52,18 @@ describe('dagreLayout', () => { expect(by.t.y).toBeGreaterThan(by.a.y); }); + it('honors an explicit node w/h, else falls back to label width + fixed height', () => { + const g = dagreLayout(dagre, { + nodes: [{ id: 'card', label: 'x', w: 240, h: 120 }, { id: 'plain', label: 'plain' }], + edges: [{ from: 'card', to: 'plain' }], + }); + const by = Object.fromEntries(g.nodes.map((n) => [n.id, n])); + expect(by.card.w).toBe(240); // explicit size honored + expect(by.card.h).toBe(120); + expect(by.plain.w).toBe(nodeWidth('plain')); // no w → label-based width + expect(by.plain.h).toBe(30); // no h → NODE_H + }); + it('drops self-loops and edges to undeclared nodes before layout', () => { const g = dagreLayout(dagre, { nodes: [{ id: 'a', label: 'a' }, { id: 'b', label: 'b' }], diff --git a/tests/unit/explain-graph.test.js b/tests/unit/explain-graph.test.js index ffd3e4c..41c2f66 100644 --- a/tests/unit/explain-graph.test.js +++ b/tests/unit/explain-graph.test.js @@ -1,6 +1,6 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import dagre from '@dagrejs/dagre'; -import { renderExplainGraph, openPipelineFullscreen, renderSchemaGraph, openSchemaFullscreen } from '../../src/ui/explain-graph.js'; +import { renderExplainGraph, openPipelineFullscreen, renderSchemaGraph, openSchemaFullscreen, buildRichSchemaSvg } from '../../src/ui/explain-graph.js'; const APP = { document, Dagre: dagre }; // app stub carrying the dagre layout seam @@ -246,3 +246,68 @@ describe('schema lineage graph', () => { expect(document.body.contains(overlay)).toBe(false); }); }); + +describe('buildRichSchemaSvg (rich cards)', () => { + const RICH = { + nodes: [ + { + id: 'lin.a', label: 'a', kind: 'table', db: 'lin', name: 'a', + card: { + title: 'lin.a', kind: 'table', summary: 'MergeTree · 5 rows · 0 B', + cols: [{ name: 'id', type: 'UInt64', roles: ['PK', 'SK'] }, { name: 'd', type: 'Date', roles: [] }], + overflow: 2, skipLine: 'idx: i (minmax)', + }, + }, + { id: 'lin.mv', label: 'mv', kind: 'mv', db: 'lin', name: 'mv' }, // no .card → header-only fallback + { id: 'lin.dst', label: 'dst', kind: 'table', db: 'lin', name: 'dst' }, + ], + edges: [ + { from: 'lin.a', to: 'lin.mv', kind: 'feeds' }, + { from: 'lin.mv', to: 'lin.dst', kind: '' }, // empty kind → no edge label drawn + ], + }; + + it('draws a card group per node with title, summary, divider, columns + role badges, overflow and skip rows', () => { + const built = buildRichSchemaSvg(RICH, dagre); + expect(built.nodeCount).toBe(3); + const svg = built.svg; + expect(svg.querySelectorAll('g.eg-card')).toHaveLength(3); + expect(svg.querySelector('rect.eg-node--table')).not.toBeNull(); + expect(svg.querySelector('rect.eg-node--mv')).not.toBeNull(); + expect([...svg.querySelectorAll('text.eg-card-title')].map((t) => t.textContent)).toContain('lin.a'); + expect([...svg.querySelectorAll('text.eg-card-header')].map((t) => t.textContent)).toContain('MergeTree · 5 rows · 0 B'); + expect(svg.querySelector('line.eg-card-divider')).not.toBeNull(); + expect(svg.querySelectorAll('text.eg-col').length).toBeGreaterThanOrEqual(2); + expect(svg.querySelector('tspan.eg-badge--pk')).not.toBeNull(); + expect(svg.querySelector('tspan.eg-badge--sk')).not.toBeNull(); + expect([...svg.querySelectorAll('text.eg-col-more')].map((t) => t.textContent)).toContain('+2 more'); + expect(svg.querySelector('text.eg-skipidx').textContent).toBe('idx: i (minmax)'); + // only the labelled edge draws a mid-edge label; the empty-kind edge draws none + expect([...svg.querySelectorAll('text.eg-edge-label')].map((t) => t.textContent)).toEqual(['feeds']); + }); + + it('falls back to a header-only card for a node without a .card model', () => { + const built = buildRichSchemaSvg(RICH, dagre); + const titles = [...built.svg.querySelectorAll('text.eg-card-title')].map((t) => t.textContent); + expect(titles).toContain('mv'); // buildCardModel(node) → label + const headers = [...built.svg.querySelectorAll('text.eg-card-header')].map((t) => t.textContent); + expect(headers).toContain('mv · — rows · —'); // engine falls back to kind, no row/byte data + }); + + it('fires onNode with the clicked node (which carries db/name for SHOW CREATE)', () => { + const onNode = vi.fn(); + const built = buildRichSchemaSvg(RICH, dagre, onNode); + built.svg.querySelector('g.eg-card').dispatchEvent(new Event('click', { bubbles: true })); + expect(onNode).toHaveBeenCalledTimes(1); + const arg = onNode.mock.calls[0][0]; + expect(arg).toMatchObject({ db: 'lin' }); + expect(typeof arg.id).toBe('string'); + }); + + it('returns an empty result (no card groups) for an empty or missing graph', () => { + expect(buildRichSchemaSvg({ nodes: [], edges: [] }, dagre).nodeCount).toBe(0); + const built = buildRichSchemaSvg(null, dagre); + expect(built.nodeCount).toBe(0); + expect(built.svg.querySelectorAll('g.eg-card')).toHaveLength(0); + }); +}); diff --git a/tests/unit/results.test.js b/tests/unit/results.test.js index fe765dc..2414a2a 100644 --- a/tests/unit/results.test.js +++ b/tests/unit/results.test.js @@ -584,9 +584,9 @@ describe('schema lineage result', () => { const expand = [...region.querySelectorAll('.res-act')].find((b) => /Expand/.test(b.textContent)); expect(expand).toBeTruthy(); click(expand); - const overlay = document.body.querySelector('.graph-overlay'); - expect(overlay).not.toBeNull(); - overlay.dispatchEvent(new Event('click', { bubbles: true })); // backdrop close + cleanup + // Expand now fires the async action that lazily loads the rich-card dataset and + // opens the overlay (the overlay itself is covered in explain-graph.test.js). + expect(app.actions.expandSchemaGraph).toHaveBeenCalledWith({ kind: 'db', db: 'lin' }); }); it('titles a table-focus graph with the qualified name', () => { const r = graphResult(); diff --git a/tests/unit/schema-cards.test.js b/tests/unit/schema-cards.test.js new file mode 100644 index 0000000..5c88363 --- /dev/null +++ b/tests/unit/schema-cards.test.js @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest'; +import { buildCardModel, cardSize, columnRoles, buildCardGraph, CARD } from '../../src/core/schema-cards.js'; + +describe('columnRoles', () => { + it('maps every key-role flag, in display order', () => { + expect(columnRoles({ + is_in_primary_key: 1, is_in_sorting_key: 1, is_in_partition_key: 1, is_in_sampling_key: 1, + })).toEqual(['PK', 'SK', 'PARTITION', 'SAMPLING']); + }); + it('returns [] when no flag is set', () => { + expect(columnRoles({ name: 'x' })).toEqual([]); + expect(columnRoles()).toEqual([]); + }); + it('treats string "1" and boolean true as set (JSON vs JSONStrings formats)', () => { + expect(columnRoles({ is_in_sorting_key: '1' })).toEqual(['SK']); + expect(columnRoles({ is_in_primary_key: true })).toEqual(['PK']); + expect(columnRoles({ is_in_partition_key: '0' })).toEqual([]); // string zero → not set + }); +}); + +describe('buildCardModel', () => { + it('builds the engine/rows/bytes summary, top-16 columns + overflow, and a skip line', () => { + const cols = Array.from({ length: 17 }, (_, i) => ({ name: 'c' + i, type: 'UInt64', position: i })); + cols[0].is_in_primary_key = 1; + const m = buildCardModel( + { label: 'db.t', kind: 'mv' }, + { engine: 'MaterializedView', total_rows: 1500000, total_bytes: 2048 }, + cols, + [{ name: 'idx_a', type: 'minmax' }], + ); + expect(m.title).toBe('db.t'); + expect(m.kind).toBe('mv'); + expect(m.summary).toBe('MaterializedView · 1.5M rows · 2.0 KB'); + expect(m.cols).toHaveLength(CARD.MAX_COLS); + expect(m.cols[0]).toEqual({ name: 'c0', type: 'UInt64', roles: ['PK'] }); + expect(m.overflow).toBe(1); + expect(m.skipLine).toBe('idx: idx_a (minmax)'); + }); + it('degrades to a header-only card for a leaf with no row/columns/indices', () => { + const leaf = buildCardModel({ id: 'ext:mysql', label: 'mysql', kind: 'external' }); + expect(leaf.summary).toBe('external · — rows · —'); // engine falls back to kind + expect(leaf.cols).toEqual([]); + expect(leaf.overflow).toBe(0); + expect(leaf.skipLine).toBe(''); + }); + it('falls back through label → id → "" for the title, and kind → "table" for the engine', () => { + expect(buildCardModel({ label: 'a.b' }).title).toBe('a.b'); + expect(buildCardModel({ id: 'a.b' }).title).toBe('a.b'); // no label → id + expect(buildCardModel(null).title).toBe(''); // no node at all + expect(buildCardModel(null).summary).toBe('table · — rows · —'); // kind → 'table' + }); +}); + +describe('cardSize', () => { + it('height = header + one row per shown column / overflow / skip row', () => { + const m = { title: 't', summary: 's', cols: [{ name: 'a', type: 'Int', roles: [] }], overflow: 3, skipLine: 'idx: x (set)' }; + expect(cardSize(m, { rowH: 10, headerH: 20 }).h).toBe(20 + 3 * 10); // 1 col + overflow + skip = 3 rows + }); + it('defaults to the CARD constants, and a tiny / empty model floors to MIN_W', () => { + expect(cardSize().h).toBe(CARD.HEADER_H); // no model → no rows + expect(cardSize().w).toBe(CARD.MIN_W); + expect(cardSize({ title: '', summary: '', cols: [], overflow: 0, skipLine: '' }).w).toBe(CARD.MIN_W); + }); + it('grows with the widest line and counts role badges into the width', () => { + const long = (roles) => ({ title: 't', summary: 's', cols: [{ name: 'x'.repeat(40), type: 'String', roles }], overflow: 0, skipLine: '' }); + expect(cardSize(long([])).w).toBeGreaterThan(CARD.MIN_W); + expect(cardSize(long(['PK', 'SK'])).w).toBeGreaterThan(cardSize(long([])).w); // badges add width + }); + it('honors a wide overflow / skip line in the width', () => { + const m = { title: 't', summary: 's', cols: [], overflow: 999, skipLine: 'idx: ' + 'z'.repeat(60) + ' (minmax)' }; + expect(cardSize(m).w).toBeGreaterThan(CARD.MIN_W); + }); +}); + +describe('buildCardGraph', () => { + it('attaches a card to each node, looking row/columns up by db.table id', () => { + const graph = { + nodes: [{ id: 'lin.a', label: 'a', kind: 'table' }, { id: 'lin.x', label: 'x', kind: 'view' }], + edges: [{ from: 'lin.a', to: 'lin.x', kind: 'feeds' }], + }; + const data = { + tables: [{ database: 'lin', name: 'a', engine: 'MergeTree', total_rows: 5, total_bytes: 0 }], + columnsByKey: { 'lin.a': [{ name: 'id', type: 'UInt64', is_in_primary_key: 1, position: 1 }] }, + skipByKey: {}, + }; + const out = buildCardGraph(graph, data); + expect(out.nodes[0].card.summary).toMatch(/^MergeTree/); + expect(out.nodes[0].card.cols[0].roles).toEqual(['PK']); + // 'lin.x' has no matching table row / columns → header-only card via kind fallback + expect(out.nodes[1].card.summary).toBe('view · — rows · —'); + expect(out.edges).toEqual(graph.edges); + }); + it('tolerates a null graph and a missing data bag', () => { + expect(buildCardGraph(null)).toEqual({ nodes: [], edges: [] }); + const out = buildCardGraph({ nodes: [{ id: 'a.b', label: 'b', kind: 'table' }] }); + expect(out.edges).toEqual([]); + expect(out.nodes[0].card.summary).toBe('table · — rows · —'); + }); +});