Skip to content

Commit cb6f334

Browse files
feat(results): render EXPLAIN raw by default (unless an explicit FORMAT is given)
EXPLAIN output is plan text; as a one-column table it loses its indentation and reads poorly. Default EXPLAIN to the raw text view as plain TabSeparated (no header noise) so the plan shows verbatim; an explicit trailing FORMAT clause still wins (detectSqlFormat is checked first). runQuery now passes a raw format name through as default_format (instead of always JSONCompact) — that's what lets the implicit EXPLAIN format request plain TabSeparated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QGBS74oUsXarGkCRQKEFLu
1 parent 1c79426 commit cb6f334

5 files changed

Lines changed: 47 additions & 6 deletions

File tree

src/core/format.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ 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+
7783
/**
7884
* Derive a short display name for a saved query: "Query · <table>" when a
7985
* FROM clause is present, else the first 48 chars of the collapsed SQL.

src/net/ch-client.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,11 +236,14 @@ export async function loadEntityDoc(ctx, name, sqlString) {
236236
export async function runQuery(ctx, sql, o = {}) {
237237
const fmt = o.format || 'Table';
238238
const isStreaming = fmt === 'Table';
239+
// Streaming gets the progress-bearing JSON; raw mode sends the requested format
240+
// verbatim as default_format (a real ClickHouse format name from a FORMAT clause
241+
// or an implicit EXPLAIN). 'TSV' keeps its with-names-and-types expansion.
239242
const fmtParam = isStreaming
240243
? 'JSONStringsEachRowWithProgress'
241244
: fmt === 'TSV'
242245
? 'TabSeparatedWithNamesAndTypes'
243-
: 'JSONCompact';
246+
: fmt;
244247
const url = chUrl(ctx.origin, {
245248
format: fmtParam,
246249
// wait_end_of_query buffers the whole response server-side so the HTTP

src/ui/app.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
} from '../state.js';
1212
import { saveJSON, saveStr } from '../core/storage.js';
1313
import { decodeJwtPayload, isTokenExpired } from '../core/jwt.js';
14-
import { sqlString, inferQueryName, shortVersion, userShortName, withStatementBreak, detectSqlFormat } from '../core/format.js';
14+
import { sqlString, inferQueryName, shortVersion, userShortName, withStatementBreak, detectSqlFormat, isExplain } from '../core/format.js';
1515
import { resolveTarget } from '../core/target.js';
1616
import { toTSV, toCSV } from '../core/export.js';
1717
import { newResult, applyStreamLine } from '../core/stream.js';
@@ -378,9 +378,10 @@ export function createApp(env = {}) {
378378
await ensureConfig();
379379
if (!(await getToken())) { chCtx.onSignedOut(); return; }
380380

381-
// Default to structured streaming (Table); if the user ends their SQL with a
382-
// FORMAT clause, run raw and show ClickHouse's response verbatim (#format).
383-
const fmt = detectSqlFormat(tab.sql) || 'Table';
381+
// Default to structured streaming (Table); an explicit FORMAT clause runs raw
382+
// and shows ClickHouse's response verbatim, and so does EXPLAIN (its plan text
383+
// reads far better raw than as a one-column table) unless a FORMAT was given.
384+
const fmt = detectSqlFormat(tab.sql) || (isExplain(tab.sql) ? 'TabSeparated' : 'Table');
384385
const t0 = now();
385386
tab.result = newResult(fmt);
386387
app.state.resultSort = { col: null, dir: 'asc' };

tests/unit/app.test.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,23 @@ describe('query run', () => {
349349
expect(app.activeTab().result.rawText).toBe('a\tb');
350350
expect(app.activeTab().result.rawFormat).toBe('TabSeparatedWithNames'); // label for the raw tab
351351
});
352+
it('runs EXPLAIN raw (plan text) when no explicit FORMAT is given', async () => {
353+
const { app } = appForRun([
354+
[(u, sql) => /EXPLAIN/.test(sql), resp({ text: 'Expression\n ReadFromTable' })],
355+
]);
356+
app.activeTab().sql = 'EXPLAIN SELECT 1';
357+
await app.actions.run();
358+
expect(app.activeTab().result.rawText).toBe('Expression\n ReadFromTable');
359+
expect(app.activeTab().result.rawFormat).toBe('TabSeparated'); // plain TS → no header noise
360+
});
361+
it('an explicit FORMAT on an EXPLAIN still wins over the raw default', async () => {
362+
const { app } = appForRun([
363+
[(u, sql) => /EXPLAIN/.test(sql), resp({ text: '{"plan":[]}' })],
364+
]);
365+
app.activeTab().sql = 'EXPLAIN SELECT 1 FORMAT JSON';
366+
await app.actions.run();
367+
expect(app.activeTab().result.rawFormat).toBe('JSON'); // FORMAT clause, not the EXPLAIN default
368+
});
352369
});
353370

354371
describe('formatQuery', () => {

tests/unit/format.test.js

Lines changed: 15 additions & 1 deletion
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,
3+
clamp, formatRows, formatBytes, timeAgo, sqlString, inferQueryName, isNumericType, shortVersion, userShortName, withStatementBreak, detectSqlFormat, isExplain,
44
} from '../../src/core/format.js';
55

66
describe('clamp', () => {
@@ -101,6 +101,20 @@ 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+
104118
describe('inferQueryName', () => {
105119
it('uses FROM table when present', () => {
106120
expect(inferQueryName('SELECT * FROM system.tables')).toBe('Query · system.tables');

0 commit comments

Comments
 (0)