Skip to content

feat: multiquery + run-selection — run a ;-separated script (#83)#95

Merged
BorisTyshkevich merged 13 commits into
mainfrom
feat/multiquery-83
Jun 30, 2026
Merged

feat: multiquery + run-selection — run a ;-separated script (#83)#95
BorisTyshkevich merged 13 commits into
mainfrom
feat/multiquery-83

Conversation

@BorisTyshkevich

Copy link
Copy Markdown
Collaborator

What & why

Closes #83 (Roadmap #68, Phase 2 — editor-intelligence track).

ClickHouse's HTTP interface runs exactly one statement per request, so pasting CREATE …; INSERT …; SELECT … used to error out, and there was no way to run just a highlighted statement. This adds multiquery + run-selection.

Behavior

  • Auto-detect on ⌘+Enter / Run. A single statement (± trailing ;) behaves byte-for-byte as today (Table/JSON/Chart, EXPLAIN, FORMAT). More than one statement runs sequentially — one request each — into a compact per-statement summary grid.
  • Run selection. A non-empty (non-whitespace) editor selection runs only that text, fed through the same split (one or many statements); the Run button flips its label to Run selection while text is highlighted. A single selected statement keeps the full rich result view.
  • Stop on first failure — the failing statement is the last grid row; later statements are skipped. Cancel aborts mid-script (KILL QUERY targets the live statement).
  • Outcomes. Row-returning statements (SELECT/WITH/SHOW/…) fetch as JSONCompact capped at 100 rows; Col 2 shows the first row comma-separated, click opens all rows in a side pane. Effectful statements (DDL/INSERT) show OK. The whole script is recorded as one history entry on a clean run.

Shape

  • src/core/sql-split.js (pure, 100%) — lexical ;-splitter that skips literals ('…'/"…"/`…`, with \' and '' escapes) and --/#//* */ comments, plus an isRowReturning classifier.
  • src/core/script-result.js (pure, 100%) — parse a JSONCompact body → {columns, rows, truncated} + a first-row preview; one SELECT_ROW_CAP constant.
  • src/net/ch-client.js — additive o.params passthrough on runQuery (own commit) so SELECTs can cap server-side.
  • src/ui/app.jsrunScript runner + run(opts.sql) override + runEntry dispatcher; hasSelection signal via selectionchange.
  • src/ui/results.jsr.script branch + summary grid + side-pane rows viewer (reuses the cell-detail scaffold; a shared Drawer stays deferred to Version-exact reference docs in the editor from ClickHouse 26.6 embedded docs #60).

A Plan subagent stress-tested the approach and a 3-angle /code-review pass ran before this PR; both folded in (notably: over-fetch by one so truncation is detectable, parenthesized-SELECT classification, exportableResult guard, no-op on comment-only selection).

Known limitation (documented): an INSERT … FORMAT … with inline data containing ; mis-splits — run those on their own.

Checklist

  • npm test passes (the per-file coverage gate is non-negotiable) — 1082 tests; new core modules at 100%
  • Tests added/updated in the same change as the code
  • npm run build succeeds (single-file dist/sql.html)
  • Layers kept honest: pure logic in src/core/, network in src/net/ (injected fetch), DOM in src/ui/
  • No new runtime dependency
  • README / CHANGELOG.md ([Unreleased]) updated — CHANGELOG updated (no deployed-surface/README change)
  • Reconciled affected tracked work (roadmap Roadmap to 1.0.0 #68 Phase 2, CHANGELOG; no ADR change — reactivity model unchanged)

Playwright e2e (Chromium/Firefox/WebKit) green. The run/results flow needs a live ClickHouse, so it's covered by unit tests rather than headless e2e.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LnuZaQGhGdQy7vNTvGXCQ2

…#83)

The pure foundation for running a ;-separated script (DDL/INSERT/SELECT):
- sql-split.js: lexical statement splitter that skips ; inside '…'/"…"/`…`
  literals (\' and '' escapes) and -- / # / block comments, plus an
  isRowReturning() classifier.
- script-result.js: parse a JSONCompact SELECT body → {columns,rows,truncated}
  (the renderTable shape) and a comma-joined first-row preview.

Both 100% covered. No wiring yet.
Additive o.params passthrough merged alongside query_id, so multiquery SELECTs
can cap results server-side (max_result_rows / result_overflow_mode) without a
new function or SQL mangling. Isolated so it can be reverted independently.
Run a ;-separated script (DDL / INSERT / SELECT) in one shot, or run just the
highlighted text. ⌘+Enter auto-detects: a single statement keeps today's rich
Table/Chart/EXPLAIN path; >1 statements run sequentially (one ClickHouse request
each, stop-on-first-error) into a per-statement summary grid.

- runScript in app.js: fresh query_id per statement (Cancel kills the live one),
  abort → stop + mark cancelled, one history entry for the whole script.
- run() gains an opts.sql override so a single selected statement uses the rich
  path; runEntry routes the Run button + ⌘+Enter and picks script vs single.
- Selection tracked via a hasSelection signal (selectionchange, gated on the
  editor being focused); the Run button flips to "Run selection".
- Row-returning statements fetched as JSONCompact capped at 100 rows
  (max_result_rows / result_overflow_mode); Col 2 shows the first row
  comma-joined, click opens all rows in a side pane (reuses the cell-detail
  scaffold; shared Drawer deferred to #60).
- state: hasSelection signal, recordScriptHistory, recordHistory sqlText override.

Per-file coverage gate green (1078 tests); build OK.
- Truncation was undetectable: result_overflow_mode=break capped at exactly
  max_result_rows, so `data.length > cap` was never true and the "first N"
  badge never showed. Over-fetch by one (cap+1) so truncation is detectable.
- leadingKeyword now skips a leading `(`, so `(SELECT …) UNION …` is classified
  row-returning instead of silently running as an effectful "OK" with rows hidden.
- exportableResult() guards the script result shape (no rows/rawText) so a
  future Copy/Export binding can't TypeError on it.
- runEntry no-ops when the split yields nothing (comment-/whitespace-only
  selection no longer POSTs a comment).
- Single SELECT_ROW_CAP constant replaces the 100 literal repeated across the
  runner, parser, and grid label.
@BorisTyshkevich BorisTyshkevich mentioned this pull request Jun 30, 2026
20 tasks
…to-run (#83)

Follow-up UX fixes for multi-statement scripts:
- Format now formats each statement via formatQuery() and rejoins them with `;`
  + a blank line (best-effort: an unformattable statement keeps its text). The
  Format button shows a busy spinner ("Formatting…") since it's one request per
  statement. Single-statement behavior (incl. syntax-error caret reveal) unchanged.
- Explain (and the EXPLAIN view-switcher) show a clear toast — "Explain isn't
  available for a multi-statement script — run one statement at a time." — instead
  of sending `EXPLAIN a; b; …` and surfacing a confusing ClickHouse parse error.
- Opening a saved query / history entry auto-runs ONLY read-only queries
  (new pure `isAutoRunnable` — every statement row-returning). An effectful query
  (CREATE/ALTER/DROP/INSERT/…) loads into the editor without executing, so a
  destructive statement is never run just by clicking it in the sidebar.

Per-file coverage gate green (1093 tests); build OK; e2e green.
…olumns (#83)

- The script summary grid gains a 3rd column showing each statement's own
  execution time (right-aligned). The toolbar still shows the script total.
- Grid columns are drag-resizable like the data table — generalized the existing
  resize helpers (applyFixedWidths/startColumnResize) with a keyOf mapper so the
  data grid keeps its row-number ('idx') column while the script grid keys by
  plain column index; no duplicated drag logic.
- Initial column proportions 25% / 65% / 10% (Statement / Result / Time) via CSS;
  a drag then pins px widths, same as the data grid.

Per-file coverage gate green (1097 tests); build OK; e2e green (39).
…sion (#83)

ClickHouse's HTTP interface is stateless per request, so a multiquery script
like CREATE TEMPORARY TABLE …; INSERT …; SELECT … couldn't see its own temp
table across the separate requests. Each tab now gets a session_id (lazily
generated) passed on its run + script requests with session_timeout=600, so
session state — temporary tables, SET settings — survives across a script's
statements and across successive runs in the tab. Schema/reference loads stay
session-less (they fan out in parallel and would deadlock on the session lock);
only one user query runs at a time, so the session is never contended.
…pane (#83)

- Column resize now uses a splitter model: dragging a border trades width between
  the column and its right neighbor (total width and other columns stay put),
  fixing the shift/scroll artifact. The last column (no neighbor) still grows the
  table. Applies to the data grid, the script grid, and the rows pane.
- Extracted renderGrid({columns,rows,sort,onSort,widths,onCell}) — one sortable +
  resizable table component. renderTable wires it to the global result/sort; the
  script-row side pane (openRowsViewer) now reuses it with local sort/width state,
  so the rows pane is sortable + resizable like the main grid (was a static table).
- Resize helpers (applyFixedWidths/startColumnResize) now take a plain widths
  object, decoupled from the result.
- Stacked drawers: only the topmost responds to Escape, so dismissing a cell
  drawer opened from the rows pane returns to the pane instead of closing both.
- Confirmed -- / # / /* */ comment support end-to-end (added a script test).

Per-file coverage gate green (1103 tests); build OK; e2e green (39).
…id fallback (#83)

Multiquery scripts intermittently failed with "Network error". Cause: the
script's statements share one ClickHouse HTTP session and run back-to-back, so a
follow-up request can reach the server before it has released the per-session
lock (released asynchronously, just after the prior response is flushed). Direct
to ClickHouse that's a 500 SESSION_IS_LOCKED, but behind a proxy/LB it shows up
as a reset connection → fetch TypeError → "Network error". Single queries issue
one session request and never race, so they were stable.

Fix:
- runScript retries a statement ONCE on a transient failure — a connection reset
  (fetch TypeError) or a SESSION_IS_LOCKED response — after a short (env-injectable)
  delay, with a fresh query_id. Genuine query errors are not retried (stop-on-first).
- Hardened the session_id / query_id fallback for non-secure (http://) contexts
  where crypto.randomUUID is undefined: mix in Math.random rather than only a
  coarse performance.now(), so two tabs can't generate the same id and collide on
  the session lock. Extracted a single uid(prefix) helper for all three id sites.

Per-file coverage gate green (1107 tests); build OK; e2e green (39; 2 Firefox
page.goto timeouts were load flakes, pass on re-run).
…ry (#83)

Reworks the multiquery session model after research into the official
@clickhouse/client-web (rejected: it can't control browser sockets so it
wouldn't fix the keep-alive reset, its auth is static at client creation which
breaks our per-request OAuth refresh seam, and it's a 4th runtime dep behind our
injected fetch). Browser JS can't pin a TCP connection; ClickHouse sessions are
logical (keyed by session_id), so the fix is to use them sparingly and retry
safely.

- Attach session_id ONLY when the SQL needs it (CREATE TEMPORARY / SET) or the
  tab already opened one (sticky, so temp tables / SET persist across runs).
  Ordinary scripts run session-less → no session-lock / affinity reset → no more
  intermittent "Network error" for the common case.
- Idempotency-aware retry: SESSION_IS_LOCKED (rejected pre-execution) and a
  connection reset on a READ-ONLY statement retry once; a connection reset on an
  INSERT/DDL is NOT retried (it may have executed) and is surfaced as such.

Per-file coverage gate green (1110 tests); build OK; e2e green (39).
…83)

ClickHouse resets a session's idle timer when each query is *released* (end of
the request) and cancels it while a query runs (Session.cpp / NamedSessionsStorage),
so the countdown only ticks during true idle between queries. A script's
statements are sent back-to-back (gap well under the 60s default), so the session
never lapses mid-batch — no session_timeout override needed. Send only session_id
and rely on the server default.

Tests + build green (1110).
Reconcile the multiquery feature with #94 (result-row-cap selector), which
landed on main and overlapped the same surfaces:
- ch-client runQuery now carries BOTH o.resultRowLimit (single-query cap) and
  o.params (multiquery cap + session_id).
- results.js: kept the shared renderGrid extraction; it now takes a `cap` param
  so the main table honors the selectable limit (visCap) while the script-row
  pane uses the default. buildToolbar keeps the script branch (early return) and
  #94's row-limit selector + "capped" badge on the normal path.
- app.js: run() passes resultRowLimit (EXPLAIN-exempt) and sessionParamsFor;
  kept setResultRowLimit alongside the multiquery explain guard.
- CHANGELOG: both Added entries; tests: both suites kept.

1122 tests green; build OK; e2e green (39).
@BorisTyshkevich BorisTyshkevich merged commit 5e8cdaa into main Jun 30, 2026
6 checks passed
BorisTyshkevich added a commit that referenced this pull request Jun 30, 2026
…blog post

Cover the new multiquery + run-selection feature (#83/#95): a Features section
("Run a whole script, not one statement at a time") with a real capture of a
6-statement script + per-statement grid, a screenshots-gallery entry, and a
blog post ("Run a whole SQL script, statement by statement") explaining the
client-side split, the per-statement OK/preview/time grid, sessions for
TEMPORARY/SET, and run-selection. Added to the blog index + cross-links.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWop2ih7HortGgWwG23ahC
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Multiquery + run-selection: execute a ;-separated script (DDL / INSERT / single-row SELECT) with per-statement output

1 participant