Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
[![Discord](https://img.shields.io/discord/1502944695808950282?color=5865F2&logo=discord&logoColor=white)](https://discord.com/invite/K2hmhfHRSt)

A free online EXPLAIN plan visualizer: paste the EXPLAIN output of
a **PostgreSQL**, **MySQL/MariaDB** or **SQLite** query and explore it as an
interactive graph, diagram, table and statistics view — with automatic
performance findings.
a **PostgreSQL**, **MySQL/MariaDB**, **SQLite** or **SQL Server** query and
explore it as an interactive graph, diagram, table and statistics view — with
automatic performance findings.

Everything runs in the browser. No query is ever executed and nothing is
uploaded to a server.
Expand All @@ -24,14 +24,17 @@ Tabularis dark theme.
| PostgreSQL | `EXPLAIN (FORMAT JSON)`, plain `EXPLAIN` / `EXPLAIN ANALYZE` text |
| MySQL / MariaDB | `EXPLAIN FORMAT=JSON`, `EXPLAIN ANALYZE` / `ANALYZE FORMAT=TEXT` trees |
| SQLite | `EXPLAIN QUERY PLAN` — the sqlite3 shell tree (`|--` / `` `-- ``) or raw `id\|parent\|…\|detail` rows |
| SQL Server | `SHOWPLAN_XML` estimated plans and `STATISTICS XML` actual plans (`ShowPlanXML` documents) |

The engine can be selected explicitly or auto-detected from the pasted text.
SQL Server's `ShowPlanXML` root element makes its format unambiguous.

## Stack

- [Vite](https://vite.dev) + [React 19](https://react.dev) + TypeScript
- [Tailwind CSS 4](https://tailwindcss.com) with the Tabularis colour tokens
- [`@tabularis/explain`](https://www.npmjs.com/package/@tabularis/explain) for parsing, analysis and the plan views
- [`@tabularis/explain-sqlserver`](https://www.npmjs.com/package/@tabularis/explain-sqlserver) for SQL Server SHOWPLAN XML
- [Vitest](https://vitest.dev) + Testing Library for tests

## Development
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "explain-plan",
"private": true,
"version": "0.1.0",
"description": "Paste a PostgreSQL, MySQL/MariaDB or SQLite EXPLAIN output and visualise the query plan.",
"description": "Paste a PostgreSQL, MySQL/MariaDB, SQLite or SQL Server EXPLAIN output and visualise the query plan.",
"type": "module",
"scripts": {
"dev": "vite",
Expand All @@ -13,7 +13,8 @@
"test:watch": "vitest"
},
"dependencies": {
"@tabularis/explain": "^0.1.0",
"@tabularis/explain": "^0.2.0",
"@tabularis/explain-sqlserver": "^1.0.0-beta.1",
"@xyflow/react": "^12.10.2",
"clsx": "^2.1.1",
"dagre": "^0.8.5",
Expand Down
23 changes: 18 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
allowBuilds:
esbuild: false
minimumReleaseAgeExclude:
- '@tabularis/explain@0.1.0'
- '@tabularis/explain@0.2.0'
- '@tabularis/explain-sqlserver@1.0.0-beta.1'
6 changes: 4 additions & 2 deletions seo.config.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
{
"title": "Explain Plan — Visualize PostgreSQL, MySQL & SQLite query plans",
"description": "Free online EXPLAIN plan visualizer. Paste the EXPLAIN output of a PostgreSQL, MySQL, MariaDB or SQLite query and explore it as an interactive graph, diagram, table and statistics — with automatic performance findings. Nothing leaves your browser.",
"title": "Explain Plan — PostgreSQL, MySQL, SQLite & SQL Server viewer",
"description": "Free online EXPLAIN plan and SHOWPLAN XML viewer. Paste a PostgreSQL, MySQL, MariaDB, SQLite or SQL Server execution plan and explore its graph, diagram, table and statistics. Nothing leaves your browser.",
"keywords": [
"explain plan",
"query plan visualizer",
"postgres explain analyze",
"mysql explain format=json",
"sqlite explain query plan",
"SQL Server execution plan",
"SHOWPLAN XML viewer",
"sql performance",
"query optimization",
"tabularis"
Expand Down
29 changes: 29 additions & 0 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ describe("App", () => {
screen.getByRole("option", { name: "MySQL / MariaDB" }),
).toBeInTheDocument();
expect(screen.getByRole("option", { name: "SQLite" })).toBeInTheDocument();
expect(
screen.getByRole("option", { name: "SQL Server" }),
).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Tabularis" }),
).toHaveAttribute("href", "https://tabularis.dev");
Expand Down Expand Up @@ -53,6 +56,32 @@ describe("App", () => {
expect(screen.getByText("Raw Output")).toBeInTheDocument();
});

it("renders every plan view for a SQL Server runtime sample", async () => {
const user = userEvent.setup();
const { container } = render(<App />);

await user.click(
screen.getByRole("button", { name: "SQL Server sample" }),
);
expect(screen.getByRole("combobox")).toHaveValue("sqlserver");
await user.click(screen.getByRole("button", { name: /visualize plan/i }));
await user.click(
screen.getByRole("button", { name: /continue in browser/i }),
);

expect(container.querySelector(".react-flow")).toBeInTheDocument();

await user.click(screen.getByRole("button", { name: "Diagram" }));
expect(screen.getByText("Metric")).toBeInTheDocument();
expect(screen.getByText("Table Scan")).toBeInTheDocument();

await user.click(screen.getByRole("button", { name: "Table" }));
expect(screen.getByRole("table")).toBeInTheDocument();

await user.click(screen.getByRole("button", { name: "Stats" }));
expect(screen.getByText("Time by Operation")).toBeInTheDocument();
});

it("shows a readable error for unparseable input", async () => {
const user = userEvent.setup();
render(<App />);
Expand Down
16 changes: 9 additions & 7 deletions src/components/PlanInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ const HIGHLIGHTS = [
{
icon: Zap,
kicker: "Multi-engine",
title: "Postgres, MySQL & SQLite",
text: "Text and JSON EXPLAIN formats are auto-detected, including ANALYZE output with timings and buffers.",
title: "Postgres, MySQL, SQLite & SQL Server",
text: "Text, JSON and SHOWPLAN XML formats are auto-detected, including runtime plans with timings.",
},
] as const;

Expand Down Expand Up @@ -104,7 +104,7 @@ export function PlanInput({ onPlan }: PlanInputProps) {
<div className="mb-8 text-center">
<div className="mb-4 flex flex-wrap items-center justify-center gap-2">
<span className="rounded-md border border-default bg-surface-primary/60 px-2.5 py-1 text-xs text-secondary">
PostgreSQL · MySQL · SQLite
PostgreSQL · MySQL · SQLite · SQL Server
</span>
<span className="rounded-md border border-success-border bg-success-bg px-2.5 py-1 text-xs text-success-text">
100% in-browser
Expand All @@ -121,9 +121,10 @@ export function PlanInput({ onPlan }: PlanInputProps) {
</span>
</h1>
<p className="mx-auto mt-3 max-w-xl text-sm leading-relaxed text-secondary">
Paste the EXPLAIN output of a PostgreSQL, MySQL/MariaDB or SQLite
query and explore it as an interactive graph, diagram, table and
statistics. Everything runs in your browser — no query is
Paste the EXPLAIN output of a PostgreSQL, MySQL/MariaDB, SQLite
or SQL Server query and explore it as an interactive graph,
diagram, table and statistics. Everything runs in your browser —
no query is
executed and nothing is uploaded.
</p>
</div>
Expand Down Expand Up @@ -179,7 +180,8 @@ export function PlanInput({ onPlan }: PlanInputProps) {
"Paste your EXPLAIN output here…\n\n" +
"PostgreSQL: EXPLAIN (ANALYZE, BUFFERS) SELECT … or EXPLAIN (FORMAT JSON) SELECT …\n" +
"MySQL: EXPLAIN FORMAT=JSON SELECT … or EXPLAIN ANALYZE SELECT …\n" +
"SQLite: EXPLAIN QUERY PLAN SELECT …"
"SQLite: EXPLAIN QUERY PLAN SELECT …\n" +
"SQL Server: SHOWPLAN_XML or STATISTICS XML output"
}
textareaClassName="focus:outline-none"
className="min-h-full"
Expand Down
31 changes: 31 additions & 0 deletions src/components/PlanView.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest";
import "../i18n";
import SQLSERVER_TRIVIAL_SCAN from "../test/fixtures/sqlserver-trivial-scan.xml?raw";
import { parsePlan } from "../lib/parse";
import { PlanView } from "./PlanView";

describe("PlanView SQL Server plans", () => {
afterEach(cleanup);

it("renders an estimated-only plan without timing empty states", async () => {
const user = userEvent.setup();
const plan = parsePlan(SQLSERVER_TRIVIAL_SCAN, "sqlserver");
render(<PlanView plan={plan} />);

expect(screen.queryByText("Execution:")).not.toBeInTheDocument();
expect(screen.queryByText("Planning:")).not.toBeInTheDocument();

await user.click(screen.getByRole("button", { name: "Diagram" }));
expect(screen.getByText("Metric")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Self Cost" }),
).toBeInTheDocument();
expect(screen.queryByText("No metric data available")).not.toBeInTheDocument();

await user.click(screen.getByRole("button", { name: "Stats" }));
expect(screen.getByText("Time by Operation")).toBeInTheDocument();
expect(screen.getAllByText("Table Scan")).not.toHaveLength(0);
});
});
35 changes: 35 additions & 0 deletions src/lib/parse.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
import { getExplainParser } from "@tabularis/explain";
import { describe, expect, it } from "vitest";
import { parsePlan, prettifyJson } from "./parse";
import { SAMPLES } from "../samples";
import SQLSERVER_TRIVIAL_SCAN from "../test/fixtures/sqlserver-trivial-scan.xml?raw";

const sample = (engine: string) =>
SAMPLES.find((candidate) => candidate.engine === engine)!.text;

describe("SQL Server parser registration", () => {
it("registers SHOWPLAN XML before application parsing starts", () => {
const parser = getExplainParser("sqlserver-showplan-xml");
expect(parser).toMatchObject({
engine: "sqlserver",
label: "SQL Server SHOWPLAN XML",
});
expect(parser?.parse).toBeTypeOf("function");
expect(parser?.sniff?.(sample("sqlserver"))).toBe(true);
});
});

describe("parsePlan", () => {
it("parses the PostgreSQL sample with its engine hint", () => {
const plan = parsePlan(sample("postgres"), "postgres");
Expand Down Expand Up @@ -32,12 +46,33 @@ describe("parsePlan", () => {
expect(details).toContain("Search");
});

it("parses the SQL Server STATISTICS XML sample with its engine hint", () => {
const plan = parsePlan(sample("sqlserver"), "sqlserver");
expect(plan.driver).toBe("sqlserver");
expect(plan.root.node_type).toBe("Table Scan");
expect(plan.root.relation).toBe("ss034_small");
expect(plan.root.actual_rows).toBe(2);
expect(plan.root.actual_time_ms).toBe(0);
expect(plan.execution_time_ms).toBe(0);
expect(plan.has_analyze_data).toBe(true);
});

it("keeps estimated-only SQL Server plans free of invented timings", () => {
const plan = parsePlan(SQLSERVER_TRIVIAL_SCAN, "sqlserver");
expect(plan.root.node_type).toBe("Table Scan");
expect(plan.root.actual_rows).toBeNull();
expect(plan.root.actual_time_ms).toBeNull();
expect(plan.execution_time_ms).toBeNull();
expect(plan.has_analyze_data).toBe(false);
});

it("auto-detects each sample's engine", () => {
expect(parsePlan(sample("postgres"), "auto").root.node_type).toBe(
"Hash Join",
);
expect(parsePlan(sample("mysql"), "auto").driver).toBe("mysql");
expect(parsePlan(sample("sqlite"), "auto").driver).toBe("sqlite");
expect(parsePlan(sample("sqlserver"), "auto").driver).toBe("sqlserver");
});

it("parses Postgres EXPLAIN (FORMAT JSON) output", () => {
Expand Down
15 changes: 11 additions & 4 deletions src/lib/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const ENGINE_OPTIONS: Array<{ value: EngineChoice; label: string }> = [
{ value: "postgres", label: "PostgreSQL" },
{ value: "mysql", label: "MySQL / MariaDB" },
{ value: "sqlite", label: "SQLite" },
{ value: "sqlserver", label: "SQL Server" },
];

/**
Expand All @@ -36,9 +37,10 @@ export function prettifyJson(text: string): string | null {

/**
* Parse a pasted EXPLAIN payload for the chosen engine. With `"auto"` the
* engines are tried in order of how distinctive their formats are —
* Postgres (JSON array / `cost=` text), SQLite (`|--` tree or `id|parent|…`
* rows), then MySQL, whose text parser is the most permissive.
* engines are tried in order of how distinctive their formats are — SQL
* Server (`ShowPlanXML`), Postgres (JSON array / `cost=` text), SQLite (`|--`
* tree or `id|parent|…` rows), then MySQL, whose text parser is the most
* permissive.
*/
export function parsePlan(raw: string, engine: EngineChoice): ExplainPlan {
const trimmed = raw.trim();
Expand All @@ -53,7 +55,12 @@ export function parsePlan(raw: string, engine: EngineChoice): ExplainPlan {
return parseExplainFor(trimmed, engine);
}

for (const candidate of ["postgres", "sqlite", "mysql"] as const) {
for (const candidate of [
"sqlserver",
"postgres",
"sqlite",
"mysql",
] as const) {
// The MySQL text parser accepts almost any text, so when sniffing only
// hand it input that plausibly is an EXPLAIN ANALYZE tree ("-> " lines)
// or a FORMAT=JSON document.
Expand Down
1 change: 1 addition & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "@tabularis/explain-sqlserver";
import "@xyflow/react/dist/style.css";
import "./index.css";
import "./i18n";
Expand Down
6 changes: 6 additions & 0 deletions src/samples.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { EngineChoice } from "./lib/parse";
import SQLSERVER_STATISTICS_XML from "./samples/sqlserver-statistics.xml?raw";

export interface SamplePlan {
engine: Exclude<EngineChoice, "auto">;
Expand Down Expand Up @@ -87,4 +88,9 @@ export const SAMPLES: SamplePlan[] = [
{ engine: "postgres", label: "PostgreSQL sample", text: POSTGRES_SAMPLE },
{ engine: "mysql", label: "MySQL sample", text: MYSQL_SAMPLE },
{ engine: "sqlite", label: "SQLite sample", text: SQLITE_SAMPLE },
{
engine: "sqlserver",
label: "SQL Server sample",
text: SQLSERVER_STATISTICS_XML,
},
];
Loading