Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` 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**
Expand Down
141 changes: 141 additions & 0 deletions src/core/dot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// 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 = 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;
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 nodeRe = /([A-Za-z_][\w]*)\s*\[\s*label\s*=\s*"((?:[^"\\]|\\.)*)"/g;
let m;
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(edgeBody))) {
if (seen.has(m[1]) && seen.has(m[2])) edges.push({ from: m[1], to: m[2] });
}
return { nodes, edges };
}

/**
* 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]));
// 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, []]));
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);

// 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 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));

for (let L = 0; L < layers.length; L++) {
const col = layers[L];
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 = x;
n.y = y;
x += n.w + H_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 / 2, y: a.y + a.h },
{ x: b.x + b.w / 2, y: b.y },
],
};
});

return {
nodes,
edges: laidEdges,
width: maxRowW + MARGIN * 2,
height: layers.length * NODE_H + Math.max(0, layers.length - 1) * V_GAP + MARGIN * 2,
};
}
92 changes: 92 additions & 0 deletions src/core/explain.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
6 changes: 0 additions & 6 deletions src/core/format.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 · <table>" when a
* FROM clause is present, else the first 48 chars of the collapsed SQL.
Expand Down
39 changes: 39 additions & 0 deletions src/core/panzoom.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// 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) {
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
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}`;
}
4 changes: 4 additions & 0 deletions src/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ export function createState(read = { loadJSON, loadStr }) {
running: false,
abortController: null,
resultView: 'table',
// `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'),
savedQueries: read.loadJSON(KEYS.saved, []),
Expand Down
49 changes: 49 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,55 @@ 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); }

/* ------------ 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
Expand Down
Loading
Loading