diff --git a/README.md b/README.md index 3b19bf0..8401ffb 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 diff --git a/package.json b/package.json index dc22b97..d4f726d 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecd1a59..512dc05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,11 @@ importers: .: dependencies: '@tabularis/explain': - specifier: ^0.1.0 - version: 0.1.0(@xyflow/react@12.11.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(clsx@2.1.1)(dagre@0.8.5)(lucide-react@0.563.0(react@19.2.8))(react-i18next@16.6.6(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(react@19.2.8) + specifier: ^0.2.0 + version: 0.2.0(@xyflow/react@12.11.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(clsx@2.1.1)(dagre@0.8.5)(lucide-react@0.563.0(react@19.2.8))(react-i18next@16.6.6(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(react@19.2.8) + '@tabularis/explain-sqlserver': + specifier: ^1.0.0-beta.1 + version: 1.0.0-beta.1(@tabularis/explain@0.2.0(@xyflow/react@12.11.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(clsx@2.1.1)(dagre@0.8.5)(lucide-react@0.563.0(react@19.2.8))(react-i18next@16.6.6(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(react@19.2.8)) '@xyflow/react': specifier: ^12.10.2 version: 12.11.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -521,8 +524,14 @@ packages: cpu: [x64] os: [win32] - '@tabularis/explain@0.1.0': - resolution: {integrity: sha512-yOEInJYbKr07nxCNaRgp4Yv9+rWGf3yF3/MVQfe9yZCBwiX0AR7RGtPSHoY/ki/WAzqXEErMMkpcxnoMnM+6Bg==} + '@tabularis/explain-sqlserver@1.0.0-beta.1': + resolution: {integrity: sha512-sJJRH/Di4MJRjIXvoh5YHSDunLM+36gyWsuFPRSStFaV0YcnZPvHjoIHsJMSbmdoagYkaOKfaTqw5b+FP/iP8Q==} + engines: {node: '>=20'} + peerDependencies: + '@tabularis/explain': ^0.2.0 + + '@tabularis/explain@0.2.0': + resolution: {integrity: sha512-oV7tkkMJ4UrH7J1ZaqjhOnLP/yFsxp0SAsNOas8ITDBYTz2LLIq18apMGxePq16QpuaKSqaDrwJL+3Tv8irO7Q==} peerDependencies: '@xyflow/react': ^12.0.0 clsx: ^2.0.0 @@ -1794,7 +1803,11 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true - '@tabularis/explain@0.1.0(@xyflow/react@12.11.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(clsx@2.1.1)(dagre@0.8.5)(lucide-react@0.563.0(react@19.2.8))(react-i18next@16.6.6(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(react@19.2.8)': + '@tabularis/explain-sqlserver@1.0.0-beta.1(@tabularis/explain@0.2.0(@xyflow/react@12.11.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(clsx@2.1.1)(dagre@0.8.5)(lucide-react@0.563.0(react@19.2.8))(react-i18next@16.6.6(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(react@19.2.8))': + dependencies: + '@tabularis/explain': 0.2.0(@xyflow/react@12.11.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(clsx@2.1.1)(dagre@0.8.5)(lucide-react@0.563.0(react@19.2.8))(react-i18next@16.6.6(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(react@19.2.8) + + '@tabularis/explain@0.2.0(@xyflow/react@12.11.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(clsx@2.1.1)(dagre@0.8.5)(lucide-react@0.563.0(react@19.2.8))(react-i18next@16.6.6(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(react@19.2.8)': optionalDependencies: '@xyflow/react': 12.11.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) clsx: 2.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e21751e..18e7078 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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' diff --git a/seo.config.json b/seo.config.json index 54d624e..6ed87c9 100644 --- a/seo.config.json +++ b/seo.config.json @@ -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" diff --git a/src/App.test.tsx b/src/App.test.tsx index d7a2532..8ebb7df 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -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"); @@ -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(); + + 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(); diff --git a/src/components/PlanInput.tsx b/src/components/PlanInput.tsx index 3904eb4..b5b9d94 100644 --- a/src/components/PlanInput.tsx +++ b/src/components/PlanInput.tsx @@ -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; @@ -104,7 +104,7 @@ export function PlanInput({ onPlan }: PlanInputProps) {
- PostgreSQL · MySQL · SQLite + PostgreSQL · MySQL · SQLite · SQL Server 100% in-browser @@ -121,9 +121,10 @@ export function PlanInput({ onPlan }: PlanInputProps) {

- 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.

@@ -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" diff --git a/src/components/PlanView.test.tsx b/src/components/PlanView.test.tsx new file mode 100644 index 0000000..81fb483 --- /dev/null +++ b/src/components/PlanView.test.tsx @@ -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(); + + 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); + }); +}); diff --git a/src/lib/parse.test.ts b/src/lib/parse.test.ts index a172908..963a29d 100644 --- a/src/lib/parse.test.ts +++ b/src/lib/parse.test.ts @@ -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"); @@ -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", () => { diff --git a/src/lib/parse.ts b/src/lib/parse.ts index f9bcdc8..becee6b 100644 --- a/src/lib/parse.ts +++ b/src/lib/parse.ts @@ -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" }, ]; /** @@ -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(); @@ -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. diff --git a/src/main.tsx b/src/main.tsx index 8e9d03d..cba5eac 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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"; diff --git a/src/samples.ts b/src/samples.ts index 5e14c9d..4a3c0ad 100644 --- a/src/samples.ts +++ b/src/samples.ts @@ -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; @@ -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, + }, ]; diff --git a/src/samples/sqlserver-statistics.xml b/src/samples/sqlserver-statistics.xml new file mode 100644 index 0000000..8a35cd5 --- /dev/null +++ b/src/samples/sqlserver-statistics.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/test/fixtures/sqlserver-trivial-scan.xml b/src/test/fixtures/sqlserver-trivial-scan.xml new file mode 100644 index 0000000..c85f8ef --- /dev/null +++ b/src/test/fixtures/sqlserver-trivial-scan.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/test/setup.ts b/src/test/setup.ts index b0d95b1..012f277 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,4 +1,5 @@ import "@testing-library/jest-dom/vitest"; +import "@tabularis/explain-sqlserver"; // jsdom lacks the observers and media APIs @xyflow/react touches. class ResizeObserverStub {