Skip to content

refactor(explain): isolate the explain core in a crate and a package - #531

Merged
debba merged 7 commits into
feat/explain-exclusive-metrics-diagram-statsfrom
refactor/explain-core-crate
Jul 26, 2026
Merged

refactor(explain): isolate the explain core in a crate and a package#531
debba merged 7 commits into
feat/explain-exclusive-metrics-diagram-statsfrom
refactor/explain-core-crate

Conversation

@debba

@debba debba commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

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 / ExplainNode lived in models.rs next to connection types.
  • The Postgres parsers were mixed into the Tauri file-import commands.
  • ~900 lines of pure MySQL/MariaDB JSON and tree-format parsing were interleaved with sqlx execution inside the driver; the SQLite parsers were likewise driver-bound despite being pure.
  • explainPlan.ts held isDataModifyingQuery — a question about a statement, not a plan — and imported a type from a component, pointing the dependency from logic back at rendering.
  • VisualExplainView imported 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. Only serde and serde_json, so it also compiles to WASM.

Engine Formats
Postgres EXPLAIN (FORMAT JSON), plain text
MySQL / MariaDB EXPLAIN FORMAT=JSON, ANALYZE FORMAT=JSON, ANALYZE FORMAT=TEXT
SQLite EXPLAIN QUERY PLAN

packages/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 of plan.ts so the analysis core no longer pulls a graph library.

isDataModifyingQuery moves to utils/sql.ts beside isExplainableQuery, with its tests folded into tests/utils/sql.test.ts.

What stays behind, deliberately

  • Statement building and execution stay in drivers/*/explain.rs.
  • explain_import.rs keeps the Tauri commands, the standalone window and the file read.
  • MySQL's tabular EXPLAIN parser stays in the driver: it only ever arrives as decoded sqlx rows, never as a serialisable payload.
  • The AI plan explanation (host-configured provider) and the Monaco raw-output tab (host theme system) stay in the app, along with the VisualExplainView composition that wires them around the package's components.

Notes for the reviewer

  • Not a Cargo workspace, on purpose. A workspace root would move the build directory away from src-tauri/target — hardcoded in release.yml:163 — and silently override src-tauri's [profile.release] (lto, panic = "abort", strip). A plain path dependency avoids both.
  • No build step added. Workspace consumers resolve the package's entry points straight to TypeScript source; publishConfig swaps in the tsup dist for publishing. dev, build, typecheck and vitest are unchanged.
  • models.rs re-exports ExplainNode / ExplainPlan, so the twelve modules importing them are untouched.
  • File moves are git mv, so history follows.

Engine hint

parse_explain sniffed the payload, and sniffing only separates the two Postgres shapes — a MySQL EXPLAIN FORMAT=JSON document starts with {, so it was attempted as Postgres and rejected for having no Plan key. A caller that knows the engine can now say so:

parse_explain_for(raw, Some(ExplainEngine::MySql))
parse_explain_for(raw, ExplainEngine::from_driver_name(driver))  // "postgres" | "mysql" | "mariadb" | "sqlite"

Omit the hint — parse_explain(raw), or None — and behaviour is exactly as before, so no existing caller changes. An unknown driver name degrades to sniffing rather than failing. ExplainEngine::Sqlite is accepted but returns an explicit error naming sqlite::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-rolled ExplainPlan assemblies (the JSON one appeared twice). load_explain_from_file gained an optional engine argument; the frontend omits it and is untouched.

Known gaps

  • The 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

  • Rust: 987 app tests + 44 crate tests, 0 failures, plus 3 live MySQL/MariaDB tests run on demand. (The 4 askpass socket tests fail under a long TMPDIRSUN_LEN — and pass with TMPDIR=/tmp; unrelated to this change.)
  • TypeScript: 3286 tests across 178 files, 0 failures.
  • pnpm typecheck, pnpm typecheck:explain, pnpm lint, pnpm build, pnpm build:explain all clean.
  • detect_changes: low risk, 0 affected execution flows, on each of the three commits.
  • The app launches with --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_changes rates explain_query high 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 existing live_pg_* pattern, gated on TABULARIS_TEST_MYSQL=1:

TABULARIS_TEST_MYSQL=1 cargo test live_mysql -- --ignored                              # MySQL
TABULARIS_TEST_MYSQL=1 TABULARIS_TEST_MYSQL_PORT=3307 cargo test live_mysql -- --ignored  # MariaDB

Between two servers they cover all three rewritten plan-assembly branches, and all pass:

Server Branch Parser
MySQL 8.4.9, analyze EXPLAIN ANALYZE text parse_mysql_text
MariaDB 11.8.8, analyze ANALYZE FORMAT=JSON parse_mysql_json (+ planning time)
either, no 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 live, MariaDB does report it.

The one intentional behaviour difference in the rewrite: if EXPLAIN ANALYZE returns rows that are all empty strings, that stage now falls through to FORMAT=JSON instead of returning a plan built from an empty string.

debba added 6 commits July 24, 2026 21:47
…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.
@kilo-code-bot

kilo-code-bot Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Files Reviewed (64 files)
  • src-tauri/src/drivers/mysql/explain.rs
  • src-tauri/src/drivers/postgres/explain.rs
  • src-tauri/src/drivers/sqlite/explain.rs
  • src-tauri/src/models.rs
  • src-tauri/src/plugins/driver.rs
  • src-tauri/src/drivers/driver_trait.rs
  • src-tauri/src/commands.rs
  • packages/explain/src/parsers/mysql.ts
  • packages/explain/src/parsers/postgres.ts
  • packages/explain/src/parsers/sqlite.ts
  • packages/explain/src/parsers/source.ts
  • packages/explain/src/parsers/node.ts
  • packages/explain/src/raw.ts
  • packages/explain/src/types.ts
  • packages/explain/src/metrics.ts
  • packages/explain/src/diagnostics.ts
  • packages/explain/src/stats.ts
  • packages/explain/src/plan.ts
  • packages/explain/src/index.ts
  • packages/explain/src/react.ts
  • packages/explain/src/flow.ts
  • packages/explain/tests/raw.test.ts
  • packages/explain/tests/parsers/mysql.test.ts
  • packages/explain/tests/parsers/postgres.test.ts
  • packages/explain/tests/parsers/source.test.ts
  • packages/explain/tests/parsers/sqlite.test.ts
  • src/components/explain/VisualExplainView.tsx
  • src/components/modals/AiApprovalModal.tsx
  • src/components/modals/VisualExplainModal.tsx
  • src/components/modals/visual-explain/ExplainAiAnalysis.tsx
  • src/pages/VisualExplainPage.tsx
  • src/utils/sql.ts
  • tests/utils/sql.test.ts
  • vitest.config.ts
  • package.json
  • pnpm-lock.yaml
  • src-tauri/Cargo.lock
  • And additional renamed/moved files in packages/explain/ and src/

Reviewed by step-3.7-flash · Input: 238.9K · Output: 19.3K · Cached: 2.9M

@debba
debba merged commit ae18fa5 into feat/explain-exclusive-metrics-diagram-stats Jul 26, 2026
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.
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.

1 participant