Skip to content

Commit e91bfe7

Browse files
fix(explain): address code-review findings
- 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QGBS74oUsXarGkCRQKEFLu
1 parent cfd6f84 commit e91bfe7

11 files changed

Lines changed: 54 additions & 48 deletions

File tree

src/core/dot.js

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,23 +33,24 @@ export function parseDot(text) {
3333

3434
const nodes = [];
3535
const seen = new Set();
36-
const add = (id, label) => {
37-
if (seen.has(id) || DOT_KEYWORDS.has(id)) return;
38-
seen.add(id);
39-
nodes.push({ id, label });
40-
};
41-
4236
const nodeRe = /([A-Za-z_][\w]*)\s*\[\s*label\s*=\s*"((?:[^"\\]|\\.)*)"/g;
4337
let m;
44-
while ((m = nodeRe.exec(body))) add(m[1], unescapeDot(m[2]));
38+
while ((m = nodeRe.exec(body))) {
39+
const id = m[1];
40+
if (seen.has(id) || DOT_KEYWORDS.has(id)) continue;
41+
seen.add(id);
42+
nodes.push({ id, label: unescapeDot(m[2]) });
43+
}
4544

45+
// Scan edges with quoted labels blanked out, so a `->` (or an id) inside a
46+
// processor label — e.g. a lambda `x -> x + 1` — can't be mistaken for a
47+
// data-flow edge. Only edges between already-declared processors are kept
48+
// (ClickHouse always declares its nodes), which also rules out phantom nodes.
4649
const edges = [];
50+
const edgeBody = body.replace(/"(?:[^"\\]|\\.)*"/g, '""');
4751
const edgeRe = /([A-Za-z_][\w]*)\s*->\s*([A-Za-z_][\w]*)/g;
48-
while ((m = edgeRe.exec(body))) {
49-
if (DOT_KEYWORDS.has(m[1]) || DOT_KEYWORDS.has(m[2])) continue;
50-
edges.push({ from: m[1], to: m[2] });
51-
add(m[1], m[1]);
52-
add(m[2], m[2]);
52+
while ((m = edgeRe.exec(edgeBody))) {
53+
if (seen.has(m[1]) && seen.has(m[2])) edges.push({ from: m[1], to: m[2] });
5354
}
5455
return { nodes, edges };
5556
}

src/core/format.js

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,6 @@ export function detectSqlFormat(sql) {
7474
return m ? m[1] : null;
7575
}
7676

77-
/** True if the statement is an EXPLAIN (leading keyword). EXPLAIN output is plan
78-
* text, so the caller renders it raw unless the user gave an explicit FORMAT. Pure. */
79-
export function isExplain(sql) {
80-
return /^\s*EXPLAIN\b/i.test(String(sql || ''));
81-
}
82-
8377
/**
8478
* Derive a short display name for a saved query: "Query · <table>" when a
8579
* FROM clause is present, else the first 48 chars of the collapsed SQL.

src/core/panzoom.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export function fitBox(gw, gh, pad = 0.04) {
1818
* aspect is preserved.
1919
*/
2020
export function zoomBox(vb, factor, cx, cy, minW, maxW) {
21+
if (!vb.w || !vb.h) return vb; // nothing to zoom (degenerate box)
2122
const want = vb.w / factor;
2223
const w = Math.max(minW, Math.min(maxW, want));
2324
const k = w / vb.w; // actual applied scale after clamping

src/state.js

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,9 @@ export function createState(read = { loadJSON, loadStr }) {
5858
running: false,
5959
abortController: null,
6060
resultView: 'table',
61-
// The active EXPLAIN view (Explain/Indexes/Projections/Pipeline/Estimate),
62-
// kept across re-runs like resultView. `forceExplain` is set by the Explain
63-
// button to put an ordinary query into EXPLAIN-view mode; a normal Run clears
64-
// it. Both session-only.
65-
explainView: 'explain',
61+
// `forceExplain` is set by the Explain button to put an ordinary query into
62+
// EXPLAIN-view mode; a normal Run clears it (session-only). The active view is
63+
// derived per-run from the typed statement / clicked tab, not stored here.
6664
forceExplain: false,
6765
resultSort: { col: null, dir: 'asc' },
6866
sidePanel: read.loadStr(KEYS.sidePanel, 'saved'),

src/ui/app.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -395,13 +395,14 @@ export function createApp(env = {}) {
395395
let fmt;
396396
let explainView = null;
397397
if (explainMode) {
398-
// The Explain view runs the statement verbatim (honoring arbitrary params);
399-
// a rich view is auto-selected only on an exact canonical match, else we keep
400-
// the last-used view. Rich views derive a query from the inner statement.
398+
// View precedence: an explicit tab click wins; otherwise a *typed* EXPLAIN
399+
// is honored exactly (canonical match → its rich view, else the verbatim
400+
// Explain view); the button-forced path falls through to Explain. We never
401+
// inherit a stale view from a previous run/tab — typing a plain EXPLAIN must
402+
// show the plan, not whatever view was last open.
401403
explainView = (opts && opts.explainView)
402404
|| (parsed && detectExplainView(parsed))
403-
|| app.state.explainView || 'explain';
404-
app.state.explainView = explainView;
405+
|| 'explain';
405406
fmt = (EXPLAIN_VIEWS.find((v) => v.id === explainView) || EXPLAIN_VIEWS[0]).chFormat;
406407
const inner = parsed ? parsed.inner : tab.sql;
407408
runSql = explainView === 'explain'

src/ui/explain-graph.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export function openPipelineFullscreen(app, rawText) {
6363
const doc = (app && app.document) || document;
6464
const built = buildPipelineSvg(rawText || '');
6565

66-
const onKey = (e) => { if (e.key === 'Escape') close(); };
66+
const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };
6767
let backdrop;
6868
// `close` only fires from listeners attached after `backdrop` is assigned.
6969
function close() {

tests/e2e/fixtures/ontime-pipeline-graph.dot

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
// Captured from the antalya demo cluster (ontime dataset) — the real output of
2+
// `EXPLAIN PIPELINE graph = 1` for the fact/dim-join query documented in
3+
// tests/e2e/pipeline-graph.spec.js. To re-capture if ClickHouse's pipeline DOT
4+
// format changes, run that query with `EXPLAIN PIPELINE graph = 1 <query> FORMAT
5+
// TabSeparatedRaw` against ontime.fact_ontime ⋈ ontime.dim_airports and paste the
6+
// digraph below (drop this comment header — the parser starts at `digraph`).
17
digraph
28
{
39
rankdir="LR";

tests/unit/app.test.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,15 @@ describe('query run', () => {
373373
await app.actions.run();
374374
expect(app.activeTab().result.explainView).toBe('indexes');
375375
});
376+
it('does not leak a previous rich view onto a freshly-typed plain EXPLAIN', async () => {
377+
const { app } = appForRun([[(u, sql) => /EXPLAIN/.test(sql), resp({ text: 'digraph{}' })]]);
378+
app.activeTab().sql = 'EXPLAIN PIPELINE graph = 1 SELECT 1';
379+
await app.actions.run();
380+
expect(app.activeTab().result.explainView).toBe('pipeline');
381+
app.activeTab().sql = 'EXPLAIN SELECT 2'; // plain → must show the plan, not pipeline
382+
await app.actions.run();
383+
expect(app.activeTab().result.explainView).toBe('explain');
384+
});
376385
it('setExplainView re-runs a derived query and never edits the SQL', async () => {
377386
const { app, e } = appForRun([[(u, sql) => /EXPLAIN/.test(sql), resp({ text: 'digraph{}' })]]);
378387
app.activeTab().sql = 'EXPLAIN SELECT 1';

tests/unit/dot.test.js

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,21 @@ digraph
2020
expect(g.nodes.map((n) => n.id)).toEqual(['a', 'b']);
2121
expect(g.edges).toEqual([{ from: 'a', to: 'b' }]);
2222
});
23-
it('de-duplicates node ids and skips DOT keywords', () => {
23+
it('de-duplicates node ids, skips DOT keywords, and drops edges to undeclared ids', () => {
2424
const g = parseDot('node [label="default"]; n1 [label="A"]; n1 [label="A again"]; n1 -> n2;');
25-
// `node` keyword skipped; n1 only once; n2 added from the edge.
26-
expect(g.nodes).toEqual([{ id: 'n1', label: 'A' }, { id: 'n2', label: 'n2' }]);
25+
// `node` keyword skipped; n1 only once; n2 never declared → no phantom node…
26+
expect(g.nodes).toEqual([{ id: 'n1', label: 'A' }]);
27+
expect(g.edges).toEqual([]); // …and its edge is dropped
2728
});
28-
it('skips edges whose endpoints are DOT keywords', () => {
29+
it('skips edges whose endpoints are not declared nodes', () => {
2930
const g = parseDot('digraph { n1 [label="A"]; node -> n1; }');
3031
expect(g.edges).toEqual([]);
3132
});
33+
it('ignores -> and ids that appear inside label strings (no phantom nodes/edges)', () => {
34+
const g = parseDot('digraph { n0 [label="Join a -> b"]; n1 [label="Scan"]; n0 -> n1; }');
35+
expect(g.nodes.map((n) => n.id)).toEqual(['n0', 'n1']); // no phantom a / b
36+
expect(g.edges).toEqual([{ from: 'n0', to: 'n1' }]);
37+
});
3238
it('unescapes quotes and collapses \\n in labels', () => {
3339
const g = parseDot('digraph { n1 [label="line1\\nline2"]; n2 [label="say \\"hi\\""]; }');
3440
expect(g.nodes[0].label).toBe('line1 line2');

tests/unit/format.test.js

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it, expect } from 'vitest';
22
import {
3-
clamp, formatRows, formatBytes, timeAgo, sqlString, inferQueryName, isNumericType, shortVersion, userShortName, withStatementBreak, detectSqlFormat, isExplain,
3+
clamp, formatRows, formatBytes, timeAgo, sqlString, inferQueryName, isNumericType, shortVersion, userShortName, withStatementBreak, detectSqlFormat,
44
} from '../../src/core/format.js';
55

66
describe('clamp', () => {
@@ -101,20 +101,6 @@ describe('detectSqlFormat', () => {
101101
});
102102
});
103103

104-
describe('isExplain', () => {
105-
it('detects a leading EXPLAIN (any variant), ignoring leading whitespace/case', () => {
106-
expect(isExplain('EXPLAIN SELECT 1')).toBe(true);
107-
expect(isExplain(' explain pipeline SELECT 1')).toBe(true);
108-
expect(isExplain('EXPLAIN AST SELECT 1')).toBe(true);
109-
});
110-
it('is false for non-EXPLAIN statements', () => {
111-
expect(isExplain('SELECT 1')).toBe(false);
112-
expect(isExplain('SELECT explain FROM t')).toBe(false); // EXPLAIN not the leading keyword
113-
expect(isExplain('')).toBe(false);
114-
expect(isExplain(null)).toBe(false);
115-
});
116-
});
117-
118104
describe('inferQueryName', () => {
119105
it('uses FROM table when present', () => {
120106
expect(inferQueryName('SELECT * FROM system.tables')).toBe('Query · system.tables');

0 commit comments

Comments
 (0)