From 6f1b915823922b98005f42ddf526615258fb3e13 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Thu, 25 Jun 2026 12:48:39 +0200 Subject: [PATCH 1/5] feat(explain): EXPLAIN result views (Explain/Indexes/Projections/Pipeline/Estimate) + Explain button Replace the one-dimensional raw EXPLAIN dump with five views in the data pane: - Explain (default) runs the user's EXPLAIN verbatim as clean TabSeparatedRaw, so arbitrary/complex parameters are honored. - Indexes / Projections derive `indexes=1` / `projections=1` from the inner query. - Pipeline derives `EXPLAIN PIPELINE graph=1` and renders the Graphviz DOT as a zero-dep SVG layered graph (pure parse+layout in src/core/dot.js). - Estimate derives `EXPLAIN ESTIMATE` and renders structured rows as a table. On Run we auto-select a rich view only when the typed statement is exactly that canonical form; anything else stays on the verbatim Explain tab. Switching a view re-runs the derived query and never edits the editor SQL. New "Explain" toolbar button (between Format and Save) explains any query without editing it. No new runtime dependency. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QGBS74oUsXarGkCRQKEFLu --- src/core/dot.js | 144 +++++++++++++++++++++++++++++++ src/core/explain.js | 92 ++++++++++++++++++++ src/state.js | 6 ++ src/styles.css | 17 ++++ src/ui/app.js | 53 ++++++++++-- src/ui/explain-graph.js | 43 +++++++++ src/ui/icons.js | 4 + src/ui/results.js | 65 +++++++++++--- tests/helpers/fake-app.js | 2 + tests/unit/app.test.js | 48 ++++++++++- tests/unit/dot.test.js | 86 ++++++++++++++++++ tests/unit/explain-graph.test.js | 39 +++++++++ tests/unit/explain.test.js | 83 ++++++++++++++++++ tests/unit/results.test.js | 59 +++++++++++++ 14 files changed, 717 insertions(+), 24 deletions(-) create mode 100644 src/core/dot.js create mode 100644 src/core/explain.js create mode 100644 src/ui/explain-graph.js create mode 100644 tests/unit/dot.test.js create mode 100644 tests/unit/explain-graph.test.js create mode 100644 tests/unit/explain.test.js diff --git a/src/core/dot.js b/src/core/dot.js new file mode 100644 index 0000000..8389a7c --- /dev/null +++ b/src/core/dot.js @@ -0,0 +1,144 @@ +// Pure Graphviz-DOT parsing + layered-DAG layout for the Pipeline view +// (`EXPLAIN PIPELINE graph = 1` returns DOT). No DOM, no globals — the SVG +// drawing lives in src/ui/explain-graph.js. Kept deliberately small: a lenient +// regex parse (we only need nodes + edges) and a Sugiyama-lite left→right layout. + +const DOT_KEYWORDS = new Set(['node', 'edge', 'graph', 'subgraph', 'digraph']); + +// Layout constants (px). Tuned for ClickHouse pipeline graphs (short labels). +const NODE_H = 30; +const V_GAP = 16; +const H_GAP = 64; +const CHAR_W = 7; +const PAD_X = 18; +const MIN_W = 64; +const MARGIN = 12; + +function unescapeDot(s) { + // Labels may carry escaped quotes and `\n` line breaks; collapse to one line. + return String(s).replace(/\\n/g, ' ').replace(/\\(.)/g, '$1').trim(); +} + +/** + * Parse a DOT digraph into `{ nodes:[{id,label}], edges:[{from,to}] }`. Lenient: + * scans from the first `digraph`, pulls `id [label="…"]` declarations (including + * inside subgraph clusters) and `a -> b` edges, and ignores everything else + * (attributes, ranks, stray format headers). Endpoints referenced only by an + * edge get a node whose label is its id. Pure. + */ +export function parseDot(text) { + const src = String(text || ''); + const at = src.indexOf('digraph'); + const body = at >= 0 ? src.slice(at) : src; + + const nodes = []; + const seen = new Set(); + const add = (id, label) => { + if (seen.has(id) || DOT_KEYWORDS.has(id)) return; + seen.add(id); + nodes.push({ id, label }); + }; + + const nodeRe = /([A-Za-z_][\w]*)\s*\[\s*label\s*=\s*"((?:[^"\\]|\\.)*)"/g; + let m; + while ((m = nodeRe.exec(body))) add(m[1], unescapeDot(m[2])); + + const edges = []; + const edgeRe = /([A-Za-z_][\w]*)\s*->\s*([A-Za-z_][\w]*)/g; + while ((m = edgeRe.exec(body))) { + if (DOT_KEYWORDS.has(m[1]) || DOT_KEYWORDS.has(m[2])) continue; + edges.push({ from: m[1], to: m[2] }); + add(m[1], m[1]); + add(m[2], m[2]); + } + return { nodes, edges }; +} + +/** + * Lay a parsed graph out left→right by layer. Returns positioned nodes + * (`{id,label,x,y,w,h}`), edges as 2-point polylines (`{from,to,points}`), and + * the overall `width`/`height` for the SVG viewBox. Longest-path layering + * (Kahn topo; cycle-safe — leftover nodes stay in layer 0), uniform column + * widths, vertically centred columns. Pure. + */ +export function layoutGraph(graph) { + const nodes = (graph.nodes || []).map((n) => ({ ...n })); + if (!nodes.length) return { nodes: [], edges: [], width: 0, height: 0 }; + const byId = new Map(nodes.map((n) => [n.id, n])); + const edges = (graph.edges || []).filter((e) => byId.has(e.from) && byId.has(e.to)); + + // Longest-path layering via Kahn topological order. + const succ = new Map(nodes.map((n) => [n.id, []])); + const indeg = new Map(nodes.map((n) => [n.id, 0])); + for (const e of edges) { + succ.get(e.from).push(e.to); + indeg.set(e.to, indeg.get(e.to) + 1); + } + const layer = new Map(nodes.map((n) => [n.id, 0])); + const queue = nodes.filter((n) => indeg.get(n.id) === 0).map((n) => n.id); + while (queue.length) { + const u = queue.shift(); + for (const v of succ.get(u)) { + if (layer.get(u) + 1 > layer.get(v)) layer.set(v, layer.get(u) + 1); + indeg.set(v, indeg.get(v) - 1); + if (indeg.get(v) === 0) queue.push(v); + } + } + + // Group node ids by layer (in discovery order). Longest-path layering yields + // contiguous layers 0..max, each non-empty, so a fixed-length array is safe. + const maxLayer = Math.max(...nodes.map((n) => layer.get(n.id))); + const layers = Array.from({ length: maxLayer + 1 }, () => []); + for (const n of nodes) layers[layer.get(n.id)].push(n.id); + + // Node sizes, then uniform width per column and column x positions. + for (const n of nodes) { + n.w = Math.max(MIN_W, n.label.length * CHAR_W + PAD_X); + n.h = NODE_H; + } + const layerX = []; + let x = MARGIN; + let maxColH = 0; + for (let L = 0; L < layers.length; L++) { + const col = layers[L]; + const colW = col.reduce((mx, id) => Math.max(mx, byId.get(id).w), MIN_W); + for (const id of col) byId.get(id).w = colW; + layerX[L] = x; + x += colW + H_GAP; + const colH = col.length * NODE_H + Math.max(0, col.length - 1) * V_GAP; + if (colH > maxColH) maxColH = colH; + } + + // Vertically centre each column within the tallest column. + for (let L = 0; L < layers.length; L++) { + const col = layers[L]; + const colH = col.length * NODE_H + Math.max(0, col.length - 1) * V_GAP; + let y = MARGIN + (maxColH - colH) / 2; + for (const id of col) { + const n = byId.get(id); + n.x = layerX[L]; + n.y = y; + y += NODE_H + V_GAP; + } + } + + const laidEdges = edges.map((e) => { + const a = byId.get(e.from); + const b = byId.get(e.to); + return { + from: e.from, + to: e.to, + points: [ + { x: a.x + a.w, y: a.y + a.h / 2 }, + { x: b.x, y: b.y + b.h / 2 }, + ], + }; + }); + + return { + nodes, + edges: laidEdges, + width: x - H_GAP + MARGIN, + height: maxColH + MARGIN * 2, + }; +} diff --git a/src/core/explain.js b/src/core/explain.js new file mode 100644 index 0000000..5b10ddc --- /dev/null +++ b/src/core/explain.js @@ -0,0 +1,92 @@ +// Pure helpers for the EXPLAIN result views (Explain / Indexes / Projections / +// Pipeline / Estimate). No DOM, no globals. +// +// The data pane offers five views of an EXPLAIN. The **Explain** view runs the +// user's statement *verbatim* (so arbitrary parameters are honored); the other +// four derive a canonical query from the inner statement and render it our way. +// On a run we auto-select a rich view only when the typed statement is *exactly* +// that canonical form (see `detectExplainView`); anything else falls back to the +// verbatim Explain view. + +/** + * The five EXPLAIN views, in tab order. `kind` picks the renderer + * (`text` → monospace, `table` → tabular, `graph` → SVG pipeline graph); + * `chFormat` is the ClickHouse output format to run the view in + * (`TabSeparatedRaw` → clean raw text/DOT; `Table` → structured streaming rows). + */ +export const EXPLAIN_VIEWS = [ + { id: 'explain', label: 'Explain', kind: 'text', chFormat: 'TabSeparatedRaw' }, + { id: 'indexes', label: 'Indexes', kind: 'text', chFormat: 'TabSeparatedRaw' }, + { id: 'projections', label: 'Projections', kind: 'text', chFormat: 'TabSeparatedRaw' }, + { id: 'pipeline', label: 'Pipeline', kind: 'graph', chFormat: 'TabSeparatedRaw' }, + { id: 'estimate', label: 'Estimate', kind: 'table', chFormat: 'Table' }, +]; + +/** + * Parse an EXPLAIN statement into `{ kind, settings, inner }`, or `null` when + * `sql` is not an EXPLAIN. `kind` is the upper-cased form keyword (default + * `PLAN`; `EXPLAIN` alone is a synonym for `EXPLAIN PLAN`), `settings` is the + * `name = value` map that precedes the inner statement (values lower-cased keys, + * string values unquoted), and `inner` is the wrapped statement. Pure. + */ +export function parseExplain(sql) { + const s = String(sql || ''); + const m = /^\s*EXPLAIN\b/i.exec(s); + if (!m) return null; + let rest = s.slice(m[0].length); + // Optional form keyword (multi-word forms first so they win the alternation). + let kind = 'PLAN'; + const km = /^\s*(QUERY\s+TREE|CURRENT\s+TRANSACTION|TABLE\s+OVERRIDE|PLAN|PIPELINE|ESTIMATE|AST|SYNTAX)\b/i.exec(rest); + if (km) { + kind = km[1].toUpperCase().replace(/\s+/g, ' '); + rest = rest.slice(km[0].length); + } + // Optional `name = value` settings (comma- or space-separated). A statement + // keyword (SELECT/WITH/…) has no `=` after it, so the loop stops at the inner + // query on its own. + const settings = {}; + const setRe = /^\s*,?\s*([a-z_][a-z0-9_]*)\s*=\s*([0-9]+|'[^']*'|[a-z_][a-z0-9_]*)/i; + let sm; + while ((sm = setRe.exec(rest))) { + settings[sm[1].toLowerCase()] = sm[2].replace(/^'|'$/g, ''); + rest = rest.slice(sm[0].length); + } + return { kind, settings, inner: rest.trim() }; +} + +/** + * The rich view a parsed EXPLAIN *exactly* matches, or `null` (= use the + * verbatim Explain view). Only a single defining setting/kind qualifies, so + * complex parameter combinations stay on the verbatim Explain tab. Pure. + */ +export function detectExplainView(parsed) { + if (!parsed) return null; + const set = parsed.settings || {}; + const keys = Object.keys(set); + const onlyOne = (k) => keys.length === 1 && set[k] === '1'; + if (parsed.kind === 'PLAN' && onlyOne('indexes')) return 'indexes'; + if (parsed.kind === 'PLAN' && onlyOne('projections')) return 'projections'; + if (parsed.kind === 'PIPELINE' && set.graph === '1') { + // graph=1 (our pipeline mode), with an optional compact tweak — nothing else. + const extra = keys.filter((k) => k !== 'graph' && k !== 'compact'); + if (!extra.length) return 'pipeline'; + } + if (parsed.kind === 'ESTIMATE' && keys.length === 0) return 'estimate'; + return null; +} + +/** + * Build the derived query for a rich view from the inner statement. The + * `explain` view does not derive (the caller runs the statement verbatim); + * unknown ids fall back to a plain `EXPLAIN`. Pure. + */ +export function buildExplainQuery(inner, viewId) { + const q = String(inner || ''); + switch (viewId) { + case 'indexes': return 'EXPLAIN indexes = 1 ' + q; + case 'projections': return 'EXPLAIN projections = 1 ' + q; + case 'pipeline': return 'EXPLAIN PIPELINE graph = 1 ' + q; + case 'estimate': return 'EXPLAIN ESTIMATE ' + q; + default: return 'EXPLAIN ' + q; + } +} diff --git a/src/state.js b/src/state.js index 162487f..791140d 100644 --- a/src/state.js +++ b/src/state.js @@ -58,6 +58,12 @@ export function createState(read = { loadJSON, loadStr }) { running: false, abortController: null, resultView: 'table', + // The active EXPLAIN view (Explain/Indexes/Projections/Pipeline/Estimate), + // kept across re-runs like resultView. `forceExplain` is set by the Explain + // button to put an ordinary query into EXPLAIN-view mode; a normal Run clears + // it. Both session-only. + explainView: 'explain', + forceExplain: false, resultSort: { col: null, dir: 'asc' }, sidePanel: read.loadStr(KEYS.sidePanel, 'saved'), savedQueries: read.loadJSON(KEYS.saved, []), diff --git a/src/styles.css b/src/styles.css index 5728039..e064cac 100644 --- a/src/styles.css +++ b/src/styles.css @@ -622,6 +622,23 @@ body { /* tabindex makes the pane keyboard-scrollable; suppress the focus ring. */ .raw-text-view:focus, .json-view:focus { outline: none; } +/* ------------ EXPLAIN pipeline graph view ------------ */ +.explain-graph-view { + height: 100%; overflow: auto; padding: 12px 14px; + background: var(--bg-table); +} +.explain-graph-view:focus { outline: none; } +/* `color` drives the arrowhead (fill:currentColor) and edge stroke. */ +.explain-graph { color: var(--fg-faint); display: block; } +.explain-graph .eg-node { + fill: var(--bg-chip); stroke: var(--border); stroke-width: 1; +} +.explain-graph .eg-label { + fill: var(--fg); font-family: var(--mono); font-size: 11px; +} +.explain-graph .eg-edge { stroke: var(--fg-faint); stroke-width: 1.3; fill: none; } +.explain-graph .eg-arrowhead { fill: var(--fg-faint); } + /* ------------ chart view ------------ */ /* `.res-body` is display:block, so height:100% (not flex:1) is what lets the chart fill it — otherwise the view collapses to the config bar and the diff --git a/src/ui/app.js b/src/ui/app.js index 0dda99f..bd41c20 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -11,7 +11,8 @@ import { } from '../state.js'; import { saveJSON, saveStr } from '../core/storage.js'; import { decodeJwtPayload, isTokenExpired } from '../core/jwt.js'; -import { sqlString, inferQueryName, shortVersion, userShortName, withStatementBreak, detectSqlFormat, isExplain } from '../core/format.js'; +import { sqlString, inferQueryName, shortVersion, userShortName, withStatementBreak, detectSqlFormat } from '../core/format.js'; +import { EXPLAIN_VIEWS, parseExplain, detectExplainView, buildExplainQuery } from '../core/explain.js'; import { resolveTarget } from '../core/target.js'; import { toTSV, toCSV } from '../core/export.js'; import { newResult, applyStreamLine, parseErrorPos } from '../core/stream.js'; @@ -378,12 +379,41 @@ export function createApp(env = {}) { await ensureConfig(); if (!(await getToken())) { chCtx.onSignedOut(); return; } - // Default to structured streaming (Table); an explicit FORMAT clause runs raw - // and shows ClickHouse's response verbatim, and so does EXPLAIN (its plan text - // reads far better raw than as a one-column table) unless a FORMAT was given. - const fmt = detectSqlFormat(tab.sql) || (isExplain(tab.sql) ? 'TabSeparated' : 'Table'); + // EXPLAIN-view bookkeeping: the Explain button (opts.explain) forces any query + // into EXPLAIN-view mode; a normal Run clears that; switching an EXPLAIN tab + // (opts.explainView) preserves it. + if (opts && opts.explain) app.state.forceExplain = true; + else if (!(opts && opts.explainView != null)) app.state.forceExplain = false; + + // An explicit FORMAT clause runs raw and shows ClickHouse's response verbatim + // (single raw tab). Otherwise an EXPLAIN (typed, or forced by the button) gets + // the five EXPLAIN views; everything else streams structured (Table). + const explicitFmt = detectSqlFormat(tab.sql); + const parsed = explicitFmt ? null : parseExplain(tab.sql); + const explainMode = !explicitFmt && (parsed != null || app.state.forceExplain); + let runSql = tab.sql; + let fmt; + let explainView = null; + if (explainMode) { + // The Explain view runs the statement verbatim (honoring arbitrary params); + // a rich view is auto-selected only on an exact canonical match, else we keep + // the last-used view. Rich views derive a query from the inner statement. + explainView = (opts && opts.explainView) + || (parsed && detectExplainView(parsed)) + || app.state.explainView || 'explain'; + app.state.explainView = explainView; + fmt = (EXPLAIN_VIEWS.find((v) => v.id === explainView) || EXPLAIN_VIEWS[0]).chFormat; + const inner = parsed ? parsed.inner : tab.sql; + runSql = explainView === 'explain' + ? (parsed ? tab.sql : 'EXPLAIN ' + tab.sql) + : buildExplainQuery(inner, explainView); + } else { + fmt = explicitFmt || 'Table'; + } + const t0 = now(); tab.result = newResult(fmt); + if (explainView) tab.result.explainView = explainView; app.state.resultSort = { col: null, dir: 'asc' }; // Keep the current Table/JSON/Chart tab across re-runs (#34); a saved-query // open passes its remembered view in opts.view to restore that instead. @@ -398,7 +428,7 @@ export function createApp(env = {}) { app.state.runTick = setInterval(tickElapsed, 100); try { - const out = await ch.runQuery(chCtx, tab.sql, { + const out = await ch.runQuery(chCtx, runSql, { format: fmt, queryId: app.state.runQueryId, signal: app.state.abortController.signal, @@ -475,6 +505,12 @@ export function createApp(env = {}) { } } + // 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 }); } + // Switch the active EXPLAIN view (re-runs the derived query, keeps the mode). + function setExplainView(id) { return run({ explainView: id }); } + // Fetch the DDL for `target` (e.g. 'db.table' or 'DATABASE db') with // SHOW CREATE, pretty-print it through formatQuery(), and drop it into the // editor (replacing its content — undo restores the prior query). Two @@ -678,6 +714,8 @@ export function createApp(env = {}) { save: openSavePopover, openUserMenu, formatQuery, + explainQuery, + setExplainView, insertCreate, openShortcuts: () => openShortcuts(app), insertAtCursor: (text) => insertAtCursor(app, text), @@ -760,10 +798,11 @@ export function renderApp(app, helpers) { app.dom.runBtn = h('button', { class: 'run-btn', onclick: () => app.actions.run() }, Icon.play(), h('span', null, 'Run'), h('kbd', null, '⌘↵')); app.dom.fmtBtn = h('button', { class: 'tb-btn', title: 'Format SQL (⌘⇧↵)', onclick: () => app.actions.formatQuery() }, Icon.braces(), 'Format'); + app.dom.explainBtn = h('button', { class: 'tb-btn', title: 'Explain this query (plan, indexes, pipeline, estimate)', onclick: () => app.actions.explainQuery() }, Icon.plan(), 'Explain'); app.dom.saveBtn = h('button', { class: 'tb-btn save-btn', onclick: () => app.actions.save() }); app.dom.shareBtn = h('button', { class: 'tb-btn', title: 'Share query (copies link)', onclick: () => app.actions.share() }, Icon.share(), 'Share'); - const editorToolbar = h('div', { class: 'ed-toolbar' }, app.dom.runBtn, app.dom.fmtBtn, app.dom.saveBtn, h('div', { style: { flex: '1' } }), app.dom.shareBtn); + 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' } }); app.dom.editorResultsSplit = h('div', { class: 'row-resize', onmousedown: (e) => helpers.startDrag(e, 'row', dragCtx) }); diff --git a/src/ui/explain-graph.js b/src/ui/explain-graph.js new file mode 100644 index 0000000..d9913d3 --- /dev/null +++ b/src/ui/explain-graph.js @@ -0,0 +1,43 @@ +// The Pipeline result view: draw the `EXPLAIN PIPELINE graph = 1` DOT output as +// an SVG boxes-and-arrows graph. All graph math (parse + layout) is pure and +// lives in src/core/dot.js; this module only turns positioned nodes/edges into +// SVG. Zero runtime deps — built with the `s()` SVG hyperscript. + +import { h, s } from './dom.js'; +import { parseDot, layoutGraph } from '../core/dot.js'; + +/** + * Render `r.rawText` (a Graphviz DOT document) as a scrollable SVG pipeline + * graph. Falls back to a placeholder when the DOT has no nodes. + */ +export function renderExplainGraph(r) { + const g = layoutGraph(parseDot(r.rawText || '')); + if (!g.nodes.length) { + return h('div', { class: 'placeholder' }, h('div', null, 'No pipeline graph to display.')); + } + + const svg = s('svg', { + class: 'explain-graph', width: g.width, height: g.height, + viewBox: `0 0 ${g.width} ${g.height}`, + }); + // A single reusable arrowhead marker. + svg.appendChild(s('defs', null, + s('marker', { + id: 'eg-arrow', viewBox: '0 0 10 10', refX: '9', refY: '5', + markerWidth: '7', markerHeight: '7', orient: 'auto-start-reverse', + }, 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)' })); + } + 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', { + class: 'eg-label', x: n.x + n.w / 2, y: n.y + n.h / 2, + 'text-anchor': 'middle', 'dominant-baseline': 'central', + }, n.label)); + } + + return h('div', { class: 'explain-graph-view', tabindex: '0' }, svg); +} diff --git a/src/ui/icons.js b/src/ui/icons.js index d41e0b5..6775391 100644 --- a/src/ui/icons.js +++ b/src/ui/icons.js @@ -72,6 +72,10 @@ export const Icon = { arrow: () => svg('M2 6h7.5M7 3.5L9.5 6 7 8.5', 12, 12, { stroke: 1.6 }), // Same glyph as the JSON view tab so the Format button's { } matches it. braces: () => svg('M4 1.5C2.5 1.5 2.5 3 2.5 4S2.5 5 1.5 6c1 1 1 2 1 2s0 1.5 1.5 1.5M8 1.5c1.5 0 1.5 1.5 1.5 2.5s0 1 1 2c-1 1-1 2-1 2s0 1.5-1.5 1.5', 12, 12), + // EXPLAIN button + Explain view: an indented plan-tree of lines. + plan: () => iconEl('', 12, 12, 1.4), + // Indexes view: a key. + key: () => iconEl('', 12, 12, 1.3), bookmark: () => iconEl('', 12, 12, 1.3), pencil: () => iconEl('', 12, 12), trash: () => iconEl('', 12, 12, 1.2), diff --git a/src/ui/results.js b/src/ui/results.js index ab22441..b89115c 100644 --- a/src/ui/results.js +++ b/src/ui/results.js @@ -8,6 +8,15 @@ import { formatRows, formatBytes, isNumericType } from '../core/format.js'; 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 } 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. +const EXPLAIN_ICONS = { + explain: Icon.plan, indexes: Icon.key, projections: Icon.layers, + pipeline: Icon.share, estimate: Icon.rows, +}; const VIS_CAP = 5000; const MIN_COL = 48; // px floor for a resized column @@ -95,6 +104,8 @@ 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.explainView) { + inner.appendChild(renderExplainView(app, r)); } else if (r.rawText != null) { inner.appendChild(h('div', { class: 'raw-text-view', tabindex: '0' }, r.rawText)); } else if (r.rows.length === 0) { @@ -110,6 +121,20 @@ export function renderResults(app) { region.replaceChildren(body); } +// Render the active EXPLAIN view: monospace text (Explain/Indexes/Projections), +// a real table (Estimate, streamed structured), or the SVG pipeline graph. +function renderExplainView(app, r) { + const desc = EXPLAIN_VIEWS.find((v) => v.id === r.explainView); + const kind = desc ? desc.kind : 'text'; + if (kind === 'graph') return renderExplainGraph(r); + if (kind === 'table') { + return r.rows.length + ? renderTable(app, r) + : h('div', { class: 'placeholder' }, h('div', null, 'No estimate rows (ESTIMATE applies to MergeTree tables).')); + } + return h('div', { class: 'raw-text-view', tabindex: '0' }, r.rawText || ''); +} + // 2px progress strip atop the results body while a query streams. function streamStrip(r) { return h('div', { class: 'stream-strip' }, @@ -119,22 +144,34 @@ function streamStrip(r) { } function buildToolbar(app, r) { - const isRaw = r && r.rawText != null; const toolbar = h('div', { class: 'res-toolbar' }); const tabs = h('div', { class: 'result-view-tabs' }); - const views = isRaw - ? [{ id: 'raw', label: r.rawFormat, icon: r.rawFormat === 'JSON' ? Icon.json() : Icon.table2() }] - : [ - { id: 'table', label: 'Table', icon: Icon.table2() }, - { id: 'json', label: 'JSON', icon: Icon.json() }, - { id: 'chart', label: 'Chart', icon: Icon.chart() }, - ]; - for (const v of views) { - const isActive = app.state.resultView === v.id || (isRaw && v.id === 'raw'); - tabs.appendChild(h('button', { - class: 'result-view-tab' + (isActive ? ' active' : ''), - onclick: () => { app.state.resultView = v.id; renderResults(app); }, - }, v.icon, h('span', null, v.label))); + if (r && r.explainView) { + // The five EXPLAIN views — clicking re-runs the derived query (editor SQL is + // never touched). Stays visible on error so a failing view can be switched. + for (const v of EXPLAIN_VIEWS) { + const icon = EXPLAIN_ICONS[v.id]; + tabs.appendChild(h('button', { + class: 'result-view-tab' + (r.explainView === v.id ? ' active' : ''), + onclick: () => app.actions.setExplainView(v.id), + }, icon ? icon() : null, h('span', null, v.label))); + } + } else { + const isRaw = r && r.rawText != null; + const views = isRaw + ? [{ id: 'raw', label: r.rawFormat, icon: r.rawFormat === 'JSON' ? Icon.json() : Icon.table2() }] + : [ + { id: 'table', label: 'Table', icon: Icon.table2() }, + { id: 'json', label: 'JSON', icon: Icon.json() }, + { id: 'chart', label: 'Chart', icon: Icon.chart() }, + ]; + for (const v of views) { + const isActive = app.state.resultView === v.id || (isRaw && v.id === 'raw'); + tabs.appendChild(h('button', { + class: 'result-view-tab' + (isActive ? ' active' : ''), + onclick: () => { app.state.resultView = v.id; renderResults(app); }, + }, v.icon, h('span', null, v.label))); + } } toolbar.appendChild(tabs); toolbar.appendChild(h('div', { style: { flex: '1' } })); diff --git a/tests/helpers/fake-app.js b/tests/helpers/fake-app.js index 9536e2b..e3dadce 100644 --- a/tests/helpers/fake-app.js +++ b/tests/helpers/fake-app.js @@ -69,6 +69,8 @@ export function makeApp(over = {}) { exportResult: vi.fn(), save: vi.fn(), formatQuery: vi.fn(), + explainQuery: vi.fn(), + setExplainView: 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 057d644..de4c43e 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -349,14 +349,56 @@ describe('query run', () => { expect(app.activeTab().result.rawText).toBe('a\tb'); expect(app.activeTab().result.rawFormat).toBe('TabSeparatedWithNames'); // label for the raw tab }); - it('runs EXPLAIN raw (plan text) when no explicit FORMAT is given', async () => { - const { app } = appForRun([ + const sentExplains = (e) => e.fetch.mock.calls.map((c) => c[1] && c[1].body).filter((b) => /EXPLAIN/.test(b || '')); + it('runs a plain EXPLAIN verbatim in the Explain view (clean TabSeparatedRaw)', async () => { + const { app, e } = appForRun([ [(u, sql) => /EXPLAIN/.test(sql), resp({ text: 'Expression\n ReadFromTable' })], ]); app.activeTab().sql = 'EXPLAIN SELECT 1'; await app.actions.run(); + expect(app.activeTab().result.explainView).toBe('explain'); expect(app.activeTab().result.rawText).toBe('Expression\n ReadFromTable'); - expect(app.activeTab().result.rawFormat).toBe('TabSeparated'); // plain TS → no header noise + expect(sentExplains(e)).toContain('EXPLAIN SELECT 1'); // verbatim + }); + it('keeps a complex EXPLAIN (extra settings) on the verbatim Explain view', async () => { + const { app, e } = appForRun([[(u, sql) => /EXPLAIN/.test(sql), resp({ text: 'plan' })]]); + app.activeTab().sql = 'EXPLAIN indexes = 1, actions = 1 SELECT 1'; + await app.actions.run(); + expect(app.activeTab().result.explainView).toBe('explain'); // not auto-jumped to Indexes + expect(sentExplains(e)).toContain('EXPLAIN indexes = 1, actions = 1 SELECT 1'); // run as typed + }); + it('auto-selects the Indexes view for an exact indexes=1 EXPLAIN', async () => { + const { app } = appForRun([[(u, sql) => /EXPLAIN/.test(sql), resp({ text: 'idx plan' })]]); + app.activeTab().sql = 'EXPLAIN indexes = 1 SELECT 1'; + await app.actions.run(); + expect(app.activeTab().result.explainView).toBe('indexes'); + }); + it('setExplainView re-runs a derived query and never edits the SQL', async () => { + const { app, e } = appForRun([[(u, sql) => /EXPLAIN/.test(sql), resp({ text: 'digraph{}' })]]); + app.activeTab().sql = 'EXPLAIN SELECT 1'; + await app.actions.run(); + await app.actions.setExplainView('pipeline'); + expect(app.activeTab().sql).toBe('EXPLAIN SELECT 1'); // editor untouched + expect(app.activeTab().result.explainView).toBe('pipeline'); + expect(sentExplains(e)).toContain('EXPLAIN PIPELINE graph = 1 SELECT 1'); + }); + it('the Explain button explains a plain SELECT (wraps it, editor untouched)', async () => { + const { app, e } = appForRun([[(u, sql) => /EXPLAIN/.test(sql), resp({ text: 'plan' })]]); + app.activeTab().sql = 'SELECT 1'; + await app.actions.explainQuery(); + expect(app.activeTab().sql).toBe('SELECT 1'); // editor untouched + expect(app.activeTab().result.explainView).toBe('explain'); + expect(sentExplains(e)).toContain('EXPLAIN SELECT 1'); + }); + it('runs ESTIMATE as a structured table (streaming), not raw', async () => { + const { app } = appForRun([ + [(u, sql) => /ESTIMATE/.test(sql), resp({ body: streamBody(['{"meta":[{"name":"rows","type":"UInt64"}]}\n', '{"row":{"rows":"42"}}\n']) })], + ]); + app.activeTab().sql = 'EXPLAIN ESTIMATE SELECT 1'; + await app.actions.run(); + expect(app.activeTab().result.explainView).toBe('estimate'); + expect(app.activeTab().result.rows).toEqual([['42']]); + expect(app.activeTab().result.rawText).toBeNull(); }); it('an explicit FORMAT on an EXPLAIN still wins over the raw default', async () => { const { app } = appForRun([ diff --git a/tests/unit/dot.test.js b/tests/unit/dot.test.js new file mode 100644 index 0000000..a064cdb --- /dev/null +++ b/tests/unit/dot.test.js @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { parseDot, layoutGraph } from '../../src/core/dot.js'; + +describe('parseDot', () => { + it('pulls labelled nodes and edges from a digraph, skipping the preamble', () => { + const dot = `some stray header line +digraph +{ + rankdir="LR"; + n1 [label="NumbersRange"]; + n2 [label="Filter"]; + n1 -> n2; +}`; + const g = parseDot(dot); + expect(g.nodes).toEqual([{ id: 'n1', label: 'NumbersRange' }, { id: 'n2', label: 'Filter' }]); + expect(g.edges).toEqual([{ from: 'n1', to: 'n2' }]); + }); + it('works without a leading "digraph" token', () => { + const g = parseDot('a [label="A"]; b [label="B"]; a -> b;'); + expect(g.nodes.map((n) => n.id)).toEqual(['a', 'b']); + expect(g.edges).toEqual([{ from: 'a', to: 'b' }]); + }); + it('de-duplicates node ids and skips DOT keywords', () => { + const g = parseDot('node [label="default"]; n1 [label="A"]; n1 [label="A again"]; n1 -> n2;'); + // `node` keyword skipped; n1 only once; n2 added from the edge. + expect(g.nodes).toEqual([{ id: 'n1', label: 'A' }, { id: 'n2', label: 'n2' }]); + }); + it('skips edges whose endpoints are DOT keywords', () => { + const g = parseDot('digraph { n1 [label="A"]; node -> n1; }'); + expect(g.edges).toEqual([]); + }); + it('unescapes quotes and collapses \\n in labels', () => { + const g = parseDot('digraph { n1 [label="line1\\nline2"]; n2 [label="say \\"hi\\""]; }'); + expect(g.nodes[0].label).toBe('line1 line2'); + expect(g.nodes[1].label).toBe('say "hi"'); + }); + it('tolerates empty / nullish input', () => { + expect(parseDot('')).toEqual({ nodes: [], edges: [] }); + expect(parseDot(null)).toEqual({ nodes: [], edges: [] }); + }); +}); + +describe('layoutGraph', () => { + it('returns an empty layout for no nodes', () => { + expect(layoutGraph({ nodes: [], edges: [] })).toEqual({ nodes: [], edges: [], width: 0, height: 0 }); + expect(layoutGraph({})).toEqual({ nodes: [], edges: [], width: 0, height: 0 }); + }); + it('lays a chain out left→right in increasing layers', () => { + const g = layoutGraph(parseDot('digraph { a [label="A"]; b [label="B"]; c [label="C"]; a -> b; b -> c; }')); + const by = Object.fromEntries(g.nodes.map((n) => [n.id, n])); + expect(by.a.x).toBeLessThan(by.b.x); + expect(by.b.x).toBeLessThan(by.c.x); + expect(g.width).toBeGreaterThan(0); + expect(g.height).toBeGreaterThan(0); + expect(g.edges).toHaveLength(2); + // each edge is a 2-point polyline from a right edge to a left edge + expect(g.edges[0].points).toHaveLength(2); + expect(g.edges[0].points[0].x).toBe(by.a.x + by.a.w); + expect(g.edges[0].points[1].x).toBe(by.b.x); + }); + it('uses the longest path for layering (diamond)', () => { + // a->b->d and a->d : d must sit a column past b, not next to a. + const g = layoutGraph(parseDot('digraph { a[label="a"]; b[label="b"]; d[label="d"]; a->b; b->d; a->d; }')); + const by = Object.fromEntries(g.nodes.map((n) => [n.id, n])); + expect(by.d.x).toBeGreaterThan(by.b.x); + }); + it('stacks a fan-in column and gives the column a uniform width', () => { + const g = layoutGraph(parseDot('digraph { a[label="a"]; b[label="b"]; c[label="c"]; t[label="target"]; a->t; b->t; c->t; }')); + const by = Object.fromEntries(g.nodes.map((n) => [n.id, n])); + expect(by.a.w).toBe(by.b.w); // sources share one column width + expect(new Set([by.a.y, by.b.y, by.c.y]).size).toBe(3); // stacked, distinct rows + }); + it('filters edges with an unknown endpoint', () => { + const g = layoutGraph({ nodes: [{ id: 'a', label: 'a' }], edges: [{ from: 'a', to: 'ghost' }] }); + expect(g.nodes).toHaveLength(1); + expect(g.edges).toHaveLength(0); + }); + it('is cycle-safe (no infinite loop) and still positions every node', () => { + const g = layoutGraph(parseDot('digraph { a[label="a"]; b[label="b"]; a->b; b->a; }')); + expect(g.nodes).toHaveLength(2); + for (const n of g.nodes) { + expect(Number.isFinite(n.x)).toBe(true); + expect(Number.isFinite(n.y)).toBe(true); + } + }); +}); diff --git a/tests/unit/explain-graph.test.js b/tests/unit/explain-graph.test.js new file mode 100644 index 0000000..5273aa4 --- /dev/null +++ b/tests/unit/explain-graph.test.js @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { renderExplainGraph } from '../../src/ui/explain-graph.js'; + +const DOT = `digraph +{ + rankdir="LR"; + n1 [label="NumbersRange"]; + n2 [label="Filter"]; + n3 [label="Aggregating"]; + n1 -> n2; + n2 -> n3; +}`; + +describe('renderExplainGraph', () => { + it('draws an SVG with one rect+label per node and a path per edge', () => { + const el = renderExplainGraph({ rawText: DOT }); + expect(el.className).toBe('explain-graph-view'); + const svg = el.querySelector('svg.explain-graph'); + expect(svg).not.toBeNull(); + expect(svg.getAttribute('viewBox')).toMatch(/^0 0 \d+(\.\d+)? \d+(\.\d+)?$/); + expect(svg.querySelectorAll('rect.eg-node')).toHaveLength(3); + expect(svg.querySelectorAll('text.eg-label')).toHaveLength(3); + expect(svg.querySelectorAll('path.eg-edge')).toHaveLength(2); + // a reusable arrowhead marker is defined and referenced + expect(svg.querySelector('marker#eg-arrow')).not.toBeNull(); + expect(svg.querySelector('path.eg-edge').getAttribute('marker-end')).toBe('url(#eg-arrow)'); + expect([...svg.querySelectorAll('text.eg-label')].map((t) => t.textContent)) + .toEqual(['NumbersRange', 'Filter', 'Aggregating']); + }); + it('shows a placeholder when the DOT has no nodes', () => { + const el = renderExplainGraph({ rawText: 'digraph {}' }); + expect(el.className).toBe('placeholder'); + expect(el.textContent).toMatch(/No pipeline graph/); + }); + it('tolerates a null rawText', () => { + const el = renderExplainGraph({ rawText: null }); + expect(el.className).toBe('placeholder'); + }); +}); diff --git a/tests/unit/explain.test.js b/tests/unit/explain.test.js new file mode 100644 index 0000000..344e839 --- /dev/null +++ b/tests/unit/explain.test.js @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest'; +import { EXPLAIN_VIEWS, parseExplain, detectExplainView, buildExplainQuery } from '../../src/core/explain.js'; + +describe('EXPLAIN_VIEWS', () => { + it('lists the five views with a renderer kind and a ClickHouse format', () => { + expect(EXPLAIN_VIEWS.map((v) => v.id)).toEqual(['explain', 'indexes', 'projections', 'pipeline', 'estimate']); + const byId = Object.fromEntries(EXPLAIN_VIEWS.map((v) => [v.id, v])); + expect(byId.pipeline.kind).toBe('graph'); + expect(byId.estimate.kind).toBe('table'); + expect(byId.estimate.chFormat).toBe('Table'); + expect(byId.explain.chFormat).toBe('TabSeparatedRaw'); + }); +}); + +describe('parseExplain', () => { + it('returns null for non-EXPLAIN statements', () => { + expect(parseExplain('SELECT 1')).toBeNull(); + expect(parseExplain('SELECT explain FROM t')).toBeNull(); + expect(parseExplain('')).toBeNull(); + expect(parseExplain(null)).toBeNull(); + }); + it('parses a bare EXPLAIN (= PLAN) with no settings', () => { + expect(parseExplain('EXPLAIN SELECT 1')).toEqual({ kind: 'PLAN', settings: {}, inner: 'SELECT 1' }); + expect(parseExplain(' explain SELECT 1 ')).toEqual({ kind: 'PLAN', settings: {}, inner: 'SELECT 1' }); + }); + it('parses an explicit PLAN keyword and a WITH/CTE inner', () => { + expect(parseExplain('EXPLAIN PLAN WITH x AS (SELECT 1) SELECT * FROM x')) + .toEqual({ kind: 'PLAN', settings: {}, inner: 'WITH x AS (SELECT 1) SELECT * FROM x' }); + }); + it('parses form keywords (PIPELINE / ESTIMATE / AST / multi-word)', () => { + expect(parseExplain('EXPLAIN PIPELINE SELECT 1').kind).toBe('PIPELINE'); + expect(parseExplain('EXPLAIN ESTIMATE SELECT 1').kind).toBe('ESTIMATE'); + expect(parseExplain('EXPLAIN AST SELECT 1').kind).toBe('AST'); + expect(parseExplain('EXPLAIN QUERY TREE SELECT 1').kind).toBe('QUERY TREE'); + }); + it('captures a single setting and the inner statement', () => { + expect(parseExplain('EXPLAIN indexes = 1 SELECT 1')).toEqual({ kind: 'PLAN', settings: { indexes: '1' }, inner: 'SELECT 1' }); + expect(parseExplain('EXPLAIN PIPELINE graph = 1 SELECT 1')).toEqual({ kind: 'PIPELINE', settings: { graph: '1' }, inner: 'SELECT 1' }); + }); + it('captures multiple comma- or space-separated settings', () => { + expect(parseExplain('EXPLAIN indexes = 1, actions = 1 SELECT 1').settings).toEqual({ indexes: '1', actions: '1' }); + expect(parseExplain("EXPLAIN PIPELINE graph = 1 compact = 0 SELECT 1").settings).toEqual({ graph: '1', compact: '0' }); + }); + it('unquotes string setting values', () => { + expect(parseExplain("EXPLAIN description = 'x' SELECT 1").settings).toEqual({ description: 'x' }); + }); +}); + +describe('detectExplainView', () => { + it('returns null for nullish input', () => { + expect(detectExplainView(null)).toBeNull(); + }); + it('maps an exact single defining setting/kind to its rich view', () => { + expect(detectExplainView(parseExplain('EXPLAIN indexes = 1 SELECT 1'))).toBe('indexes'); + expect(detectExplainView(parseExplain('EXPLAIN projections = 1 SELECT 1'))).toBe('projections'); + expect(detectExplainView(parseExplain('EXPLAIN PIPELINE graph = 1 SELECT 1'))).toBe('pipeline'); + expect(detectExplainView(parseExplain('EXPLAIN PIPELINE graph = 1 compact = 0 SELECT 1'))).toBe('pipeline'); + expect(detectExplainView(parseExplain('EXPLAIN ESTIMATE SELECT 1'))).toBe('estimate'); + }); + it('returns null for plain EXPLAIN, extra settings, or unmanaged kinds', () => { + expect(detectExplainView(parseExplain('EXPLAIN SELECT 1'))).toBeNull(); + expect(detectExplainView(parseExplain('EXPLAIN indexes = 1, actions = 1 SELECT 1'))).toBeNull(); + expect(detectExplainView(parseExplain('EXPLAIN indexes = 0 SELECT 1'))).toBeNull(); + expect(detectExplainView(parseExplain('EXPLAIN PIPELINE graph = 1 header = 1 SELECT 1'))).toBeNull(); + expect(detectExplainView(parseExplain('EXPLAIN ESTIMATE indexes = 1 SELECT 1'))).toBeNull(); + expect(detectExplainView(parseExplain('EXPLAIN AST SELECT 1'))).toBeNull(); + expect(detectExplainView(parseExplain('EXPLAIN PIPELINE SELECT 1'))).toBeNull(); + }); +}); + +describe('buildExplainQuery', () => { + it('builds the derived query for each rich view', () => { + expect(buildExplainQuery('SELECT 1', 'indexes')).toBe('EXPLAIN indexes = 1 SELECT 1'); + expect(buildExplainQuery('SELECT 1', 'projections')).toBe('EXPLAIN projections = 1 SELECT 1'); + expect(buildExplainQuery('SELECT 1', 'pipeline')).toBe('EXPLAIN PIPELINE graph = 1 SELECT 1'); + expect(buildExplainQuery('SELECT 1', 'estimate')).toBe('EXPLAIN ESTIMATE SELECT 1'); + }); + it('falls back to a plain EXPLAIN for explain/unknown ids', () => { + expect(buildExplainQuery('SELECT 1', 'explain')).toBe('EXPLAIN SELECT 1'); + expect(buildExplainQuery('SELECT 1', 'bogus')).toBe('EXPLAIN SELECT 1'); + expect(buildExplainQuery(null, 'explain')).toBe('EXPLAIN '); + }); +}); diff --git a/tests/unit/results.test.js b/tests/unit/results.test.js index 58083b0..dd55ad9 100644 --- a/tests/unit/results.test.js +++ b/tests/unit/results.test.js @@ -484,3 +484,62 @@ describe('installChartZoomFix', () => { expect(installChartZoomFix(null, null)).toBeNull(); }); }); + +describe('EXPLAIN views', () => { + function explainResult(view, over = {}) { + const r = newResult(view === 'estimate' ? 'Table' : 'TabSeparatedRaw'); + r.explainView = view; + return Object.assign(r, over); + } + + it('toolbar shows the five EXPLAIN tabs with the active one marked', () => { + const app = appWithResult(explainResult('pipeline', { rawText: 'digraph { n1 [label="A"]; }' })); + renderResults(app); + const tabs = [...app.dom.resultsRegion.querySelectorAll('.result-view-tab')]; + expect(tabs.map((t) => t.textContent)).toEqual(['Explain', 'Indexes', 'Projections', 'Pipeline', 'Estimate']); + expect(tabs.find((t) => t.classList.contains('active')).textContent).toBe('Pipeline'); + }); + + it('clicking a tab calls setExplainView (re-runs the derived query)', () => { + const app = appWithResult(explainResult('explain', { rawText: 'plan text' })); + renderResults(app); + const tabs = [...app.dom.resultsRegion.querySelectorAll('.result-view-tab')]; + click(tabs[3]); // Pipeline + expect(app.actions.setExplainView).toHaveBeenCalledWith('pipeline'); + }); + + it('renders Explain/Indexes/Projections as monospace text', () => { + const app = appWithResult(explainResult('explain', { rawText: 'Expression\n ReadFromTable' })); + renderResults(app); + const view = app.dom.resultsRegion.querySelector('.raw-text-view'); + expect(view).not.toBeNull(); + expect(view.textContent).toBe('Expression\n ReadFromTable'); + }); + + it('renders Pipeline as the SVG graph', () => { + const app = appWithResult(explainResult('pipeline', { rawText: 'digraph { n1 [label="A"]; n2 [label="B"]; n1 -> n2; }' })); + renderResults(app); + expect(app.dom.resultsRegion.querySelector('.explain-graph-view svg.explain-graph')).not.toBeNull(); + }); + + it('renders Estimate as a structured table, with a placeholder when empty', () => { + const r = explainResult('estimate'); + r.columns = [{ name: 'rows', type: 'UInt64' }]; + r.rows = [['42']]; + const app = appWithResult(r); + renderResults(app); + expect(app.dom.resultsRegion.querySelector('table.res-table')).not.toBeNull(); + + const empty = appWithResult(explainResult('estimate', { columns: [], rows: [] })); + renderResults(empty); + expect(empty.dom.resultsRegion.querySelector('table.res-table')).toBeNull(); + expect(empty.dom.resultsRegion.textContent).toMatch(/ESTIMATE applies to MergeTree/); + }); + + it('keeps the EXPLAIN tabs visible when a view errors', () => { + const app = appWithResult(explainResult('indexes', { error: 'DB::Exception: boom' })); + renderResults(app); + expect(app.dom.resultsRegion.querySelectorAll('.result-view-tab')).toHaveLength(5); + expect(app.dom.resultsRegion.querySelector('.results-error').textContent).toContain('boom'); + }); +}); From ec7503f4b9a9a736337c9b29b5dccefc023db16e Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Thu, 25 Jun 2026 12:53:51 +0200 Subject: [PATCH 2/5] docs(explain): README EXPLAIN-views section; neutral empty-Estimate message Reword the empty-Estimate placeholder (a trivial count() is answered from metadata, so ESTIMATE returns no part rows even on a MergeTree table). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QGBS74oUsXarGkCRQKEFLu --- README.md | 22 ++++++++++++++++++++++ src/ui/results.js | 2 +- tests/unit/results.test.js | 2 +- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 835cf1a..c9a79ef 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,28 @@ of scope for a textarea and tracked separately (CodeMirror, issue #21). > work. The `.jsx` files there are React prototypes; production is the vanilla > ES-module code under `src/`. +## EXPLAIN views + +Run an `EXPLAIN` (or click **Explain** in the editor toolbar to explain the +current query without editing it) and the results pane offers five views of the +plan — switching one re-runs the query in that form; **the editor SQL is never +rewritten**: + +- **Explain** — your `EXPLAIN` run *verbatim*, so any parameters you typed + (`EXPLAIN indexes=1, actions=1, json=1 …`) are honored. Shown as plan text. +- **Indexes** / **Projections** — `EXPLAIN indexes = 1` / `projections = 1` of the + inner query (used parts/granules, analyzed projections). Plan text. +- **Pipeline** — `EXPLAIN PIPELINE graph = 1`, whose Graphviz DOT is drawn as a + boxes-and-arrows processor graph by a small self-contained SVG renderer (no new + dependency; DOT parse + layered layout are pure in `src/core/dot.js`). +- **Estimate** — `EXPLAIN ESTIMATE`, rendered as a real table (database, table, + parts, rows, marks). + +Running a statement that *exactly* matches one of the rich forms auto-selects its +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. + ## Saved queries & the Library Queries you save (★ **Save** next to Run, or `⌘S`) land in the sidebar **★ Library** diff --git a/src/ui/results.js b/src/ui/results.js index b89115c..fc306d7 100644 --- a/src/ui/results.js +++ b/src/ui/results.js @@ -130,7 +130,7 @@ function renderExplainView(app, r) { if (kind === 'table') { return r.rows.length ? renderTable(app, r) - : h('div', { class: 'placeholder' }, h('div', null, 'No estimate rows (ESTIMATE applies to MergeTree tables).')); + : h('div', { class: 'placeholder' }, h('div', null, 'No rows to estimate for this query (only MergeTree reads that scan parts produce an estimate).')); } return h('div', { class: 'raw-text-view', tabindex: '0' }, r.rawText || ''); } diff --git a/tests/unit/results.test.js b/tests/unit/results.test.js index dd55ad9..94c38c4 100644 --- a/tests/unit/results.test.js +++ b/tests/unit/results.test.js @@ -533,7 +533,7 @@ describe('EXPLAIN views', () => { const empty = appWithResult(explainResult('estimate', { columns: [], rows: [] })); renderResults(empty); expect(empty.dom.resultsRegion.querySelector('table.res-table')).toBeNull(); - expect(empty.dom.resultsRegion.textContent).toMatch(/ESTIMATE applies to MergeTree/); + expect(empty.dom.resultsRegion.textContent).toMatch(/No rows to estimate/); }); it('keeps the EXPLAIN tabs visible when a view errors', () => { From d87d2aec3eec4dad94228056d32ca7da2744d977 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Thu, 25 Jun 2026 13:14:46 +0200 Subject: [PATCH 3/5] feat(explain): vertical pipeline graph, drop EXPLAIN header stats, e2e graph test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pipeline graph now lays out top→bottom: sequential stages stack vertically, parallel processors in a stage sit side-by-side horizontally. Edges flow bottom→top-centre; self-loops are filtered. - EXPLAIN views drop the ms/rows/bytes header stats (not meaningful for a plan) to give the five tabs room. - Add a Playwright e2e test that renders a real `EXPLAIN PIPELINE graph = 1` capture from the antalya ontime dataset (fact/dim join + aggregated subquery join, 37 processors / 40 edges) and asserts the vertical-with-parallel-lanes SVG. Fixture: tests/e2e/fixtures/ontime-pipeline-graph.dot. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QGBS74oUsXarGkCRQKEFLu --- src/core/dot.js | 56 ++-- src/ui/results.js | 31 ++- tests/e2e/fixtures/ontime-pipeline-graph.dot | 261 +++++++++++++++++++ tests/e2e/pipeline-graph.spec.js | 81 ++++++ tests/e2e/pipeline.html | 28 ++ tests/unit/dot.test.js | 34 +-- 6 files changed, 433 insertions(+), 58 deletions(-) create mode 100644 tests/e2e/fixtures/ontime-pipeline-graph.dot create mode 100644 tests/e2e/pipeline-graph.spec.js create mode 100644 tests/e2e/pipeline.html diff --git a/src/core/dot.js b/src/core/dot.js index 8389a7c..119e539 100644 --- a/src/core/dot.js +++ b/src/core/dot.js @@ -7,8 +7,8 @@ const DOT_KEYWORDS = new Set(['node', 'edge', 'graph', 'subgraph', 'digraph']); // Layout constants (px). Tuned for ClickHouse pipeline graphs (short labels). const NODE_H = 30; -const V_GAP = 16; -const H_GAP = 64; +const V_GAP = 30; // vertical gap between sequential stages (room for the arrow) +const H_GAP = 28; // horizontal gap between parallel processors in a stage const CHAR_W = 7; const PAD_X = 18; const MIN_W = 64; @@ -55,17 +55,20 @@ export function parseDot(text) { } /** - * Lay a parsed graph out left→right by layer. Returns positioned nodes - * (`{id,label,x,y,w,h}`), edges as 2-point polylines (`{from,to,points}`), and - * the overall `width`/`height` for the SVG viewBox. Longest-path layering - * (Kahn topo; cycle-safe — leftover nodes stay in layer 0), uniform column - * widths, vertically centred columns. Pure. + * Lay a parsed graph out top→bottom by layer: sequential stages stack + * vertically, parallel processors in the same stage sit side-by-side. Returns + * positioned nodes (`{id,label,x,y,w,h}`), edges as 2-point polylines + * (`{from,to,points}`, bottom-centre → top-centre), and the overall + * `width`/`height` for the SVG viewBox. Longest-path layering (Kahn topo; + * cycle-safe — leftover nodes stay in layer 0), each row centred. Pure. */ export function layoutGraph(graph) { const nodes = (graph.nodes || []).map((n) => ({ ...n })); if (!nodes.length) return { nodes: [], edges: [], width: 0, height: 0 }; const byId = new Map(nodes.map((n) => [n.id, n])); - const edges = (graph.edges || []).filter((e) => byId.has(e.from) && byId.has(e.to)); + // Drop edges to unknown nodes and self-loops (a self-loop would just draw a + // line over its own node and can stall the topological layering). + const edges = (graph.edges || []).filter((e) => byId.has(e.from) && byId.has(e.to) && e.from !== e.to); // Longest-path layering via Kahn topological order. const succ = new Map(nodes.map((n) => [n.id, []])); @@ -91,34 +94,27 @@ export function layoutGraph(graph) { const layers = Array.from({ length: maxLayer + 1 }, () => []); for (const n of nodes) layers[layer.get(n.id)].push(n.id); - // Node sizes, then uniform width per column and column x positions. + // Vertical layout: layers stack top→bottom (sequential stages), and nodes + // within a layer sit side-by-side left→right (parallel processors). Node widths + // vary with the label; each row is centred under the widest row. for (const n of nodes) { n.w = Math.max(MIN_W, n.label.length * CHAR_W + PAD_X); n.h = NODE_H; } - const layerX = []; - let x = MARGIN; - let maxColH = 0; - for (let L = 0; L < layers.length; L++) { - const col = layers[L]; - const colW = col.reduce((mx, id) => Math.max(mx, byId.get(id).w), MIN_W); - for (const id of col) byId.get(id).w = colW; - layerX[L] = x; - x += colW + H_GAP; - const colH = col.length * NODE_H + Math.max(0, col.length - 1) * V_GAP; - if (colH > maxColH) maxColH = colH; - } + const rowWidth = (col) => + col.reduce((sum, id) => sum + byId.get(id).w, 0) + Math.max(0, col.length - 1) * H_GAP; + let maxRowW = 0; + for (const col of layers) maxRowW = Math.max(maxRowW, rowWidth(col)); - // Vertically centre each column within the tallest column. for (let L = 0; L < layers.length; L++) { const col = layers[L]; - const colH = col.length * NODE_H + Math.max(0, col.length - 1) * V_GAP; - let y = MARGIN + (maxColH - colH) / 2; + const y = MARGIN + L * (NODE_H + V_GAP); + let x = MARGIN + (maxRowW - rowWidth(col)) / 2; for (const id of col) { const n = byId.get(id); - n.x = layerX[L]; + n.x = x; n.y = y; - y += NODE_H + V_GAP; + x += n.w + H_GAP; } } @@ -129,8 +125,8 @@ export function layoutGraph(graph) { from: e.from, to: e.to, points: [ - { x: a.x + a.w, y: a.y + a.h / 2 }, - { x: b.x, y: b.y + b.h / 2 }, + { x: a.x + a.w / 2, y: a.y + a.h }, + { x: b.x + b.w / 2, y: b.y }, ], }; }); @@ -138,7 +134,7 @@ export function layoutGraph(graph) { return { nodes, edges: laidEdges, - width: x - H_GAP + MARGIN, - height: maxColH + MARGIN * 2, + width: maxRowW + MARGIN * 2, + height: layers.length * NODE_H + Math.max(0, layers.length - 1) * V_GAP + MARGIN * 2, }; } diff --git a/src/ui/results.js b/src/ui/results.js index fc306d7..5972397 100644 --- a/src/ui/results.js +++ b/src/ui/results.js @@ -175,15 +175,20 @@ function buildToolbar(app, r) { } toolbar.appendChild(tabs); toolbar.appendChild(h('div', { style: { flex: '1' } })); + // EXPLAIN views suppress the ms/rows/bytes stats — they're not meaningful for a + // plan and the freed space lets the five tabs breathe. + const showStats = !(r && r.explainView); if (app.state.running) { // Live counters (accent, mono) + Cancel — replaces the static stats while // streaming. The ms element is updated in place by app.tickElapsed(). - app.dom.runElapsedEl = h('span', { class: 'v' }, app.elapsedMs().toFixed(0) + ' ms'); - toolbar.appendChild(h('div', { class: 'stat live' }, h('span', { class: 'ic spin' }, Icon.spinner()), app.dom.runElapsedEl)); - toolbar.appendChild(h('div', { class: 'stat live' }, h('span', { class: 'ic' }, Icon.rows()), - h('span', { class: 'v' }, formatRows(r ? r.progress.rows : 0) + ' rows'))); - toolbar.appendChild(h('div', { class: 'stat live' }, h('span', { class: 'ic' }, Icon.bytes()), - h('span', { class: 'v' }, formatBytes(r ? r.progress.bytes : 0)))); + if (showStats) { + app.dom.runElapsedEl = h('span', { class: 'v' }, app.elapsedMs().toFixed(0) + ' ms'); + toolbar.appendChild(h('div', { class: 'stat live' }, h('span', { class: 'ic spin' }, Icon.spinner()), app.dom.runElapsedEl)); + toolbar.appendChild(h('div', { class: 'stat live' }, h('span', { class: 'ic' }, Icon.rows()), + h('span', { class: 'v' }, formatRows(r ? r.progress.rows : 0) + ' rows'))); + toolbar.appendChild(h('div', { class: 'stat live' }, h('span', { class: 'ic' }, Icon.bytes()), + h('span', { class: 'v' }, formatBytes(r ? r.progress.bytes : 0)))); + } toolbar.appendChild(h('button', { class: 'res-act cancel-act', title: 'Cancel query (Esc)', onclick: () => app.actions.cancel(), @@ -192,12 +197,14 @@ function buildToolbar(app, r) { if (r.cancelled) { toolbar.appendChild(h('span', { class: 'cancelled-badge' }, 'Cancelled · partial')); } - const ms = (r.progress.elapsed_ns / 1e6).toFixed(0); - toolbar.appendChild(h('div', { class: 'stat' }, h('span', { class: 'ic' }, Icon.clock()), h('span', { class: 'v' }, ms + ' ms'))); - toolbar.appendChild(h('div', { class: 'stat' }, h('span', { class: 'ic' }, Icon.rows()), - h('span', { class: 'v' }, (r.rawText != null ? '—' : r.rows.length) + ' rows'))); - toolbar.appendChild(h('div', { class: 'stat', title: r.progress.rows + ' rows scanned' }, - h('span', { class: 'ic' }, Icon.bytes()), h('span', { class: 'v' }, formatBytes(r.progress.bytes)))); + if (showStats) { + const ms = (r.progress.elapsed_ns / 1e6).toFixed(0); + toolbar.appendChild(h('div', { class: 'stat' }, h('span', { class: 'ic' }, Icon.clock()), h('span', { class: 'v' }, ms + ' ms'))); + toolbar.appendChild(h('div', { class: 'stat' }, h('span', { class: 'ic' }, Icon.rows()), + h('span', { class: 'v' }, (r.rawText != null ? '—' : r.rows.length) + ' rows'))); + toolbar.appendChild(h('div', { class: 'stat', title: r.progress.rows + ' rows scanned' }, + h('span', { class: 'ic' }, Icon.bytes()), h('span', { class: 'v' }, formatBytes(r.progress.bytes)))); + } if (!r.error) { toolbar.appendChild(h('button', { class: 'res-act', title: 'Copy results to clipboard', diff --git a/tests/e2e/fixtures/ontime-pipeline-graph.dot b/tests/e2e/fixtures/ontime-pipeline-graph.dot new file mode 100644 index 0000000..436cff6 --- /dev/null +++ b/tests/e2e/fixtures/ontime-pipeline-graph.dot @@ -0,0 +1,261 @@ +digraph +{ + rankdir="LR"; + { node [shape = rect] + n17 [label="ColumnPermuteTransform × 8"]; + n9 [label="JoiningTransform × 8"]; + n7 [label="Resize × 2"]; + n8 [label="SimpleSquashingTransform × 16"]; + subgraph cluster_0 { + label ="Expression"; + style=filled; + color=lightgrey; + node [style=filled,color=white]; + { rank = same; + n5 [label="ExpressionTransform × 4"]; + } + } + subgraph cluster_1 { + label ="Expression"; + style=filled; + color=lightgrey; + node [style=filled,color=white]; + { rank = same; + n13 [label="ExpressionTransform"]; + n16 [label="FillingRightJoinSide × 4"]; + n14 [label="Resize × 3"]; + n15 [label="SimpleSquashingTransform × 4"]; + } + } + subgraph cluster_2 { + label ="Expression"; + style=filled; + color=lightgrey; + node [style=filled,color=white]; + { rank = same; + n19 [label="ExpressionTransform × 4"]; + } + } + subgraph cluster_3 { + label ="Expression"; + style=filled; + color=lightgrey; + node [style=filled,color=white]; + { rank = same; + n27 [label="ExpressionTransform × 4"]; + n30 [label="FillingRightJoinSide × 4"]; + n28 [label="Resize × 3"]; + n29 [label="SimpleSquashingTransform × 4"]; + } + } + subgraph cluster_4 { + label ="Expression"; + style=filled; + color=lightgrey; + node [style=filled,color=white]; + { rank = same; + n2 [label="ExpressionTransform × 4"]; + } + } + subgraph cluster_5 { + label ="Expression"; + style=filled; + color=lightgrey; + node [style=filled,color=white]; + { rank = same; + n31 [label="ExpressionTransform × 4"]; + } + } + subgraph cluster_6 { + label ="Expression"; + style=filled; + color=lightgrey; + node [style=filled,color=white]; + { rank = same; + n11 [label="ExpressionTransform"]; + } + } + subgraph cluster_7 { + label ="Expression"; + style=filled; + color=lightgrey; + node [style=filled,color=white]; + { rank = same; + n22 [label="ExpressionTransform × 4"]; + } + } + subgraph cluster_8 { + label ="Expression"; + style=filled; + color=lightgrey; + node [style=filled,color=white]; + { rank = same; + n6 [label="ExpressionTransform × 4"]; + } + } + subgraph cluster_9 { + label ="Expression"; + style=filled; + color=lightgrey; + node [style=filled,color=white]; + { rank = same; + n21 [label="ExpressionTransform × 4"]; + } + } + subgraph cluster_10 { + label ="Expression"; + style=filled; + color=lightgrey; + node [style=filled,color=white]; + { rank = same; + n37 [label="ExpressionTransform"]; + } + } + subgraph cluster_11 { + label ="Limit"; + style=filled; + color=lightgrey; + node [style=filled,color=white]; + { rank = same; + n36 [label="Limit"]; + } + } + subgraph cluster_12 { + label ="Expression"; + style=filled; + color=lightgrey; + node [style=filled,color=white]; + { rank = same; + n18 [label="ExpressionTransform × 4"]; + } + } + subgraph cluster_13 { + label ="Expression"; + style=filled; + color=lightgrey; + node [style=filled,color=white]; + { rank = same; + n25 [label="ExpressionTransform × 4"]; + } + } + subgraph cluster_14 { + label ="ReadFromMergeTree"; + style=filled; + color=lightgrey; + node [style=filled,color=white]; + { rank = same; + n1 [label="MergeTreeSelect(pool: ReadPool, algorithm: Thread) × 4"]; + } + } + subgraph cluster_15 { + label ="ReadFromMergeTree"; + style=filled; + color=lightgrey; + node [style=filled,color=white]; + { rank = same; + n20 [label="MergeTreeSelect(pool: ReadPool, algorithm: Thread) × 4"]; + } + } + subgraph cluster_16 { + label ="ReadFromMergeTree"; + style=filled; + color=lightgrey; + node [style=filled,color=white]; + { rank = same; + n10 [label="MergeTreeSelect(pool: ReadPoolInOrder, algorithm: InOrder)"]; + } + } + subgraph cluster_17 { + label ="BuildRuntimeFilter"; + style=filled; + color=lightgrey; + node [style=filled,color=white]; + { rank = same; + n12 [label="BuildRuntimeFilterTransform"]; + } + } + subgraph cluster_18 { + label ="Sorting"; + style=filled; + color=lightgrey; + node [style=filled,color=white]; + { rank = same; + n33 [label="LimitsCheckingTransform × 4"]; + n34 [label="MergeSortingTransform × 4"]; + n35 [label="MergingSortedTransform"]; + n32 [label="PartialSortingTransform × 4"]; + } + } + subgraph cluster_19 { + label ="BuildRuntimeFilter"; + style=filled; + color=lightgrey; + node [style=filled,color=white]; + { rank = same; + n26 [label="BuildRuntimeFilterTransform × 4"]; + } + } + subgraph cluster_20 { + label ="Aggregating"; + style=filled; + color=lightgrey; + node [style=filled,color=white]; + { rank = same; + n3 [label="AggregatingTransform × 4"]; + n4 [label="Resize"]; + } + } + subgraph cluster_21 { + label ="Aggregating"; + style=filled; + color=lightgrey; + node [style=filled,color=white]; + { rank = same; + n23 [label="AggregatingTransform × 4"]; + n24 [label="Resize"]; + } + } + } + n17 -> n8 [label="× 8"]; + n9 -> n17 [label="× 8"]; + n7 -> n8 [label="× 8"]; + n8 -> n18 [label="× 4"]; + n8 -> n31 [label="× 4"]; + n8 -> n9 [label="× 8"]; + n5 -> n6 [label="× 4"]; + n13 -> n14 [label=""]; + n16 -> n14 [label="× 4"]; + n14 -> n15 [label="× 4"]; + n14 -> n14 [label=""]; + n14 -> n9 [label="× 4"]; + n15 -> n16 [label="× 4"]; + n19 -> n7 [label="× 4"]; + n27 -> n28 [label="× 4"]; + n30 -> n28 [label="× 4"]; + n28 -> n29 [label="× 4"]; + n28 -> n28 [label=""]; + n28 -> n9 [label="× 4"]; + n29 -> n30 [label="× 4"]; + n2 -> n3 [label="× 4"]; + n31 -> n32 [label="× 4"]; + n11 -> n12 [label=""]; + n22 -> n23 [label="× 4"]; + n6 -> n7 [label="× 4"]; + n21 -> n22 [label="× 4"]; + n36 -> n37 [label=""]; + n18 -> n19 [label="× 4"]; + n25 -> n26 [label="× 4"]; + n1 -> n2 [label="× 4"]; + n20 -> n21 [label="× 4"]; + n10 -> n11 [label=""]; + n12 -> n13 [label=""]; + n33 -> n34 [label="× 4"]; + n34 -> n35 [label="× 4"]; + n35 -> n36 [label=""]; + n32 -> n33 [label="× 4"]; + n26 -> n27 [label="× 4"]; + n3 -> n4 [label="× 4"]; + n4 -> n5 [label="× 4"]; + n23 -> n24 [label="× 4"]; + n24 -> n25 [label="× 4"]; +} diff --git a/tests/e2e/pipeline-graph.spec.js b/tests/e2e/pipeline-graph.spec.js new file mode 100644 index 0000000..a7fcda4 --- /dev/null +++ b/tests/e2e/pipeline-graph.spec.js @@ -0,0 +1,81 @@ +import { test, expect } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +// Real-browser regression for the EXPLAIN PIPELINE graph view. The DOT fixture +// is the *actual* `EXPLAIN PIPELINE graph = 1` output captured from the **antalya +// demo cluster** running the **ontime** dataset, for a deliberately complicated +// query — a fact/dim join plus a second aggregated subquery join, with GROUP BYs, +// ORDER BY and LIMIT — so the pipeline has many parallel lanes and stages: +// +// SELECT a.DisplayAirportName AS airport, o.flights, o.avg_dep_delay, st.state_flights +// FROM ( +// SELECT OriginCode, count() AS flights, round(avg(DepDelayMinutes), 1) AS avg_dep_delay +// FROM ontime.fact_ontime WHERE Year = 2023 AND DepDel15 = 1 GROUP BY OriginCode +// ) o +// INNER JOIN ontime.dim_airports a ON o.OriginCode = a.AirportCode +// INNER JOIN ( +// SELECT OriginState, count() AS state_flights +// FROM ontime.fact_ontime WHERE Year = 2023 GROUP BY OriginState +// ) st ON a.StateCode = st.OriginState +// ORDER BY o.flights DESC LIMIT 20 +// +// Rendering the captured DOT (rather than hitting a live cluster, which needs +// OAuth and isn't available in CI) keeps the test deterministic while still +// exercising the parser + layout + SVG on a real-world complex graph. + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DOT = readFileSync(join(__dirname, 'fixtures', 'ontime-pipeline-graph.dot'), 'utf8'); + +test.describe('EXPLAIN PIPELINE graph (antalya ontime fact/dim join)', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tests/e2e/pipeline.html'); + await page.waitForFunction(() => window.__ready === true); + await page.evaluate((dot) => window.__renderPipeline(dot), DOT); + }); + + test('draws every processor and edge from the captured pipeline', async ({ page }) => { + const svg = page.locator('svg.explain-graph'); + await expect(svg).toBeVisible(); + // 37 processors (n1..n37); 42 edges in the DOT, of which 2 are self-loops + // (Resize feedback) → 40 rendered. + await expect(page.locator('rect.eg-node')).toHaveCount(37); + await expect(page.locator('text.eg-label')).toHaveCount(37); + await expect(page.locator('path.eg-edge')).toHaveCount(40); + // a single reusable arrowhead, referenced by the edges + await expect(page.locator('marker#eg-arrow')).toHaveCount(1); + expect(await page.locator('path.eg-edge').first().getAttribute('marker-end')).toBe('url(#eg-arrow)'); + }); + + test('labels the real ClickHouse processors (compact × N lanes)', async ({ page }) => { + const labels = await page.locator('text.eg-label').allTextContents(); + expect(labels).toContain('JoiningTransform × 8'); + expect(labels).toContain('MergeTreeSelect(pool: ReadPool, algorithm: Thread) × 4'); + expect(labels).toContain('AggregatingTransform × 4'); + expect(labels).toContain('MergingSortedTransform'); + expect(labels).toContain('Limit'); + }); + + test('lays out vertically (stages stacked) with horizontal parallel lanes', async ({ page }) => { + const m = await page.evaluate(() => { + const rects = [...document.querySelectorAll('rect.eg-node')]; + const rows = {}; + for (const r of rects) { + const y = +r.getAttribute('y'); + rows[y] = (rows[y] || 0) + 1; + } + const svg = document.querySelector('svg.explain-graph'); + return { + rowCount: Object.keys(rows).length, + maxPerRow: Math.max(...Object.values(rows)), + width: +svg.getAttribute('width'), + height: +svg.getAttribute('height'), + viewBox: svg.getAttribute('viewBox'), + }; + }); + expect(m.rowCount).toBeGreaterThanOrEqual(5); // many sequential stages → vertical + expect(m.maxPerRow).toBeGreaterThanOrEqual(2); // parallel processors → side-by-side + expect(m.viewBox).toBe(`0 0 ${m.width} ${m.height}`); + }); +}); diff --git a/tests/e2e/pipeline.html b/tests/e2e/pipeline.html new file mode 100644 index 0000000..6bcf3d0 --- /dev/null +++ b/tests/e2e/pipeline.html @@ -0,0 +1,28 @@ + + + + + pipeline graph harness + + + + + +
+ + + + diff --git a/tests/unit/dot.test.js b/tests/unit/dot.test.js index a064cdb..2f074af 100644 --- a/tests/unit/dot.test.js +++ b/tests/unit/dot.test.js @@ -45,35 +45,37 @@ describe('layoutGraph', () => { expect(layoutGraph({ nodes: [], edges: [] })).toEqual({ nodes: [], edges: [], width: 0, height: 0 }); expect(layoutGraph({})).toEqual({ nodes: [], edges: [], width: 0, height: 0 }); }); - it('lays a chain out left→right in increasing layers', () => { + it('lays a chain out top→bottom in increasing layers', () => { const g = layoutGraph(parseDot('digraph { a [label="A"]; b [label="B"]; c [label="C"]; a -> b; b -> c; }')); const by = Object.fromEntries(g.nodes.map((n) => [n.id, n])); - expect(by.a.x).toBeLessThan(by.b.x); - expect(by.b.x).toBeLessThan(by.c.x); + expect(by.a.y).toBeLessThan(by.b.y); + expect(by.b.y).toBeLessThan(by.c.y); expect(g.width).toBeGreaterThan(0); expect(g.height).toBeGreaterThan(0); expect(g.edges).toHaveLength(2); - // each edge is a 2-point polyline from a right edge to a left edge + // each edge is a 2-point polyline from a bottom-centre to a top-centre expect(g.edges[0].points).toHaveLength(2); - expect(g.edges[0].points[0].x).toBe(by.a.x + by.a.w); - expect(g.edges[0].points[1].x).toBe(by.b.x); + expect(g.edges[0].points[0]).toEqual({ x: by.a.x + by.a.w / 2, y: by.a.y + by.a.h }); + expect(g.edges[0].points[1]).toEqual({ x: by.b.x + by.b.w / 2, y: by.b.y }); }); - it('uses the longest path for layering (diamond)', () => { - // a->b->d and a->d : d must sit a column past b, not next to a. + it('uses the longest path for layering (diamond stacks vertically)', () => { + // a->b->d and a->d : d must sit a row below b, not beside a. const g = layoutGraph(parseDot('digraph { a[label="a"]; b[label="b"]; d[label="d"]; a->b; b->d; a->d; }')); const by = Object.fromEntries(g.nodes.map((n) => [n.id, n])); - expect(by.d.x).toBeGreaterThan(by.b.x); + expect(by.d.y).toBeGreaterThan(by.b.y); }); - it('stacks a fan-in column and gives the column a uniform width', () => { + it('spreads parallel processors of one stage horizontally on the same row', () => { const g = layoutGraph(parseDot('digraph { a[label="a"]; b[label="b"]; c[label="c"]; t[label="target"]; a->t; b->t; c->t; }')); const by = Object.fromEntries(g.nodes.map((n) => [n.id, n])); - expect(by.a.w).toBe(by.b.w); // sources share one column width - expect(new Set([by.a.y, by.b.y, by.c.y]).size).toBe(3); // stacked, distinct rows + expect(by.a.y).toBe(by.b.y); // same stage → same row + expect(by.b.y).toBe(by.c.y); + expect(new Set([by.a.x, by.b.x, by.c.x]).size).toBe(3); // side-by-side, distinct columns + expect(by.t.y).toBeGreaterThan(by.a.y); // target below the parallel sources }); - it('filters edges with an unknown endpoint', () => { - const g = layoutGraph({ nodes: [{ id: 'a', label: 'a' }], edges: [{ from: 'a', to: 'ghost' }] }); - expect(g.nodes).toHaveLength(1); - expect(g.edges).toHaveLength(0); + it('filters edges with an unknown endpoint and self-loops', () => { + const g = layoutGraph({ nodes: [{ id: 'a', label: 'a' }, { id: 'b', label: 'b' }], edges: [{ from: 'a', to: 'ghost' }, { from: 'a', to: 'a' }, { from: 'a', to: 'b' }] }); + expect(g.nodes).toHaveLength(2); + expect(g.edges).toHaveLength(1); // only a->b survives }); it('is cycle-safe (no infinite loop) and still positions every node', () => { const g = layoutGraph(parseDot('digraph { a[label="a"]; b[label="b"]; a->b; b->a; }')); From cfd6f84499ca11a251ca510bcf1cdedee247b58d Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Thu, 25 Jun 2026 13:39:05 +0200 Subject: [PATCH 4/5] feat(explain): fullscreen pan/zoom overlay for the pipeline graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an Expand button (results header, Pipeline view) that opens the graph in a fullscreen overlay with wheel-zoom around the cursor, drag-pan, +/- and Fit-to-screen controls, and Esc/✕/backdrop close. Makes complex pipelines (which the current layout draws wide) navigable; better automatic layout is deferred to a follow-up PR. - src/core/panzoom.js: pure viewBox algebra (fit/zoom/pan), 100% covered. - src/ui/explain-graph.js: extract buildPipelineSvg; openPipelineFullscreen. - src/ui/results.js: Expand button; icons.js: expand + minus glyphs. - e2e: pipeline.html exposes __openFullscreen; spec drives real wheel/drag/Esc. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QGBS74oUsXarGkCRQKEFLu --- src/core/panzoom.js | 38 +++++++++ src/styles.css | 32 ++++++++ src/ui/explain-graph.js | 136 +++++++++++++++++++++++++++---- src/ui/icons.js | 4 + src/ui/results.js | 8 +- tests/e2e/pipeline-graph.spec.js | 30 +++++++ tests/e2e/pipeline.html | 6 +- tests/unit/explain-graph.test.js | 94 ++++++++++++++++++++- tests/unit/panzoom.test.js | 48 +++++++++++ tests/unit/results.test.js | 18 ++++ 10 files changed, 392 insertions(+), 22 deletions(-) create mode 100644 src/core/panzoom.js create mode 100644 tests/unit/panzoom.test.js diff --git a/src/core/panzoom.js b/src/core/panzoom.js new file mode 100644 index 0000000..1bf4aa1 --- /dev/null +++ b/src/core/panzoom.js @@ -0,0 +1,38 @@ +// Pure SVG viewBox algebra for the fullscreen pipeline-graph pan/zoom. A viewBox +// is `{ x, y, w, h }` in svg user units; the DOM wiring (wheel/drag listeners, +// pixel→svg conversion) lives in src/ui/explain-graph.js. No DOM, no globals. + +/** + * Initial viewBox framing a `gw × gh` graph with `pad` (fraction of each side). + * SVG `preserveAspectRatio` handles fitting the box into any viewport shape. + */ +export function fitBox(gw, gh, pad = 0.04) { + const px = gw * pad; + const py = gh * pad; + return { x: -px, y: -py, w: gw + 2 * px, h: gh + 2 * py }; +} + +/** + * Zoom by `factor` (>1 = zoom in) keeping the svg-space point `(cx, cy)` fixed. + * Width is clamped to `[minW, maxW]`; height scales by the same ratio so the + * aspect is preserved. + */ +export function zoomBox(vb, factor, cx, cy, minW, maxW) { + const want = vb.w / factor; + const w = Math.max(minW, Math.min(maxW, want)); + const k = w / vb.w; // actual applied scale after clamping + const h = vb.h * k; + const rx = (cx - vb.x) / vb.w; + const ry = (cy - vb.y) / vb.h; + return { x: cx - rx * w, y: cy - ry * h, w, h }; +} + +/** Translate the viewBox by svg-unit deltas. */ +export function panBox(vb, dx, dy) { + return { x: vb.x - dx, y: vb.y - dy, w: vb.w, h: vb.h }; +} + +/** Serialize a viewBox for the SVG `viewBox` attribute. */ +export function viewBoxStr(vb) { + return `${vb.x} ${vb.y} ${vb.w} ${vb.h}`; +} diff --git a/src/styles.css b/src/styles.css index e064cac..107e3a0 100644 --- a/src/styles.css +++ b/src/styles.css @@ -639,6 +639,38 @@ body { .explain-graph .eg-edge { stroke: var(--fg-faint); stroke-width: 1.3; fill: none; } .explain-graph .eg-arrowhead { fill: var(--fg-faint); } +/* ------------ fullscreen pipeline overlay (pan/zoom) ------------ */ +.graph-overlay { + position: fixed; inset: 0; z-index: 60; + background: color-mix(in oklab, var(--bg-editor) 78%, transparent); + display: flex; align-items: center; justify-content: center; padding: 24px; +} +.graph-overlay-panel { + width: 100%; height: 100%; + background: var(--bg-modal); border: 1px solid var(--border); + border-radius: 11px; overflow: hidden; + display: flex; flex-direction: column; +} +.graph-overlay-bar { + display: flex; align-items: center; gap: 10px; + padding: 9px 12px; border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.graph-overlay-title { font-size: 12.5px; font-weight: 600; color: var(--fg); } +.graph-overlay-zoom { display: flex; gap: 6px; margin-left: auto; } +.graph-overlay-close { + display: flex; align-items: center; justify-content: center; + width: 26px; height: 26px; border: none; border-radius: 6px; + background: transparent; color: var(--fg-mute); cursor: pointer; +} +.graph-overlay-close:hover { background: var(--bg-hover); color: var(--fg); } +.graph-overlay-canvas { + flex: 1; min-height: 0; overflow: hidden; + background: var(--bg-table); cursor: grab; +} +.graph-overlay-canvas.grabbing { cursor: grabbing; } +.graph-overlay-canvas > svg.explain-graph { width: 100%; height: 100%; } + /* ------------ chart view ------------ */ /* `.res-body` is display:block, so height:100% (not flex:1) is what lets the chart fill it — otherwise the view collapses to the config bar and the diff --git a/src/ui/explain-graph.js b/src/ui/explain-graph.js index d9913d3..e0cee45 100644 --- a/src/ui/explain-graph.js +++ b/src/ui/explain-graph.js @@ -1,32 +1,30 @@ // The Pipeline result view: draw the `EXPLAIN PIPELINE graph = 1` DOT output as -// an SVG boxes-and-arrows graph. All graph math (parse + layout) is pure and -// lives in src/core/dot.js; this module only turns positioned nodes/edges into -// SVG. Zero runtime deps — built with the `s()` SVG hyperscript. +// an SVG boxes-and-arrows graph, plus a fullscreen pan/zoom overlay for big +// plans. All graph math (parse + layout) is pure in src/core/dot.js and the +// viewBox algebra in src/core/panzoom.js; this module only does SVG + DOM. +// Zero runtime deps — built with the `s()`/`h()` hyperscript. import { h, s } from './dom.js'; +import { Icon } from './icons.js'; import { parseDot, layoutGraph } from '../core/dot.js'; +import { fitBox, zoomBox, panBox, viewBoxStr } from '../core/panzoom.js'; + +const ZOOM_STEP = 1.2; // per wheel notch / button press /** - * Render `r.rawText` (a Graphviz DOT document) as a scrollable SVG pipeline - * graph. Falls back to a placeholder when the DOT has no nodes. + * Build the pipeline SVG from a DOT document. Returns the `` element plus + * the graph's intrinsic size and node count (0 → caller shows a placeholder). */ -export function renderExplainGraph(r) { - const g = layoutGraph(parseDot(r.rawText || '')); - if (!g.nodes.length) { - return h('div', { class: 'placeholder' }, h('div', null, 'No pipeline graph to display.')); - } - - const svg = s('svg', { - class: 'explain-graph', width: g.width, height: g.height, - viewBox: `0 0 ${g.width} ${g.height}`, - }); +export function buildPipelineSvg(rawText) { + const g = layoutGraph(parseDot(rawText || '')); + 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', markerWidth: '7', markerHeight: '7', orient: 'auto-start-reverse', }, 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)' })); @@ -38,6 +36,110 @@ export function renderExplainGraph(r) { 'text-anchor': 'middle', 'dominant-baseline': 'central', }, n.label)); } + return { svg, width: g.width, height: g.height, nodeCount: g.nodes.length }; +} + +/** + * Render `r.rawText` as the inline (scrollable) pipeline graph. Falls back to a + * placeholder when the DOT has no nodes. The SVG is sized to its intrinsic px so + * the pane scrolls; the fullscreen overlay (openPipelineFullscreen) is where + * pan/zoom lives. + */ +export function renderExplainGraph(r) { + const built = buildPipelineSvg(r.rawText || ''); + if (!built.nodeCount) { + return h('div', { class: 'placeholder' }, h('div', null, 'No pipeline graph to display.')); + } + built.svg.setAttribute('width', built.width); + built.svg.setAttribute('height', built.height); + return h('div', { class: 'explain-graph-view', tabindex: '0' }, built.svg); +} + +/** + * Open the pipeline graph in a fullscreen overlay with wheel-zoom (around the + * cursor), drag-pan, and fit/zoom buttons. Esc / ✕ / backdrop close it. + */ +export function openPipelineFullscreen(app, rawText) { + const doc = (app && app.document) || document; + const built = buildPipelineSvg(rawText || ''); + + const onKey = (e) => { if (e.key === 'Escape') close(); }; + let backdrop; + // `close` only fires from listeners attached after `backdrop` is assigned. + 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 canvas = h('div', { class: 'graph-overlay-canvas' }); + + if (!built.nodeCount) { + canvas.appendChild(h('div', { class: 'placeholder' }, h('div', null, 'No pipeline graph to display.'))); + } else { + const svg = built.svg; + svg.setAttribute('width', '100%'); + svg.setAttribute('height', '100%'); + svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); + const minW = built.width / 8; + const maxW = built.width * 3; + let vb = fitBox(built.width, built.height); + const apply = () => svg.setAttribute('viewBox', viewBoxStr(vb)); + apply(); + + // Cursor px → svg-space coords, using the live canvas rect. + const toSvg = (clientX, clientY) => { + const rc = canvas.getBoundingClientRect(); + return { + x: vb.x + ((clientX - rc.left) / rc.width) * vb.w, + y: vb.y + ((clientY - rc.top) / rc.height) * vb.h, + }; + }; + const zoomAt = (factor, clientX, clientY) => { + const p = toSvg(clientX, clientY); + vb = zoomBox(vb, factor, p.x, p.y, minW, maxW); + apply(); + }; + const centre = () => { + const rc = canvas.getBoundingClientRect(); + return { x: rc.left + rc.width / 2, y: rc.top + rc.height / 2 }; + }; + + canvas.addEventListener('wheel', (e) => { + e.preventDefault(); + zoomAt(e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP, e.clientX, e.clientY); + }); + + let drag = null; + canvas.addEventListener('mousedown', (e) => { + drag = { x: e.clientX, y: e.clientY }; + canvas.classList.add('grabbing'); + }); + const onMove = (e) => { + if (!drag) return; + const rc = canvas.getBoundingClientRect(); + const scale = vb.w / rc.width; + vb = panBox(vb, (e.clientX - drag.x) * scale, (e.clientY - drag.y) * scale); + drag = { x: e.clientX, y: e.clientY }; + apply(); + }; + const onUp = () => { drag = null; canvas.classList.remove('grabbing'); }; + canvas.addEventListener('mousemove', onMove); + canvas.addEventListener('mouseup', onUp); + canvas.addEventListener('mouseleave', onUp); + + canvas.appendChild(svg); + bar.appendChild(h('div', { class: 'graph-overlay-zoom' }, + h('button', { class: 'res-act', title: 'Zoom out', onclick: () => { const c = centre(); zoomAt(1 / ZOOM_STEP, c.x, c.y); } }, Icon.minus()), + h('button', { class: 'res-act', title: 'Zoom in', onclick: () => { const c = centre(); zoomAt(ZOOM_STEP, c.x, c.y); } }, Icon.plus()), + h('button', { class: 'res-act', title: 'Fit to screen', onclick: () => { vb = fitBox(built.width, built.height); apply(); } }, 'Fit'))); + } - return h('div', { class: 'explain-graph-view', tabindex: '0' }, svg); + 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); + doc.body.appendChild(backdrop); + doc.addEventListener('keydown', onKey, true); + return backdrop; } diff --git a/src/ui/icons.js b/src/ui/icons.js index 6775391..8fc4f87 100644 --- a/src/ui/icons.js +++ b/src/ui/icons.js @@ -76,6 +76,10 @@ export const Icon = { plan: () => iconEl('', 12, 12, 1.4), // Indexes view: a key. key: () => iconEl('', 12, 12, 1.3), + // Expand to fullscreen: corner arrows. + expand: () => iconEl('', 12, 12, 1.4), + // Zoom-out bar (pairs with plus for zoom-in). + minus: () => svg('M2 6h8', 12, 12, { stroke: 1.6 }), bookmark: () => iconEl('', 12, 12, 1.3), pencil: () => iconEl('', 12, 12), trash: () => iconEl('', 12, 12, 1.2), diff --git a/src/ui/results.js b/src/ui/results.js index 5972397..8c4bac4 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 } from './explain-graph.js'; +import { renderExplainGraph, openPipelineFullscreen } 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. @@ -205,6 +205,12 @@ function buildToolbar(app, r) { toolbar.appendChild(h('div', { class: 'stat', title: r.progress.rows + ' rows scanned' }, h('span', { class: 'ic' }, Icon.bytes()), h('span', { class: 'v' }, formatBytes(r.progress.bytes)))); } + if (r.explainView === 'pipeline' && r.rawText && !r.error) { + toolbar.appendChild(h('button', { + class: 'res-act', title: 'Open the graph fullscreen (pan & zoom)', + onclick: () => openPipelineFullscreen(app, r.rawText), + }, Icon.expand(), h('span', null, 'Expand'))); + } if (!r.error) { toolbar.appendChild(h('button', { class: 'res-act', title: 'Copy results to clipboard', diff --git a/tests/e2e/pipeline-graph.spec.js b/tests/e2e/pipeline-graph.spec.js index a7fcda4..5ab38fc 100644 --- a/tests/e2e/pipeline-graph.spec.js +++ b/tests/e2e/pipeline-graph.spec.js @@ -57,6 +57,36 @@ test.describe('EXPLAIN PIPELINE graph (antalya ontime fact/dim join)', () => { expect(labels).toContain('Limit'); }); + test('fullscreen overlay pans and zooms the graph and closes on Escape', async ({ page }) => { + await page.evaluate((dot) => window.__openFullscreen(dot), DOT); + const overlay = page.locator('.graph-overlay'); + await expect(overlay).toBeVisible(); + const svg = page.locator('.graph-overlay-canvas svg.explain-graph'); + const vb = () => svg.getAttribute('viewBox').then((s) => s.split(' ').map(Number)); + + const [, , w0] = await vb(); + // wheel over the canvas → zoom in (smaller viewBox width) + await page.locator('.graph-overlay-canvas').hover(); + await page.mouse.wheel(0, -300); + const [, , w1] = await vb(); + expect(w1).toBeLessThan(w0); + + // drag → pan (viewBox x changes) + const [x1] = await vb(); + const box = await page.locator('.graph-overlay-canvas').boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2 - 80, box.y + box.height / 2); + await page.mouse.up(); + const [x2] = await vb(); + expect(x2).not.toBe(x1); + + // Fit resets; Escape closes + await page.getByRole('button', { name: 'Fit' }).click(); + await page.keyboard.press('Escape'); + await expect(overlay).toHaveCount(0); + }); + test('lays out vertically (stages stacked) with horizontal parallel lanes', async ({ page }) => { const m = await page.evaluate(() => { const rects = [...document.querySelectorAll('rect.eg-node')]; diff --git a/tests/e2e/pipeline.html b/tests/e2e/pipeline.html index 6bcf3d0..b77ae8c 100644 --- a/tests/e2e/pipeline.html +++ b/tests/e2e/pipeline.html @@ -16,12 +16,14 @@ diff --git a/tests/unit/explain-graph.test.js b/tests/unit/explain-graph.test.js index 5273aa4..8db852a 100644 --- a/tests/unit/explain-graph.test.js +++ b/tests/unit/explain-graph.test.js @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest'; -import { renderExplainGraph } from '../../src/ui/explain-graph.js'; +import { describe, it, expect, afterEach } from 'vitest'; +import { renderExplainGraph, openPipelineFullscreen } from '../../src/ui/explain-graph.js'; const DOT = `digraph { @@ -37,3 +37,93 @@ describe('renderExplainGraph', () => { expect(el.className).toBe('placeholder'); }); }); + +describe('openPipelineFullscreen', () => { + afterEach(() => { document.body.innerHTML = ''; }); + + // happy-dom has no layout, so stub the canvas rect the pan/zoom math reads. + const stubRect = (canvas) => { + canvas.getBoundingClientRect = () => ({ left: 0, top: 0, width: 400, height: 200, right: 400, bottom: 200 }); + }; + const vbOf = (overlay) => overlay.querySelector('svg.explain-graph').getAttribute('viewBox').split(' ').map(Number); + + it('mounts a fullscreen overlay with the graph and an initial fitted viewBox', () => { + const overlay = openPipelineFullscreen({ document }, DOT); + expect(document.body.contains(overlay)).toBe(true); + expect(overlay.className).toBe('graph-overlay'); + const svg = overlay.querySelector('svg.explain-graph'); + expect(svg.getAttribute('width')).toBe('100%'); + expect(svg.querySelectorAll('rect.eg-node')).toHaveLength(3); + expect(overlay.querySelector('.graph-overlay-zoom')).not.toBeNull(); + expect(vbOf(overlay)[2]).toBeGreaterThan(0); // a real fitted width + }); + + it('wheel zooms in (smaller viewBox) and out (larger) around the cursor', () => { + const overlay = openPipelineFullscreen({ document }, DOT); + const canvas = overlay.querySelector('.graph-overlay-canvas'); + stubRect(canvas); + const w0 = vbOf(overlay)[2]; + canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: -1, clientX: 200, clientY: 100, bubbles: true, cancelable: true })); + const w1 = vbOf(overlay)[2]; + expect(w1).toBeLessThan(w0); // zoomed in + canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: 1, clientX: 200, clientY: 100, bubbles: true, cancelable: true })); + expect(vbOf(overlay)[2]).toBeGreaterThan(w1); // zoomed back out + }); + + it('drag pans the viewBox; a stray mousemove without a drag is a no-op', () => { + const overlay = openPipelineFullscreen({ document }, DOT); + const canvas = overlay.querySelector('.graph-overlay-canvas'); + stubRect(canvas); + const [x0] = vbOf(overlay); + canvas.dispatchEvent(new MouseEvent('mousemove', { clientX: 50, clientY: 0, bubbles: true })); // no drag yet + expect(vbOf(overlay)[0]).toBe(x0); + canvas.dispatchEvent(new MouseEvent('mousedown', { clientX: 100, clientY: 100, bubbles: true })); + canvas.dispatchEvent(new MouseEvent('mousemove', { clientX: 60, clientY: 100, bubbles: true })); // drag left → pan right + const x1 = vbOf(overlay)[0]; + expect(x1).not.toBe(x0); + canvas.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + canvas.dispatchEvent(new MouseEvent('mousemove', { clientX: 0, clientY: 100, bubbles: true })); // after release → no change + expect(vbOf(overlay)[0]).toBe(x1); + }); + + it('zoom buttons and Fit reframe the graph', () => { + const overlay = openPipelineFullscreen({ document }, DOT); + const canvas = overlay.querySelector('.graph-overlay-canvas'); + stubRect(canvas); + const fitW = vbOf(overlay)[2]; + const [zoomOut, zoomIn, fit] = overlay.querySelectorAll('.graph-overlay-zoom .res-act'); + zoomIn.dispatchEvent(new Event('click', { bubbles: true })); + expect(vbOf(overlay)[2]).toBeLessThan(fitW); + zoomOut.dispatchEvent(new Event('click', { bubbles: true })); + fit.dispatchEvent(new Event('click', { bubbles: true })); + expect(vbOf(overlay)[2]).toBeCloseTo(fitW); + }); + + it('closes on Escape, the ✕ button, and a backdrop click (but not a panel click)', () => { + // Escape + let overlay = openPipelineFullscreen({ document }, DOT); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); // ignored + expect(document.body.contains(overlay)).toBe(true); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(document.body.contains(overlay)).toBe(false); + // panel click does NOT close; ✕ does + overlay = openPipelineFullscreen({ document }, DOT); + overlay.querySelector('.graph-overlay-panel').dispatchEvent(new Event('click', { bubbles: true })); + expect(document.body.contains(overlay)).toBe(true); + overlay.querySelector('.graph-overlay-close').dispatchEvent(new Event('click', { bubbles: true })); + expect(document.body.contains(overlay)).toBe(false); + // backdrop click closes; app-less call uses the global document + overlay = openPipelineFullscreen(null, DOT); + overlay.dispatchEvent(new Event('click', { bubbles: true })); + expect(document.body.contains(overlay)).toBe(false); + }); + + it('shows a placeholder (no canvas svg / zoom controls) for an empty graph', () => { + const overlay = openPipelineFullscreen({ document }, 'digraph {}'); + expect(overlay.querySelector('svg.explain-graph')).toBeNull(); + expect(overlay.querySelector('.graph-overlay-zoom')).toBeNull(); + expect(overlay.textContent).toMatch(/No pipeline graph/); + overlay.querySelector('.graph-overlay-close').dispatchEvent(new Event('click', { bubbles: true })); + expect(document.body.contains(overlay)).toBe(false); + }); +}); diff --git a/tests/unit/panzoom.test.js b/tests/unit/panzoom.test.js new file mode 100644 index 0000000..4adc41c --- /dev/null +++ b/tests/unit/panzoom.test.js @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { fitBox, zoomBox, panBox, viewBoxStr } from '../../src/core/panzoom.js'; + +describe('fitBox', () => { + it('frames the graph with fractional padding on every side', () => { + expect(fitBox(100, 50, 0.04)).toEqual({ x: -4, y: -2, w: 108, h: 54 }); + }); + it('defaults the padding', () => { + const b = fitBox(200, 100); + expect(b.w).toBeGreaterThan(200); + expect(b.x).toBeLessThan(0); + }); +}); + +describe('zoomBox', () => { + const vb = { x: 0, y: 0, w: 100, h: 100 }; + it('zooms in around a point, keeping it fixed', () => { + const out = zoomBox(vb, 2, 50, 50, 10, 300); + expect(out).toEqual({ x: 25, y: 25, w: 50, h: 50 }); // centred point stays centred + }); + it('keeps an off-centre point fixed', () => { + const cx = 20, cy = 80; + const out = zoomBox(vb, 2, cx, cy, 10, 300); + expect((cx - out.x) / out.w).toBeCloseTo((cx - vb.x) / vb.w); // same relative x + expect((cy - out.y) / out.h).toBeCloseTo((cy - vb.y) / vb.h); + }); + it('clamps zoom-in at minW (and scales height by the same ratio)', () => { + const out = zoomBox({ x: 0, y: 0, w: 20, h: 20 }, 4, 10, 10, 10, 300); + expect(out.w).toBe(10); // wanted 5, clamped to 10 + expect(out.h).toBe(10); + }); + it('clamps zoom-out at maxW', () => { + const out = zoomBox({ x: 0, y: 0, w: 200, h: 200 }, 0.5, 100, 100, 10, 300); + expect(out.w).toBe(300); // wanted 400, clamped to 300 + }); +}); + +describe('panBox', () => { + it('translates by svg-unit deltas (size unchanged)', () => { + expect(panBox({ x: 0, y: 0, w: 100, h: 100 }, 10, 5)).toEqual({ x: -10, y: -5, w: 100, h: 100 }); + }); +}); + +describe('viewBoxStr', () => { + it('serializes to the SVG viewBox attribute form', () => { + expect(viewBoxStr({ x: -4, y: -2, w: 108, h: 54 })).toBe('-4 -2 108 54'); + }); +}); diff --git a/tests/unit/results.test.js b/tests/unit/results.test.js index 94c38c4..72d3573 100644 --- a/tests/unit/results.test.js +++ b/tests/unit/results.test.js @@ -542,4 +542,22 @@ describe('EXPLAIN views', () => { expect(app.dom.resultsRegion.querySelectorAll('.result-view-tab')).toHaveLength(5); expect(app.dom.resultsRegion.querySelector('.results-error').textContent).toContain('boom'); }); + + it('shows an Expand button for the Pipeline view that opens the fullscreen overlay', () => { + const app = appWithResult(explainResult('pipeline', { rawText: 'digraph { n1 [label="A"]; }' })); + renderResults(app); + const expand = [...app.dom.resultsRegion.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 click closes + cleans up + expect(document.body.querySelector('.graph-overlay')).toBeNull(); + }); + + it('has no Expand button for non-pipeline explain views', () => { + const app = appWithResult(explainResult('explain', { rawText: 'plan text' })); + renderResults(app); + expect([...app.dom.resultsRegion.querySelectorAll('.res-act')].some((b) => /Expand/.test(b.textContent))).toBe(false); + }); }); From e91bfe7ed08099dff493257cce7d90f094372bc3 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Thu, 25 Jun 2026 13:59:13 +0200 Subject: [PATCH 5/5] fix(explain): address code-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - run(): a typed EXPLAIN now honors exactly what was typed — a plain EXPLAIN always opens the verbatim Explain view instead of inheriting a stale rich view from a previous run/tab. Drop the unused app.state.explainView. (review #1) - dot.parseDot: blank quoted label strings before edge scanning and only keep edges between declared processors, so a `->` inside a label (e.g. a lambda `x -> x + 1`) no longer fabricates phantom nodes/edges. (review #2) - panzoom.zoomBox: guard a degenerate zero-size viewBox (defensive). - overlay: Esc stops propagation so it doesn't also reach the app key handler. - remove now-dead isExplain from core/format.js (superseded by parseExplain). - document the ontime DOT fixture's capture provenance. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QGBS74oUsXarGkCRQKEFLu --- src/core/dot.js | 25 ++++++++++---------- src/core/format.js | 6 ----- src/core/panzoom.js | 1 + src/state.js | 8 +++---- src/ui/app.js | 11 +++++---- src/ui/explain-graph.js | 2 +- tests/e2e/fixtures/ontime-pipeline-graph.dot | 6 +++++ tests/unit/app.test.js | 9 +++++++ tests/unit/dot.test.js | 14 +++++++---- tests/unit/format.test.js | 16 +------------ tests/unit/panzoom.test.js | 4 ++++ 11 files changed, 54 insertions(+), 48 deletions(-) diff --git a/src/core/dot.js b/src/core/dot.js index 119e539..94efe17 100644 --- a/src/core/dot.js +++ b/src/core/dot.js @@ -33,23 +33,24 @@ export function parseDot(text) { const nodes = []; const seen = new Set(); - const add = (id, label) => { - if (seen.has(id) || DOT_KEYWORDS.has(id)) return; - seen.add(id); - nodes.push({ id, label }); - }; - const nodeRe = /([A-Za-z_][\w]*)\s*\[\s*label\s*=\s*"((?:[^"\\]|\\.)*)"/g; let m; - while ((m = nodeRe.exec(body))) add(m[1], unescapeDot(m[2])); + while ((m = nodeRe.exec(body))) { + const id = m[1]; + if (seen.has(id) || DOT_KEYWORDS.has(id)) continue; + seen.add(id); + nodes.push({ id, label: unescapeDot(m[2]) }); + } + // Scan edges with quoted labels blanked out, so a `->` (or an id) inside a + // processor label — e.g. a lambda `x -> x + 1` — can't be mistaken for a + // data-flow edge. Only edges between already-declared processors are kept + // (ClickHouse always declares its nodes), which also rules out phantom nodes. const edges = []; + const edgeBody = body.replace(/"(?:[^"\\]|\\.)*"/g, '""'); const edgeRe = /([A-Za-z_][\w]*)\s*->\s*([A-Za-z_][\w]*)/g; - while ((m = edgeRe.exec(body))) { - if (DOT_KEYWORDS.has(m[1]) || DOT_KEYWORDS.has(m[2])) continue; - edges.push({ from: m[1], to: m[2] }); - add(m[1], m[1]); - add(m[2], m[2]); + while ((m = edgeRe.exec(edgeBody))) { + if (seen.has(m[1]) && seen.has(m[2])) edges.push({ from: m[1], to: m[2] }); } return { nodes, edges }; } diff --git a/src/core/format.js b/src/core/format.js index 0ca05f6..5b36283 100644 --- a/src/core/format.js +++ b/src/core/format.js @@ -74,12 +74,6 @@ export function detectSqlFormat(sql) { return m ? m[1] : null; } -/** True if the statement is an EXPLAIN (leading keyword). EXPLAIN output is plan - * text, so the caller renders it raw unless the user gave an explicit FORMAT. Pure. */ -export function isExplain(sql) { - return /^\s*EXPLAIN\b/i.test(String(sql || '')); -} - /** * Derive a short display name for a saved query: "Query · " when a * FROM clause is present, else the first 48 chars of the collapsed SQL. diff --git a/src/core/panzoom.js b/src/core/panzoom.js index 1bf4aa1..2e84684 100644 --- a/src/core/panzoom.js +++ b/src/core/panzoom.js @@ -18,6 +18,7 @@ export function fitBox(gw, gh, pad = 0.04) { * aspect is preserved. */ export function zoomBox(vb, factor, cx, cy, minW, maxW) { + if (!vb.w || !vb.h) return vb; // nothing to zoom (degenerate box) const want = vb.w / factor; const w = Math.max(minW, Math.min(maxW, want)); const k = w / vb.w; // actual applied scale after clamping diff --git a/src/state.js b/src/state.js index 791140d..018d3b7 100644 --- a/src/state.js +++ b/src/state.js @@ -58,11 +58,9 @@ export function createState(read = { loadJSON, loadStr }) { running: false, abortController: null, resultView: 'table', - // The active EXPLAIN view (Explain/Indexes/Projections/Pipeline/Estimate), - // kept across re-runs like resultView. `forceExplain` is set by the Explain - // button to put an ordinary query into EXPLAIN-view mode; a normal Run clears - // it. Both session-only. - explainView: 'explain', + // `forceExplain` is set by the Explain button to put an ordinary query into + // EXPLAIN-view mode; a normal Run clears it (session-only). The active view is + // derived per-run from the typed statement / clicked tab, not stored here. forceExplain: false, resultSort: { col: null, dir: 'asc' }, sidePanel: read.loadStr(KEYS.sidePanel, 'saved'), diff --git a/src/ui/app.js b/src/ui/app.js index bd41c20..7a893f4 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -395,13 +395,14 @@ export function createApp(env = {}) { let fmt; let explainView = null; if (explainMode) { - // The Explain view runs the statement verbatim (honoring arbitrary params); - // a rich view is auto-selected only on an exact canonical match, else we keep - // the last-used view. Rich views derive a query from the inner statement. + // View precedence: an explicit tab click wins; otherwise a *typed* EXPLAIN + // is honored exactly (canonical match → its rich view, else the verbatim + // Explain view); the button-forced path falls through to Explain. We never + // inherit a stale view from a previous run/tab — typing a plain EXPLAIN must + // show the plan, not whatever view was last open. explainView = (opts && opts.explainView) || (parsed && detectExplainView(parsed)) - || app.state.explainView || 'explain'; - app.state.explainView = explainView; + || 'explain'; fmt = (EXPLAIN_VIEWS.find((v) => v.id === explainView) || EXPLAIN_VIEWS[0]).chFormat; const inner = parsed ? parsed.inner : tab.sql; runSql = explainView === 'explain' diff --git a/src/ui/explain-graph.js b/src/ui/explain-graph.js index e0cee45..453ad30 100644 --- a/src/ui/explain-graph.js +++ b/src/ui/explain-graph.js @@ -63,7 +63,7 @@ export function openPipelineFullscreen(app, rawText) { const doc = (app && app.document) || document; const built = buildPipelineSvg(rawText || ''); - const onKey = (e) => { if (e.key === 'Escape') close(); }; + const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } }; let backdrop; // `close` only fires from listeners attached after `backdrop` is assigned. function close() { diff --git a/tests/e2e/fixtures/ontime-pipeline-graph.dot b/tests/e2e/fixtures/ontime-pipeline-graph.dot index 436cff6..bf84c63 100644 --- a/tests/e2e/fixtures/ontime-pipeline-graph.dot +++ b/tests/e2e/fixtures/ontime-pipeline-graph.dot @@ -1,3 +1,9 @@ +// Captured from the antalya demo cluster (ontime dataset) — the real output of +// `EXPLAIN PIPELINE graph = 1` for the fact/dim-join query documented in +// tests/e2e/pipeline-graph.spec.js. To re-capture if ClickHouse's pipeline DOT +// format changes, run that query with `EXPLAIN PIPELINE graph = 1 FORMAT +// TabSeparatedRaw` against ontime.fact_ontime ⋈ ontime.dim_airports and paste the +// digraph below (drop this comment header — the parser starts at `digraph`). digraph { rankdir="LR"; diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index de4c43e..1b82c77 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -373,6 +373,15 @@ describe('query run', () => { await app.actions.run(); expect(app.activeTab().result.explainView).toBe('indexes'); }); + it('does not leak a previous rich view onto a freshly-typed plain EXPLAIN', async () => { + const { app } = appForRun([[(u, sql) => /EXPLAIN/.test(sql), resp({ text: 'digraph{}' })]]); + app.activeTab().sql = 'EXPLAIN PIPELINE graph = 1 SELECT 1'; + await app.actions.run(); + expect(app.activeTab().result.explainView).toBe('pipeline'); + app.activeTab().sql = 'EXPLAIN SELECT 2'; // plain → must show the plan, not pipeline + await app.actions.run(); + expect(app.activeTab().result.explainView).toBe('explain'); + }); it('setExplainView re-runs a derived query and never edits the SQL', async () => { const { app, e } = appForRun([[(u, sql) => /EXPLAIN/.test(sql), resp({ text: 'digraph{}' })]]); app.activeTab().sql = 'EXPLAIN SELECT 1'; diff --git a/tests/unit/dot.test.js b/tests/unit/dot.test.js index 2f074af..f8c9095 100644 --- a/tests/unit/dot.test.js +++ b/tests/unit/dot.test.js @@ -20,15 +20,21 @@ digraph expect(g.nodes.map((n) => n.id)).toEqual(['a', 'b']); expect(g.edges).toEqual([{ from: 'a', to: 'b' }]); }); - it('de-duplicates node ids and skips DOT keywords', () => { + it('de-duplicates node ids, skips DOT keywords, and drops edges to undeclared ids', () => { const g = parseDot('node [label="default"]; n1 [label="A"]; n1 [label="A again"]; n1 -> n2;'); - // `node` keyword skipped; n1 only once; n2 added from the edge. - expect(g.nodes).toEqual([{ id: 'n1', label: 'A' }, { id: 'n2', label: 'n2' }]); + // `node` keyword skipped; n1 only once; n2 never declared → no phantom node… + expect(g.nodes).toEqual([{ id: 'n1', label: 'A' }]); + expect(g.edges).toEqual([]); // …and its edge is dropped }); - it('skips edges whose endpoints are DOT keywords', () => { + it('skips edges whose endpoints are not declared nodes', () => { const g = parseDot('digraph { n1 [label="A"]; node -> n1; }'); expect(g.edges).toEqual([]); }); + it('ignores -> and ids that appear inside label strings (no phantom nodes/edges)', () => { + const g = parseDot('digraph { n0 [label="Join a -> b"]; n1 [label="Scan"]; n0 -> n1; }'); + expect(g.nodes.map((n) => n.id)).toEqual(['n0', 'n1']); // no phantom a / b + expect(g.edges).toEqual([{ from: 'n0', to: 'n1' }]); + }); it('unescapes quotes and collapses \\n in labels', () => { const g = parseDot('digraph { n1 [label="line1\\nline2"]; n2 [label="say \\"hi\\""]; }'); expect(g.nodes[0].label).toBe('line1 line2'); diff --git a/tests/unit/format.test.js b/tests/unit/format.test.js index 621c8d1..5094f06 100644 --- a/tests/unit/format.test.js +++ b/tests/unit/format.test.js @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { - clamp, formatRows, formatBytes, timeAgo, sqlString, inferQueryName, isNumericType, shortVersion, userShortName, withStatementBreak, detectSqlFormat, isExplain, + clamp, formatRows, formatBytes, timeAgo, sqlString, inferQueryName, isNumericType, shortVersion, userShortName, withStatementBreak, detectSqlFormat, } from '../../src/core/format.js'; describe('clamp', () => { @@ -101,20 +101,6 @@ describe('detectSqlFormat', () => { }); }); -describe('isExplain', () => { - it('detects a leading EXPLAIN (any variant), ignoring leading whitespace/case', () => { - expect(isExplain('EXPLAIN SELECT 1')).toBe(true); - expect(isExplain(' explain pipeline SELECT 1')).toBe(true); - expect(isExplain('EXPLAIN AST SELECT 1')).toBe(true); - }); - it('is false for non-EXPLAIN statements', () => { - expect(isExplain('SELECT 1')).toBe(false); - expect(isExplain('SELECT explain FROM t')).toBe(false); // EXPLAIN not the leading keyword - expect(isExplain('')).toBe(false); - expect(isExplain(null)).toBe(false); - }); -}); - describe('inferQueryName', () => { it('uses FROM table when present', () => { expect(inferQueryName('SELECT * FROM system.tables')).toBe('Query · system.tables'); diff --git a/tests/unit/panzoom.test.js b/tests/unit/panzoom.test.js index 4adc41c..ba8a992 100644 --- a/tests/unit/panzoom.test.js +++ b/tests/unit/panzoom.test.js @@ -33,6 +33,10 @@ describe('zoomBox', () => { const out = zoomBox({ x: 0, y: 0, w: 200, h: 200 }, 0.5, 100, 100, 10, 300); expect(out.w).toBe(300); // wanted 400, clamped to 300 }); + it('returns a degenerate (zero-size) box unchanged', () => { + const vb = { x: 0, y: 0, w: 0, h: 0 }; + expect(zoomBox(vb, 2, 0, 0, 10, 300)).toBe(vb); + }); }); describe('panBox', () => {