From 428a1f3fdf5452e03109d925ea62cf5db4783da3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 17:03:58 +0000 Subject: [PATCH 1/2] Add SQLite Query Explainer tool Runs SQL queries against a SQLite database via Pyodide (Python's sqlite3 in the browser) and annotates the output of both EXPLAIN QUERY PLAN and EXPLAIN with plain-English descriptions of what SQLite is doing. - "Load example database" builds 170k+ rows across five tables (with indexes, a WITHOUT ROWID table and a view) using loops with a fixed random seed, plus 22 example queries chosen to illustrate as many query plan patterns as possible: full scans, rowid lookups, covering indexes, nested-loop joins, temp b-tree sorts, MULTI-INDEX OR, automatic indexes, materialized vs co-routine CTEs, recursive CTEs, window functions and more - Or open your own SQLite database file (copied into the browser sandbox, never modified) - it starts by running a query against sqlite_master to show the schema - EXPLAIN QUERY PLAN renders as an annotated tree with badges, and consecutive loops are labeled with their nested-loop join role - EXPLAIN bytecode instructions cross-reference each other: jump targets are clickable links, jump-target rows show which instructions jump to them, loop bodies get depth bars, and hovering a register or cursor highlights everywhere else it is used Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Vx3Jgd4JPLFsKLwT8BemnL --- sqlite-query-explainer.docs.md | 1 + sqlite-query-explainer.html | 1565 ++++++++++++++++++++++++++ tests/test_sqlite_query_explainer.py | 64 ++ 3 files changed, 1630 insertions(+) create mode 100644 sqlite-query-explainer.docs.md create mode 100644 sqlite-query-explainer.html create mode 100644 tests/test_sqlite_query_explainer.py diff --git a/sqlite-query-explainer.docs.md b/sqlite-query-explainer.docs.md new file mode 100644 index 00000000..6d0e366f --- /dev/null +++ b/sqlite-query-explainer.docs.md @@ -0,0 +1 @@ +Run SQL queries against a SQLite database in your browser and see exactly how SQLite executes them: the tool runs your query, then annotates every line of both `EXPLAIN QUERY PLAN` and the low-level `EXPLAIN` bytecode output with plain-English descriptions of what the query planner and virtual machine are doing. Load the built-in example database — 170,000+ rows with indexes, a view and 22 example queries illustrating patterns like covering indexes, nested-loop joins, temp b-tree sorts, automatic indexes and recursive CTEs — or open your own SQLite file, which is inspected via `sqlite_master` to show its schema first. Bytecode instructions are cross-linked, so jump targets are clickable and hovering a register or cursor highlights everywhere else it is used. Powered by Python's `sqlite3` module running in Pyodide, so no data leaves your machine. diff --git a/sqlite-query-explainer.html b/sqlite-query-explainer.html new file mode 100644 index 00000000..3c886453 --- /dev/null +++ b/sqlite-query-explainer.html @@ -0,0 +1,1565 @@ + + + + + +SQLite Query Explainer + + + +
+

SQLite Query Explainer

+

+Run SQL against a SQLite database and see what the query planner is really doing: +the tool executes your query, then runs EXPLAIN QUERY PLAN and +EXPLAIN against it and annotates every line of their output with a +plain-English description. Powered by Python's sqlite3 module running in +your browser via Pyodide — nothing you load or type +leaves your machine. +

+ +
+

1. Choose a database

+
+ + or + + +
+
+ + +
+ + + + + + +
+ + + + + + diff --git a/tests/test_sqlite_query_explainer.py b/tests/test_sqlite_query_explainer.py new file mode 100644 index 00000000..571dfd56 --- /dev/null +++ b/tests/test_sqlite_query_explainer.py @@ -0,0 +1,64 @@ +""" +Playwright tests for sqlite-query-explainer.html + +The initial-state tests run offline. The full flow (loading Pyodide from the +CDN, building the example database, running annotated queries) is covered by +a single test marked as needing network access. +""" + +import pathlib +import pytest +from playwright.sync_api import Page, expect + + +test_dir = pathlib.Path(__file__).parent.absolute() +root = test_dir.parent.absolute() + + +def test_initial_state(page: Page, unused_port_server): + unused_port_server.start(root) + page.goto(f"http://localhost:{unused_port_server.port}/sqlite-query-explainer.html") + + expect(page).to_have_title("SQLite Query Explainer") + expect(page.locator("h1")).to_have_text("SQLite Query Explainer") + + # Both database entry points are offered + expect(page.locator("#load-example")).to_be_visible() + expect(page.locator("#open-db-btn")).to_be_visible() + + # Query UI and output stay hidden until a database is loaded + expect(page.locator("#query-card")).to_be_hidden() + expect(page.locator("#output")).to_be_hidden() + expect(page.locator("#schema-details")).to_be_hidden() + + +def test_full_flow_with_example_database(page: Page, unused_port_server): + """Loads Pyodide from the CDN - needs network access.""" + unused_port_server.start(root) + page.goto(f"http://localhost:{unused_port_server.port}/sqlite-query-explainer.html") + + page.click("#load-example") + # Pyodide (~15 MB) plus building 170k rows can take a while on CI + page.wait_for_selector("#output:not([hidden])", timeout=240_000) + + # The first example query auto-ran: results, plan and bytecode all render + expect(page.locator("#results-meta")).to_contain_text("row") + expect(page.locator("#eqp")).to_contain_text("Full table scan") + assert page.locator("#bytecode-table tbody tr").count() > 5 + + # Schema panel lists the example tables + expect(page.locator("#schema")).to_contain_text("customers") + expect(page.locator("#schema")).to_contain_text("order_items") + + # Bytecode instructions cross-reference each other with address links + assert page.locator("#bytecode-table a.addr-link").count() > 3 + first_link = page.locator("#bytecode-table a.addr-link").first + addr = first_link.get_attribute("data-addr") + first_link.click() + assert "flash" in (page.locator(f"#op-{addr}").get_attribute("class") or "") + + # Errors are reported + page.fill("#sql", "SELECT nope FROM customers") + page.click("#run") + page.wait_for_selector("#error:not([hidden])") + expect(page.locator("#error")).to_contain_text("no such column") From 7a17461ddfbac38f1afb4835645be8a0bdf41a45 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 17:06:25 +0000 Subject: [PATCH 2/2] Make sqlite-query-explainer queries bookmarkable Queries run against the example database are stored in the URL hash (#sql=...). Opening a bookmarked URL automatically loads Pyodide, rebuilds the example database and runs the query; changing the hash (back/forward navigation or pasting a link) re-runs it in place. Queries against a user-opened database file clear the hash instead, since that file can't be restored from a URL. The example picker gains a placeholder entry and deselects itself when the SQL in the editor no longer matches the chosen example. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Vx3Jgd4JPLFsKLwT8BemnL --- sqlite-query-explainer.html | 58 +++++++++++++++++++++++++--- tests/test_sqlite_query_explainer.py | 10 +++++ 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/sqlite-query-explainer.html b/sqlite-query-explainer.html index 3c886453..7c5fa512 100644 --- a/sqlite-query-explainer.html +++ b/sqlite-query-explainer.html @@ -1219,7 +1219,7 @@

Bytecode — EXPLAIN

// ---------------------------------------------------------------- database loading -$("load-example").addEventListener("click", async () => { +async function loadExampleDatabase(initialSql) { $("load-example").disabled = true; try { await ensurePyodide(); @@ -1230,18 +1230,25 @@

Bytecode — EXPLAIN

renderSchema(schemaJson); const sqliteVersion = pyodide.runPython("sqlite3.sqlite_version"); setStatus(`Example database loaded (SQLite ${sqliteVersion}): customers, products, orders, order_items and events, plus four indexes and a view.`); - $("db-note").textContent = "The data is generated with a fixed random seed, so results are reproducible."; + $("db-note").textContent = "The data is generated with a fixed random seed, so results are reproducible — and queries you run are stored in the page URL, so you can bookmark or share them."; $("db-note").hidden = false; setupExamples(); $("query-card").hidden = false; - $("examples").selectedIndex = 0; - $("examples").dispatchEvent(new Event("change")); + if (initialSql) { + $("sql").value = initialSql; + runCurrentQuery(); + } else { + $("examples").value = "0"; + $("examples").dispatchEvent(new Event("change")); + } } catch (err) { setStatusError(err.message || String(err)); } finally { $("load-example").disabled = false; } -}); +} + +$("load-example").addEventListener("click", () => loadExampleDatabase(null)); $("open-db-btn").addEventListener("click", () => $("db-file").click()); @@ -1275,6 +1282,10 @@

Bytecode — EXPLAIN

function setupExamples() { const select = $("examples"); if (!select.options.length) { + const placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = "Choose an example…"; + select.appendChild(placeholder); EXAMPLES.forEach((ex, i) => { const opt = document.createElement("option"); opt.value = i; @@ -1282,6 +1293,7 @@

Bytecode — EXPLAIN

select.appendChild(opt); }); select.addEventListener("change", () => { + if (select.value === "") return; const ex = EXAMPLES[select.value]; $("example-desc").textContent = ex.desc; $("sql").value = ex.sql; @@ -1318,6 +1330,7 @@

Bytecode — EXPLAIN

renderEqp(result); renderBytecode(result); $("output").hidden = false; + updateHash(sql); } catch (err) { setStatus(""); showError(err.message || String(err)); @@ -1326,6 +1339,41 @@

Bytecode — EXPLAIN

} } +// Make queries against the example database bookmarkable: the SQL lives in +// the URL hash, and opening a URL with #sql=... rebuilds the example +// database and runs it. (A user-supplied database file can't be restored +// from a URL, so those queries don't touch the hash.) +function updateHash(sql) { + if (usingExampleDb) { + history.replaceState(null, "", "#sql=" + encodeURIComponent(sql)); + } else if (location.hash) { + history.replaceState(null, "", location.pathname + location.search); + } + // Deselect the example picker when the SQL is no longer that example + const select = $("examples"); + if (select.value !== "" && EXAMPLES[select.value].sql.trim() !== sql.trim()) { + select.value = ""; + $("example-desc").textContent = ""; + } +} + +function getHashSql() { + return new URLSearchParams(location.hash.slice(1)).get("sql"); +} + +window.addEventListener("hashchange", () => { + const sql = getHashSql(); + if (sql && pyRun && usingExampleDb && sql.trim() !== $("sql").value.trim()) { + $("sql").value = sql; + runCurrentQuery(); + } +}); + +const bookmarkedSql = getHashSql(); +if (bookmarkedSql) { + loadExampleDatabase(bookmarkedSql); +} + $("run").addEventListener("click", runCurrentQuery); $("sql").addEventListener("keydown", (e) => { if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { diff --git a/tests/test_sqlite_query_explainer.py b/tests/test_sqlite_query_explainer.py index 571dfd56..0cdea9e1 100644 --- a/tests/test_sqlite_query_explainer.py +++ b/tests/test_sqlite_query_explainer.py @@ -62,3 +62,13 @@ def test_full_flow_with_example_database(page: Page, unused_port_server): page.click("#run") page.wait_for_selector("#error:not([hidden])") expect(page.locator("#error")).to_contain_text("no such column") + + # Queries are bookmarkable: running one stores it in the URL hash + page.fill("#sql", "SELECT * FROM customers WHERE id = 42") + page.click("#run") + page.wait_for_function("location.hash.includes('customers')") + # ...and reloading the page restores and re-runs it from the hash + page.reload() + page.wait_for_selector("#output:not([hidden])", timeout=240_000) + assert "WHERE id = 42" in page.input_value("#sql") + expect(page.locator("#eqp")).to_contain_text("rowid")