diff --git a/CHANGELOG.md b/CHANGELOG.md index 3289a50..262f494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/src/core/script-result.js b/src/core/script-result.js new file mode 100644 index 0000000..5b7e9d6 --- /dev/null +++ b/src/core/script-result.js @@ -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; +} diff --git a/src/core/sql-split.js b/src/core/sql-split.js new file mode 100644 index 0000000..493ec8c --- /dev/null +++ b/src/core/sql-split.js @@ -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` 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); +} diff --git a/src/net/ch-client.js b/src/net/ch-client.js index 794198d..217463d 100644 --- a/src/net/ch-client.js +++ b/src/net/ch-client.js @@ -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'; @@ -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); diff --git a/src/state.js b/src/state.js index 8c8214e..b4434a1 100644 --- a/src/state.js +++ b/src/state.js @@ -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. @@ -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 = []; diff --git a/src/styles.css b/src/styles.css index e2da104..de2c923 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; } diff --git a/src/ui/app.js b/src/ui/app.js index 93fa53d..f2fb938 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -7,8 +7,10 @@ import { h, zoomScale, fixedAnchor } from './dom.js'; import { Icon } from './icons.js'; import { - createState, activeTab, KEYS, recordHistory, saveQuery, savedForTab, tabChart, normalizeRowLimit, + createState, activeTab, KEYS, recordHistory, recordScriptHistory, saveQuery, savedForTab, tabChart, normalizeRowLimit, } from '../state.js'; +import { splitStatements, isRowReturning, leadingKeyword } from '../core/sql-split.js'; +import { parseSelectResult, firstRowPreview, SELECT_ROW_CAP } from '../core/script-result.js'; import { saveJSON, saveStr } from '../core/storage.js'; import { decodeJwtPayload, isTokenExpired } from '../core/jwt.js'; import { sqlString, inferQueryName, shortVersion, userShortName, withStatementBreak, detectSqlFormat } from '../core/format.js'; @@ -421,6 +423,20 @@ export function createApp(env = {}) { // --- query run --------------------------------------------------------- const now = () => (env.now || (() => win.performance.now()))(); + // A unique id for a query_id / session_id. Prefer crypto.randomUUID; its + // fallback (non-secure context, where randomUUID is undefined) must still be + // unique across tabs sharing one time origin — so mix in Math.random, not just + // `now()` (performance.now is coarsened and can repeat for back-to-back calls). + const uid = (prefix) => (cryptoObj.randomUUID + ? cryptoObj.randomUUID() + : prefix + now().toString(36) + '-' + Math.random().toString(36).slice(2, 10)); + // One retry after this delay (ms) smooths a transient failure on the rapid, + // same-session requests of a script (env-injectable; tests set 0). + const retryMs = env.retryMs != null ? env.retryMs : 250; + const sleep = (ms) => new Promise((r) => win.setTimeout(r, ms)); + // ClickHouse's transient "session is busy / locked by a concurrent client" + // (SESSION_IS_LOCKED, code 373) — retryable once the prior request releases it. + const SESSION_BUSY = /SESSION_IS_LOCKED|session .* is locked|locked by a concurrent/i; // Milliseconds since the running query started (0 when idle). Used for the // live counter, computed fresh so each render/tick shows the current value. app.elapsedMs = () => (app.state.runT0 != null ? now() - app.state.runT0 : 0); @@ -431,10 +447,41 @@ export function createApp(env = {}) { } app.tickElapsed = tickElapsed; + // A ClickHouse HTTP session ties a tab's requests together so session state — + // temporary tables, SET settings — survives across the separate HTTP requests + // of a multiquery script (and across successive runs in the tab). ClickHouse's + // HTTP interface runs one statement per request and is otherwise stateless, so + // without this a `CREATE TEMPORARY TABLE …; INSERT …; SELECT …` script can't + // see its own temp table. The id is per-tab (lazily minted) so tabs don't share + // state and never collide on the per-session lock (only one query runs at a + // time, guarded by `running`). No `session_timeout` override is needed: + // ClickHouse resets the idle timer when each query is *released* (end of the + // request, not the start) and cancels it while a query runs, so the default + // (60s) never lapses between a script's back-to-back statements. + function sessionParams(tab) { + tab.chSession = tab.chSession || uid('sess-'); + return { session_id: tab.chSession }; + } + // Only TEMPORARY tables and session `SET`s need a session; permanent DDL/DML and + // SELECTs are global. So we attach a session_id ONLY when the SQL needs one — or + // when the tab already opened one (sticky, so a temp table / SET from an earlier + // run stays visible to later runs in that tab). Ordinary scripts run session-LESS, + // which avoids the session lock / replica-affinity reset that intermittently + // surfaces as a "Network error". (Schema / reference loads are always + // session-less — they fan out in parallel and would deadlock on the lock.) + const needsSession = (sqls) => sqls.some((s) => /\bTEMPORARY\b/i.test(s) || leadingKeyword(s) === 'SET'); + function sessionParamsFor(tab, sqls) { + return tab.chSession != null || needsSession(sqls) ? sessionParams(tab) : {}; + } + async function run(opts) { if (app.state.running.value) return; // already running — cancel via cancel()/Esc const tab = app.activeTab(); - if (!tab.sql.trim()) return; + // `opts.sql` overrides the source SQL (a single selected statement); otherwise + // the whole tab runs, byte-for-byte as before (FORMAT / EXPLAIN detection, + // trailing `;`, history). + const srcSql = opts && opts.sql != null ? opts.sql : tab.sql; + if (!srcSql.trim()) return; await ensureConfig(); if (!(await getToken())) { chCtx.onSignedOut(); return; } @@ -447,10 +494,10 @@ export function createApp(env = {}) { // 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 explicitFmt = detectSqlFormat(srcSql); + const parsed = explicitFmt ? null : parseExplain(srcSql); const explainMode = !explicitFmt && (parsed != null || app.state.forceExplain); - let runSql = tab.sql; + let runSql = srcSql; let fmt; let explainView = null; if (explainMode) { @@ -463,9 +510,9 @@ export function createApp(env = {}) { || (parsed && detectExplainView(parsed)) || 'explain'; fmt = (EXPLAIN_VIEWS.find((v) => v.id === explainView) || EXPLAIN_VIEWS[0]).chFormat; - const inner = parsed ? parsed.inner : tab.sql; + const inner = parsed ? parsed.inner : srcSql; runSql = explainView === 'explain' - ? (parsed ? tab.sql : 'EXPLAIN ' + tab.sql) + ? (parsed ? srcSql : 'EXPLAIN ' + srcSql) : buildExplainQuery(inner, explainView); } else { fmt = explicitFmt || 'Table'; @@ -481,7 +528,7 @@ export function createApp(env = {}) { if (explainView) tab.result.explainView = explainView; app.state.resultSort = { col: null, dir: 'asc' }; app.state.runT0 = t0; - app.state.runQueryId = cryptoObj.randomUUID ? cryptoObj.randomUUID() : 'q' + t0; + app.state.runQueryId = uid('q'); app.state.abortController = new AbortController(); app.state.runTick = setInterval(tickElapsed, 100); // Keep the current Table/JSON/Chart tab across re-runs (#34); a saved-query @@ -502,6 +549,7 @@ export function createApp(env = {}) { resultRowLimit: rowLimit, queryId: app.state.runQueryId, signal: app.state.abortController.signal, + params: sessionParamsFor(tab, [srcSql]), onLine: (json) => applyStreamLine(json, tab.result), onChunk: () => renderResults(app), }); @@ -526,9 +574,129 @@ export function createApp(env = {}) { // render the final stats, so elapsed_ns must already be recorded. (Old // explicit setRunBtn(false)/renderResults are now those effects' job.) app.state.running.value = false; - if (!tab.result.error && !tab.result.cancelled) app.recordHistory(tab); + if (!tab.result.error && !tab.result.cancelled) app.recordHistory(tab, opts && opts.sql); + } + } + + // Run one script statement, classifying the outcome for the retry logic: a + // Cancel → { aborted }; a connection-level fetch failure → { error:'Network + // error', transient } (retryable); any other throw → { error }. Otherwise the + // runQuery result itself ({ raw } | { error }). + async function attemptStatement(stmt, opts) { + try { + return await ch.runQuery(chCtx, stmt, opts); + } catch (e) { + if (e.name === 'AbortError') return { aborted: true }; + return { error: e instanceof TypeError ? 'Network error' : String((e && e.message) || e), transient: e instanceof TypeError }; } } + + // Run a `;`-separated script sequentially: one ClickHouse request per statement + // (CH's HTTP interface runs exactly one statement per request), stopping on the + // first failure. Row-returning statements (SELECT/WITH/SHOW/…) are fetched as + // JSONCompact capped at 100 rows; everything else runs for effect and reports + // OK. The result is a per-statement summary grid (tab.result.script). The whole + // script is recorded as one history entry on a clean run. `originalInput` is the + // exact text that was split (the selection or the whole editor). + async function runScript(statements, originalInput) { + if (app.state.running.value) return; + await ensureConfig(); + if (!(await getToken())) { chCtx.onSignedOut(); return; } + app.state.forceExplain = false; + const tab = app.activeTab(); + const t0 = now(); + const entries = []; + tab.result = { script: entries }; + app.state.resultSort = { col: null, dir: 'asc' }; + app.state.runT0 = t0; + app.state.abortController = new AbortController(); + app.state.runTick = setInterval(tickElapsed, 100); + let aborted = false; + // Attach a session only if the script needs one (TEMPORARY / SET) or the tab + // already has one — same params for every statement, computed once. + const sp = sessionParamsFor(tab, statements); + app.state.running.value = true; // the results effect paints the (empty) grid + try { + for (let i = 0; i < statements.length; i++) { + const stmt = statements[i]; + const rowReturning = isRowReturning(stmt); + // Over-fetch SELECTs by one past the display cap so a truncated result is + // detectable (at exactly the cap it isn't). + const opts = { + format: rowReturning ? 'JSONCompact' : 'TSV', + signal: app.state.abortController.signal, + params: { ...sp, ...(rowReturning ? { max_result_rows: SELECT_ROW_CAP + 1, result_overflow_mode: 'break' } : {}) }, + }; + const s0 = now(); // this statement's own wall-clock (grid Time column) + // Fresh query_id per attempt, published before the request so Cancel + // issues KILL QUERY against the statement that's actually running. + app.state.runQueryId = uid('q'); + let out = await attemptStatement(stmt, { ...opts, queryId: app.state.runQueryId }); + // Retry ONLY when it's safe. SESSION_IS_LOCKED means the statement was + // rejected before running → safe to retry (any statement). A connection + // reset (fetch TypeError → "Network error") leaves it UNKNOWN whether the + // statement ran, so only retry read-only statements — re-running an + // INSERT/DDL could double-apply it. (A mid-retry Cancel aborts the retry.) + const locked = out.error != null && SESSION_BUSY.test(out.error); + if (!out.aborted && (locked || (out.transient && rowReturning))) { + await sleep(retryMs); + app.state.runQueryId = uid('q'); + out = await attemptStatement(stmt, { ...opts, queryId: app.state.runQueryId }); + } + if (out.aborted) { aborted = true; break; } + // A connection reset on a non-idempotent statement: don't silently retry — + // tell the user it may have run so they can decide whether to re-run. + if (out.transient && !rowReturning) out.error = 'Network error — the statement may have executed; re-run it manually if needed.'; + const ms = now() - s0; + if (out.error != null) { + entries.push({ sql: stmt, status: 'error', error: out.error, ms }); + renderResults(app); + break; // stop-on-first-failure: skip the remaining statements + } + if (rowReturning) { + const sel = parseSelectResult(out.raw, SELECT_ROW_CAP); + entries.push({ sql: stmt, status: 'rows', columns: sel.columns, rows: sel.rows, truncated: sel.truncated, preview: firstRowPreview(sel.rows), ms }); + } else { + entries.push({ sql: stmt, status: 'ok', ms }); + } + renderResults(app); + } + } finally { + clearInterval(app.state.runTick); + app.state.runTick = null; + app.state.abortController = null; + app.state.runQueryId = null; + app.state.runT0 = null; + tab.result.elapsedMs = now() - t0; + if (aborted) tab.result.cancelled = true; + app.state.running.value = false; + // One history entry for the whole script — but only on a clean run (mirrors + // run(): no history for an aborted or failed script). + if (!aborted && !entries.some((e) => e.status === 'error')) { + recordScriptHistory(app.state, originalInput, tab.result.elapsedMs, saveJSON); + if (app.state.sidePanel.value === 'history') renderSavedHistory(app); + } + } + } + + // The Run button / ⌘+Enter entry point. A non-empty (non-whitespace) editor + // selection runs just that text; otherwise the whole tab. The chosen text is + // split: one statement keeps today's rich Table/Chart/EXPLAIN path (run()); + // more than one runs sequentially as a script (runScript). + function runEntry(opts) { + if (app.state.running.value) return; + const ta = app.dom.editorTextarea; + const sel = ta ? ta.value.slice(ta.selectionStart, ta.selectionEnd) : ''; + const hasSel = sel.trim() !== ''; + const input = hasSel ? sel : app.activeTab().sql; + const statements = splitStatements(input); + if (!statements.length) return; // nothing runnable (empty / comments-only) + // >1 statement → script grid (a remembered single-result view doesn't apply). + if (statements.length > 1) return runScript(statements, input); + // 1 statement → today's rich path. Forward opts (e.g. a saved query's + // remembered view / Explain); a selection adds the sql override. + return run(hasSel ? { ...opts, sql: input } : opts); + } // Stop an in-flight query: abort the stream and KILL QUERY on the server. function cancel() { if (!app.state.running.value) return; @@ -538,41 +706,80 @@ export function createApp(env = {}) { function setRunBtn(running) { if (!app.dom.runBtn) return; app.dom.runBtn.disabled = running; - // Build the children and drop the null (replaceChildren would otherwise - // coerce a null arg into a "null" text node → "Running…null"). + // "Run selection" while the editor has a non-empty selection (so the mode is + // discoverable); plain "Run" otherwise. Build the children and drop the null + // (replaceChildren would coerce a null arg into a "null" text node). + const label = running ? 'Running…' : (app.state.hasSelection.value ? 'Run selection' : 'Run'); app.dom.runBtn.replaceChildren( - ...[Icon.play(), h('span', null, running ? 'Running…' : 'Run'), + ...[Icon.play(), h('span', null, label), running ? null : h('kbd', null, '⌘↵')].filter(Boolean)); } app.setRunBtn = setRunBtn; + // Busy state for the Format button — formatting a multi-statement script is one + // request per statement, so it can take a moment; show a spinner + disable. + function setFmtBtn(busy) { + if (!app.dom.fmtBtn) return; + app.dom.fmtBtn.disabled = busy; + app.dom.fmtBtn.replaceChildren( + busy ? h('span', { class: 'spin' }, Icon.spinner()) : Icon.braces(), + busy ? 'Formatting…' : 'Format'); + } + app.setFmtBtn = setFmtBtn; // Pretty-print the editor's SQL via ClickHouse's formatQuery(), in place. The // raw (untrimmed) SQL is sent so a syntax error's reported position maps 1:1 // onto the editor text. On error we show it persistently in the results panel // and jump the caret to the offending token; a later successful format clears // that error. Success never touches real run results. + // Clear a prior format-error result (a later successful format clears just this). + function clearFormatError() { + const tab = app.activeTab(); + if (tab.result && tab.result.formatError) { tab.result = null; renderResults(app); } + } + // Format one statement via ClickHouse's formatQuery(); returns the formatted text. + const formatOne = async (s) => { + const json = await ch.queryJson(chCtx, 'SELECT formatQuery(' + sqlString(s) + ') AS q FORMAT JSON'); + return (json.data && json.data[0] && json.data[0].q) || ''; + }; + async function formatQuery() { const raw = app.activeTab().sql || ''; if (!raw.trim()) return; await ensureConfig(); if (!(await getToken())) { chCtx.onSignedOut(); return; } const tab = app.activeTab(); + const stmts = splitStatements(raw); + setFmtBtn(true); // formatting a script is one request per statement — show busy try { - const json = await ch.queryJson(chCtx, 'SELECT formatQuery(' + sqlString(raw) + ') AS q FORMAT JSON'); - const q = (json.data && json.data[0] && json.data[0].q) || ''; - // Terminate so the caret lands past the last token — otherwise the input - // event from the replace re-opens autocomplete on the trailing word. - if (q) replaceEditor(app, withStatementBreak(q)); - if (tab.result && tab.result.formatError) { tab.result = null; renderResults(app); } // clear a prior format error - } catch (e) { - const msg = String((e && e.message) || e); - tab.result = newResult('Table'); - tab.result.error = msg; - tab.result.formatError = true; // a format error, not a run result (so success can clear just this) - app.state.resultView.value = 'table'; - renderResults(app); // explicit: the format-error tab.result is an in-place write, and resultView may already be 'table' (no effect) - const pos = parseErrorPos(msg); - if (pos != null) app.dom.editorRevealCaret(pos); + if (stmts.length > 1) { + // Multi-statement: format each (best-effort — keep the original text for any + // statement that won't format, like insertCreate), then reassemble with a + // `;` and a blank line between statements. + const formatted = await Promise.all(stmts.map((s) => formatOne(s).catch(() => s))); + replaceEditor(app, withStatementBreak(formatted.map((q, i) => q || stmts[i]).join(';\n\n'))); + clearFormatError(); + return; + } + // Single statement: send the raw (untrimmed) SQL so a syntax error's reported + // position maps 1:1 onto the editor text; show it persistently + jump the caret. + try { + const q = await formatOne(raw); + // Terminate so the caret lands past the last token — otherwise the input + // event from the replace re-opens autocomplete on the trailing word. + if (q) replaceEditor(app, withStatementBreak(q)); + clearFormatError(); + } catch (e) { + const msg = String((e && e.message) || e); + tab.result = newResult('Table'); + tab.result.error = msg; + tab.result.formatError = true; // a format error, not a run result (so success can clear just this) + app.state.resultView.value = 'table'; + renderResults(app); // explicit: the format-error tab.result is an in-place write, and resultView may already be 'table' (no effect) + const pos = parseErrorPos(msg); + if (pos != null) app.dom.editorRevealCaret(pos); + } + } finally { + setFmtBtn(false); } } @@ -653,11 +860,19 @@ export function createApp(env = {}) { openDetailPane(app, node, detail, targetDoc); } + // EXPLAIN wraps the whole editor as a single statement, so it can't run against a + // `;`-separated script (ClickHouse would reject `EXPLAIN a; b; …` with a confusing + // parse error). Say so with our own message instead. + function explainMultiBlocked() { + if (splitStatements(app.activeTab().sql).length <= 1) return false; + flashToast('Explain isn’t available for a multi-statement script — run one statement at a time.', { document: doc }); + return true; + } // 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 }); } + function explainQuery() { return explainMultiBlocked() ? undefined : run({ explain: true }); } // Switch the active EXPLAIN view (re-runs the derived query, keeps the mode). - function setExplainView(id) { return run({ explainView: id }); } + function setExplainView(id) { return explainMultiBlocked() ? undefined : run({ explainView: id }); } // Change the global result-row cap: persist the (normalized) preference and // re-run the current query so a raise genuinely fetches more (server-side cap), // a lower one stops sooner. run() no-ops on an empty editor, so changing the @@ -691,8 +906,8 @@ export function createApp(env = {}) { } // --- saved / history bridges ------------------------------------------ - app.recordHistory = (tab) => { - recordHistory(app.state, tab, saveJSON); + app.recordHistory = (tab, sqlText) => { + recordHistory(app.state, tab, saveJSON, undefined, sqlText); if (app.state.sidePanel.value === 'history') renderSavedHistory(app); }; @@ -716,7 +931,8 @@ export function createApp(env = {}) { // A result is exportable once it has raw text or at least one row. function exportableResult() { const r = app.activeTab().result; - return r && !r.error && (r.rawText != null || r.rows.length > 0) ? r : null; + // A script result is a per-statement grid, not a single exportable table. + return r && !r.error && !r.script && (r.rawText != null || r.rows.length > 0) ? r : null; } function copyResult() { const r = exportableResult(); @@ -862,7 +1078,7 @@ export function createApp(env = {}) { // --- actions registry -------------------------------------------------- app.actions = { - run, + run: runEntry, cancel, newTab: () => newTab(app), selectTab: (id) => selectTab(app, id), @@ -1012,8 +1228,20 @@ export function renderApp(app, helpers) { app.state.running.value; renderResults(app); }); - // The Run button reflects the run state (label + disabled). - effect(() => app.setRunBtn(app.state.running.value)); + // The Run button reflects the run state (label + disabled) and the selection + // (Run ↔ Run selection). + effect(() => { app.state.hasSelection.value; app.setRunBtn(app.state.running.value); }); + // Track the editor's text selection into a signal so the Run button label and + // ⌘+Enter target just the highlighted text. `selectionchange` is the one event + // that fires for keyboard, mouse, and programmatic selection; gate on the + // editor being focused so selecting elsewhere (results, address bar) is ignored. + app.syncSelection = () => { + const ta = app.dom.editorTextarea; + const focused = ta && (app.document || document).activeElement === ta; + const sel = focused ? ta.value.slice(ta.selectionStart, ta.selectionEnd) : ''; + app.state.hasSelection.value = sel.trim() !== ''; + }; + (app.document || document).addEventListener('selectionchange', app.syncSelection); // Reactive repaint of the schema tree — replaces the scattered renderSchema() // calls: re-runs on schema load, load error, filter text, or expand/collapse. // Registered here (post-mount) so app.dom.schemaList already exists; the effect diff --git a/src/ui/results.js b/src/ui/results.js index 9115ba4..b428943 100644 --- a/src/ui/results.js +++ b/src/ui/results.js @@ -9,6 +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 { SELECT_ROW_CAP } from '../core/script-result.js'; import { RESULT_ROW_LIMIT_OPTIONS } from '../state.js'; import { renderExplainGraph, openPipelineFullscreen, renderSchemaGraph } from './explain-graph.js'; @@ -41,17 +42,23 @@ export function colResizeWidth(startW, dx, scale) { return Math.max(MIN_COL, Math.round(startW + dx / (scale || 1))); } +// Map a header cell index to its `r.colWidths` key. The data grid's first cell is +// the row-number column ('idx'); its data columns are then 0-based. The script +// grid has no row-number column, so every cell keys by its own index. +const IDX_KEY = (k) => (k === 0 ? 'idx' : k - 1); +const PLAIN_KEY = (k) => k; + /** - * Pin every column of `table` to the px widths in `r.colWidths` (key 'idx' for - * the row-number column, then 0-based data-column indices) and switch it to - * fixed layout so columns honor those widths exactly (and the wrap scrolls). + * Pin every column of `table` to the px widths in `widths` (keyed via + * `keyOf(cellIndex)`) and switch it to fixed layout so columns honor those widths + * exactly (and the wrap scrolls). Shared by the data grid and the script grid. */ -function applyFixedWidths(table, r) { +function applyFixedWidths(table, widths, keyOf) { table.classList.add('fixed'); const cells = table.querySelectorAll('thead th'); let total = 0; for (let k = 0; k < cells.length; k++) { - const w = r.colWidths[k === 0 ? 'idx' : k - 1]; + const w = widths[keyOf(k)]; cells[k].style.width = w + 'px'; total += w; } @@ -59,27 +66,45 @@ function applyFixedWidths(table, r) { table.style.minWidth = '0'; } -/** Begin dragging the right edge of header `th` (a data column) to resize it. */ -function startColumnResize(r, th, ev) { +/** + * Begin dragging the right edge of header `th` to resize its column. `keyOf` maps + * a cell index to its `r.colWidths` key (see IDX_KEY / PLAIN_KEY). + * + * Splitter model: the drag moves the *border* between this column and its right + * neighbor — the column grows and the neighbor shrinks by the same amount, so the + * table's total width (and every other column) stays put. Dragging the last + * column's edge has no neighbor to take from, so it grows the table (scroll). + */ +function startColumnResize(widths, th, ev, keyOf) { ev.preventDefault(); ev.stopPropagation(); // don't let the handle's mousedown reach the sort header const table = th.closest('table'); const cells = table.querySelectorAll('thead th'); - const colIndex = [].indexOf.call(cells, th) - 1; // 'idx' is cell 0 + const cellIdx = [].indexOf.call(cells, th); + const colIndex = keyOf(cellIdx); + const nextKey = cellIdx + 1 < cells.length ? keyOf(cellIdx + 1) : null; // First resize: freeze every column at its current rendered width, then fix. - if (!Object.keys(r.colWidths).length) { + if (!Object.keys(widths).length) { for (let k = 0; k < cells.length; k++) { - r.colWidths[k === 0 ? 'idx' : k - 1] = cells[k].offsetWidth; + widths[keyOf(k)] = cells[k].offsetWidth; } } - applyFixedWidths(table, r); + applyFixedWidths(table, widths, keyOf); const win = th.ownerDocument.defaultView; const scale = zoomScale(th); const startX = ev.clientX; - const startW = r.colWidths[colIndex]; + const startW = widths[colIndex]; + const pairW = nextKey != null ? startW + widths[nextKey] : 0; // combined width of the pair const onMove = (m) => { - r.colWidths[colIndex] = colResizeWidth(startW, m.clientX - startX, scale); - applyFixedWidths(table, r); + let w = colResizeWidth(startW, m.clientX - startX, scale); + if (nextKey != null) { + // Keep the pair's combined width constant; both stay ≥ MIN_COL (a pair + // narrower than 2·MIN_COL can't satisfy both — the floor wins over total). + w = Math.max(MIN_COL, Math.min(w, pairW - MIN_COL)); + widths[nextKey] = Math.max(MIN_COL, pairW - w); + } + widths[colIndex] = w; + applyFixedWidths(table, widths, keyOf); }; const onUp = () => { win.removeEventListener('mousemove', onMove); @@ -104,6 +129,14 @@ export function renderResults(app) { // While running, pin a streaming strip to the top of the body: a determinate // fill at read/total when known, else an indeterminate sweep. if (app.state.running.value) inner.appendChild(streamStrip(r)); + // Multiquery script: a per-statement summary grid. Handled before the + // single-result chain below (a script result has no `rows`/`rawText`). + if (r && r.script) { + inner.appendChild(renderScriptGrid(app, r)); + body.appendChild(inner); + region.replaceChildren(body); + return; + } const streamingBlank = app.state.running.value && (!r || (r.rows.length === 0 && r.rawText == null)); if (streamingBlank) { inner.appendChild(h('div', { class: 'placeholder starting' }, @@ -160,6 +193,106 @@ function streamStrip(r) { : h('i', { class: 'sweep' })); } +// The multiquery summary grid: one row per executed statement. Col 1 is the +// collapsed statement text (full text on hover); Col 2 is the outcome — OK for an +// effectful statement (DDL/INSERT), the first-row preview for a SELECT (click to +// open all rows in a side pane), or the error for the failing statement (the last +// row, since the run stops on first failure); Col 3 is that statement's own +// execution time (the toolbar still shows the script total). Columns are +// drag-resizable like the data grid (initial 25 / 65 / 10 from CSS). +function renderScriptGrid(app, r) { + r.colWidths = r.colWidths || {}; // persists drag-resized widths across re-renders + const wrap = h('div', { class: 'res-table-wrap script-grid' }); + const table = document.createElement('table'); + table.className = 'res-table'; + const thead = document.createElement('thead'); + const trh = document.createElement('tr'); + for (const [cls, label] of [['script-sql', 'Statement'], ['script-res', 'Result'], ['script-time', 'Time']]) { + const th = h('th', { class: cls }, h('span', { class: 'h-name' }, label), + h('span', { + class: 'col-resize-h', + title: 'Drag to resize column', + onmousedown: (e) => startColumnResize(r.colWidths, th, e, PLAIN_KEY), + onclick: (e) => e.stopPropagation(), + })); + trh.appendChild(th); + } + thead.appendChild(trh); + table.appendChild(thead); + if (Object.keys(r.colWidths).length) applyFixedWidths(table, r.colWidths, PLAIN_KEY); + const tbody = document.createElement('tbody'); + r.script.forEach((e) => { + const tr = document.createElement('tr'); + tr.appendChild(h('td', { class: 'script-sql', title: e.sql || '' }, + h('div', { class: 'cell-val' }, (e.sql || '').replace(/\s+/g, ' ').trim()))); + tr.appendChild(scriptOutcomeCell(app, e)); + tr.appendChild(h('td', { class: 'script-time' }, e.ms != null ? e.ms.toFixed(0) + ' ms' : '')); + tbody.appendChild(tr); + }); + table.appendChild(tbody); + wrap.appendChild(table); + if (app.state.running.value) { + wrap.appendChild(h('div', { class: 'script-running' }, + h('span', { class: 'spin' }, Icon.spinner()), h('span', null, 'Running…'))); + } + return wrap; +} + +// Column 2 of one script row, by outcome. +function scriptOutcomeCell(app, e) { + if (e.status === 'error') return h('td', { class: 'script-cell err' }, e.error || 'Error'); + if (e.status === 'ok') return h('td', { class: 'script-cell ok' }, 'OK'); + // status === 'rows' + if (!e.rows || !e.rows.length) return h('td', { class: 'script-cell' }, '(0 rows)'); + const n = e.rows.length; + const meta = '(' + n + ' row' + (n === 1 ? '' : 's') + (e.truncated ? ', first ' + SELECT_ROW_CAP : '') + ')'; + return h('td', { + class: 'script-cell rows', title: 'Click to view all rows', + onclick: () => openRowsViewer(app, e), + }, h('span', { class: 'script-preview' }, e.preview || ''), h('span', { class: 'script-meta' }, meta)); +} + +/** + * Open a right-side pane with the full rows of one script SELECT, using the same + * sortable + resizable grid as the main results table (renderGrid). Sort state and + * column widths are local to this pane; clicking a cell opens its value (the same + * cell-detail drawer, stacked). Reuses the .cd-* drawer scaffold (a shared Drawer + * primitive is deferred to #60). Escape / backdrop / ✕ closes. Exported for tests. + */ +export function openRowsViewer(app, entry) { + const doc = app.document || document; + let backdrop; + const onKey = (ev) => { if (ev.key === 'Escape' && isTopDrawer(doc, backdrop)) close(); }; + function close() { + if (backdrop) backdrop.remove(); + doc.removeEventListener('keydown', onKey, true); + } + const n = entry.rows.length; + const head = h('div', { class: 'cd-head' }, + h('div', { class: 'cd-title' }, + h('span', { class: 'cd-name' }, 'Result rows'), + h('span', { class: 'cd-type' }, n + (entry.truncated ? '+' : '') + ' row' + (n === 1 ? '' : 's'))), + h('button', { class: 'cd-close', title: 'Close (Esc)', onclick: close }, Icon.close())); + // Local sort + width state (persist for the lifetime of this open via the entry). + const sort = entry.viewerSort || (entry.viewerSort = { col: null, dir: 'asc' }); + const widths = entry.viewerWidths || (entry.viewerWidths = {}); + const body = h('div', { class: 'cd-body' }); + const paint = () => body.replaceChildren(renderGrid({ + columns: entry.columns || [], + rows: entry.rows, + sort, + onSort: (col, dir) => { sort.col = col; sort.dir = dir; paint(); }, + widths, + onCell: (name, type, value) => openCellDetail(app, name, type, value), + })); + paint(); + const panel = h('div', { class: 'cd-panel', onclick: (ev) => ev.stopPropagation() }, head, body); + backdrop = h('div', { class: 'cd-backdrop', onclick: close }, panel); + doc.body.appendChild(backdrop); + doc.addEventListener('keydown', onKey, true); + return backdrop; +} + /** * A