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
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,30 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
## [Unreleased]

### Added
- **Multiquery + run-selection** (#83): run a `;`-separated script (DDL / INSERT /
SELECT) in one shot, or run just the highlighted text. ⌘+Enter auto-detects — a
single statement behaves exactly as before; more than one runs **sequentially**
(one ClickHouse request per statement, stopping on the first failure) into a
compact per-statement summary grid. A non-empty editor selection runs only that
text (the Run button flips to **Run selection**); a single selected statement
still gets the full Table/Chart/EXPLAIN view. Row-returning statements show the
first row inline (comma-separated) — click to open all rows (capped at 100) in a
side pane; effectful statements show **OK**. Each grid row also shows that
statement's own execution time (the toolbar still shows the script total). The
click-to-open row pane is the **same sortable + resizable grid** as the main
results table (one shared component). A script that needs cross-statement state
(a `CREATE TEMPORARY` table or a session `SET`) runs inside a **per-tab
ClickHouse HTTP session** so that state persists across its separate
per-statement requests; ordinary scripts run session-less. Cancel aborts mid-script. Splitting
is purely lexical (`src/core/sql-split.js`), skipping `;` inside string/identifier
literals and `--` / `#` / `/* */` comments. Known limitation: an `INSERT … FORMAT
…` with inline data containing `;` mis-splits — run those on their own.
**Format** pretty-prints each statement of a script and rejoins them (`;` + blank
line; best-effort — an unformattable statement keeps its text), with a busy
spinner on the button. **Explain** shows a clear message instead of a generic
ClickHouse error when the editor holds more than one statement. Opening a saved
query / history entry **auto-runs only read-only queries** — an effectful one
(CREATE/ALTER/DROP/INSERT/…) loads into the editor without executing.
- **Result-row cap** with a 100 / 500 / 1000 / 5000 / 10000 selector in the result
toolbar (default **500**, a global preference persisted across tabs and reloads).
A normal `SELECT` now fetches at most the selected cap rather than pulling every
Expand Down Expand Up @@ -43,6 +67,26 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
loads rather than in-place mutation. This **completes the migration**. (#88, #91)

### Fixed
- Multiquery scripts no longer fail intermittently with **"Network error"**. A
ClickHouse HTTP session is now attached **only when the SQL actually needs one**
(a `CREATE TEMPORARY` table or a session `SET`), or when the tab already opened
one (sticky, so that state persists across runs in the tab) — ordinary scripts
run session-less, removing the session-lock / replica-affinity reset that
surfaced (behind a proxy/LB) as a reset connection. When a session *is* in use,
a transient failure is retried **only when safe**: a `SESSION_IS_LOCKED`
(rejected before execution) or a connection reset on a **read-only** statement.
A connection reset on an `INSERT`/DDL is **not** retried — it may have executed
server-side, so it's surfaced as "the statement may have executed; re-run
manually" rather than silently double-applied.
- The `session_id` / `query_id` fallback used when `crypto.randomUUID` is
unavailable (non-secure `http://` contexts) now mixes in `Math.random` instead of
only a coarse `performance.now()`, so two tabs can't mint the same id and collide
on the session lock.
- Result-table **column resize** now uses a splitter model: dragging a column's
right edge trades width with its right neighbor (the table's total width and the
other columns stay put), instead of growing the whole table and shifting later
columns sideways. Dragging the last column still widens the table. Applies to the
data grid, the multiquery script grid, and the script-row pane (one shared grid).
- The fullscreen schema / EXPLAIN graph panels were mis-sized on **Safari** (#70).
They size off viewport units, and engines disagree on how `vw`/`vh` interact
with `html{zoom}`: Chromium's ignore `zoom` (so `100vh` overshoots one screen by
Expand Down
43 changes: 43 additions & 0 deletions src/core/script-result.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Pure helpers for script-mode SELECT outcomes. A row-returning statement is
// run with FORMAT JSONCompact (one JSON object: { meta:[{name,type}], data:[[…]] })
// through the raw / wait_end_of_query path, so the whole body arrives as text and
// is parsed here once into a { columns, rows } shape — the same shape the result
// grid (renderTable) consumes. The script summary grid shows a one-line preview
// of the first row in column 2; clicking it opens the full table in a side pane.

// The display cap for a script-mode SELECT. The runner asks the server for
// SELECT_ROW_CAP + 1 rows (so it can tell a result was truncated — at exactly
// the cap it can't) and shows at most SELECT_ROW_CAP.
export const SELECT_ROW_CAP = 100;

/**
* Parse a JSONCompact response body into `{ columns, rows, truncated }`, capping
* `rows` at `cap` (default SELECT_ROW_CAP). `truncated` is true when more than
* `cap` rows came back (the runner over-fetches by one to detect this). A blank
* body or one that isn't valid JSON yields an empty result rather than throwing.
* Pure.
*/
export function parseSelectResult(rawText, cap = SELECT_ROW_CAP) {
const text = String(rawText == null ? '' : rawText).trim();
if (!text) return { columns: [], rows: [], truncated: false };
let json;
try {
json = JSON.parse(text);
} catch {
return { columns: [], rows: [], truncated: false };
}
const columns = (json.meta || []).map((m) => ({ name: m.name, type: m.type }));
const data = json.data || [];
return { columns, rows: data.slice(0, cap), truncated: data.length > cap };
}

/**
* A compact, comma-joined preview of the first row's values (the normal case is
* one row / one number, e.g. a count). NULLs render empty, matching the result
* grid. Truncated with an ellipsis past `max`. '' when there are no rows. Pure.
*/
export function firstRowPreview(rows, max = 160) {
if (!rows || !rows.length) return '';
const s = rows[0].map((v) => (v == null ? '' : String(v))).join(', ');
return s.length > max ? s.slice(0, max - 1) + '…' : s;
}
114 changes: 114 additions & 0 deletions src/core/sql-split.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Pure client-side SQL script splitter. ClickHouse's HTTP interface runs exactly
// one statement per request, so to run a `;`-separated script (DDL / INSERT /
// SELECT) we split it here and POST each statement in turn (the same model as
// `clickhouse-client --multiquery`). Splitting is purely lexical: it skips `;`
// inside '…' / "…" / `…` literals (honoring both `\'` backslash and `''` doubled
// escapes) and inside -- / # line comments and /* */ block comments.
//
// Known limitation: `INSERT … FORMAT CSV\n<inline data>` whose inline data
// contains a `;` will mis-split — the splitter has no way to know where the
// format payload ends. Inline-data inserts should be run on their own.

/**
* Split `sql` into individual statements on top-level `;`. Literals and comments
* are scanned so their `;` (and quote/comment characters) don't break a
* statement. Each returned statement is trimmed; comment-only / whitespace-only
* fragments are dropped. A single statement (± a trailing `;`) yields a
* one-element list, so the caller can preserve today's single-query path. Pure.
*/
export function splitStatements(sql) {
const text = String(sql || '');
const n = text.length;
const out = [];
let buf = '';
let hasCode = false; // the current fragment holds runnable (non-comment) text
let i = 0;
const push = () => { if (hasCode) out.push(buf.trim()); buf = ''; hasCode = false; };
while (i < n) {
const c = text[i];
const c2 = text[i + 1];
// -- and # line comments: copy verbatim to end of line (not code).
if ((c === '-' && c2 === '-') || c === '#') {
let j = i;
while (j < n && text[j] !== '\n') j++;
buf += text.slice(i, j);
i = j;
continue;
}
// /* */ block comment (non-nesting, matching ClickHouse): copy verbatim.
if (c === '/' && c2 === '*') {
let j = i + 2;
while (j < n && !(text[j] === '*' && text[j + 1] === '/')) j++;
j = Math.min(n, j + 2); // include the closing */ (or run to EOF if unterminated)
buf += text.slice(i, j);
i = j;
continue;
}
// '…' string, "…" / `…` quoted identifier. Backslash escapes the next char;
// a doubled quote (`''`) is an escaped quote, not a terminator.
if (c === "'" || c === '"' || c === '`') {
const quote = c;
buf += c;
let j = i + 1;
while (j < n) {
const d = text[j];
if (d === '\\') { buf += text.slice(j, j + 2); j += 2; continue; }
if (d === quote) {
if (text[j + 1] === quote) { buf += d + quote; j += 2; continue; }
buf += d; j += 1; break;
}
buf += d; j += 1;
}
i = j;
hasCode = true;
continue;
}
if (c === ';') { push(); i += 1; continue; }
buf += c;
if (!/\s/.test(c)) hasCode = true;
i += 1;
}
push();
return out;
}

// Statement keywords whose result is a row set (so script mode fetches them with
// a row-bearing format and shows a result preview). Everything else (CREATE /
// INSERT / ALTER / DROP / …) is run for effect and reported as OK.
const ROW_RETURNING = new Set([
'SELECT', 'WITH', 'SHOW', 'DESC', 'DESCRIBE', 'EXISTS', 'VALUES', 'EXPLAIN',
]);

/** The first SQL keyword of `stmt`, uppercased, after skipping leading
* whitespace, -- / # / block comments, and `(` (so a parenthesized
* `(SELECT …) UNION …` is still recognized as row-returning). '' when none. Pure. */
export function leadingKeyword(stmt) {
let s = String(stmt || '');
for (;;) {
const before = s;
s = s.replace(/^\s+/, '')
.replace(/^--[^\n]*/, '')
.replace(/^#[^\n]*/, '')
.replace(/^\/\*[\s\S]*?\*\//, '')
.replace(/^\(+/, '');
if (s === before) break;
}
const m = /^([A-Za-z]+)/.exec(s);
return m ? m[1].toUpperCase() : '';
}

/** True when `stmt` is a row-returning statement (SELECT/WITH/SHOW/…). Pure. */
export function isRowReturning(stmt) {
return ROW_RETURNING.has(leadingKeyword(stmt));
}

/**
* True when `sql` is safe to auto-run on open (e.g. clicking a saved query): it
* has at least one statement and **every** statement is row-returning. An
* effectful statement (CREATE/ALTER/DROP/INSERT/…) anywhere makes it false, so
* opening such a query loads it into the editor without executing it. Pure.
*/
export function isAutoRunnable(sql) {
const stmts = splitStatements(sql);
return stmts.length > 0 && stmts.every(isRowReturning);
}
9 changes: 7 additions & 2 deletions src/net/ch-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,10 @@ export async function loadEntityDoc(ctx, name, sqlString) {
*
* @param ctx
* @param sql
* @param o { format, signal, resultRowLimit, onLine(json), onChunk(), onRaw(text) }
* @param o { format, signal, resultRowLimit, params, onLine(json), onChunk(), onRaw(text) }
* `resultRowLimit` caps a normal result server-side (max_result_rows +
* result_overflow_mode); `params` are extra query-string options that ride
* alongside query_id (e.g. multiquery SELECTs pass their own cap + session_id).
*/
export async function runQuery(ctx, sql, o = {}) {
const fmt = o.format || 'Table';
Expand Down Expand Up @@ -402,7 +405,9 @@ export async function runQuery(ctx, sql, o = {}) {
// and surfaces mid-stream errors via the in-band `exception` line instead.
extra: { ...(isStreaming ? {} : { wait_end_of_query: 1 }), ...cap, add_http_cors_header: 1 },
// Tagging the request with a query_id lets Cancel issue KILL QUERY for it.
params: o.queryId ? { query_id: o.queryId } : {},
// Caller-supplied params (o.params) ride alongside — e.g. multiquery SELECTs
// add max_result_rows / result_overflow_mode to cap the result server-side.
params: { ...(o.queryId ? { query_id: o.queryId } : {}), ...(o.params || {}) },
});
const resp = await authedFetch(ctx, url, sql, o.signal);

Expand Down
41 changes: 30 additions & 11 deletions src/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ export function createState(read = { loadJSON, loadStr }) {
running: signal(false),
abortController: null,
resultView: signal('table'),
// True while the editor has a non-empty (non-whitespace) text selection, so
// ⌘+Enter / Run target just that text. Drives the Run button's
// "Run" ↔ "Run selection" label (an effect in createApp). Via `.value`.
hasSelection: signal(false),
// `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.
Expand Down Expand Up @@ -311,21 +315,36 @@ export function markLibrarySaved(state) {
state.libraryDirty.value = false;
}

/** Record a successful run in history (most-recent first, capped at 50). */
export function recordHistory(state, tab, save = saveJSON, now = Date.now()) {
const sql = String(tab.sql || '').trim();
if (!sql) return;
state.history.unshift({
id: makeId('h', now),
sql,
ts: now,
rows: tab.result.rawText != null ? null : tab.result.rows.length,
ms: Math.round(tab.result.progress.elapsed_ns / 1e6),
});
// Push one history entry (most-recent first, capped at 50). Internal — the
// exported recorders below supply the sql/rows/ms.
function pushHistory(state, sql, rows, ms, save, now) {
const s = String(sql || '').trim();
if (!s) return;
state.history.unshift({ id: makeId('h', now), sql: s, ts: now, rows, ms });
state.history = state.history.slice(0, 50);
save(KEYS.history, state.history);
}

/**
* Record a successful run in history. `sqlText` overrides the recorded SQL (used
* when a selection — not the whole tab — was run); it defaults to `tab.sql`.
*/
export function recordHistory(state, tab, save = saveJSON, now = Date.now(), sqlText) {
pushHistory(
state,
sqlText != null ? sqlText : tab.sql,
tab.result.rawText != null ? null : tab.result.rows.length,
Math.round(tab.result.progress.elapsed_ns / 1e6),
save, now,
);
}

/** Record a successful multiquery script run as one history entry (the whole
* script text); per-statement row counts aren't meaningful, so rows is null. */
export function recordScriptHistory(state, sql, ms, save = saveJSON, now = Date.now()) {
pushHistory(state, sql, null, Math.round(ms), save, now);
}

/** Clear all history. */
export function clearHistory(state, save = saveJSON) {
state.history = [];
Expand Down
22 changes: 22 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1491,6 +1491,28 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); }
padding: 0 6px; outline: none; cursor: pointer;
}

/* multiquery script summary grid */
.script-grid table.res-table { table-layout: fixed; width: 100%; }
/* initial proportions (drag-resize then pins px widths, like the data grid) */
.script-grid th.script-sql, .script-grid td.script-sql { width: 25%; }
.script-grid th.script-res { width: 65%; }
.script-grid th.script-time, .script-grid td.script-time { width: 10%; text-align: right; }
.script-grid th { position: relative; } /* anchor the col-resize-h handle */
.script-grid td.script-sql .cell-val { font-family: var(--mono); color: var(--fg-mute); }
.script-grid td.script-time { font-family: var(--mono); font-size: 11px; color: var(--fg-faint); white-space: nowrap; }
.script-cell { font-family: var(--mono); font-size: 12px; }
.script-cell.ok { color: #10b981; font-weight: 600; }
.script-cell.err { color: #ef4444; white-space: normal; word-break: break-word; }
.script-cell.rows { cursor: pointer; }
.script-cell.rows:hover { background: var(--hover); }
.script-cell .script-preview { color: var(--fg); }
.script-cell .script-meta { color: var(--fg-faint); margin-left: 8px; font-size: 11px; }
.script-running {
display: flex; align-items: center; gap: 8px;
padding: 10px 14px; font-size: 12px; color: var(--fg-mute);
border-top: 1px solid var(--border);
}

/* scrollbars */
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-track { background: transparent; }
Expand Down
Loading