diff --git a/README.md b/README.md index 6f7ab9d..3899d5f 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ editor library — it adds nothing to the single served file). On top of that: onto the editor: a schema identifier drops as text at the caret, and a saved/history query drops as a `( … )` subquery at the drop point (its trailing `FORMAT`/`;` stripped). Undoable; click-to-load still works for keyboard users. + Dragging a **database or table onto the results pane** instead renders a + [schema lineage graph](#schema-lineage-graph). **The keystroke rule:** none of this runs SQL while you type. Reference data — the server's keyword and function lists — is fetched **once per connection** @@ -99,6 +101,30 @@ tab (e.g. `EXPLAIN ESTIMATE …` opens **Estimate**); anything else opens the verbatim **Explain** tab. An explicit `… FORMAT ` on an EXPLAIN bypasses the views and shows ClickHouse's raw response. +## Schema lineage graph + +Drag a **database** or **table** row from the schema sidebar onto the results pane +to see how its ClickHouse objects relate — not generic foreign keys, but the +engine-specific lineage: materialized views (`feeds` from sources, `writes` to the +target), regular views (`reads` their sources), dictionaries (`dict` from a source +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). + +Discovery is **structured-first, parse-fallback**, because the helpful +`system.tables` columns are build-dependent: it prefers `dependencies_table` / +`loading_dependencies_*` / `system.dictionaries.source` when populated, and +otherwise lets ClickHouse parse the SQL via **`EXPLAIN AST`** (for query sources) +plus light regex on `create_table_query` (`TO` target) and `engine_full` +(Distributed/Buffer/Merge args). This keeps it working on older deployed builds +(e.g. Altinity-antalya 26.3, where `target_*` is absent and `dependencies_*` can be +empty). Graph math is pure in `src/core/schema-graph.js` (100%-covered); the SVG is +the same dagre-laid-out renderer the pipeline graph uses. + ## Saved queries & the Library Queries you save (★ **Save** next to Run, or `⌘S`) land in the sidebar **★ Library** diff --git a/src/core/dot-layout.js b/src/core/dot-layout.js index dd77269..63804d4 100644 --- a/src/core/dot-layout.js +++ b/src/core/dot-layout.js @@ -41,10 +41,11 @@ export function dagreLayout(dagre, graph) { const outNodes = nodes.map((n) => { const dn = g.node(n.id); - return { id: n.id, label: n.label, x: dn.x - dn.width / 2, y: dn.y - dn.height / 2, w: dn.width, h: dn.height }; + // `kind` (node) / `label` (edge) pass through for the schema graph's colouring. + return { id: n.id, label: n.label, kind: n.kind, 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, + from: e.from, to: e.to, kind: e.kind, label: e.label, points: g.edge(e.from, e.to).points.map((p) => ({ x: p.x, y: p.y })), })); const gg = g.graph(); diff --git a/src/core/schema-graph.js b/src/core/schema-graph.js new file mode 100644 index 0000000..2a9a4ec --- /dev/null +++ b/src/core/schema-graph.js @@ -0,0 +1,204 @@ +// Pure assembly of a ClickHouse object-lineage graph from system.* rows. No DOM, +// no globals, no fetch — the queries live in src/net/ch-client.js (loadSchemaLineage) +// and the SVG drawing in src/ui (reusing the dagre graph renderer). Mirrors the +// load→assemble pattern of src/core/completions.js. +// +// Discovery is structured-first, parse-fallback (see the plan): structured columns +// (dependencies_table, loading_dependencies_*, dictionaries.source) when populated, +// else parse — EXPLAIN AST `TableIdentifier`s for query sources (attached as +// row.astTables by the loader), create_table_query `TO`/`.inner` for the MV target, +// engine_full for Distributed/Buffer/Merge. All best-effort: a miss yields a node +// with no edge, never a throw. + +/** Map a ClickHouse engine name to a node kind. */ +export function objectKind(engine) { + const e = String(engine || ''); + if (e === 'MaterializedView') return 'mv'; + if (e === 'View' || e === 'LiveView' || e === 'WindowView') return 'view'; + if (e === 'Dictionary') return 'dictionary'; + if (e === 'Distributed') return 'distributed'; + if (e === 'Buffer') return 'buffer'; + if (e === 'Merge') return 'merge'; + return 'table'; +} + +/** Table names from `EXPLAIN AST` text — the `TableIdentifier (alias …)` lines. */ +export function parseAstTables(astText) { + const out = []; + const re = /^\s*TableIdentifier\s+([^\s(]+)/gm; + let m; + while ((m = re.exec(String(astText || '')))) out.push(m[1]); + return out; +} + +/** The explicit `TO db.table` target of a materialized view, or null. */ +export function parseMvTarget(createTableQuery) { + const s = String(createTableQuery || ''); + const head = s.split(/\sAS\s+SELECT/i)[0]; // only look before the SELECT body + const m = /\sTO\s+([A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*)?)/.exec(head); + return m ? m[1] : null; +} + +/** A dictionary's source as `{ db, table }` (ClickHouse source) or `{ external }`. */ +export function parseDictSource(source, createTableQuery) { + const src = String(source || ''); + let m = /^ClickHouse:\s*([\w]+)\.([\w]+)/i.exec(src); + if (m) return { db: m[1], table: m[2] }; + // pre-load `source` can be empty — fall back to the CREATE's SOURCE(CLICKHOUSE(…)). + const cq = String(createTableQuery || ''); + if (/SOURCE\s*\(\s*CLICKHOUSE/i.test(cq)) { + const t = /\bTABLE\s+'([^']+)'/i.exec(cq); + const d = /\bDB\s+'([^']+)'/i.exec(cq); + if (t) return { db: d ? d[1] : null, table: t[1] }; + } + if (src) return { external: src.split(':')[0].trim() }; + return null; +} + +/** Engine-arg reference for Distributed/Buffer/Merge from `engine_full`. */ +export function parseEngineRef(engine, engineFull) { + const s = String(engineFull || ''); + if (engine === 'Distributed') { + const m = /Distributed\(\s*'([^']*)'\s*,\s*'([^']*)'\s*,\s*'([^']*)'/.exec(s); + if (m) return { kind: 'distributed', cluster: m[1], db: m[2], table: m[3] }; + } else if (engine === 'Buffer') { + const m = /Buffer\(\s*'([^']*)'\s*,\s*'([^']*)'/.exec(s); + if (m) return { kind: 'buffer', db: m[1], table: m[2] }; + } else if (engine === 'Merge') { + const m = /Merge\(\s*'([^']*)'\s*,\s*'([^']*)'/.exec(s); + if (m) return { kind: 'merge', db: m[1], regex: m[2] }; + } + return null; +} + +// A *reference* may already be `db.table` or a bare `table`; an actual row's id is +// always `database.name` (table names like `.inner_id.` contain dots). +const qualify = (db, name) => (name && name.includes('.') ? name : db + '.' + name); +const rowId = (r) => r.database + '.' + r.name; + +/** + * Build `{ nodes:[{id,label,kind}], edges:[{from,to,kind}] }` from system.* rows. + * `rows = { tables:[…], dictionaries:[…] }`; each table row may carry `astTables` + * (EXPLAIN AST sources). `focus = { kind:'db'|'table', db, table? }` scopes the + * result (table focus → the table + its 1-hop neighbours). + */ +export function buildSchemaGraph(rows, focus) { + const tables = (rows && rows.tables) || []; + const dicts = (rows && rows.dictionaries) || []; + const nodes = new Map(); + 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) }); + } + return nodes.get(id); + }; + // external (non-CH dictionary source) leaf + const external = (label) => { + const id = 'ext:' + label; + if (!nodes.has(id)) nodes.set(id, { id, label, kind: 'external', db: '', name: label }); + return id; + }; + + for (const t of tables) { + const id = rowId(t); + byId.set(id, t); + if (/^\.inner/.test(t.name)) { + const uuid = t.name.replace(/^\.inner(_id)?\./, ''); + innerByUuid.set(uuid, id); + } + node(id, objectKind(t.engine)); + } + // friendlier labels for inner storage tables + for (const [uuid, id] of innerByUuid) { + const n = nodes.get(id); + if (n) n.label = '·inner'; + void uuid; + } + + const edges = []; + const seen = new Set(); + const addEdge = (from, to, kind) => { + if (!from || !to || from === to) return; + if (!nodes.has(from) || !nodes.has(to)) return; // both endpoints must be real nodes + const k = JSON.stringify([from, to, kind]); + if (seen.has(k)) return; + seen.add(k); + edges.push({ from, to, kind }); + }; + const zip = (dbs, names) => (names || []).map((nm, i) => qualify((dbs && dbs[i]) || '', 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'); + } + // fallback: EXPLAIN AST sources of a view/MV → source → this object. Only real + // (in-scope) objects count, so CTE/alias names from the AST are dropped. + if ((kind === 'mv' || kind === 'view') && Array.isArray(t.astTables)) { + for (const src of t.astTables) { + const sid = qualify(t.database, src); + if (byId.has(sid)) addEdge(sid, id, kind === 'mv' ? 'feeds' : 'reads'); + } + } + if (kind === 'mv') { + const target = parseMvTarget(t.create_table_query); + const targetId = target ? qualify(t.database, target) : innerByUuid.get(String(t.uuid || '')); + if (targetId) { node(targetId, byId.has(targetId) ? nodes.get(targetId).kind : 'table'); 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 = qualify(ref.db || t.database, ref.table); + node(refId, byId.has(refId) ? nodes.get(refId).kind : 'table'); + addEdge(refId, id, ref.kind === 'buffer' ? 'buffer' : 'shard'); + } else if (ref && ref.regex) { + let rx = null; + try { rx = new RegExp(ref.regex); } catch { /* keep the no-throw contract */ } + for (const cand of rx ? tables : []) { + if (cand.database === (ref.db || t.database) && cand.name !== t.name && rx.test(cand.name)) { + addEdge(rowId(cand), id, 'merge'); + } + } + } + } + } + + // dictionaries: prefer loading_dependencies (structured) else parse source/CREATE + for (const t of tables) { + if (nodes.get(rowId(t)).kind !== 'dictionary') continue; + const id = rowId(t); + const ld = zip(t.loading_dependencies_database, t.loading_dependencies_table); + const d = dicts.find((x) => x.database === t.database && x.name === t.name); + if (ld.length) { + for (const src of ld) { node(src, byId.has(src) ? nodes.get(src).kind : 'table'); addEdge(src, id, 'dict'); } + } else { + const s = parseDictSource(d && d.source, t.create_table_query); + if (s && s.table) { const sid = qualify(s.db || t.database, s.table); node(sid, 'table'); addEdge(sid, id, 'dict'); } + else if (s && s.external) addEdge(external(s.external), id, 'dict'); + } + } + + let outNodes = [...nodes.values()]; + let outEdges = edges; + if (focus && focus.kind === 'table') { + const center = qualify(focus.db, focus.table); + const keep = new Set([center]); + 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. + 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)); + } + return { nodes: outNodes, edges: outEdges }; +} diff --git a/src/net/ch-client.js b/src/net/ch-client.js index f1ddb88..9bd784b 100644 --- a/src/net/ch-client.js +++ b/src/net/ch-client.js @@ -8,6 +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'; /** Build a ClickHouse HTTP URL with query-string options. Pure. */ export function chUrl(origin, opts = {}) { @@ -128,6 +129,35 @@ export async function loadSchema(ctx) { return [...byDb.entries()].map(([db, tables]) => ({ db, expanded: false, tables })); } +/** + * Load object-lineage rows for a database: the `system.tables` columns the graph + * builder needs + `system.dictionaries` sources, and (for views/MVs) the + * `EXPLAIN AST` source tables attached as `row.astTables`. `target_database`/ + * `target_table` are intentionally not selected — they're a ClickHouse-Cloud-only + * column (absent on OSS/Altinity builds), so the MV target is parsed from + * `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 tables = tablesJson.data || []; + const dictsJson = await queryJson(ctx, `SELECT database, name, source FROM system.dictionaries WHERE database = ${q(db)}`); + const dictionaries = dictsJson.data || []; + // Robust source extraction for views/MVs: let ClickHouse parse the SELECT. + await Promise.all(tables.map(async (t) => { + if (!t.as_select || (t.engine !== 'View' && t.engine !== 'MaterializedView')) return; + try { + const ast = await queryJson(ctx, 'EXPLAIN AST ' + t.as_select); + t.astTables = parseAstTables((ast.data || []).map((r) => r.explain).join('\n')); + } catch { /* best-effort — leave astTables undefined */ } + })); + return { tables, dictionaries }; +} + /** Load the columns of one table. Returns [{name,type,comment}]. */ export async function loadColumns(ctx, db, table, sqlString) { const sql = diff --git a/src/styles.css b/src/styles.css index fabbc46..38128d6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -630,6 +630,9 @@ body { background: var(--bg-table); cursor: grab; } .explain-graph-view.grabbing { cursor: grabbing; } +/* Schema graph: normal pointer (click a node → SHOW CREATE); ⌘/Ctrl-drag to pan + swaps in the grabbing hand via .grabbing above. */ +.schema-graph-view { cursor: default; } .explain-graph-view:focus { outline: none; } .explain-graph-view > svg.explain-graph { width: 100%; height: 100%; } /* `color` drives the arrowhead (fill:currentColor) and edge stroke. */ @@ -642,6 +645,39 @@ body { } .explain-graph .eg-edge { stroke: var(--fg-faint); stroke-width: 1.3; fill: none; } .explain-graph .eg-arrowhead { fill: var(--fg-faint); } +.explain-graph .eg-edge-label { fill: var(--fg-faint); font-family: var(--mono); font-size: 9px; } + +/* ------------ schema lineage graph (kind-coloured nodes + edges) ------------ */ +/* Node fill by object kind. Scoped under `.explain-graph` so these beat the base + `.explain-graph .eg-node` fill (same specificity → source order would lose). */ +.explain-graph .eg-node--table { fill: var(--bg-chip); stroke: var(--border); } +.explain-graph .eg-node--view { fill: color-mix(in oklab, #14b8a6 22%, var(--bg-table)); stroke: #14b8a6; } +.explain-graph .eg-node--mv { fill: color-mix(in oklab, #8b5cf6 24%, var(--bg-table)); stroke: #8b5cf6; } +.explain-graph .eg-node--dictionary { fill: color-mix(in oklab, #3b82f6 22%, var(--bg-table)); stroke: #3b82f6; } +.explain-graph .eg-node--distributed { fill: color-mix(in oklab, #f97316 22%, var(--bg-table)); stroke: #f97316; } +.explain-graph .eg-node--buffer { fill: color-mix(in oklab, #eab308 22%, var(--bg-table)); stroke: #eab308; } +.explain-graph .eg-node--merge { fill: color-mix(in oklab, #64748b 26%, var(--bg-table)); stroke: #64748b; } +.explain-graph .eg-node--external { fill: transparent; stroke: var(--fg-faint); stroke-dasharray: 3 2; } +.explain-graph .eg-edge--writes { stroke: #8b5cf6; } +.explain-graph .eg-edge--dict { stroke: #3b82f6; } +.explain-graph .eg-edge--shard { stroke: #f97316; } +.explain-graph .eg-edge--buffer { stroke: #eab308; } +.explain-graph .eg-edge--merge { stroke: #64748b; } +.schema-graph-view { position: relative; } +.schema-graph-legend { + position: absolute; top: 8px; left: 10px; pointer-events: none; + display: flex; flex-wrap: wrap; gap: 4px 12px; max-width: 70%; + font-size: 10.5px; color: var(--fg-mute); +} +.schema-graph-legend .sg-leg { display: flex; align-items: center; gap: 5px; } +.schema-graph-legend .sg-swatch { width: 11px; height: 11px; border-radius: 2px; border: 1px solid var(--border); display: inline-block; } +.sg-swatch--table { background: var(--bg-chip); border-color: var(--border); } +.sg-swatch--view { background: #14b8a6; border-color: #14b8a6; } +.sg-swatch--mv { background: #8b5cf6; border-color: #8b5cf6; } +.sg-swatch--dictionary { background: #3b82f6; border-color: #3b82f6; } +.sg-swatch--distributed { background: #f97316; border-color: #f97316; } +.sg-swatch--external { background: transparent; border-style: dashed; } +.res-graph-title { font-size: 11.5px; color: var(--fg-mute); font-weight: 500; padding: 0 4px; } /* ------------ fullscreen pipeline overlay (pan/zoom) ------------ */ .graph-overlay { diff --git a/src/ui/app.js b/src/ui/app.js index 3aa4323..ed5294d 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -13,6 +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 { resolveTarget } from '../core/target.js'; import { toTSV, toCSV } from '../core/export.js'; import { newResult, applyStreamLine, parseErrorPos } from '../core/stream.js'; @@ -22,7 +23,7 @@ import { generatePKCE, randomState } from '../core/pkce.js'; import * as oauthCfg from '../net/oauth-config.js'; import * as oauth from '../net/oauth.js'; import * as ch from '../net/ch-client.js'; -import { mountEditor, insertAtCursor, replaceEditor } from './editor.js'; +import { mountEditor, insertAtCursor, replaceEditor, SCHEMA_GRAPH_MIME } from './editor.js'; import { renderTabs, selectTab, newTab, closeTab, loadIntoNewTab } from './tabs.js'; import { renderSchema } from './schema.js'; import { renderResults } from './results.js'; @@ -509,6 +510,29 @@ export function createApp(env = {}) { } } + // Render the ClickHouse object-lineage graph for a dropped database/table into + // the data pane (queries system.* + EXPLAIN AST; the editor SQL is untouched). + async function showSchemaGraph(focus) { + if (!focus || !focus.db) return; + await ensureConfig(); + if (!(await getToken())) { chCtx.onSignedOut(); return; } + const tab = app.activeTab(); + // Show a loading placeholder first — the lineage queries (system.* + an + // EXPLAIN AST per view/MV) can take a moment on a large database. + tab.result = newResult('Table'); + tab.result.schemaGraph = { focus, loading: true, nodes: [], edges: [] }; + renderResults(app); + try { + const rows = await ch.loadSchemaLineage(chCtx, focus); + const g = buildSchemaGraph(rows, focus); + tab.result.schemaGraph = { focus, nodes: g.nodes, edges: g.edges }; + } catch (e) { + tab.result = newResult('Table'); + tab.result.error = String((e && e.message) || e); + } + renderResults(app); + } + // 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 }); } @@ -720,6 +744,7 @@ export function createApp(env = {}) { formatQuery, explainQuery, setExplainView, + showSchemaGraph, insertCreate, openShortcuts: () => openShortcuts(app), insertAtCursor: (text) => insertAtCursor(app, text), @@ -809,6 +834,16 @@ export function renderApp(app, helpers) { const editorToolbar = h('div', { class: 'ed-toolbar' }, app.dom.runBtn, app.dom.fmtBtn, app.dom.explainBtn, app.dom.saveBtn, h('div', { style: { flex: '1' } }), app.dom.shareBtn); app.dom.editorRegion = h('div', { class: 'editor-region', style: { height: state.editorPct + '%', minHeight: '0', overflow: 'hidden', flexShrink: '0' } }); app.dom.resultsRegion = h('div', { class: 'results-region', style: { flex: '1', minHeight: '0', overflow: 'hidden' } }); + // Drop a database/table from the schema tree here → render its lineage graph. + app.dom.resultsRegion.addEventListener('dragover', (e) => { + if (e.dataTransfer && [...e.dataTransfer.types].includes(SCHEMA_GRAPH_MIME)) e.preventDefault(); + }); + app.dom.resultsRegion.addEventListener('drop', (e) => { + const payload = e.dataTransfer && e.dataTransfer.getData(SCHEMA_GRAPH_MIME); + if (!payload) return; + e.preventDefault(); + try { app.actions.showSchemaGraph(JSON.parse(payload)); } catch { /* malformed payload */ } + }); app.dom.editorResultsSplit = h('div', { class: 'row-resize', onmousedown: (e) => helpers.startDrag(e, 'row', dragCtx) }); const workbench = h('div', { class: 'workbench' }, qtabsRow, editorToolbar, app.dom.editorRegion, app.dom.editorResultsSplit, app.dom.resultsRegion); diff --git a/src/ui/editor.js b/src/ui/editor.js index 380d964..33d47b6 100644 --- a/src/ui/editor.js +++ b/src/ui/editor.js @@ -30,6 +30,10 @@ export const IDENT_MIME = 'application/x-asb-identifier'; // drop wraps it as a `( … )` subquery at the drop position (see the drop handler). export const SUBQUERY_MIME = 'application/x-asb-subquery'; +// dataTransfer MIME for dragging a database/table from the schema tree onto the +// results pane → render its lineage graph. Payload is JSON `{kind, db, table?}`. +export const SCHEMA_GRAPH_MIME = 'application/x-asb-schema-graph'; + /** * Paint tokenized SQL into `preEl` (whitespace as text, tokens as spans). * `opts` (optional) forwards dynamic keyword/function sets to the tokenizer so diff --git a/src/ui/explain-graph.js b/src/ui/explain-graph.js index 6e8060d..930c7c1 100644 --- a/src/ui/explain-graph.js +++ b/src/ui/explain-graph.js @@ -19,7 +19,11 @@ const ZOOM_STEP = 1.2; // per wheel notch / button press * for external controls (the overlay buttons). Shared by the inline pane and the * fullscreen overlay so both behave identically. */ -function attachPanZoom(container, svg, dims) { +function attachPanZoom(container, svg, dims, opts = {}) { + // When modifierPan is set, drag-to-pan requires ⌘/Ctrl held — so a plain click + // selects a node (schema graph) instead of grabbing the canvas. The cursor then + // stays default (see .schema-graph-view CSS) rather than the grab hand. + const modifierPan = !!opts.modifierPan; svg.setAttribute('width', '100%'); svg.setAttribute('height', '100%'); svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); @@ -48,7 +52,11 @@ function attachPanZoom(container, svg, dims) { else panBy(-e.deltaX, -e.deltaY); }); let drag = null; - container.addEventListener('mousedown', (e) => { drag = { x: e.clientX, y: e.clientY }; container.classList.add('grabbing'); }); + container.addEventListener('mousedown', (e) => { + if (modifierPan && !(e.metaKey || e.ctrlKey)) return; // plain drag → let the click through + drag = { x: e.clientX, y: e.clientY }; + container.classList.add('grabbing'); + }); container.addEventListener('mousemove', (e) => { if (!drag) return; panBy(e.clientX - drag.x, e.clientY - drag.y); @@ -64,15 +72,17 @@ function attachPanZoom(container, svg, dims) { } /** - * Build the pipeline SVG from a DOT document, laying it out with the injected - * dagre engine. Returns the `` element plus the graph's intrinsic size and - * node count (0 → caller shows a placeholder). + * Draw a laid-out graph (`{nodes,edges,width,height}` from dagreLayout) as SVG. + * `opts.nodeClass(n)` / `opts.edgeClass(e)` pick CSS classes (kind colouring), + * `opts.edgeLabel(e)` an optional mid-edge label, `opts.onNode(n)` a click handler. + * Returns `{ svg, width, height, nodeCount }`. DOT-agnostic — reused by both the + * pipeline graph (DOT) and the schema graph (system.* rows). */ -export function buildPipelineSvg(rawText, dagre) { - const g = dagreLayout(dagre, parseDot(rawText || '')); +function renderGraphSvg(g, opts = {}) { + const nodeClass = opts.nodeClass || (() => 'eg-node'); + const edgeClass = opts.edgeClass || (() => 'eg-edge'); 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 }; - // A single reusable arrowhead marker. svg.appendChild(s('defs', null, s('marker', { id: 'eg-arrow', viewBox: '0 0 10 10', refX: '9', refY: '5', @@ -80,18 +90,44 @@ export function buildPipelineSvg(rawText, dagre) { }, s('path', { class: 'eg-arrowhead', d: 'M0 0L10 5L0 10z' })))); for (const e of g.edges) { const d = 'M' + e.points.map((p) => p.x + ' ' + p.y).join(' L'); - svg.appendChild(s('path', { class: 'eg-edge', d, 'marker-end': 'url(#eg-arrow)' })); + svg.appendChild(s('path', { class: edgeClass(e), d, 'marker-end': 'url(#eg-arrow)' })); + const lbl = opts.edgeLabel && opts.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)); + } } for (const n of g.nodes) { - svg.appendChild(s('rect', { class: 'eg-node', x: n.x, y: n.y, width: n.w, height: n.h, rx: '4' })); - svg.appendChild(s('text', { + 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', { class: 'eg-label', x: n.x + n.w / 2, y: n.y + n.h / 2, 'text-anchor': 'middle', 'dominant-baseline': 'central', - }, n.label)); + }, n.label); + if (opts.onNode) { + rect.setAttribute('cursor', 'pointer'); text.setAttribute('cursor', 'pointer'); + const fire = (e) => { e.stopPropagation(); opts.onNode(n); }; + rect.addEventListener('click', fire); text.addEventListener('click', fire); + } + svg.appendChild(rect); svg.appendChild(text); } return { svg, width: g.width, height: g.height, nodeCount: g.nodes.length }; } +/** Build the pipeline SVG from a DOT document (kind-agnostic boxes). */ +export function buildPipelineSvg(rawText, dagre) { + return renderGraphSvg(dagreLayout(dagre, parseDot(rawText || ''))); +} + +/** Build the schema-lineage SVG from a `{nodes,edges}` graph (kind-coloured). */ +export function buildSchemaSvg(graph, dagre, onNode) { + return renderGraphSvg(dagreLayout(dagre, graph || { nodes: [], edges: [] }), { + 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 @@ -107,37 +143,44 @@ export function renderExplainGraph(app, r) { return view; } +// The schema-graph kinds + their legend labels (also drive the .eg-node-- +// and .eg-edge-- CSS colours). +const NODE_LEGEND = [ + ['table', 'Table'], ['view', 'View'], ['mv', 'Materialized View'], + ['dictionary', 'Dictionary'], ['distributed', 'Distributed'], ['external', 'External'], +]; +function schemaLegend() { + return h('div', { class: 'schema-graph-legend' }, + ...NODE_LEGEND.map(([k, label]) => + h('span', { class: 'sg-leg' }, h('i', { class: 'sg-swatch sg-swatch--' + k }), label))); +} + /** - * Open the pipeline graph in a fullscreen overlay with wheel-zoom (around the - * cursor), drag-pan, and fit/zoom buttons. Esc / ✕ / backdrop close it. + * Open a graph in a fullscreen overlay (drag-pan, ⌘/Ctrl+wheel zoom, fit/zoom + * buttons; Esc / ✕ / backdrop close). `build()` returns `{svg,width,height,nodeCount}` + * — shared by the pipeline and schema graphs. `extra` is an optional overlay node + * (e.g. the schema legend). */ -export function openPipelineFullscreen(app, rawText) { +function openGraphFullscreen(app, title, build, extra) { const doc = (app && app.document) || document; - const built = buildPipelineSvg(rawText || '', app && app.Dagre); - + const built = build(); const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } }; let backdrop; - // `close` only fires from listeners attached after `backdrop` is assigned. - function close() { - backdrop.remove(); - doc.removeEventListener('keydown', onKey, true); - } + function close() { backdrop.remove(); doc.removeEventListener('keydown', onKey, true); } - const bar = h('div', { class: 'graph-overlay-bar' }, - h('span', { class: 'graph-overlay-title' }, 'Pipeline')); + 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, 'No pipeline graph to display.'))); + canvas.appendChild(h('div', { class: 'placeholder' }, h('div', null, 'Nothing to display.'))); } else { canvas.appendChild(built.svg); + if (extra) canvas.appendChild(extra); const pz = attachPanZoom(canvas, built.svg, built); bar.appendChild(h('div', { class: 'graph-overlay-zoom' }, h('button', { class: 'res-act', title: 'Zoom out', onclick: pz.zoomOut }, Icon.minus()), h('button', { class: 'res-act', title: 'Zoom in', onclick: pz.zoomIn }, Icon.plus()), h('button', { class: 'res-act', title: 'Fit to screen', onclick: pz.fit }, 'Fit'))); } - bar.appendChild(h('button', { class: 'graph-overlay-close', title: 'Close (Esc)', onclick: close }, Icon.close())); const panel = h('div', { class: 'graph-overlay-panel', onclick: (e) => e.stopPropagation() }, bar, canvas); backdrop = h('div', { class: 'graph-overlay', onclick: close }, panel); @@ -145,3 +188,36 @@ export function openPipelineFullscreen(app, rawText) { doc.addEventListener('keydown', onKey, true); return backdrop; } + +/** Fullscreen pipeline graph (DOT). */ +export function openPipelineFullscreen(app, rawText) { + return openGraphFullscreen(app, 'Pipeline', () => buildPipelineSvg(rawText || '', app && app.Dagre)); +} + +// Clicking an object runs SHOW CREATE for it, dropping the (formatted) DDL into +// the editor — the same action as a shift-click in the schema tree. External +// dictionary-source leaves have no DDL. +const schemaClick = (app) => (n) => { + if (!n.id || n.id.startsWith('ext:')) return; + app.actions.insertCreate(n.id); +}; + +/** Fullscreen schema-lineage graph. */ +export function openSchemaFullscreen(app, graph) { + return openGraphFullscreen(app, 'Schema', () => buildSchemaSvg(graph, app && app.Dagre, schemaClick(app)), schemaLegend()); +} + +/** + * Render `r.schemaGraph` as the inline schema-lineage graph (kind-coloured boxes, + * relationship-coloured edges, legend, click-a-node to expand). Same pan/zoom as + * the pipeline view. + */ +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.')); + } + 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 a1fcabf..5e6e472 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 } from './explain-graph.js'; +import { renderExplainGraph, openPipelineFullscreen, renderSchemaGraph, openSchemaFullscreen } 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. @@ -104,6 +104,12 @@ export function renderResults(app) { h('div', null, 'Press ', h('kbd', null, '⌘↵'), ' to run query'))); } else if (r.error) { inner.appendChild(h('div', { class: 'results-error' }, r.error)); + } else if (r.schemaGraph) { + inner.appendChild(r.schemaGraph.loading + ? h('div', { class: 'placeholder starting' }, + h('span', { class: 'spin' }, Icon.spinner()), + h('div', null, 'Loading lineage…')) + : renderSchemaGraph(app, r)); } else if (r.explainView) { inner.appendChild(renderExplainView(app, r)); } else if (r.rawText != null) { @@ -145,6 +151,21 @@ function streamStrip(r) { function buildToolbar(app, r) { const toolbar = h('div', { class: 'res-toolbar' }); + if (r && r.schemaGraph) { + // Schema-lineage view: a title + Expand (fullscreen); no view-switcher / stats. + const f = r.schemaGraph.focus || {}; + 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) { + toolbar.appendChild(h('button', { + class: 'res-act', title: 'Open the graph fullscreen (pan & zoom)', + onclick: () => openSchemaFullscreen(app, r.schemaGraph), + }, Icon.expand(), h('span', null, 'Expand'))); + } + return toolbar; + } const tabs = h('div', { class: 'result-view-tabs' }); if (r && r.explainView) { // The five EXPLAIN views — clicking re-runs the derived query (editor SQL is diff --git a/src/ui/schema.js b/src/ui/schema.js index 3d7fe0a..46582c6 100644 --- a/src/ui/schema.js +++ b/src/ui/schema.js @@ -4,7 +4,7 @@ import { h } from './dom.js'; import { Icon } from './icons.js'; import { formatRows } from '../core/format.js'; -import { IDENT_MIME } from './editor.js'; +import { IDENT_MIME, SCHEMA_GRAPH_MIME } from './editor.js'; // Make a tree row a drag source carrying `text` as the schema identifier, so it // can be dropped onto the editor (see editor.js drop handler). Click behavior is @@ -14,6 +14,16 @@ const dragProps = (text) => ({ ondragstart: (e) => e.dataTransfer.setData(IDENT_MIME, text), }); +// Database/table rows carry BOTH the identifier (for an editor drop) and a +// schema-graph payload (for a results-pane drop → lineage graph). +const lineageDrag = (ident, payload) => ({ + draggable: 'true', + ondragstart: (e) => { + e.dataTransfer.setData(IDENT_MIME, ident); + e.dataTransfer.setData(SCHEMA_GRAPH_MIME, JSON.stringify(payload)); + }, +}); + // The four spans every tree row shares: chevron, icon, label, meta. `expanded` // null → an empty chevron (column rows); true/false → the open/closed chevron. const treeRow = (icon, label, meta, { expanded, iconColor } = {}) => [ @@ -73,7 +83,7 @@ export function renderSchema(app) { db.expanded = !db.expanded; renderSchema(app); }, - ...dragProps(db.db), + ...lineageDrag(db.db, { kind: 'db', db: db.db }), }, ...treeRow(Icon.database(), db.db, String(db.tables.length), { expanded: db.expanded }), )); @@ -95,7 +105,7 @@ export function renderSchema(app) { class: 'tree-row' + (filter && tableMatch ? ' match' : ''), style: { paddingLeft: '24px' }, title, - ...dragProps(key), + ...lineageDrag(key, { kind: 'table', db: db.db, table: tb.name }), onclick: (e) => { if (e.shiftKey) { app.actions.insertCreate(key); return; } if (isDoubleClick(app, 'tb:' + key)) { app.actions.replaceEditor('SELECT * FROM ' + key + ' LIMIT 100'); return; } diff --git a/tests/e2e/pipeline.html b/tests/e2e/pipeline.html index 086c1cf..d042edf 100644 --- a/tests/e2e/pipeline.html +++ b/tests/e2e/pipeline.html @@ -19,15 +19,19 @@ // dagre is injected (the app.Dagre seam) — load its self-contained ESM build // straight from node_modules, served by the e2e static server (repo root). import dagre from '/node_modules/@dagrejs/dagre/dist/dagre.esm.js'; - import { renderExplainGraph, openPipelineFullscreen } from '/src/ui/explain-graph.js'; + import { renderExplainGraph, openPipelineFullscreen, renderSchemaGraph } from '/src/ui/explain-graph.js'; - const app = { document, Dagre: dagre }; + const app = { document, Dagre: dagre, actions: { showSchemaGraph() {} } }; // Render a DOT document exactly as the inline Pipeline result view does. window.__renderPipeline = (dotText) => { document.getElementById('host').replaceChildren(renderExplainGraph(app, { rawText: dotText })); }; // Open the fullscreen pan/zoom overlay (same call the Expand button makes). window.__openFullscreen = (dotText) => openPipelineFullscreen(app, dotText); + // Render a schema-lineage graph from a {nodes,edges} object. + window.__renderSchema = (graph) => { + document.getElementById('host').replaceChildren(renderSchemaGraph(app, { schemaGraph: graph })); + }; window.__ready = true; diff --git a/tests/e2e/schema-graph.spec.js b/tests/e2e/schema-graph.spec.js new file mode 100644 index 0000000..e01b5dd --- /dev/null +++ b/tests/e2e/schema-graph.spec.js @@ -0,0 +1,62 @@ +import { test, expect } from '@playwright/test'; + +// Real-browser regression for the schema-lineage graph view. The graph object is +// shaped exactly like buildSchemaGraph's output for a small `lin` lineage schema +// (source table → MV → target table, plus a dictionary sourcing the table) so the +// test exercises the real dagre geometry + the kind-coloured SVG renderer + legend +// in a browser, without needing a live cluster (which requires OAuth). + +const GRAPH = { + focus: { kind: 'db', db: 'lin' }, + nodes: [ + { id: 'lin.events', label: 'lin.events', kind: 'table' }, + { id: 'lin.events_mv', label: 'lin.events_mv', kind: 'mv' }, + { id: 'lin.events_daily', label: 'lin.events_daily', kind: 'table' }, + { id: 'lin.dim_dict', label: 'lin.dim_dict', kind: 'dictionary' }, + ], + edges: [ + { from: 'lin.events', to: 'lin.events_mv', kind: 'feeds', label: 'feeds' }, + { from: 'lin.events_mv', to: 'lin.events_daily', kind: 'writes', label: 'writes' }, + { from: 'lin.events', to: 'lin.dim_dict', kind: 'dict', label: 'dict' }, + ], +}; + +test.describe('schema lineage graph (lin MV + dictionary)', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tests/e2e/pipeline.html'); + await page.waitForFunction(() => window.__ready === true); + await page.evaluate((g) => window.__renderSchema(g), GRAPH); + }); + + test('draws kind-coloured nodes, relationship edges, edge labels and a legend', async ({ page }) => { + await expect(page.locator('svg.explain-graph')).toBeVisible(); + await expect(page.locator('rect.eg-node')).toHaveCount(4); + await expect(page.locator('rect.eg-node--mv')).toHaveCount(1); + await expect(page.locator('rect.eg-node--dictionary')).toHaveCount(1); + await expect(page.locator('rect.eg-node--table')).toHaveCount(2); + await expect(page.locator('path.eg-edge')).toHaveCount(3); + await expect(page.locator('path.eg-edge--writes')).toHaveCount(1); + await expect(page.locator('path.eg-edge--dict')).toHaveCount(1); + const edgeLabels = await page.locator('text.eg-edge-label').allTextContents(); + expect(edgeLabels).toContain('feeds'); + expect(edgeLabels).toContain('writes'); + // qualified node labels (db visible) and the kind legend + const nodeLabels = await page.locator('text.eg-label').allTextContents(); + expect(nodeLabels).toContain('lin.events_mv'); + await expect(page.locator('.schema-graph-legend')).toBeVisible(); + }); + + test('lays out the source→MV→target flow top-to-bottom', async ({ page }) => { + const y = await page.evaluate(() => { + // rect+label aren't grouped; the label's y is the node centre (all nodes + // share a height, so label-y ordering == node ordering). + const at = (label) => { + const t = [...document.querySelectorAll('text.eg-label')].find((n) => n.textContent === label); + return Math.round(+t.getAttribute('y')); + }; + return { src: at('lin.events'), mv: at('lin.events_mv'), dst: at('lin.events_daily') }; + }); + expect(y.mv).toBeGreaterThan(y.src); + expect(y.dst).toBeGreaterThan(y.mv); + }); +}); diff --git a/tests/helpers/fake-app.js b/tests/helpers/fake-app.js index 6552103..df86592 100644 --- a/tests/helpers/fake-app.js +++ b/tests/helpers/fake-app.js @@ -73,6 +73,7 @@ export function makeApp(over = {}) { formatQuery: vi.fn(), explainQuery: vi.fn(), setExplainView: vi.fn(), + showSchemaGraph: 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 1b82c77..e4917d6 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { webcrypto } from 'node:crypto'; +import dagre from '@dagrejs/dagre'; import { createApp } from '../../src/ui/app.js'; function jwt(payload) { @@ -52,6 +53,7 @@ function env(over = {}) { location: { host: 'ch.example', origin: 'https://ch.example', pathname: '/sql', search: '', hash: '', href: 'https://ch.example/sql' }, sessionStorage: memSession({ oauth_id_token: validToken }), crypto: webcrypto, + Dagre: dagre, fetch: makeFetch([]), now: () => 0, navigator: { clipboard: { writeText: vi.fn(async () => {}) } }, @@ -1157,3 +1159,75 @@ describe('exhaustive controller coverage', () => { expect(app.state.history.length).toBe(1); }); }); + +describe('schema lineage graph (drag a db/table onto the results pane)', () => { + const lineageRoutes = [ + [(u, sql) => /EXPLAIN AST/.test(sql), resp({ json: { data: [{ explain: ' TableIdentifier lin.events (alias e)' }] } })], + [(u, sql) => /system\.dictionaries/.test(sql), resp({ json: { data: [] } })], + [(u, sql) => /system\.tables/.test(sql), resp({ json: { data: [ + { database: 'lin', name: 'events', engine: 'MergeTree', engine_full: '', create_table_query: '', as_select: '', uuid: '', dependencies_database: ['lin'], dependencies_table: ['mv'], loading_dependencies_database: [], loading_dependencies_table: [] }, + { database: 'lin', name: 'mv', engine: 'MaterializedView', engine_full: '', create_table_query: 'CREATE MATERIALIZED VIEW lin.mv TO lin.dst AS SELECT 1 FROM lin.events', as_select: 'SELECT 1 FROM lin.events', uuid: '', dependencies_database: [], dependencies_table: [], loading_dependencies_database: [], loading_dependencies_table: [] }, + { database: 'lin', name: 'dst', engine: 'MergeTree', engine_full: '', create_table_query: '', as_select: '', uuid: '', dependencies_database: [], dependencies_table: [], loading_dependencies_database: [], loading_dependencies_table: [] }, + ] } })], + ]; + function appForRun(routes, over) { + const e = env({ fetch: makeFetch(routes), ...over }); + const app = createApp(e); + app.renderApp(); + return { app, e }; + } + + it('showSchemaGraph queries system.* and sets a schemaGraph result', async () => { + const { app } = appForRun(lineageRoutes); + await app.actions.showSchemaGraph({ kind: 'db', db: 'lin' }); + const sg = app.activeTab().result.schemaGraph; + expect(sg.focus).toEqual({ kind: 'db', db: 'lin' }); + const E = new Set(sg.edges.map((x) => `${x.from}>${x.to}:${x.kind}`)); + expect(E.has('lin.events>lin.mv:feeds')).toBe(true); + expect(E.has('lin.mv>lin.dst:writes')).toBe(true); + }); + + it('a drop on the results region with the schema-graph MIME triggers showSchemaGraph', () => { + const { app } = appForRun(lineageRoutes); + app.actions.showSchemaGraph = vi.fn(); + const e = new Event('drop', { cancelable: true }); + e.dataTransfer = { getData: (m) => (m === 'application/x-asb-schema-graph' ? '{"kind":"table","db":"lin","table":"events"}' : '') }; + app.dom.resultsRegion.dispatchEvent(e); + expect(e.defaultPrevented).toBe(true); + expect(app.actions.showSchemaGraph).toHaveBeenCalledWith({ kind: 'table', db: 'lin', table: 'events' }); + }); + + it('surfaces a load error in the results panel', async () => { + const { app } = appForRun([[(u, sql) => /system\.tables/.test(sql), resp({ ok: false, status: 500, text: '{"exception":"DB::Exception: nope"}' })]]); + await app.actions.showSchemaGraph({ kind: 'db', db: 'lin' }); + expect(app.activeTab().result.error).toContain('nope'); + }); +}); + +describe('schema graph drop edge cases', () => { + function mk() { const app = createApp(env({ fetch: makeFetch([]) })); app.renderApp(); return app; } + it('dragover accepts only the schema-graph MIME', () => { + const app = mk(); + const a = new Event('dragover', { cancelable: true }); + a.dataTransfer = { types: ['application/x-asb-schema-graph'] }; + app.dom.resultsRegion.dispatchEvent(a); + expect(a.defaultPrevented).toBe(true); + const b = new Event('dragover', { cancelable: true }); + b.dataTransfer = { types: ['text/plain'] }; + app.dom.resultsRegion.dispatchEvent(b); + expect(b.defaultPrevented).toBe(false); + }); + it('drop ignores a non-schema payload and tolerates malformed JSON', () => { + const app = mk(); + app.actions.showSchemaGraph = vi.fn(); + const none = new Event('drop', { cancelable: true }); + none.dataTransfer = { getData: () => '' }; + app.dom.resultsRegion.dispatchEvent(none); + expect(none.defaultPrevented).toBe(false); + const bad = new Event('drop', { cancelable: true }); + bad.dataTransfer = { getData: (m) => (m === 'application/x-asb-schema-graph' ? 'not json' : '') }; + expect(() => app.dom.resultsRegion.dispatchEvent(bad)).not.toThrow(); + expect(bad.defaultPrevented).toBe(true); + expect(app.actions.showSchemaGraph).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/ch-client.test.js b/tests/unit/ch-client.test.js index 1b513ee..09c331c 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, + chUrl, authedFetch, queryJson, loadServerVersion, loadSchema, loadColumns, loadReferenceData, loadEntityDoc, runQuery, killQuery, loadSchemaLineage, } from '../../src/net/ch-client.js'; import { sqlString } from '../../src/core/format.js'; @@ -324,3 +324,39 @@ describe('killQuery', () => { await expect(killQuery(ctx, 'q', sqlString)).resolves.toBeUndefined(); }); }); + +describe('loadSchemaLineage', () => { + it('fetches scoped system.tables + dictionaries and attaches EXPLAIN AST sources', async () => { + const seen = []; + const ctx = ctxWith((url, init) => { + const sql = init.body; + seen.push(sql); + if (/EXPLAIN AST/.test(sql)) return jsonResp({ data: [{ explain: ' TableIdentifier lin.events (alias e)' }] }); + if (/system\.dictionaries/.test(sql)) return jsonResp({ data: [{ database: 'lin', name: 'd', source: 'ClickHouse: lin.dim' }] }); + // system.tables scope query + return jsonResp({ data: [ + { database: 'lin', name: 'events', engine: 'MergeTree', as_select: '' }, + { database: 'lin', name: 'mv', engine: 'MaterializedView', as_select: 'SELECT 1 FROM lin.events', create_table_query: 'CREATE MATERIALIZED VIEW lin.mv TO lin.dst AS SELECT 1 FROM lin.events' }, + ] }); + }); + const out = await loadSchemaLineage(ctx, { kind: 'db', db: 'lin' }); + expect(out.tables).toHaveLength(2); + expect(out.dictionaries).toEqual([{ database: 'lin', name: 'd', source: 'ClickHouse: lin.dim' }]); + // the MV (non-empty as_select) got EXPLAIN AST sources; the plain table did not + expect(out.tables.find((t) => t.name === 'mv').astTables).toEqual(['lin.events']); + expect(out.tables.find((t) => t.name === 'events').astTables).toBeUndefined(); + // scoped to the database, and target_* never requested (OSS-portable) + expect(seen.some((s) => /WHERE database = 'lin'/.test(s))).toBe(true); + expect(seen.some((s) => /target_database/.test(s))).toBe(false); + }); + it('tolerates an EXPLAIN AST failure (leaves astTables undefined)', async () => { + const ctx = ctxWith((url, init) => { + const sql = init.body; + if (/EXPLAIN AST/.test(sql)) return jsonResp('parse error', false, 500); + if (/system\.dictionaries/.test(sql)) return jsonResp({ data: [] }); + return jsonResp({ data: [{ database: 'lin', name: 'v', engine: 'View', as_select: 'SELECT bad' }] }); + }); + const out = await loadSchemaLineage(ctx, { kind: 'db', db: 'lin' }); + expect(out.tables[0].astTables).toBeUndefined(); + }); +}); diff --git a/tests/unit/explain-graph.test.js b/tests/unit/explain-graph.test.js index fbedd8f..06978b5 100644 --- a/tests/unit/explain-graph.test.js +++ b/tests/unit/explain-graph.test.js @@ -1,6 +1,6 @@ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import dagre from '@dagrejs/dagre'; -import { renderExplainGraph, openPipelineFullscreen } from '../../src/ui/explain-graph.js'; +import { renderExplainGraph, openPipelineFullscreen, renderSchemaGraph, openSchemaFullscreen } from '../../src/ui/explain-graph.js'; const APP = { document, Dagre: dagre }; // app stub carrying the dagre layout seam @@ -155,7 +155,72 @@ describe('openPipelineFullscreen', () => { expect(document.body.contains(overlay)).toBe(true); expect(overlay.querySelector('svg.explain-graph')).toBeNull(); expect(overlay.querySelector('.graph-overlay-zoom')).toBeNull(); - expect(overlay.textContent).toMatch(/No pipeline graph/); + expect(overlay.textContent).toMatch(/Nothing to display/); + overlay.querySelector('.graph-overlay-close').dispatchEvent(new Event('click', { bubbles: true })); + expect(document.body.contains(overlay)).toBe(false); + }); +}); + +describe('schema lineage graph', () => { + afterEach(() => { document.body.innerHTML = ''; }); + const GRAPH = { + focus: { kind: 'db', db: 'lin' }, + nodes: [ + { id: 'lin.a', label: 'a', kind: 'table' }, + { id: 'lin.mv', label: 'mv', kind: 'mv' }, + { id: 'lin.dst', label: 'dst', kind: 'table' }, + ], + edges: [ + { from: 'lin.a', to: 'lin.mv', kind: 'feeds' }, + { from: 'lin.mv', to: 'lin.dst', kind: 'writes' }, + ], + }; + + it('draws kind-coloured nodes, relationship-coloured edges, edge labels, and a legend', () => { + const el = renderSchemaGraph(APP, { schemaGraph: GRAPH }); + expect(el.className).toContain('schema-graph-view'); + expect(el.querySelector('svg.explain-graph')).not.toBeNull(); + expect(el.querySelector('rect.eg-node--mv')).not.toBeNull(); + expect(el.querySelector('rect.eg-node--table')).not.toBeNull(); + expect(el.querySelector('path.eg-edge--writes')).not.toBeNull(); + expect([...el.querySelectorAll('text.eg-edge-label')].map((t) => t.textContent)).toContain('feeds'); + expect(el.querySelector('.schema-graph-legend')).not.toBeNull(); + }); + + it('clicking a node runs SHOW CREATE for it (insertCreate) into the editor', () => { + const actions = { insertCreate: vi.fn() }; + const el = renderSchemaGraph({ document, Dagre: dagre, actions }, { schemaGraph: GRAPH }); + el.querySelector('rect.eg-node--mv').dispatchEvent(new Event('click', { bubbles: true })); + expect(actions.insertCreate).toHaveBeenCalledWith('lin.mv'); + }); + + it('a plain drag does not pan (click selects); ⌘/Ctrl-drag pans', () => { + const el = renderSchemaGraph({ document, Dagre: dagre, actions: { insertCreate: vi.fn() } }, { schemaGraph: GRAPH }); + const svg = el.querySelector('svg.explain-graph'); + el.getBoundingClientRect = () => ({ left: 0, top: 0, width: 400, height: 200, right: 400, bottom: 200 }); + const vbX = () => svg.getAttribute('viewBox').split(' ').map(Number)[0]; + const x0 = vbX(); + // plain drag → no pan (modifierPan gate blocks the mousedown) + el.dispatchEvent(new MouseEvent('mousedown', { clientX: 100, clientY: 100, bubbles: true })); + el.dispatchEvent(new MouseEvent('mousemove', { clientX: 40, clientY: 100, bubbles: true })); + expect(vbX()).toBe(x0); + // ⌘-drag → pans + el.dispatchEvent(new MouseEvent('mousedown', { clientX: 100, clientY: 100, metaKey: true, bubbles: true })); + el.dispatchEvent(new MouseEvent('mousemove', { clientX: 40, clientY: 100, bubbles: true })); + expect(vbX()).not.toBe(x0); + el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + }); + + it('shows a placeholder for an empty graph', () => { + const el = renderSchemaGraph(APP, { schemaGraph: { focus: {}, nodes: [], edges: [] } }); + expect(el.className).toBe('placeholder'); + }); + + 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); + expect(overlay.querySelector('svg.explain-graph')).not.toBeNull(); + expect(overlay.querySelector('.schema-graph-legend')).not.toBeNull(); overlay.querySelector('.graph-overlay-close').dispatchEvent(new Event('click', { bubbles: true })); expect(document.body.contains(overlay)).toBe(false); }); diff --git a/tests/unit/results.test.js b/tests/unit/results.test.js index 72d3573..aa5a851 100644 --- a/tests/unit/results.test.js +++ b/tests/unit/results.test.js @@ -561,3 +561,49 @@ describe('EXPLAIN views', () => { expect([...app.dom.resultsRegion.querySelectorAll('.res-act')].some((b) => /Expand/.test(b.textContent))).toBe(false); }); }); + +describe('schema lineage result', () => { + function graphResult() { + const r = newResult('Table'); + r.schemaGraph = { + focus: { kind: 'db', db: 'lin' }, + nodes: [{ id: 'lin.a', label: 'a', kind: 'table' }, { id: 'lin.mv', label: 'mv', kind: 'mv' }], + edges: [{ from: 'lin.a', to: 'lin.mv', kind: 'feeds' }], + }; + return r; + } + it('renders the schema graph (svg + legend) and a Schema toolbar with Expand', () => { + const app = appWithResult(graphResult()); + renderResults(app); + const region = app.dom.resultsRegion; + expect(region.querySelector('svg.explain-graph')).not.toBeNull(); + expect(region.querySelector('.schema-graph-legend')).not.toBeNull(); + expect(region.querySelector('.res-graph-title').textContent).toBe('Schema · lin'); + // no Table/JSON/Chart tabs in this mode + expect(region.querySelector('.result-view-tab')).toBeNull(); + 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 + }); + it('titles a table-focus graph with the qualified name', () => { + const r = graphResult(); + r.schemaGraph.focus = { kind: 'table', db: 'lin', table: 'events' }; + const app = appWithResult(r); + renderResults(app); + expect(app.dom.resultsRegion.querySelector('.res-graph-title').textContent).toBe('Schema · lin.events'); + }); + it('shows a loading placeholder (and no graph/Expand) while the lineage loads', () => { + const r = newResult('Table'); + r.schemaGraph = { focus: { kind: 'db', db: 'lin' }, loading: true, nodes: [], edges: [] }; + const app = appWithResult(r); + renderResults(app); + const region = app.dom.resultsRegion; + expect(region.querySelector('.placeholder.starting').textContent).toMatch(/Loading lineage/); + expect(region.querySelector('svg.explain-graph')).toBeNull(); + expect(region.querySelector('.res-graph-title').textContent).toBe('Schema · lin'); + 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 new file mode 100644 index 0000000..9941fc0 --- /dev/null +++ b/tests/unit/schema-graph.test.js @@ -0,0 +1,202 @@ +import { describe, it, expect } from 'vitest'; +import { + objectKind, parseAstTables, parseMvTarget, parseDictSource, parseEngineRef, buildSchemaGraph, +} from '../../src/core/schema-graph.js'; + +// Fixtures are the *actual* outputs captured from ClickHouse 26.5.1 (Docker) for a +// lineage schema: an MV with explicit TO, an implicit MV (.inner storage), a JOIN +// view, a dictionary, and Distributed/Buffer/Merge tables. + +describe('objectKind', () => { + it('maps engines to node kinds', () => { + expect(objectKind('MaterializedView')).toBe('mv'); + expect(objectKind('View')).toBe('view'); + expect(objectKind('Dictionary')).toBe('dictionary'); + expect(objectKind('Distributed')).toBe('distributed'); + expect(objectKind('Buffer')).toBe('buffer'); + expect(objectKind('Merge')).toBe('merge'); + expect(objectKind('ReplicatedMergeTree')).toBe('table'); + expect(objectKind('')).toBe('table'); + }); +}); + +describe('parseAstTables', () => { + it('extracts TableIdentifier names across a JOIN (real EXPLAIN AST)', () => { + const ast = [ + ' TablesInSelectQuery (children 2)', + ' TableExpression (children 1)', + ' TableIdentifier lin.events (alias e)', + ' TableIdentifier lin.dim (alias d)', + ' Function equals (children 1)', + ].join('\n'); + expect(parseAstTables(ast)).toEqual(['lin.events', 'lin.dim']); + }); + it('tolerates empty/nullish', () => { + expect(parseAstTables('')).toEqual([]); + expect(parseAstTables(null)).toEqual([]); + }); +}); + +describe('parseMvTarget', () => { + it('reads the explicit TO target before the SELECT body', () => { + expect(parseMvTarget('CREATE MATERIALIZED VIEW lin.events_mv TO lin.events_daily (`day` Date) AS SELECT toDate(ts) AS day FROM lin.events GROUP BY day')).toBe('lin.events_daily'); + }); + it('returns null for an implicit MV (ENGINE = …, no TO)', () => { + expect(parseMvTarget('CREATE MATERIALIZED VIEW lin.events_mv2 (`day` Date) ENGINE = SummingMergeTree ORDER BY day AS SELECT toDate(ts) AS day FROM lin.events GROUP BY day')).toBeNull(); + }); +}); + +describe('parseDictSource', () => { + it('parses the loaded ClickHouse source string', () => { + expect(parseDictSource('ClickHouse: lin.dim')).toEqual({ db: 'lin', table: 'dim' }); + }); + it('falls back to SOURCE(CLICKHOUSE(…)) in the CREATE when source is empty', () => { + expect(parseDictSource('', "CREATE DICTIONARY lin.dim_dict (`id` UInt64) PRIMARY KEY id SOURCE(CLICKHOUSE(TABLE 'dim' DB 'lin')) LIFETIME(MIN 0 MAX 0) LAYOUT(HASHED())")).toEqual({ db: 'lin', table: 'dim' }); + }); + it('reports a non-ClickHouse source as external', () => { + expect(parseDictSource('MySQL: host=db user=x')).toEqual({ external: 'MySQL' }); + expect(parseDictSource('', '')).toBeNull(); + }); +}); + +describe('parseEngineRef', () => { + it('parses Distributed / Buffer / Merge engine_full', () => { + expect(parseEngineRef('Distributed', "Distributed('default', 'lin', 'events', rand())")).toEqual({ kind: 'distributed', cluster: 'default', db: 'lin', table: 'events' }); + expect(parseEngineRef('Buffer', "Buffer('lin', 'events', 1, 10, 100, 10000, 1000000, 10000000, 100000000)")).toEqual({ kind: 'buffer', db: 'lin', table: 'events' }); + expect(parseEngineRef('Merge', "Merge('lin', '^events$')")).toEqual({ kind: 'merge', db: 'lin', regex: '^events$' }); + expect(parseEngineRef('MergeTree', 'MergeTree')).toBeNull(); + }); +}); + +// ---- whole-graph assembly against the captured `lin` schema ---- +const UUID = '79c63514-8064-4314-b6eb-e12147f0b28b'; +const T = (database, name, engine, over = {}) => ({ + database, name, engine, engine_full: '', create_table_query: '', as_select: '', uuid: '', + dependencies_database: [], dependencies_table: [], loading_dependencies_database: [], loading_dependencies_table: [], ...over, +}); +const ROWS = { + tables: [ + T('lin', 'dim', 'MergeTree'), + T('lin', 'events', 'MergeTree', { dependencies_database: ['lin', 'lin'], dependencies_table: ['events_mv2', 'events_mv'] }), + T('lin', 'events_daily', 'SummingMergeTree'), + T('lin', 'events_mv', 'MaterializedView', { create_table_query: 'CREATE MATERIALIZED VIEW lin.events_mv TO lin.events_daily (`day` Date) AS SELECT toDate(ts) AS day FROM lin.events GROUP BY day' }), + T('lin', 'events_mv2', 'MaterializedView', { uuid: UUID, create_table_query: 'CREATE MATERIALIZED VIEW lin.events_mv2 (`day` Date) ENGINE = SummingMergeTree ORDER BY day AS SELECT toDate(ts) AS day FROM lin.events GROUP BY day' }), + T('lin', '.inner_id.' + UUID, 'SummingMergeTree'), + T('lin', 'events_view', 'View', { astTables: ['lin.events', 'lin.dim', 'lin.cte_not_real'] }), + T('lin', 'dim_dict', 'Dictionary', { loading_dependencies_database: ['lin'], loading_dependencies_table: ['dim'] }), + T('lin', 'events_buf', 'Buffer', { engine_full: "Buffer('lin', 'events', 1, 10, 100, 10000, 1000000, 10000000, 100000000)" }), + T('lin', 'events_all', 'Merge', { engine_full: "Merge('lin', '^events$')" }), + T('lin', 'events_dist', 'Distributed', { engine_full: "Distributed('default', 'lin', 'events', rand())" }), + ], + dictionaries: [{ database: 'lin', name: 'dim_dict', source: 'ClickHouse: lin.dim' }], +}; +const eset = (g) => new Set(g.edges.map((e) => `${e.from}>${e.to}:${e.kind}`)); + +describe('buildSchemaGraph', () => { + it('derives every CH relationship type for the whole DB', () => { + const g = buildSchemaGraph(ROWS, { kind: 'db', db: 'lin' }); + const E = eset(g); + expect(E.has('lin.events>lin.events_mv:feeds')).toBe(true); // dependencies_table + expect(E.has('lin.events>lin.events_mv2:feeds')).toBe(true); + expect(E.has('lin.events_mv>lin.events_daily:writes')).toBe(true); // TO target + expect(E.has('lin.events_mv2>lin..inner_id.' + UUID + ':writes')).toBe(true); // implicit → inner + expect(E.has('lin.events>lin.events_view:reads')).toBe(true); // AST source + expect(E.has('lin.dim>lin.events_view:reads')).toBe(true); + expect(E.has('lin.dim>lin.dim_dict:dict')).toBe(true); // loading_dependencies + expect(E.has('lin.events>lin.events_dist:shard')).toBe(true); // engine_full + expect(E.has('lin.events>lin.events_buf:buffer')).toBe(true); + expect(E.has('lin.events>lin.events_all:merge')).toBe(true); // Merge regex match + // the CTE/alias name in the AST is NOT a real object → no phantom node/edge + expect(g.nodes.some((n) => n.id === 'lin.cte_not_real')).toBe(false); + // node kinds carried through + expect(g.nodes.find((n) => n.id === 'lin.events_mv').kind).toBe('mv'); + expect(g.nodes.find((n) => n.id === 'lin.dim_dict').kind).toBe('dictionary'); + }); + + it('falls back to AST feeds for an MV when dependencies_table is empty', () => { + const rows = { tables: [ + T('lin', 'src', 'MergeTree'), + T('lin', 'mv', 'MaterializedView', { astTables: ['lin.src'], create_table_query: 'CREATE MATERIALIZED VIEW lin.mv TO lin.dst AS SELECT 1 FROM lin.src' }), + T('lin', 'dst', 'MergeTree'), + ], dictionaries: [] }; + const E = eset(buildSchemaGraph(rows, { kind: 'db', db: 'lin' })); + expect(E.has('lin.src>lin.mv:feeds')).toBe(true); + expect(E.has('lin.mv>lin.dst:writes')).toBe(true); + }); + + it('parses an external dictionary source as a leaf', () => { + const rows = { tables: [T('lin', 'd', 'Dictionary', { create_table_query: "CREATE DICTIONARY lin.d (id UInt64) PRIMARY KEY id SOURCE(HTTP(url 'http://x')) LAYOUT(FLAT())" })], dictionaries: [{ database: 'lin', name: 'd', source: 'HTTP: http://x' }] }; + const g = buildSchemaGraph(rows, { kind: 'db', db: 'lin' }); + expect(g.nodes.some((n) => n.kind === 'external' && n.label === 'HTTP')).toBe(true); + expect(eset(g).has('ext:HTTP>lin.d:dict')).toBe(true); + }); + + it('table focus keeps only the table and its 1-hop neighbours', () => { + const g = buildSchemaGraph(ROWS, { kind: 'table', db: 'lin', table: 'events' }); + const ids = new Set(g.nodes.map((n) => n.id)); + expect(ids.has('lin.events')).toBe(true); + expect(ids.has('lin.events_mv')).toBe(true); // direct neighbour + expect(ids.has('lin.events_view')).toBe(true); + expect(ids.has('lin.dim')).toBe(false); // only connects via events_view, not to events + expect(ids.has('lin.events_daily')).toBe(false); // connects via events_mv, not events + }); + + it('creates leaf nodes (kind table) for cross-database references', () => { + const rows = { tables: [ + T('lin', 'mv', 'MaterializedView', { create_table_query: 'CREATE MATERIALIZED VIEW lin.mv TO other.dst AS SELECT 1 FROM lin.src' }), + T('lin', 'src', 'MergeTree', { dependencies_database: ['other'], dependencies_table: ['xmv'] }), + T('lin', 'dist', 'Distributed', { engine_full: "Distributed('c', 'other', 'remote')" }), + T('lin', 'd', 'Dictionary', { loading_dependencies_database: ['other'], loading_dependencies_table: ['dsrc'] }), + ], dictionaries: [] }; + const g = buildSchemaGraph(rows, { kind: 'db', db: 'lin' }); + const kindOf = (id) => (g.nodes.find((n) => n.id === id) || {}).kind; + expect(kindOf('other.dst')).toBe('table'); // unknown target → leaf + expect(kindOf('other.xmv')).toBe('table'); + expect(kindOf('other.remote')).toBe('table'); + expect(kindOf('other.dsrc')).toBe('table'); + const E = eset(g); + expect(E.has('lin.mv>other.dst:writes')).toBe(true); + expect(E.has('lin.src>other.xmv:feeds')).toBe(true); + expect(E.has('other.remote>lin.dist:shard')).toBe(true); + expect(E.has('other.dsrc>lin.d:dict')).toBe(true); + }); + + it('table focus keeps incoming neighbours too', () => { + const g = buildSchemaGraph(ROWS, { kind: 'table', db: 'lin', table: 'events_mv' }); + const ids = new Set(g.nodes.map((n) => n.id)); + expect(ids.has('lin.events')).toBe(true); // events → events_mv (incoming, to===center) + expect(ids.has('lin.events_daily')).toBe(true); // events_mv → events_daily (outgoing) + }); + + it('drops isolated tables from a whole-DB graph when there is lineage', () => { + const rows = { tables: [ + T('lin', 'src', 'MergeTree'), + T('lin', 'mv', 'MaterializedView', { astTables: ['lin.src'], create_table_query: 'CREATE MATERIALIZED VIEW lin.mv TO lin.dst AS SELECT 1 FROM lin.src' }), + T('lin', 'dst', 'MergeTree'), + T('lin', 'orphan', 'MergeTree'), // no relationships → pruned + ], dictionaries: [] }; + const ids = new Set(buildSchemaGraph(rows, { kind: 'db', db: 'lin' }).nodes.map((n) => n.id)); + expect(ids.has('lin.src')).toBe(true); + 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('never throws on a malformed Merge regex (keeps the no-throw contract)', () => { + const rows = { tables: [ + T('lin', 'm', 'Merge', { engine_full: "Merge('lin', '([')" }), // invalid regex + T('lin', 'events', 'MergeTree'), + ], dictionaries: [] }; + expect(() => buildSchemaGraph(rows, { kind: 'db', db: 'lin' })).not.toThrow(); + }); + + it('tolerates empty input', () => { + expect(buildSchemaGraph(null, { kind: 'db', db: 'x' })).toEqual({ nodes: [], edges: [] }); + expect(buildSchemaGraph({}, null)).toEqual({ nodes: [], edges: [] }); + }); +}); diff --git a/tests/unit/schema.test.js b/tests/unit/schema.test.js index f65f837..29019cb 100644 --- a/tests/unit/schema.test.js +++ b/tests/unit/schema.test.js @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { renderSchema } from '../../src/ui/schema.js'; -import { IDENT_MIME } from '../../src/ui/editor.js'; +import { IDENT_MIME, SCHEMA_GRAPH_MIME } from '../../src/ui/editor.js'; import { makeApp } from '../helpers/fake-app.js'; const rows = (app) => [...app.dom.schemaList.querySelectorAll('.tree-row')]; @@ -11,13 +11,14 @@ const shiftClick = (el) => el.dispatchEvent(new MouseEvent('click', { bubbles: t // re-renders between clicks). Clicking the same captured node twice works even // though the first click detaches it: the listener + per-app state still fire. const dblclick = (el) => { click(el); click(el); }; -// Fire a dragstart with a stub dataTransfer and return what setData captured. +// Fire a dragstart with a stub dataTransfer and return all setData payloads by MIME. const dragstart = (el) => { const e = new Event('dragstart', { bubbles: true }); - let captured = null; - e.dataTransfer = { setData: (mime, value) => { captured = { mime, value }; } }; + const by = {}; + e.dataTransfer = { setData: (mime, value) => { by[mime] = value; } }; el.dispatchEvent(e); - return captured; + by.mime = Object.keys(by)[0]; by.value = by[by.mime]; // back-compat for single-MIME rows + return by; }; function withSchema() { @@ -188,17 +189,21 @@ describe('renderSchema tree', () => { }); describe('renderSchema drag sources', () => { - it('dragging a db carries the bare database name', () => { + it('dragging a db carries the identifier and a schema-graph payload', () => { const app = withSchema(); renderSchema(app); const dbRow = rows(app).find((r) => r.querySelector('.label').textContent === 'db1'); - expect(dragstart(dbRow)).toEqual({ mime: IDENT_MIME, value: 'db1' }); + const d = dragstart(dbRow); + expect(d[IDENT_MIME]).toBe('db1'); + expect(JSON.parse(d[SCHEMA_GRAPH_MIME])).toEqual({ kind: 'db', db: 'db1' }); }); - it('dragging a table carries the qualified name', () => { + it('dragging a table carries the qualified identifier and a schema-graph payload', () => { const app = withSchema(); renderSchema(app); const ordersRow = rows(app).find((r) => r.querySelector('.label').textContent === 'orders'); - expect(dragstart(ordersRow)).toEqual({ mime: IDENT_MIME, value: 'db1.orders' }); + const d = dragstart(ordersRow); + expect(d[IDENT_MIME]).toBe('db1.orders'); + expect(JSON.parse(d[SCHEMA_GRAPH_MIME])).toEqual({ kind: 'table', db: 'db1', table: 'orders' }); }); it('dragging a column carries the bare column name', () => { const app = withSchema(); @@ -207,7 +212,9 @@ describe('renderSchema drag sources', () => { renderSchema(app); const colRow = [...app.dom.schemaList.querySelectorAll('.tree-row.small')] .find((r) => r.querySelector('.label').textContent === 'id'); - expect(dragstart(colRow)).toEqual({ mime: IDENT_MIME, value: 'id' }); + const d = dragstart(colRow); + expect(d[IDENT_MIME]).toBe('id'); + expect(d[SCHEMA_GRAPH_MIME]).toBeUndefined(); // columns aren't graph drag sources }); });