refactor(explain): isolate the explain core in a crate and a package - #531
Merged
debba merged 7 commits intoJul 26, 2026
Merged
Conversation
…explain The EXPLAIN plan model and every parser lived next to the code that runs EXPLAIN statements: `ExplainPlan` sat in `models.rs` alongside connection types, the Postgres parsers were mixed into the Tauri file-import commands, and ~900 lines of pure MySQL/MariaDB JSON and tree-format parsing were interleaved with `sqlx` execution in the driver. Move all of it to a standalone `tabularis-explain` crate whose only dependencies are `serde` and `serde_json`. It converts already-produced EXPLAIN output into a plan tree and knows nothing about connections, statement building, async runtimes or the filesystem — so the same parsers can also be compiled to WASM for a browser-only plan visualiser. What stayed behind, deliberately: - `explain_import.rs` keeps the Tauri commands, the standalone window and the file read — host concerns, not parsing. - `drivers/*/explain.rs` keep statement building and execution. - MySQL's tabular `EXPLAIN` parser stays in the driver: it only ever arrives as decoded `sqlx` rows, never as a serialisable payload. `models.rs` re-exports `ExplainNode` / `ExplainPlan`, so the twelve modules importing them are untouched. Parser tests move with their parsers: 31 of them now run against the crate's public API with no driver or host in scope. Kept out of a Cargo workspace on purpose — a workspace root would move the build directory away from `src-tauri/target` (hardcoded in release.yml) and silently override `src-tauri`'s `[profile.release]`.
…plain The plan analysis and the Visual Explain views were spread across `src/types`, `src/utils` and three component directories, and were entangled with code that runs EXPLAIN: `explainPlan.ts` held `isDataModifyingQuery` (a question about a statement, not a plan) and imported a type from a component, which pointed the dependency from logic back at rendering. Move all of it to a `@tabularis/explain` workspace package with three entry points: - `.` — types, metrics, diagnostics, stats, formatters, tree helpers. No runtime dependencies at all, so plan analysis runs in a browser, a worker or a plain Node script. - `./react` — the graph, table, diagram, stats, node-details and bar views. - `./flow` — the ReactFlow/dagre adapter, split out of `plan.ts` so the analysis core no longer pulls a graph library. `isDataModifyingQuery` moves to `utils/sql.ts` beside `isExplainableQuery`, where the rest of the statement-level questions live, and its tests move into `tests/utils/sql.test.ts`. What stays in the app, deliberately: the AI plan explanation (a host feature on a host-configured provider), the Monaco raw-output tab (host theme system), and the `VisualExplainView` composition that wires those two around the package's components. Those host pieces are why the view was not portable before. Workspace consumers resolve the entry points to TypeScript source, so no build step is needed for `dev`, `build`, `typecheck` or `vitest`; `publishConfig` swaps in the tsup `dist` for publishing. Verified: 3286 tests pass, app and package typecheck clean, eslint clean, `pnpm build` and the package's tsup build both succeed.
`build_sqlite_tree` takes `(id, parent, detail)` triples and `parse_sqlite_detail` takes a string — neither touches `sqlx`, so both were already pure and only their location tied them to the driver. Move them to `tabularis_explain::sqlite`, leaving the driver to run the statement and decode rows. This closes the last gap in the boundary: a host holding SQLite `EXPLAIN QUERY PLAN` output can now build a plan tree without a driver, as it already could for Postgres and MySQL/MariaDB. Their three tests move with them, bringing the crate to 34.
`parse_explain` sniffed the payload, and sniffing only distinguishes the two
Postgres shapes — a MySQL `EXPLAIN FORMAT=JSON` document starts with `{` and
was therefore attempted as Postgres and rejected for having no `Plan` key.
Add an optional engine hint:
parse_explain_for(raw, Some(ExplainEngine::MySql))
parse_explain_for(raw, ExplainEngine::from_driver_name(driver))
With a hint the choice is between that engine's own formats. Without one —
`parse_explain(raw)`, or `None` — behaviour is exactly as before, so no
existing caller changes. `from_driver_name` maps driver ids
(`postgres`/`mysql`/`mariadb`/`sqlite`, case-insensitive) and returns `None`
for anything else, so an unknown driver degrades to sniffing rather than
failing.
`ExplainEngine::Sqlite` is accepted rather than omitted, and returns an
explicit error naming `sqlite::build_sqlite_tree`: SQLite has no serialised
text form here, only `(id, parent, detail)` rows. That is a better answer to
a host passing through its driver id than no variant at all.
This required plan-level MySQL entry points, `mysql::parse_mysql_json` and
`mysql::parse_mysql_text`, which the driver now uses in place of three
hand-rolled `ExplainPlan` assemblies — the JSON one appeared twice, once for
MariaDB `ANALYZE FORMAT=JSON` and once for plain `EXPLAIN FORMAT=JSON`. The
fallback chain is unchanged: each stage still falls through to the next when
parsing fails.
`load_explain_from_file` takes an optional `engine` argument for the same
reason. The frontend omits it and so is untouched.
10 new tests cover the dispatch, both MySQL formats, the driver-name mapping
and the unhinted path being unchanged; 44 in the crate, 987 in the app, all
passing.
The parsers are unit-tested in the `tabularis-explain` crate, but the chain in `explain_query` was not covered by anything: which EXPLAIN variant a given server version accepts, and whether each stage falls through to the next, only shows up against a real server. `detect_changes` rates that function high risk — six execution flows run through it — so leaving it untested was the actual gap. Add three `#[ignore]` live tests following the existing `live_pg_*` pattern, gated on `TABULARIS_TEST_MYSQL=1` and pointed with `TABULARIS_TEST_MYSQL_PORT`. Between two servers they exercise all three plan-assembly branches: - MySQL 8.0.18+ with analyze → `EXPLAIN ANALYZE` text → `parse_mysql_text` - MariaDB 10.1+ with analyze → `ANALYZE FORMAT=JSON` → `parse_mysql_json` - either without analyze → `EXPLAIN FORMAT=JSON` → `parse_mysql_json` The analyze test also pins the field unique to the MariaDB branch: when the server reports `query_optimization`, the plan must carry a planning time. Verified green against MySQL 8.4.9 and MariaDB 11.8.8, both reached over the demo compose stack. Ignored by default, so the default suite is unchanged at 987 passing.
Delete the tabularis-explain Rust crate and port its parsers (Postgres
JSON/text, MySQL/MariaDB JSON and ANALYZE text, SQLite EQP) to TypeScript
behind a shared ExplainSourceParser interface, so any host holding raw
EXPLAIN text — the desktop app or a standalone visualiser — parses it the
same way.
Built-in drivers now stop at the payload boundary: they return
RawExplainOutput {engine, format, payload, original_query}, re-serialising
the row-born formats (MySQL tabular, SQLite EQP) as JSON arrays. The MySQL
fallback chain stays driver-side, keyed on structural checks equivalent to
the old parser failure conditions. Plugin drivers keep their JSON-RPC
contract untouched: their already-parsed plan passes through as opaque JSON
and the frontend normalises both shapes via resolveExplainOutput.
The crate's test suite is ported to vitest under
packages/explain/tests/parsers/, and the live MySQL fallback tests now
assert on the raw contract.
Contributor
Code Review SummaryStatus: No Issues Found | Recommendation: Merge Files Reviewed (64 files)
Reviewed by step-3.7-flash · Input: 238.9K · Output: 19.3K · Cached: 2.9M |
…ctor/explain-core-crate
debba
added a commit
that referenced
this pull request
Jul 26, 2026
…531) (#536) * refactor(explain): extract the plan model and parsers into tabularis-explain The EXPLAIN plan model and every parser lived next to the code that runs EXPLAIN statements: `ExplainPlan` sat in `models.rs` alongside connection types, the Postgres parsers were mixed into the Tauri file-import commands, and ~900 lines of pure MySQL/MariaDB JSON and tree-format parsing were interleaved with `sqlx` execution in the driver. Move all of it to a standalone `tabularis-explain` crate whose only dependencies are `serde` and `serde_json`. It converts already-produced EXPLAIN output into a plan tree and knows nothing about connections, statement building, async runtimes or the filesystem — so the same parsers can also be compiled to WASM for a browser-only plan visualiser. What stayed behind, deliberately: - `explain_import.rs` keeps the Tauri commands, the standalone window and the file read — host concerns, not parsing. - `drivers/*/explain.rs` keep statement building and execution. - MySQL's tabular `EXPLAIN` parser stays in the driver: it only ever arrives as decoded `sqlx` rows, never as a serialisable payload. `models.rs` re-exports `ExplainNode` / `ExplainPlan`, so the twelve modules importing them are untouched. Parser tests move with their parsers: 31 of them now run against the crate's public API with no driver or host in scope. Kept out of a Cargo workspace on purpose — a workspace root would move the build directory away from `src-tauri/target` (hardcoded in release.yml) and silently override `src-tauri`'s `[profile.release]`. * refactor(explain): extract plan analysis and views into @tabularis/explain The plan analysis and the Visual Explain views were spread across `src/types`, `src/utils` and three component directories, and were entangled with code that runs EXPLAIN: `explainPlan.ts` held `isDataModifyingQuery` (a question about a statement, not a plan) and imported a type from a component, which pointed the dependency from logic back at rendering. Move all of it to a `@tabularis/explain` workspace package with three entry points: - `.` — types, metrics, diagnostics, stats, formatters, tree helpers. No runtime dependencies at all, so plan analysis runs in a browser, a worker or a plain Node script. - `./react` — the graph, table, diagram, stats, node-details and bar views. - `./flow` — the ReactFlow/dagre adapter, split out of `plan.ts` so the analysis core no longer pulls a graph library. `isDataModifyingQuery` moves to `utils/sql.ts` beside `isExplainableQuery`, where the rest of the statement-level questions live, and its tests move into `tests/utils/sql.test.ts`. What stays in the app, deliberately: the AI plan explanation (a host feature on a host-configured provider), the Monaco raw-output tab (host theme system), and the `VisualExplainView` composition that wires those two around the package's components. Those host pieces are why the view was not portable before. Workspace consumers resolve the entry points to TypeScript source, so no build step is needed for `dev`, `build`, `typecheck` or `vitest`; `publishConfig` swaps in the tsup `dist` for publishing. Verified: 3286 tests pass, app and package typecheck clean, eslint clean, `pnpm build` and the package's tsup build both succeed. * refactor(explain): move the SQLite plan parsers into tabularis-explain `build_sqlite_tree` takes `(id, parent, detail)` triples and `parse_sqlite_detail` takes a string — neither touches `sqlx`, so both were already pure and only their location tied them to the driver. Move them to `tabularis_explain::sqlite`, leaving the driver to run the statement and decode rows. This closes the last gap in the boundary: a host holding SQLite `EXPLAIN QUERY PLAN` output can now build a plan tree without a driver, as it already could for Postgres and MySQL/MariaDB. Their three tests move with them, bringing the crate to 34. * feat(explain): let the caller name the engine that produced a plan `parse_explain` sniffed the payload, and sniffing only distinguishes the two Postgres shapes — a MySQL `EXPLAIN FORMAT=JSON` document starts with `{` and was therefore attempted as Postgres and rejected for having no `Plan` key. Add an optional engine hint: parse_explain_for(raw, Some(ExplainEngine::MySql)) parse_explain_for(raw, ExplainEngine::from_driver_name(driver)) With a hint the choice is between that engine's own formats. Without one — `parse_explain(raw)`, or `None` — behaviour is exactly as before, so no existing caller changes. `from_driver_name` maps driver ids (`postgres`/`mysql`/`mariadb`/`sqlite`, case-insensitive) and returns `None` for anything else, so an unknown driver degrades to sniffing rather than failing. `ExplainEngine::Sqlite` is accepted rather than omitted, and returns an explicit error naming `sqlite::build_sqlite_tree`: SQLite has no serialised text form here, only `(id, parent, detail)` rows. That is a better answer to a host passing through its driver id than no variant at all. This required plan-level MySQL entry points, `mysql::parse_mysql_json` and `mysql::parse_mysql_text`, which the driver now uses in place of three hand-rolled `ExplainPlan` assemblies — the JSON one appeared twice, once for MariaDB `ANALYZE FORMAT=JSON` and once for plain `EXPLAIN FORMAT=JSON`. The fallback chain is unchanged: each stage still falls through to the next when parsing fails. `load_explain_from_file` takes an optional `engine` argument for the same reason. The frontend omits it and so is untouched. 10 new tests cover the dispatch, both MySQL formats, the driver-name mapping and the unhinted path being unchanged; 44 in the crate, 987 in the app, all passing. * test(mysql): cover the explain fallback chain against a live server The parsers are unit-tested in the `tabularis-explain` crate, but the chain in `explain_query` was not covered by anything: which EXPLAIN variant a given server version accepts, and whether each stage falls through to the next, only shows up against a real server. `detect_changes` rates that function high risk — six execution flows run through it — so leaving it untested was the actual gap. Add three `#[ignore]` live tests following the existing `live_pg_*` pattern, gated on `TABULARIS_TEST_MYSQL=1` and pointed with `TABULARIS_TEST_MYSQL_PORT`. Between two servers they exercise all three plan-assembly branches: - MySQL 8.0.18+ with analyze → `EXPLAIN ANALYZE` text → `parse_mysql_text` - MariaDB 10.1+ with analyze → `ANALYZE FORMAT=JSON` → `parse_mysql_json` - either without analyze → `EXPLAIN FORMAT=JSON` → `parse_mysql_json` The analyze test also pins the field unique to the MariaDB branch: when the server reports `query_optimization`, the plan must carry a planning time. Verified green against MySQL 8.4.9 and MariaDB 11.8.8, both reached over the demo compose stack. Ignored by default, so the default suite is unchanged at 987 passing. * refactor(explain): move plan parsing into @tabularis/explain Delete the tabularis-explain Rust crate and port its parsers (Postgres JSON/text, MySQL/MariaDB JSON and ANALYZE text, SQLite EQP) to TypeScript behind a shared ExplainSourceParser interface, so any host holding raw EXPLAIN text — the desktop app or a standalone visualiser — parses it the same way. Built-in drivers now stop at the payload boundary: they return RawExplainOutput {engine, format, payload, original_query}, re-serialising the row-born formats (MySQL tabular, SQLite EQP) as JSON arrays. The MySQL fallback chain stays driver-side, keyed on structural checks equivalent to the old parser failure conditions. Plugin drivers keep their JSON-RPC contract untouched: their already-parsed plan passes through as opaque JSON and the frontend normalises both shapes via resolveExplainOutput. The crate's test suite is ported to vitest under packages/explain/tests/parsers/, and the live MySQL fallback tests now assert on the raw contract.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #529 — review that one first; this PR's diff is against its branch.
Why
The explain code was spread across the app and entangled with the code that runs EXPLAIN. That entanglement is what blocks reusing it outside the desktop app:
ExplainPlan/ExplainNodelived inmodels.rsnext to connection types.sqlxexecution inside the driver; the SQLite parsers were likewise driver-bound despite being pure.explainPlan.tsheldisDataModifyingQuery— a question about a statement, not a plan — and imported a type from a component, pointing the dependency from logic back at rendering.VisualExplainViewimported the Tauri-backed AI analysis directly, so the whole view tree was non-portable.What this does
Two units, each holding only what is needed to explain a plan, and nothing about obtaining one.
crates/tabularis-explain— plan model + every parser. Onlyserdeandserde_json, so it also compiles to WASM.EXPLAIN (FORMAT JSON), plain textEXPLAIN FORMAT=JSON,ANALYZE FORMAT=JSON,ANALYZE FORMAT=TEXTEXPLAIN QUERY PLANpackages/explain(@tabularis/explain) — three entry points:.— types, metrics, diagnostics, stats, formatters, tree helpers. No runtime dependencies at all../react— the views: graph, table, diagram, stats, node details, bars../flow— the ReactFlow/dagre adapter, split out ofplan.tsso the analysis core no longer pulls a graph library.isDataModifyingQuerymoves toutils/sql.tsbesideisExplainableQuery, with its tests folded intotests/utils/sql.test.ts.What stays behind, deliberately
drivers/*/explain.rs.explain_import.rskeeps the Tauri commands, the standalone window and the file read.EXPLAINparser stays in the driver: it only ever arrives as decodedsqlxrows, never as a serialisable payload.VisualExplainViewcomposition that wires them around the package's components.Notes for the reviewer
src-tauri/target— hardcoded inrelease.yml:163— and silently overridesrc-tauri's[profile.release](lto,panic = "abort",strip). A plain path dependency avoids both.publishConfigswaps in the tsupdistfor publishing.dev,build,typecheckandvitestare unchanged.models.rsre-exportsExplainNode/ExplainPlan, so the twelve modules importing them are untouched.git mv, so history follows.Engine hint
parse_explainsniffed the payload, and sniffing only separates the two Postgres shapes — a MySQLEXPLAIN FORMAT=JSONdocument starts with{, so it was attempted as Postgres and rejected for having noPlankey. A caller that knows the engine can now say so:Omit the hint —
parse_explain(raw), orNone— and behaviour is exactly as before, so no existing caller changes. An unknown driver name degrades to sniffing rather than failing.ExplainEngine::Sqliteis accepted but returns an explicit error namingsqlite::build_sqlite_tree, since SQLite has no serialised text form here.This required plan-level
mysql::parse_mysql_json/mysql::parse_mysql_text, which the driver now uses in place of three hand-rolledExplainPlanassemblies (the JSON one appeared twice).load_explain_from_filegained an optionalengineargument; the frontend omits it and is untouched.Known gaps
editor.visualExplain.*i18n keys stay in the app's catalogue rather than being bundled in the package, so any other host must supply that namespace. Documented in the package README.Verification
askpasssocket tests fail under a longTMPDIR—SUN_LEN— and pass withTMPDIR=/tmp; unrelated to this change.)pnpm typecheck,pnpm typecheck:explain,pnpm lint,pnpm build,pnpm build:explainall clean.detect_changes: low risk, 0 affected execution flows, on each of the three commits.--explain <file>and accepts the flag with no errors logged; the built bundle contains the package's code. The rendered window was not visually confirmed — screen recording and the Accessibility API were unavailable in the environment used.The MySQL driver rewrite is now covered.
detect_changesratesexplain_queryhigh risk (six execution flows run through it), and the chain — which EXPLAIN variant a server version accepts, and whether each stage falls through — can only be exercised against a real server. Three#[ignore]live tests were added following the existinglive_pg_*pattern, gated onTABULARIS_TEST_MYSQL=1:Between two servers they cover all three rewritten plan-assembly branches, and all pass:
EXPLAIN ANALYZEtextparse_mysql_textANALYZE FORMAT=JSONparse_mysql_json(+ planning time)EXPLAIN FORMAT=JSONparse_mysql_jsonThe analyze test also pins the field unique to the MariaDB branch: when the server reports
query_optimization, the plan must carry a planning time — verified live, MariaDB does report it.The one intentional behaviour difference in the rewrite: if
EXPLAIN ANALYZEreturns rows that are all empty strings, that stage now falls through toFORMAT=JSONinstead of returning a plan built from an empty string.