diff --git a/README.md b/README.md index 3fb30bd..25bec1e 100644 --- a/README.md +++ b/README.md @@ -170,13 +170,46 @@ Connect to a database that only its bastion can reach - no `ssh -L` in a side te - **Run selection** (`Ctrl+Enter`) / **run all** (`Ctrl+Shift+Enter`) / **stop** long-running queries - **Streaming results** - rows render as the driver yields them - **Multi-statement scripts** - run several `;`-separated statements at once; they execute in order on one connection, so temp tables, `SET` and scripted `BEGIN` / `COMMIT` hold -- **Multiple result outputs** - a script or stored procedure that returns several result sets shows each in its own switchable result tab; a failing statement reports its error and stops the run +- **Multiple result outputs** - a script or stored procedure that returns several result sets shows each in its own switchable result tab, query plans included; a failing statement reports its error and stops the run - **Pinned transactions** per tab - run `BEGIN` / `COMMIT` / `ROLLBACK` as SQL or from the toolbar; queries run inside the open transaction until you commit or roll back +- **Query plan viewer** (`Ctrl+Shift+E`) - see below - `UPDATE` / `DELETE` / `INSERT` with **`RETURNING`** flow back to the Results Grid - Gutter icons to run individual statements - Right-click menu with **format SQL** - **Remappable keyboard shortcuts** +### 🧭 Query Plan Viewer + +**Explain** (`Ctrl+Shift+E`) plans the statement under the cursor and shows the engine's plan as one +tree, whichever database you're on. Nothing runs - the numbers are the planner's estimates. +**Explain analyze** (`Ctrl+Shift+A`, or the button's menu) *executes* the statement instead and +reports what really happened: measured rows, real timings, loop counts. + +Typing `EXPLAIN` yourself works the same way - **Run** recognises it and opens the plan viewer instead +of dumping the engine's raw rows into the grid. Statements whose output already carries structure (any +`FORMAT JSON`, MySQL's `EXPLAIN ANALYZE`, `EXPLAIN QUERY PLAN`) run exactly as typed; the rest are +asked again in JSON. Name a format on purpose (`EXPLAIN (FORMAT TEXT)`, `FORMAT=TRADITIONAL`) or use +SQLite's bytecode `EXPLAIN` and you get the raw rows, as asked. + +Plans are outputs like any other, so a script can mix them freely - `SELECT …; EXPLAIN SELECT …;` +gives you `Result 1 Β· Plan 1` as switchable tabs, each keeping its own state. + +- **Heat map** over the tree - each node's bar is its own share of the run's time, cost or rows, so + the expensive step is the one you see first; switch the metric to re-rank +- **Own vs. total** for time and cost, side by side - a node isn't flagged just because its children + are slow. Loop counts are folded in, so a node inside a nested loop compares fairly to its siblings +- **Bad row estimates flagged** - when the measured count is 10x off the planner's guess, the node is + marked with the factor: usually where a missing index or stale statistics hides +- **Relations and indexes** called out per node, plus the filter or join condition behind it +- **Node details** - every field the engine reported (sort method, buffer hits, rows removed by + filter, …), and the untouched engine output on a raw tab +- Per engine: PostgreSQL `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)`, MySQL `EXPLAIN FORMAT=JSON` and + `EXPLAIN ANALYZE`, MariaDB `ANALYZE FORMAT=JSON`, SQLite `EXPLAIN QUERY PLAN` (plan shape only - + SQLite reports no cost or timings) +- **Analyzing a write never leaves data behind** - `EXPLAIN ANALYZE` executes the statement, so a + write runs inside a transaction that is always rolled back (and says so). On a tab with an open + transaction it runs there, exactly where a plain **Run** would have + --- ## πŸ—ƒοΈ Schema Explorer @@ -261,6 +294,8 @@ Every shortcut is remappable in the in-app shortcuts editor. | Quick Search palette | `Ctrl/⌘ + P` | | Run selection | `Ctrl/⌘ + Enter` | | Run all statements | `Ctrl/⌘ + Shift + Enter` | +| Explain query plan | `Ctrl/⌘ + Shift + E` | +| Explain query plan (analyze) | `Ctrl/⌘ + Shift + A` | | Save query | `Ctrl/⌘ + S` | | Rename saved query | `F2` | | New / close tab | `Ctrl/⌘ + T` / `Ctrl/⌘ + W` | diff --git a/docs/index.html b/docs/index.html index 9d45276..1db821c 100644 --- a/docs/index.html +++ b/docs/index.html @@ -166,7 +166,7 @@

🧠 SQL Editor

πŸ“œ Multi-statement scripts

-

Run several ;-separated statements at once on a single connection - temp tables, SET and scripted transactions hold. Each result set gets its own tab.

+

Run several ;-separated statements at once on a single connection - temp tables, SET and scripted transactions hold. Every output gets its own tab, query plans included.

πŸ“Œ Pinned transactions

@@ -176,6 +176,14 @@

πŸ“Œ Pinned transactions

🌊 Streaming results

Rows render as the driver yields them, in a virtualized grid that scrolls smoothly over thousands of rows. Stop long-running queries with one click.

+
+

🧭 Query plan viewer

+

See why a query is slow, in one tree whatever the engine. Each node's bar is its own share of the time, cost or rows, so the expensive step is the one you see first - and a row count 10x off the planner's guess is flagged where the missing index hides.

+
+
+

πŸ“Š Estimated or measured

+

Ctrl+Shift+E plans without running. Ctrl+Shift+A executes the statement for real rows and timings - and rolls a write back so nothing lands. Type EXPLAIN yourself and Run opens the same viewer instead of a table of raw output.

+

πŸ—ƒοΈ Schema Explorer

Tree view of schemas, tables and columns with instant search. Double-click a table for a SELECT, Ctrl+double-click to browse and edit its data.

diff --git a/e2e/pages/plan-page.ts b/e2e/pages/plan-page.ts new file mode 100644 index 0000000..34c3c1c --- /dev/null +++ b/e2e/pages/plan-page.ts @@ -0,0 +1,105 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +/** The query-plan viewer. It has no close action: the next run replaces it. */ +export class PlanPage { + readonly page: Page; + readonly view: Locator; + readonly rows: Locator; + readonly notes: Locator; + + constructor(page: Page) { + this.page = page; + // Scoped to the visible set: a run's other sets stay mounted but hidden. + this.view = page.locator('.tab-results-layer.tab-layer-active .result-set-layer.tab-layer-active .plan-view'); + this.rows = this.view.locator('.plan-row'); + this.notes = this.view.locator('.plan-note'); + } + + /** For the cases where no plan is expected. */ + async clickExplain(): Promise { + await this.page.getByRole('button', { name: 'Explain', exact: true }).click(); + } + + async explain(): Promise { + await this.clickExplain(); + await this.waitForPlan(); + } + + async explainAnalyze(): Promise { + await this.page.getByRole('button', { name: 'Explain options' }).click(); + await this.page.getByRole('menuitem', { name: 'Explain analyze' }).click(); + await this.waitForPlan(); + } + + async explainByShortcut(): Promise { + await this.page.keyboard.press('ControlOrMeta+Shift+E'); + await this.waitForPlan(); + } + + /** Left unbound on engines that can't measure. */ + async explainAnalyzeByShortcut(): Promise { + await this.page.keyboard.press('ControlOrMeta+Shift+A'); + await this.waitForPlan(); + } + + async waitForPlan(): Promise { + await expect(this.view).toBeVisible({ timeout: 30_000 }); + await expect(this.rows.first()).toBeVisible(); + } + + /** SQLite has no EXPLAIN ANALYZE, so it gets no menu. */ + async canAnalyze(): Promise { + return (await this.page.getByRole('button', { name: 'Explain options' }).count()) > 0; + } + + get badge(): Locator { + return this.view.locator('.plan-badge'); + } + + nodeLabels(): Promise { + return this.view.locator('.plan-node-label').allInnerTexts(); + } + + metricColumns(): Promise { + return this.view.locator('.plan-head-metric').allInnerTexts(); + } + + async heats(): Promise { + return this.rows.evaluateAll((rows) => + rows.map((row) => Number((row as HTMLElement).style.getPropertyValue('--plan-heat') || 0)), + ); + } + + /** The node the heat map points at. */ + async hottestLabel(): Promise { + return this.view.locator('.plan-row-hottest .plan-node-label').first().textContent(); + } + + async selectNode(index: number): Promise { + await this.view.locator('.plan-node-btn').nth(index).click(); + } + + get detailsTitle(): Locator { + return this.view.locator('.plan-details-title'); + } + + detailFields(): Promise { + return this.view.locator('.plan-details-grid dt').allInnerTexts(); + } + + async heatBy(metric: 'Time' | 'Cost' | 'Rows'): Promise { + await this.view.getByRole('button', { name: metric, exact: true }).click(); + } + + async toggleRaw(): Promise { + await this.view.getByRole('button', { name: 'Raw plan', exact: true }).click(); + } + + get raw(): Locator { + return this.view.locator('.plan-raw'); + } + + async toggleFirstNode(): Promise { + await this.view.locator('.plan-twisty').first().click(); + } +} diff --git a/e2e/specs/editor/query-plan.spec.ts b/e2e/specs/editor/query-plan.spec.ts new file mode 100644 index 0000000..4973c94 --- /dev/null +++ b/e2e/specs/editor/query-plan.spec.ts @@ -0,0 +1,190 @@ +import { ALL_DATABASES, POSTGRES, SQLITE } from '@support/databases'; +import { expect, test } from '@support/fixtures'; + +test.describe('Query plan', () => { + for (const db of ALL_DATABASES) { + test(`explains a query: ${db.label}`, async ({ connections, editor, plan, seed }) => { + await connections.createAndConnect(db); + const table = await seed.table('plan', { insert: "(id, name) VALUES (1, 'Alice'), (2, 'Bob')" }); + + await editor.setSql(`SELECT * FROM ${table} WHERE id = 1;`); + await plan.explain(); + + expect(await plan.nodeLabels()).not.toHaveLength(0); + await expect(plan.badge).toHaveText('Estimated'); + // Only SQLite has a note: its missing metric columns. + await expect(plan.notes).toHaveCount(db.key === 'sqlite' ? 1 : 0); + }); + } + + // Only analyze executes the statement. + test('the shortcuts pick estimates vs measured', async ({ connections, editor, plan, seed }) => { + await connections.createAndConnect(POSTGRES); + const table = await seed.table('plan', { insert: "(id, name) VALUES (1, 'Alice')" }); + + await editor.setSql(`SELECT * FROM ${table};`); + await plan.explainByShortcut(); + await expect(plan.badge).toHaveText('Estimated'); + + await plan.explainAnalyzeByShortcut(); + await expect(plan.badge).toHaveText('Measured'); + }); + + test('measures rows and timings with analyze', async ({ connections, editor, plan, seed }) => { + await connections.createAndConnect(POSTGRES); + const table = await seed.table('plan', { insert: "(id, name) VALUES (1, 'Alice'), (2, 'Bob')" }); + + await editor.setSql(`SELECT * FROM ${table};`); + await plan.explainAnalyze(); + + await expect(plan.badge).toHaveText('Measured'); + expect(await plan.metricColumns()).toContain('Time'); + expect(await plan.hottestLabel()).toBeTruthy(); + const heats = await plan.heats(); + expect(Math.max(...heats)).toBe(1); + }); + + test('heat map re-ranks when the metric changes', async ({ connections, editor, plan, seed }) => { + await connections.createAndConnect(POSTGRES); + const table = await seed.table('plan', { insert: "(id, name) VALUES (1, 'Alice'), (2, 'Bob')" }); + + await editor.setSql(`SELECT * FROM ${table} ORDER BY name;`); + await plan.explain(); + + expect(await plan.metricColumns()).toEqual(['Rows', 'Cost']); + await plan.heatBy('Rows'); + expect(Math.max(...(await plan.heats()))).toBe(1); + }); + + test('shows the selected node’s details and the raw plan', async ({ connections, editor, plan, seed }) => { + await connections.createAndConnect(POSTGRES); + const table = await seed.table('plan', { insert: "(id, name) VALUES (1, 'Alice')" }); + + await editor.setSql(`SELECT * FROM ${table};`); + await plan.explain(); + + await plan.selectNode(0); + await expect(plan.detailsTitle).toBeVisible(); + expect(await plan.detailFields()).not.toHaveLength(0); + + await plan.toggleRaw(); + await expect(plan.raw).toBeVisible(); + await expect(plan.raw).toContainText('Node Type'); + }); + + test('collapses and expands the tree', async ({ connections, editor, plan, seed }) => { + await connections.createAndConnect(POSTGRES); + const table = await seed.table('plan', { insert: "(id, name) VALUES (1, 'Alice')" }); + + await editor.setSql(`SELECT COUNT(*) FROM ${table};`); + await plan.explain(); + const expanded = await plan.rows.count(); + expect(expanded).toBeGreaterThan(1); + + await plan.toggleFirstNode(); + await expect(plan.rows).toHaveCount(1); + await plan.toggleFirstNode(); + await expect(plan.rows).toHaveCount(expanded); + }); + + test('running a query replaces the plan with results', async ({ connections, editor, plan, results, seed }) => { + await connections.createAndConnect(POSTGRES); + const table = await seed.table('plan', { insert: "(id, name) VALUES (1, 'Alice')" }); + + await editor.setSql(`SELECT * FROM ${table};`); + await plan.explain(); + await expect(results.grid).toBeHidden(); + + await editor.runAll(); + await results.waitForRows(); + await expect(plan.view).toBeHidden(); + await expect(results.cell(0, 0)).toHaveText('1'); + }); + + test('a failed explain reports the error instead of a plan', async ({ app, connections, editor, plan }) => { + await connections.createAndConnect(POSTGRES); + + await editor.setSql('SELECT * FROM definitely_missing_table_e2e;'); + await plan.clickExplain(); + await expect(app.status).toHaveClass(/error/); + await expect(plan.view).toBeHidden(); + }); + + for (const db of ALL_DATABASES) { + test(`a typed EXPLAIN shows the plan viewer: ${db.label}`, async ({ connections, editor, plan, results, seed }) => { + await connections.createAndConnect(db); + const table = await seed.table('plan', { insert: "(id, name) VALUES (1, 'Alice')" }); + const explain = db.key === 'sqlite' ? 'EXPLAIN QUERY PLAN' : 'EXPLAIN'; + + await editor.run(`${explain} SELECT * FROM ${table} WHERE id = 1;`); + await plan.waitForPlan(); + await expect(results.grid).toBeHidden(); + expect(await plan.nodeLabels()).not.toHaveLength(0); + }); + } + + test('a typed EXPLAIN ANALYZE reports measured values', async ({ connections, editor, plan, seed }) => { + await connections.createAndConnect(POSTGRES); + const table = await seed.table('plan', { insert: "(id, name) VALUES (1, 'Alice')" }); + + await editor.run(`EXPLAIN ANALYZE SELECT * FROM ${table};`); + await plan.waitForPlan(); + await expect(plan.badge).toHaveText('Measured'); + }); + + test('a script mixes grids and plans across tabs', async ({ connections, editor, plan, results, seed }) => { + await connections.createAndConnect(POSTGRES); + const table = await seed.table('plan', { insert: "(id, name) VALUES (1, 'Alice')" }); + + await editor.run(`SELECT * FROM ${table}; EXPLAIN SELECT * FROM ${table}; SELECT COUNT(*) FROM ${table};`); + await expect.poll(() => results.resultTabCount()).toBe(3); + + await results.selectResultTab(0); + await expect(results.grid).toBeVisible(); + await expect(plan.view).toBeHidden(); + + await results.selectResultTab(1); + await expect(plan.view).toBeVisible(); + expect(await plan.nodeLabels()).not.toHaveLength(0); + + await results.selectResultTab(2); + await expect(results.grid).toBeVisible(); + await expect(plan.view).toBeHidden(); + }); + + // A named format means the user wants raw output. + test('an explicitly formatted EXPLAIN stays a grid', async ({ connections, editor, plan, results, seed }) => { + await connections.createAndConnect(POSTGRES); + const table = await seed.table('plan', { insert: "(id, name) VALUES (1, 'Alice')" }); + + await editor.run(`EXPLAIN (FORMAT TEXT) SELECT * FROM ${table};`); + await results.waitForRows(); + await expect(plan.view).toBeHidden(); + }); + + test('SQLite offers no measured plan', async ({ connections, editor, plan, seed }) => { + await connections.createAndConnect(SQLITE); + const table = await seed.table('plan', { insert: "(id, name) VALUES (1, 'Alice')" }); + + await editor.setSql(`SELECT * FROM ${table};`); + await plan.explain(); + + expect(await plan.canAnalyze()).toBe(false); + expect(await plan.metricColumns()).toEqual([]); + await expect(plan.notes.filter({ hasText: 'SQLite' })).toBeVisible(); + + // Unbound, so the key does nothing. + await plan.page.keyboard.press('ControlOrMeta+Shift+A'); + await expect(plan.badge).toHaveText('Estimated'); + }); + + // Bytecode, not a plan: the rows belong in the grid. + test('SQLite bare EXPLAIN keeps its bytecode rows', async ({ connections, editor, plan, results, seed }) => { + await connections.createAndConnect(SQLITE); + const table = await seed.table('plan', { insert: "(id, name) VALUES (1, 'Alice')" }); + + await editor.run(`EXPLAIN SELECT * FROM ${table};`); + await results.waitForRows(); + await expect(plan.view).toBeHidden(); + }); +}); diff --git a/e2e/support/fixtures.ts b/e2e/support/fixtures.ts index 0ea7acc..23c89eb 100644 --- a/e2e/support/fixtures.ts +++ b/e2e/support/fixtures.ts @@ -4,6 +4,7 @@ import { CellViewerPage } from '../pages/cell-viewer-page'; import { ConnectionsPage } from '../pages/connections-page'; import { EditorPage } from '../pages/editor-page'; import { JsonViewerPage } from '../pages/json-viewer-page'; +import { PlanPage } from '../pages/plan-page'; import { QueriesPage } from '../pages/queries-page'; import { ResultsPage } from '../pages/results-page'; import { SchemaPage } from '../pages/schema-page'; @@ -16,6 +17,7 @@ interface Fixtures { connections: ConnectionsPage; editor: EditorPage; results: ResultsPage; + plan: PlanPage; schema: SchemaPage; tableView: TableViewPage; queries: QueriesPage; @@ -50,6 +52,7 @@ export const test = base.extend({ connections: pageObject(ConnectionsPage), editor: pageObject(EditorPage), results: pageObject(ResultsPage), + plan: pageObject(PlanPage), schema: pageObject(SchemaPage), tableView: pageObject(TableViewPage), queries: pageObject(QueriesPage), diff --git a/frontend/bindings/xensql/internal/app/app.ts b/frontend/bindings/xensql/internal/app/app.ts index 821090e..7a683e5 100644 --- a/frontend/bindings/xensql/internal/app/app.ts +++ b/frontend/bindings/xensql/internal/app/app.ts @@ -116,6 +116,17 @@ export function ExecuteQueryStream(connectionID: string, tabID: string, sql: str return $Call.ByID(3110751220, connectionID, tabID, sql); } +/** + * ExplainQuery returns a normalized plan for one statement. analyze executes the statement, so a + * write runs inside a transaction that is always rolled back; a tab with an open transaction runs + * there instead, matching where a plain Run would have. + */ +export function ExplainQuery(connectionID: string, tabID: string, sql: string, analyze: boolean): $CancellablePromise { + return $Call.ByID(3996023304, connectionID, tabID, sql, analyze).then(($result: any) => { + return $$createType3($result); + }); +} + export function ExportResult(result: database$0.QueryResult, format: string): $CancellablePromise { return $Call.ByID(1465290314, result, format); } @@ -126,43 +137,43 @@ export function FormatSQL(sql: string): $CancellablePromise { export function GetAppInfo(): $CancellablePromise<$models.AppInfo> { return $Call.ByID(1631861786).then(($result: any) => { - return $$createType2($result); + return $$createType4($result); }); } export function GetConnectionStatus(connectionID: string): $CancellablePromise { return $Call.ByID(2880332169, connectionID).then(($result: any) => { - return $$createType3($result); + return $$createType5($result); }); } export function GetEditorSession(): $CancellablePromise { return $Call.ByID(881302078).then(($result: any) => { - return $$createType4($result); + return $$createType6($result); }); } export function GetPathDefaults(): $CancellablePromise<$models.PathDefaults> { return $Call.ByID(230484794).then(($result: any) => { - return $$createType5($result); + return $$createType7($result); }); } export function GetPendingFile(): $CancellablePromise<{ [_ in string]?: string }> { return $Call.ByID(109090272).then(($result: any) => { - return $$createType6($result); + return $$createType8($result); }); } export function GetQueryHistory(connectionID: string, limit: number): $CancellablePromise { return $Call.ByID(4033942967, connectionID, limit).then(($result: any) => { - return $$createType8($result); + return $$createType10($result); }); } export function GetSettings(): $CancellablePromise<{ [_ in string]?: string }> { return $Call.ByID(222085978).then(($result: any) => { - return $$createType6($result); + return $$createType8($result); }); } @@ -172,7 +183,7 @@ export function InitStores(configDir: string): $CancellablePromise { export function InsertRow(connectionID: string, schema: string, table: string, values: { [_ in string]?: any }): $CancellablePromise<{ [_ in string]?: any }> { return $Call.ByID(1655633052, connectionID, schema, table, values).then(($result: any) => { - return $$createType9($result); + return $$createType11($result); }); } @@ -186,37 +197,37 @@ export function IsDesktopMode(): $CancellablePromise { export function ListColumns(connectionID: string, schema: string, table: string): $CancellablePromise { return $Call.ByID(3118816600, connectionID, schema, table).then(($result: any) => { - return $$createType11($result); + return $$createType13($result); }); } export function ListConnections(): $CancellablePromise { return $Call.ByID(1654481338).then(($result: any) => { - return $$createType13($result); + return $$createType15($result); }); } export function ListFolders(): $CancellablePromise { return $Call.ByID(1373072582).then(($result: any) => { - return $$createType15($result); + return $$createType17($result); }); } export function ListSavedQueries(connectionID: string): $CancellablePromise { return $Call.ByID(2254370512, connectionID).then(($result: any) => { - return $$createType17($result); + return $$createType19($result); }); } export function ListSchemas(connectionID: string): $CancellablePromise { return $Call.ByID(2969331507, connectionID).then(($result: any) => { - return $$createType19($result); + return $$createType21($result); }); } export function ListTables(connectionID: string, schema: string): $CancellablePromise { return $Call.ByID(773846824, connectionID, schema).then(($result: any) => { - return $$createType21($result); + return $$createType23($result); }); } @@ -226,7 +237,7 @@ export function ListTables(connectionID: string, schema: string): $CancellablePr */ export function LoadSchemaData(connectionID: string): $CancellablePromise { return $Call.ByID(4233994986, connectionID).then(($result: any) => { - return $$createType22($result); + return $$createType24($result); }); } @@ -263,7 +274,7 @@ export function RollbackTransaction(tabID: string): $CancellablePromise { export function SaveConnection(cfg: database$0.ConnectionConfig): $CancellablePromise { return $Call.ByID(571429232, cfg).then(($result: any) => { - return $$createType12($result); + return $$createType14($result); }); } @@ -273,13 +284,13 @@ export function SaveEditorSession(session: storage$0.EditorSession): $Cancellabl export function SaveFolder(f: storage$0.ConnectionFolder): $CancellablePromise { return $Call.ByID(1026390748, f).then(($result: any) => { - return $$createType14($result); + return $$createType16($result); }); } export function SaveSavedQuery(q: database$0.SavedQuery): $CancellablePromise { return $Call.ByID(1936361457, q).then(($result: any) => { - return $$createType16($result); + return $$createType18($result); }); } @@ -315,7 +326,7 @@ export function SetWindowStateFlush(flush: any): $CancellablePromise { export function SettingsStore(): $CancellablePromise { return $Call.ByID(2329735545).then(($result: any) => { - return $$createType24($result); + return $$createType26($result); }); } @@ -337,26 +348,28 @@ export function UpdateRow(connectionID: string, upd: database$0.RowUpdate): $Can // Private type creation functions const $$createType0 = database$0.QueryResult.createFrom; const $$createType1 = $Create.Nullable($$createType0); -const $$createType2 = $models.AppInfo.createFrom; -const $$createType3 = database$0.ConnectionStatus.createFrom; -const $$createType4 = storage$0.EditorSession.createFrom; -const $$createType5 = $models.PathDefaults.createFrom; -const $$createType6 = $Create.Map($Create.Any, $Create.Any); -const $$createType7 = database$0.HistoryEntry.createFrom; -const $$createType8 = $Create.Array($$createType7); -const $$createType9 = $Create.Map($Create.Any, $Create.Any); -const $$createType10 = database$0.ColumnInfo.createFrom; -const $$createType11 = $Create.Array($$createType10); -const $$createType12 = database$0.ConnectionConfig.createFrom; +const $$createType2 = database$0.QueryPlan.createFrom; +const $$createType3 = $Create.Nullable($$createType2); +const $$createType4 = $models.AppInfo.createFrom; +const $$createType5 = database$0.ConnectionStatus.createFrom; +const $$createType6 = storage$0.EditorSession.createFrom; +const $$createType7 = $models.PathDefaults.createFrom; +const $$createType8 = $Create.Map($Create.Any, $Create.Any); +const $$createType9 = database$0.HistoryEntry.createFrom; +const $$createType10 = $Create.Array($$createType9); +const $$createType11 = $Create.Map($Create.Any, $Create.Any); +const $$createType12 = database$0.ColumnInfo.createFrom; const $$createType13 = $Create.Array($$createType12); -const $$createType14 = storage$0.ConnectionFolder.createFrom; +const $$createType14 = database$0.ConnectionConfig.createFrom; const $$createType15 = $Create.Array($$createType14); -const $$createType16 = database$0.SavedQuery.createFrom; +const $$createType16 = storage$0.ConnectionFolder.createFrom; const $$createType17 = $Create.Array($$createType16); -const $$createType18 = database$0.SchemaInfo.createFrom; +const $$createType18 = database$0.SavedQuery.createFrom; const $$createType19 = $Create.Array($$createType18); -const $$createType20 = database$0.TableInfo.createFrom; +const $$createType20 = database$0.SchemaInfo.createFrom; const $$createType21 = $Create.Array($$createType20); -const $$createType22 = database$0.SchemaBundle.createFrom; -const $$createType23 = storage$0.SettingsStore.createFrom; -const $$createType24 = $Create.Nullable($$createType23); +const $$createType22 = database$0.TableInfo.createFrom; +const $$createType23 = $Create.Array($$createType22); +const $$createType24 = database$0.SchemaBundle.createFrom; +const $$createType25 = storage$0.SettingsStore.createFrom; +const $$createType26 = $Create.Nullable($$createType25); diff --git a/frontend/bindings/xensql/internal/database/index.ts b/frontend/bindings/xensql/internal/database/index.ts index 8aee94e..27f0055 100644 --- a/frontend/bindings/xensql/internal/database/index.ts +++ b/frontend/bindings/xensql/internal/database/index.ts @@ -7,6 +7,9 @@ export { ConnectionStatus, DriverType, HistoryEntry, + PlanField, + PlanNode, + QueryPlan, QueryResult, RowDelete, RowUpdate, diff --git a/frontend/bindings/xensql/internal/database/models.ts b/frontend/bindings/xensql/internal/database/models.ts index 6d6443d..ed6dec8 100644 --- a/frontend/bindings/xensql/internal/database/models.ts +++ b/frontend/bindings/xensql/internal/database/models.ts @@ -201,6 +201,137 @@ export class HistoryEntry { } } +export class PlanField { + "key": string; + "value": string; + + /** Creates a new PlanField instance. */ + constructor($$source: Partial = {}) { + if (!("key" in $$source)) { + this["key"] = ""; + } + if (!("value" in $$source)) { + this["value"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new PlanField instance from a string or object. + */ + static createFrom($$source: any = {}): PlanField { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new PlanField($$parsedSource as Partial); + } +} + +/** + * PlanNode is one operation in a normalized plan tree. + */ +export class PlanNode { + "label": string; + "detail"?: string; + "relation"?: string; + "index"?: string; + "costTotal"?: number | null; + "costSelf"?: number | null; + "rowsPlanned"?: number | null; + + /** + * Totals across every loop, not the per-loop averages Postgres and MariaDB report. + */ + "rowsActual"?: number | null; + "loops"?: number | null; + "timeMs"?: number | null; + "selfTimeMs"?: number | null; + "neverRun"?: boolean; + "fields"?: PlanField[]; + "children"?: PlanNode[]; + + /** Creates a new PlanNode instance. */ + constructor($$source: Partial = {}) { + if (!("label" in $$source)) { + this["label"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new PlanNode instance from a string or object. + */ + static createFrom($$source: any = {}): PlanNode { + const $$createField12_0 = $$createType2; + const $$createField13_0 = $$createType4; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("fields" in $$parsedSource) { + $$parsedSource["fields"] = $$createField12_0($$parsedSource["fields"]); + } + if ("children" in $$parsedSource) { + $$parsedSource["children"] = $$createField13_0($$parsedSource["children"]); + } + return new PlanNode($$parsedSource as Partial); + } +} + +export class QueryPlan { + "driver": DriverType; + "statement": string; + "explainSql": string; + "analyzed": boolean; + "nodes": PlanNode[]; + "totalCost"?: number | null; + "planningMs"?: number | null; + "executionMs"?: number | null; + "durationMs": number; + "notes"?: string[]; + "raw": string; + + /** Creates a new QueryPlan instance. */ + constructor($$source: Partial = {}) { + if (!("driver" in $$source)) { + this["driver"] = DriverType.$zero; + } + if (!("statement" in $$source)) { + this["statement"] = ""; + } + if (!("explainSql" in $$source)) { + this["explainSql"] = ""; + } + if (!("analyzed" in $$source)) { + this["analyzed"] = false; + } + if (!("nodes" in $$source)) { + this["nodes"] = []; + } + if (!("durationMs" in $$source)) { + this["durationMs"] = 0; + } + if (!("raw" in $$source)) { + this["raw"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new QueryPlan instance from a string or object. + */ + static createFrom($$source: any = {}): QueryPlan { + const $$createField4_0 = $$createType4; + const $$createField9_0 = $$createType5; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("nodes" in $$parsedSource) { + $$parsedSource["nodes"] = $$createField4_0($$parsedSource["nodes"]); + } + if ("notes" in $$parsedSource) { + $$parsedSource["notes"] = $$createField9_0($$parsedSource["notes"]); + } + return new QueryPlan($$parsedSource as Partial); + } +} + export class QueryResult { "columns": string[]; "columnTypes": string[]; @@ -241,10 +372,10 @@ export class QueryResult { * Creates a new QueryResult instance from a string or object. */ static createFrom($$source: any = {}): QueryResult { - const $$createField0_0 = $$createType1; - const $$createField1_0 = $$createType1; - const $$createField2_0 = $$createType3; - const $$createField7_0 = $$createType1; + const $$createField0_0 = $$createType5; + const $$createField1_0 = $$createType5; + const $$createField2_0 = $$createType7; + const $$createField7_0 = $$createType5; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("columns" in $$parsedSource) { $$parsedSource["columns"] = $$createField0_0($$parsedSource["columns"]); @@ -286,7 +417,7 @@ export class RowDelete { * Creates a new RowDelete instance from a string or object. */ static createFrom($$source: any = {}): RowDelete { - const $$createField2_0 = $$createType5; + const $$createField2_0 = $$createType9; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("primaryKeys" in $$parsedSource) { $$parsedSource["primaryKeys"] = $$createField2_0($$parsedSource["primaryKeys"]); @@ -323,8 +454,8 @@ export class RowUpdate { * Creates a new RowUpdate instance from a string or object. */ static createFrom($$source: any = {}): RowUpdate { - const $$createField2_0 = $$createType4; - const $$createField3_0 = $$createType4; + const $$createField2_0 = $$createType8; + const $$createField3_0 = $$createType8; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("primaryKey" in $$parsedSource) { $$parsedSource["primaryKey"] = $$createField2_0($$parsedSource["primaryKey"]); @@ -444,9 +575,9 @@ export class SchemaBundle { * Creates a new SchemaBundle instance from a string or object. */ static createFrom($$source: any = {}): SchemaBundle { - const $$createField0_0 = $$createType6; - const $$createField1_0 = $$createType8; - const $$createField2_0 = $$createType10; + const $$createField0_0 = $$createType10; + const $$createField1_0 = $$createType12; + const $$createField2_0 = $$createType14; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("status" in $$parsedSource) { $$parsedSource["status"] = $$createField0_0($$parsedSource["status"]); @@ -502,7 +633,7 @@ export class SchemaTables { * Creates a new SchemaTables instance from a string or object. */ static createFrom($$source: any = {}): SchemaTables { - const $$createField1_0 = $$createType12; + const $$createField1_0 = $$createType16; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("tables" in $$parsedSource) { $$parsedSource["tables"] = $$createField1_0($$parsedSource["tables"]); @@ -578,15 +709,19 @@ export class TableInfo { // Private type creation functions const $$createType0 = SSHConfig.createFrom; -const $$createType1 = $Create.Array($Create.Any); -const $$createType2 = $Create.Array($Create.Any); -const $$createType3 = $Create.Array($$createType2); -const $$createType4 = $Create.Map($Create.Any, $Create.Any); -const $$createType5 = $Create.Array($$createType4); -const $$createType6 = ConnectionStatus.createFrom; -const $$createType7 = SchemaInfo.createFrom; -const $$createType8 = $Create.Array($$createType7); -const $$createType9 = SchemaTables.createFrom; -const $$createType10 = $Create.Array($$createType9); -const $$createType11 = TableInfo.createFrom; +const $$createType1 = PlanField.createFrom; +const $$createType2 = $Create.Array($$createType1); +const $$createType3 = PlanNode.createFrom; +const $$createType4 = $Create.Array($$createType3); +const $$createType5 = $Create.Array($Create.Any); +const $$createType6 = $Create.Array($Create.Any); +const $$createType7 = $Create.Array($$createType6); +const $$createType8 = $Create.Map($Create.Any, $Create.Any); +const $$createType9 = $Create.Array($$createType8); +const $$createType10 = ConnectionStatus.createFrom; +const $$createType11 = SchemaInfo.createFrom; const $$createType12 = $Create.Array($$createType11); +const $$createType13 = SchemaTables.createFrom; +const $$createType14 = $Create.Array($$createType13); +const $$createType15 = TableInfo.createFrom; +const $$createType16 = $Create.Array($$createType15); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3fc365d..163d2e5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -94,7 +94,7 @@ function App() { const [quickSearchOpen, setQuickSearchOpen] = useState(false); const [connPickerOpen, setConnPickerOpen] = useState(false); - const { runQueryForTab, cancelQueryForTab } = useQueryRunner(); + const { runQueryForTab, explainQueryForTab, cancelQueryForTab } = useQueryRunner(); const { beginTransaction, commitTransaction, rollbackTransaction, cleanupTabTransaction } = useTransactionActions(); const { persistTabSavedQuery, handleSaveQuery, openRenameDialog, confirmRenameSavedQuery, openSavedQuery } = useSavedQueryActions(renameTabId, setRenameTabId); @@ -310,6 +310,10 @@ function App() { (tabId: string, sql: string) => void runQueryForTab(tabId, sql), [runQueryForTab], ); + const handleExplainForTab = useCallback( + (tabId: string, sql: string, analyze: boolean) => void explainQueryForTab(tabId, sql, analyze), + [explainQueryForTab], + ); const handleSaveQueryWrapped = useCallback(() => void handleSaveQuery(), [handleSaveQuery]); const handleDragEnd = useCallback(() => { setDragTabId(null); @@ -428,6 +432,7 @@ function App() { loadColumnsForConnection={loadColumnsForConnection} onChangeSql={handleChangeSql} onRun={handleRunForTab} + onExplain={handleExplainForTab} onCancel={cancelQueryForTab} onSaveQuery={handleSaveQueryWrapped} onRenameSavedQuery={openRenameDialog} diff --git a/frontend/src/features/editor/EditorPane.tsx b/frontend/src/features/editor/EditorPane.tsx index d05494d..42c7cd3 100644 --- a/frontend/src/features/editor/EditorPane.tsx +++ b/frontend/src/features/editor/EditorPane.tsx @@ -21,6 +21,7 @@ interface EditorPaneProps { loadColumnsForConnection: (connectionId: string) => (schema: string, table: string) => Promise; onChangeSql: (tabId: string, sql: string) => void; onRun: (tabId: string, sql: string) => void; + onExplain: (tabId: string, sql: string, analyze: boolean) => void; onCancel: (tabId: string) => void; onSaveQuery: () => void; onRenameSavedQuery: () => void; @@ -46,6 +47,7 @@ interface EditorPaneTabProps { loadColumnsForConnection: EditorPaneProps['loadColumnsForConnection']; onChangeSql: EditorPaneProps['onChangeSql']; onRun: EditorPaneProps['onRun']; + onExplain: EditorPaneProps['onExplain']; onCancel: EditorPaneProps['onCancel']; onSaveQuery: EditorPaneProps['onSaveQuery']; onRenameSavedQuery: EditorPaneProps['onRenameSavedQuery']; @@ -69,6 +71,7 @@ const EditorPaneTab = memo(function EditorPaneTab({ loadColumnsForConnection, onChangeSql, onRun, + onExplain, onCancel, onSaveQuery, onRenameSavedQuery, @@ -84,6 +87,10 @@ const EditorPaneTab = memo(function EditorPaneTab({ const handleChange = useCallback((sql: string) => onChangeSql(tabId, sql), [onChangeSql, tabId]); const handleRun = useCallback((sql: string) => onRun(tabId, sql), [onRun, tabId]); + const handleExplain = useCallback( + (sql: string, analyze: boolean) => onExplain(tabId, sql, analyze), + [onExplain, tabId], + ); const handleCancel = useCallback(() => onCancel(tabId), [onCancel, tabId]); const handleCursorStateChange = useCallback( (cursor: EditorCursorState) => onCursorStateChange(tabId, cursor), @@ -106,6 +113,7 @@ const EditorPaneTab = memo(function EditorPaneTab({ color={tab.color} onChange={handleChange} onRun={handleRun} + onExplain={handleExplain} isQueryRunning={isQueryRunning} onCancelQuery={handleCancel} savedQueryId={tab.savedQueryId} @@ -134,6 +142,7 @@ export const EditorPane = memo(function EditorPane({ loadColumnsForConnection, onChangeSql, onRun, + onExplain, onCancel, onSaveQuery, onRenameSavedQuery, @@ -161,6 +170,7 @@ export const EditorPane = memo(function EditorPane({ loadColumnsForConnection={loadColumnsForConnection} onChangeSql={onChangeSql} onRun={onRun} + onExplain={onExplain} onCancel={onCancel} onSaveQuery={onSaveQuery} onRenameSavedQuery={onRenameSavedQuery} diff --git a/frontend/src/features/editor/EditorToolbar.tsx b/frontend/src/features/editor/EditorToolbar.tsx index f99eb4d..2eaf402 100644 --- a/frontend/src/features/editor/EditorToolbar.tsx +++ b/frontend/src/features/editor/EditorToolbar.tsx @@ -1,5 +1,21 @@ -import { Bookmark, Check, CircleAlert, Clock, GitBranch, Pencil, Play, PlayCircle, Square, X } from 'lucide-react'; +import { + Bookmark, + Check, + ChevronDown, + CircleAlert, + Clock, + Gauge, + GitBranch, + Pencil, + Play, + PlayCircle, + Route, + Square, + X, +} from 'lucide-react'; +import { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { ContextMenu } from '@/shared/components/ContextMenu'; import { formatBinding, getEffectiveBinding } from '@/shared/lib/shortcuts'; import type { TxnState } from '@/types'; @@ -7,6 +23,8 @@ interface Props { isQueryRunning: boolean; onCancelQuery?: () => void; runQuery: (selectedOnly: boolean) => void; + explainQuery?: (analyze: boolean) => void; + canAnalyze?: boolean; onSaveQuery?: () => void; onRenameSavedQuery?: () => void; savedQueryId?: string; @@ -20,6 +38,8 @@ export function EditorToolbar({ isQueryRunning, onCancelQuery, runQuery, + explainQuery, + canAnalyze = false, onSaveQuery, onRenameSavedQuery, savedQueryId, @@ -30,6 +50,14 @@ export function EditorToolbar({ }: Props) { const { t } = useTranslation(); const inTxn = txnState === 'active' || txnState === 'error'; + const explainMenuRef = useRef(null); + const [explainMenu, setExplainMenu] = useState<{ x: number; y: number } | null>(null); + + const openExplainMenu = () => { + const rect = explainMenuRef.current?.getBoundingClientRect(); + if (!rect) return; + setExplainMenu({ x: rect.left, y: rect.bottom + 2 }); + }; return (
@@ -64,6 +92,36 @@ export function EditorToolbar({ > {t('editor.runAll')} + {explainQuery && ( + + + {canAnalyze && ( + + )} + + )} )} {onSaveQuery && ( @@ -144,6 +202,25 @@ export function EditorToolbar({ )} )} + {explainMenu && explainQuery && ( + setExplainMenu(null)} + items={[ + { + label: t('editor.explain'), + icon: , + action: () => explainQuery(false), + }, + { + label: t('editor.explainAnalyze'), + icon: , + action: () => explainQuery(true), + }, + ]} + /> + )}
); } diff --git a/frontend/src/features/editor/SqlEditor.tsx b/frontend/src/features/editor/SqlEditor.tsx index 3f09a6b..032841c 100644 --- a/frontend/src/features/editor/SqlEditor.tsx +++ b/frontend/src/features/editor/SqlEditor.tsx @@ -57,6 +57,7 @@ interface Props { onCursorStateChange?: (state: EditorCursorState) => void; onChange: (sql: string) => void; onRun: (sql: string) => void; + onExplain?: (sql: string, analyze: boolean) => void; isQueryRunning?: boolean; onCancelQuery?: () => void; onSaveQuery?: () => void; @@ -82,6 +83,7 @@ export const SqlEditor = memo(function SqlEditor({ onCursorStateChange, onChange, onRun, + onExplain, isQueryRunning = false, onCancelQuery, onSaveQuery, @@ -173,6 +175,36 @@ export const SqlEditor = memo(function SqlEditor({ }, []); const { updateRunGlyphs, statementsRef } = useRunGlyphs(editorRef, monacoRef, sql, languageRevision, driver); + + // SQLite has no EXPLAIN ANALYZE. + const canAnalyze = driver !== 'sqlite'; + + // A plan is for one statement: the selection, else the statement at the cursor, else the buffer. + const explainQuery = useCallback( + (analyze: boolean) => { + if (isQueryRunning || !onExplain) return; + const ed = editorRef.current; + if (!ed) return; + const selection = ed.getSelection(); + const selected = (selection ? ed.getModel()?.getValueInRange(selection) : '') || ''; + let text = selected.trim(); + if (!text) { + const model = ed.getModel(); + const pos = ed.getPosition(); + if (model && pos) { + const offset = model.getOffsetAt(pos); + const atCursor = statementsRef.current.find((s) => offset >= s.start && offset <= s.end); + text = atCursor?.text.trim() ?? ''; + } + } + if (!text) text = ed.getValue().trim(); + if (text) { + clearQueryErrorMarkers(monacoRef.current, ed.getModel()); + onExplain(text, analyze); + } + }, + [isQueryRunning, onExplain, statementsRef], + ); useSqlDiagnostics(editorRef, monacoRef, sql, allTables, schemas, driver, languageRevision); const { bindEditorActions } = useEditorActions({ @@ -180,6 +212,8 @@ export const SqlEditor = memo(function SqlEditor({ monacoRef, isActive, runQuery, + explainQuery, + canAnalyze, onSaveQueryRef, onRenameSavedQueryRef, shortcutRevision, @@ -342,6 +376,8 @@ export const SqlEditor = memo(function SqlEditor({ isQueryRunning={isQueryRunning} onCancelQuery={onCancelQuery} runQuery={runQuery} + explainQuery={onExplain ? explainQuery : undefined} + canAnalyze={canAnalyze} onSaveQuery={onSaveQuery} onRenameSavedQuery={onRenameSavedQuery} savedQueryId={savedQueryId} diff --git a/frontend/src/features/editor/hooks/useEditorActions.ts b/frontend/src/features/editor/hooks/useEditorActions.ts index 4559c8a..3f7eb38 100644 --- a/frontend/src/features/editor/hooks/useEditorActions.ts +++ b/frontend/src/features/editor/hooks/useEditorActions.ts @@ -10,6 +10,8 @@ interface UseEditorActionsArgs { monacoRef: RefObject; isActive: boolean; runQuery: (selectedOnly: boolean) => void; + explainQuery: (analyze: boolean) => void; + canAnalyze: boolean; onSaveQueryRef: RefObject<(() => void) | undefined>; onRenameSavedQueryRef: RefObject<(() => void) | undefined>; shortcutRevision: number; @@ -21,6 +23,8 @@ export function useEditorActions({ monacoRef, isActive, runQuery, + explainQuery, + canAnalyze, onSaveQueryRef, onRenameSavedQueryRef, shortcutRevision, @@ -32,7 +36,7 @@ export function useEditorActions({ const bindEditorActions = useCallback( (ed: editor.IStandaloneCodeEditor, monaco: Monaco) => { for (const d of editorActionsRef.current) d.dispose(); - editorActionsRef.current = [ + const actions = [ ed.addAction({ id: 'run-selected', label: t('editor.actionRunSelection'), @@ -45,6 +49,12 @@ export function useEditorActions({ keybindings: [toMonacoKeybinding(monaco, getEffectiveBinding('runAll'))], run: () => runQuery(false), }), + ed.addAction({ + id: 'explain-query', + label: t('editor.actionExplain'), + keybindings: [toMonacoKeybinding(monaco, getEffectiveBinding('explainQuery'))], + run: () => explainQuery(false), + }), ed.addAction({ id: 'save-query', label: t('editor.actionSaveQuery'), @@ -78,8 +88,20 @@ export function useEditorActions({ }, }), ]; + // Left unbound where the engine cannot measure, so the key does nothing rather than error. + if (canAnalyze) { + actions.push( + ed.addAction({ + id: 'explain-analyze', + label: t('editor.actionExplainAnalyze'), + keybindings: [toMonacoKeybinding(monaco, getEffectiveBinding('explainAnalyze'))], + run: () => explainQuery(true), + }), + ); + } + editorActionsRef.current = actions; }, - [runQuery, t, onSaveQueryRef, onRenameSavedQueryRef], + [runQuery, explainQuery, canAnalyze, t, onSaveQueryRef, onRenameSavedQueryRef], ); useEffect(() => { diff --git a/frontend/src/features/editor/hooks/useQueryRunner.ts b/frontend/src/features/editor/hooks/useQueryRunner.ts index f56feb0..d012206 100644 --- a/frontend/src/features/editor/hooks/useQueryRunner.ts +++ b/frontend/src/features/editor/hooks/useQueryRunner.ts @@ -36,7 +36,7 @@ function txnResult(message: string): QueryResult { export function useQueryRunner() { const tabs = useTabs(); const { t } = useTranslation(); - const { setRunningTab, updateTabSession } = useStoreActions(); + const { setRunningTab, updateTabSession, showPlan } = useStoreActions(); const { beginTransaction, commitTransaction, rollbackTransaction } = useTransactionActions(); const runQueryForTab = useCallback( @@ -74,6 +74,24 @@ export function useQueryRunner() { [tabs, setRunningTab, updateTabSession, beginTransaction, commitTransaction, rollbackTransaction, t], ); + // Reuses the running-tab indicator: an EXPLAIN ANALYZE is as slow as the query and cancels alike. + const explainQueryForTab = useCallback( + async (tabId: string, sql: string, analyze: boolean) => { + const tab = tabs.find((tab) => tab.id === tabId); + if (!tab) return; + + setRunningTab(tabId); + try { + showPlan(tabId, await api.explainQuery(tab.connectionId, tabId, sql, analyze)); + } catch (e) { + updateTabSession(tabId, { result: null, resultError: formatError(e) }); + } finally { + setRunningTab(null); + } + }, + [tabs, setRunningTab, updateTabSession, showPlan], + ); + const cancelQueryForTab = useCallback( (tabId: string) => { const tab = tabs.find((tab) => tab.id === tabId); @@ -83,5 +101,5 @@ export function useQueryRunner() { [tabs], ); - return { runQueryForTab, cancelQueryForTab }; + return { runQueryForTab, explainQueryForTab, cancelQueryForTab }; } diff --git a/frontend/src/features/editor/hooks/useQueryStreamEvents.ts b/frontend/src/features/editor/hooks/useQueryStreamEvents.ts index 42b529d..c810503 100644 --- a/frontend/src/features/editor/hooks/useQueryStreamEvents.ts +++ b/frontend/src/features/editor/hooks/useQueryStreamEvents.ts @@ -101,7 +101,7 @@ export function useQueryStreamEvents(onConnectionStatusChange: (status: Connecti reorder.ingest(payload.streamId, payload.seq, false, () => { // Flush buffered rows before finalise so the row count can't snap ahead of visible rows. flushNow(); - const { tabId, streamId, resultIndex, result, statement, error, errorInfo } = payload; + const { tabId, streamId, resultIndex, result, plan, statement, error, errorInfo } = payload; const cancelled = isCancelled(error, errorInfo); const displayError = error ? (cancelled ? tRef.current('dialog.queryCancelled') : error) : null; const displayInfo = error && !cancelled ? (errorInfo ?? null) : null; @@ -111,6 +111,7 @@ export function useQueryStreamEvents(onConnectionStatusChange: (status: Connecti streamId, resultIndex, result ?? null, + plan ?? null, statement ?? null, displayError, displayInfo, diff --git a/frontend/src/features/results/PlanView.tsx b/frontend/src/features/results/PlanView.tsx new file mode 100644 index 0000000..0c9a957 --- /dev/null +++ b/frontend/src/features/results/PlanView.tsx @@ -0,0 +1,410 @@ +import { ChevronDown, ChevronRight, Code2, Copy, ListTree, TriangleAlert } from 'lucide-react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + availableMetrics, + collectParentKeys, + defaultMetric, + flattenPlan, + formatEstimateFactor, + formatPlanCost, + formatPlanMs, + formatPlanRows, + isEstimateOff, + type PlanMetric, + type PlanRow, +} from '@/features/results/lib/planTree'; +import { api } from '@/shared/lib/api'; +import { appToast, toastError } from '@/shared/lib/appToast'; +import { cx } from '@/shared/lib/cx'; +import type { PlanNode, QueryPlan } from '@/types'; + +interface Props { + plan: QueryPlan; +} + +const INDENT_REM = 1.077; + +/** Fixed widths in header order: each row is its own grid, so only fixed tracks line up. */ +const METRIC_COLUMNS: { metric: PlanMetric; width: string }[] = [ + { metric: 'rows', width: '10rem' }, + { metric: 'time', width: '10rem' }, + { metric: 'cost', width: '10rem' }, +]; + +export const PlanView = memo(function PlanView({ plan }: Props) { + const { t } = useTranslation(); + const metrics = useMemo(() => availableMetrics(plan), [plan]); + const [metric, setMetric] = useState(() => defaultMetric(plan)); + const [collapsed, setCollapsed] = useState>(() => new Set()); + const [selectedKey, setSelectedKey] = useState('0'); + const [showRaw, setShowRaw] = useState(false); + + // A new plan can replace the old one in place. + useEffect(() => { + setMetric(defaultMetric(plan)); + setCollapsed(new Set()); + setSelectedKey('0'); + setShowRaw(false); + }, [plan]); + + // Only the metrics the engine reported get a column, or the grid would be mostly blank. + const columns = useMemo(() => METRIC_COLUMNS.filter((column) => metrics.includes(column.metric)), [metrics]); + const gridColumns = useMemo( + () => ['minmax(18rem, 1fr)', ...columns.map((column) => column.width)].join(' '), + [columns], + ); + const shown = useMemo(() => new Set(columns.map((column) => column.metric)), [columns]); + + const rows = useMemo(() => flattenPlan(plan, metric, collapsed), [plan, metric, collapsed]); + const selected = useMemo(() => rows.find((row) => row.key === selectedKey), [rows, selectedKey]); + const allCollapsed = useMemo(() => collectParentKeys(plan.nodes ?? []), [plan.nodes]); + const isAllCollapsed = allCollapsed.length > 0 && allCollapsed.every((key) => collapsed.has(key)); + + const toggle = useCallback((key: string) => { + setCollapsed((prev) => { + const next = new Set(prev); + if (!next.delete(key)) next.add(key); + return next; + }); + }, []); + + const toggleAll = useCallback(() => { + setCollapsed((prev) => (allCollapsed.every((key) => prev.has(key)) ? new Set() : new Set(allCollapsed))); + }, [allCollapsed]); + + const treeRef = useRef(null); + + // One tab stop with a roving focus, so arrows walk the tree. + const focusRow = useCallback((key: string) => { + setSelectedKey(key); + treeRef.current?.querySelector(`[data-plan-key="${key}"]`)?.focus(); + }, []); + + const onTreeKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const index = rows.findIndex((row) => row.key === selectedKey); + if (index < 0) return; + const row = rows[index]; + switch (e.key) { + case 'ArrowDown': + if (index + 1 < rows.length) focusRow(rows[index + 1].key); + break; + case 'ArrowUp': + if (index > 0) focusRow(rows[index - 1].key); + break; + case 'ArrowRight': + if (!row.hasChildren) return; + if (collapsed.has(row.key)) toggle(row.key); + else if (index + 1 < rows.length) focusRow(rows[index + 1].key); + break; + case 'ArrowLeft': + if (row.hasChildren && !collapsed.has(row.key)) { + toggle(row.key); + break; + } + if (row.depth > 0) focusRow(row.key.slice(0, row.key.lastIndexOf('.'))); + break; + case 'Home': + focusRow(rows[0].key); + break; + case 'End': + focusRow(rows[rows.length - 1].key); + break; + default: + return; + } + e.preventDefault(); + }, + [rows, selectedKey, collapsed, focusRow, toggle], + ); + + const copyRaw = useCallback(async () => { + try { + await api.copyToClipboard(plan.raw); + appToast.success(t('toast.copiedClipboard')); + } catch (e) { + toastError(e, t('errors.copyFailed')); + } + }, [plan.raw, t]); + + return ( +
+
+ + {plan.analyzed ? t('results.planAnalyzed') : t('results.planEstimated')} + + +
+ {metrics.length > 1 && !showRaw && ( +
+ {metrics.map((option) => ( + + ))} +
+ )} + {!showRaw && allCollapsed.length > 0 && ( + + )} + + +
+ + {plan.notes && plan.notes.length > 0 && ( +
+ {plan.notes.map((note) => ( + + {t(`results.planNote.${note}`, { defaultValue: note })} + + ))} +
+ )} + + {showRaw ? ( +
{plan.raw}
+ ) : ( +
+
+
+
+ {t('results.planNode')} + {shown.has('rows') && {t('results.planColRows')}} + {shown.has('time') && {t('results.planColTime')}} + {shown.has('cost') && {t('results.planColCost')}} +
+ {rows.map((row) => ( + + ))} +
+
+ {selected && } +
+ )} +
+ ); +}); + +function PlanSummary({ plan }: { plan: QueryPlan }) { + const { t } = useTranslation(); + return ( +
+ {plan.planningMs != null && ( + + {t('results.planPlanning')} + {formatPlanMs(plan.planningMs)} + + )} + {plan.executionMs != null && ( + + {t('results.planExecution')} + {formatPlanMs(plan.executionMs)} + + )} + {plan.totalCost != null && ( + + {t('results.planTotalCost')} + {formatPlanCost(plan.totalCost)} + + )} +
+ ); +} + +interface PlanTreeRowProps { + row: PlanRow; + /** Metric columns the plan carries; the others get no cell, matching the header. */ + shown: Set; + selected: boolean; + collapsed: boolean; + onSelect: (key: string) => void; + onToggle: (key: string) => void; +} + +const PlanTreeRow = memo(function PlanTreeRow({ + row, + shown, + selected, + collapsed, + onSelect, + onToggle, +}: PlanTreeRowProps) { + const { t } = useTranslation(); + const { node } = row; + const estimateOff = isEstimateOff(row.estimateFactor, node.neverRun); + const rows = node.rowsActual ?? node.rowsPlanned; + + return ( +
+
+ ); +}); + +function planRowsTitle(t: (key: string, opts?: Record) => string, node: PlanNode): string { + if (node.rowsActual != null && node.rowsPlanned != null) { + return t('results.planRowsTitle', { + actual: formatPlanRows(node.rowsActual), + planned: formatPlanRows(node.rowsPlanned), + }); + } + return ''; +} + +function PlanDetails({ row }: { row: PlanRow }) { + const { t } = useTranslation(); + const { node } = row; + const stats: { label: string; value: string }[] = []; + if (node.rowsActual != null) + stats.push({ label: t('results.planRowsActual'), value: formatPlanRows(node.rowsActual) }); + if (node.rowsPlanned != null) + stats.push({ label: t('results.planRowsPlanned'), value: formatPlanRows(node.rowsPlanned) }); + if (node.loops != null) stats.push({ label: t('results.planLoops'), value: formatPlanRows(node.loops) }); + if (node.selfTimeMs != null) stats.push({ label: t('results.planSelfTime'), value: formatPlanMs(node.selfTimeMs) }); + if (node.timeMs != null) stats.push({ label: t('results.planTotalTime'), value: formatPlanMs(node.timeMs) }); + if (node.costSelf != null) stats.push({ label: t('results.planSelfCost'), value: formatPlanCost(node.costSelf) }); + if (node.costTotal != null) stats.push({ label: t('results.planTotalCost'), value: formatPlanCost(node.costTotal) }); + + return ( +
+
+ {node.label} + {node.relation && {node.relation}} + {node.index && {node.index}} +
+ {node.detail &&
{node.detail}
} + {stats.length > 0 && ( +
+ {stats.map((stat) => ( +
+
{stat.label}
+
{stat.value}
+
+ ))} +
+ )} + {node.fields && node.fields.length > 0 && ( +
+ {node.fields.map((field) => ( +
+
{field.key}
+
{field.value}
+
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/features/results/ResultTabs.tsx b/frontend/src/features/results/ResultTabs.tsx index d36a214..396feb3 100644 --- a/frontend/src/features/results/ResultTabs.tsx +++ b/frontend/src/features/results/ResultTabs.tsx @@ -1,7 +1,8 @@ -// biome-ignore-all lint/suspicious/noArrayIndexKey: result-set tabs are positional ("Result 1..N"), rebuilt wholesale on each run and never reordered, so the array index is the stable identity. -import { CircleAlert } from 'lucide-react'; -import { useRef } from 'react'; +// biome-ignore-all lint/suspicious/noArrayIndexKey: result-set tabs are positional, rebuilt wholesale on each run and never reordered, so the array index is the stable identity. +import { CircleAlert, Route } from 'lucide-react'; +import { useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import { resultTabLabels } from '@/features/results/lib/resultTabLabels'; import { useHorizontalWheelScroll } from '@/shared/hooks/useHorizontalWheelScroll'; import type { ResultSet } from '@/types'; @@ -11,24 +12,20 @@ interface Props { onSelect: (index: number) => void; } -// Result-set switcher shown when a run produced more than one result set (a multi-statement script, -// or a stored procedure returning several sets). Hidden for the common single-result case. +// Shown when a run produced more than one output. Hidden for the common single-output case. export function ResultTabs({ results, activeIndex, onSelect }: Props) { const { t } = useTranslation(); const tabsRef = useRef(null); const showTabs = results.length > 1; useHorizontalWheelScroll(tabsRef, showTabs); + const labels = useMemo(() => resultTabLabels(results), [results]); if (!showTabs) return null; return (
{results.map((rs, i) => { const isActive = i === activeIndex; - const count = rs.error - ? null - : rs.result?.columns?.length - ? rs.result.rowCount - : (rs.result?.affectedRows ?? 0); + const label = labels[i]; const tooltip = rs.statement ? rs.statement.replace(/\s+/g, ' ').slice(0, 120) : undefined; return ( diff --git a/frontend/src/features/results/ResultsPane.tsx b/frontend/src/features/results/ResultsPane.tsx index 1b04270..0f3682b 100644 --- a/frontend/src/features/results/ResultsPane.tsx +++ b/frontend/src/features/results/ResultsPane.tsx @@ -1,4 +1,5 @@ import { memo, useCallback } from 'react'; +import { PlanView } from '@/features/results/PlanView'; import { ResultsGrid } from '@/features/results/ResultsGrid'; import { ResultTabs } from '@/features/results/ResultTabs'; import { useAppStore } from '@/store/appStore'; @@ -55,7 +56,7 @@ const ResultsPaneTab = memo(function ResultsPaneTab({ } }, [dataBrowser, onRefreshTable, connectionId, tabId]); - // One grid per result set, hidden via CSS, so per-set grid state survives result-tab switches. + // One layer per set, hidden via CSS, so per-set state survives tab switches. const runKey = session.runStreamId ?? 'direct'; return ( @@ -82,18 +83,22 @@ const ResultsPaneTab = memo(function ResultsPaneTab({ return ( // biome-ignore lint/suspicious/noArrayIndexKey: sets never reorder within a run; runKey remounts them on a new run
- + {set.plan ? ( + + ) : ( + + )}
); }) diff --git a/frontend/src/features/results/lib/planTree.test.ts b/frontend/src/features/results/lib/planTree.test.ts new file mode 100644 index 0000000..60fc6ad --- /dev/null +++ b/frontend/src/features/results/lib/planTree.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest'; +import { + availableMetrics, + collectParentKeys, + defaultMetric, + estimateFactor, + flattenPlan, + formatEstimateFactor, + formatPlanCost, + formatPlanMs, + formatPlanRows, + isEstimateOff, +} from '@/features/results/lib/planTree'; +import type { PlanNode, QueryPlan } from '@/types'; + +function plan(nodes: PlanNode[], overrides: Partial = {}): QueryPlan { + return { + driver: 'postgres', + statement: 'SELECT 1', + explainSql: 'EXPLAIN (FORMAT JSON) SELECT 1', + analyzed: true, + nodes, + durationMs: 3, + raw: '[]', + ...overrides, + }; +} + +// A three-node plan whose middle child is by far the most expensive. +const measured = plan([ + { + label: 'Nested Loop', + selfTimeMs: 2, + timeMs: 12, + costSelf: 5, + costTotal: 40, + rowsActual: 9, + rowsPlanned: 10, + children: [ + { label: 'Seq Scan', relation: 'orders', selfTimeMs: 8, timeMs: 8, costSelf: 30, costTotal: 30, rowsActual: 9 }, + { + label: 'Index Scan', + relation: 'customers', + selfTimeMs: 2, + timeMs: 2, + costSelf: 5, + costTotal: 5, + rowsActual: 9, + }, + ], + }, +]); + +describe('availableMetrics', () => { + it('offers every metric the engine reported', () => { + expect(availableMetrics(measured)).toEqual(['time', 'cost', 'rows']); + }); + + it('drops timings when the plan was never measured', () => { + const estimated = plan([{ label: 'Seq Scan', costSelf: 3, rowsPlanned: 100 }], { analyzed: false }); + expect(availableMetrics(estimated)).toEqual(['cost', 'rows']); + expect(defaultMetric(estimated)).toBe('cost'); + }); + + it('offers nothing for a plan without metrics, as SQLite reports', () => { + const shapeOnly = plan([{ label: 'SCAN', relation: 'users', children: [{ label: 'SCAN', relation: 'x' }] }]); + expect(availableMetrics(shapeOnly)).toEqual([]); + expect(defaultMetric(shapeOnly)).toBeNull(); + }); + + it('finds a metric that only a nested node carries', () => { + const nested = plan([{ label: 'Result', children: [{ label: 'Seq Scan', costSelf: 1 }] }]); + expect(availableMetrics(nested)).toEqual(['cost']); + }); +}); + +describe('flattenPlan', () => { + it('walks the tree depth-first with keys that encode the path', () => { + const rows = flattenPlan(measured, 'time', new Set()); + expect(rows.map((row) => row.key)).toEqual(['0', '0.0', '0.1']); + expect(rows.map((row) => row.depth)).toEqual([0, 1, 1]); + expect(rows.map((row) => row.node.label)).toEqual(['Nested Loop', 'Seq Scan', 'Index Scan']); + }); + + it('scales heat against the largest own-metric and marks the hottest node', () => { + const rows = flattenPlan(measured, 'time', new Set()); + // Self time, not total: the root is cheap even though its subtree is slow. + expect(rows.map((row) => row.heat)).toEqual([2 / 8, 1, 2 / 8]); + expect(rows.filter((row) => row.hottest).map((row) => row.node.label)).toEqual(['Seq Scan']); + }); + + it('re-ranks when the metric changes', () => { + const byCost = flattenPlan(measured, 'cost', new Set()); + expect(byCost.map((row) => row.heat)).toEqual([5 / 30, 1, 5 / 30]); + }); + + it('hides the children of a collapsed node but keeps the heat scale', () => { + const rows = flattenPlan(measured, 'time', new Set(['0'])); + expect(rows.map((row) => row.key)).toEqual(['0']); + // Still scaled against the hidden Seq Scan's 8ms, so expanding never recolours the row. + expect(rows[0].heat).toBe(2 / 8); + expect(rows[0].hasChildren).toBe(true); + }); + + it('leaves every row cold when the engine reported no metrics', () => { + const shapeOnly = plan([{ label: 'SCAN', children: [{ label: 'SEARCH' }] }]); + const rows = flattenPlan(shapeOnly, null, new Set()); + expect(rows.map((row) => row.heat)).toEqual([0, 0]); + expect(rows.some((row) => row.hottest)).toBe(false); + }); + + it('falls back to estimated rows when nothing was measured', () => { + const estimated = plan( + [ + { label: 'Seq Scan', rowsPlanned: 50 }, + { label: 'Index Scan', rowsPlanned: 100 }, + ], + { + analyzed: false, + }, + ); + expect(flattenPlan(estimated, 'rows', new Set()).map((row) => row.heat)).toEqual([0.5, 1]); + }); + + it('handles a plan with no nodes', () => { + expect(flattenPlan(plan([]), 'time', new Set())).toEqual([]); + }); +}); + +describe('collectParentKeys', () => { + it('returns only the keys that can be collapsed', () => { + expect(collectParentKeys(measured.nodes)).toEqual(['0']); + }); + + it('descends into nested parents', () => { + const nodes: PlanNode[] = [{ label: 'a', children: [{ label: 'b', children: [{ label: 'c' }] }] }]; + expect(collectParentKeys(nodes)).toEqual(['0', '0.0']); + }); +}); + +describe('estimateFactor', () => { + it('is the ratio of measured to estimated rows', () => { + expect(estimateFactor({ label: 'n', rowsActual: 500, rowsPlanned: 10 })).toBe(50); + }); + + it('is undefined unless both counts are known', () => { + expect(estimateFactor({ label: 'n', rowsActual: 500 })).toBeUndefined(); + expect(estimateFactor({ label: 'n', rowsPlanned: 10 })).toBeUndefined(); + // A zero estimate would divide by zero; Postgres reports 0 for an empty partition. + expect(estimateFactor({ label: 'n', rowsActual: 5, rowsPlanned: 0 })).toBeUndefined(); + }); + + it('flags estimates off by ten times in either direction', () => { + expect(isEstimateOff(1)).toBe(false); + expect(isEstimateOff(9)).toBe(false); + expect(isEstimateOff(10)).toBe(true); + expect(isEstimateOff(0.1)).toBe(true); + expect(isEstimateOff(0.5)).toBe(false); + expect(isEstimateOff(undefined)).toBe(false); + }); + + it('says nothing about a node the executor never reached', () => { + // 0 measured against 1 estimated is a factor of 0, but the node simply never ran. + expect(isEstimateOff(0, true)).toBe(false); + expect(isEstimateOff(0, false)).toBe(true); + }); +}); + +describe('formatting', () => { + it('keeps sub-millisecond timings legible and switches to seconds when slow', () => { + expect(formatPlanMs(0)).toBe('0 ms'); + expect(formatPlanMs(0.0216)).toBe('0.022 ms'); + expect(formatPlanMs(4.27)).toBe('4.3 ms'); + expect(formatPlanMs(2500)).toBe('2.5 s'); + expect(formatPlanMs(null)).toBe(''); + expect(formatPlanMs(undefined)).toBe(''); + }); + + it('renders whole rows exactly and averages to one decimal', () => { + expect(formatPlanRows(0)).toBe('0'); + expect(formatPlanRows(1234)).toBe('1,234'); + expect(formatPlanRows(2.25)).toBe('2.3'); + expect(formatPlanRows(null)).toBe(''); + }); + + it('renders cost with cents up to a thousand', () => { + expect(formatPlanCost(18.1)).toBe('18.1'); + expect(formatPlanCost(0.295)).toBe('0.3'); + expect(formatPlanCost(12345.6)).toBe('12,346'); + expect(formatPlanCost(undefined)).toBe(''); + }); + + it('renders how far off an estimate was as a multiplier', () => { + expect(formatEstimateFactor(50)).toBe('50x'); + expect(formatEstimateFactor(2.5)).toBe('2.5x'); + expect(formatEstimateFactor(0.05)).toBe('0.05x'); + expect(formatEstimateFactor(undefined)).toBe(''); + }); +}); diff --git a/frontend/src/features/results/lib/planTree.ts b/frontend/src/features/results/lib/planTree.ts new file mode 100644 index 0000000..3300cf3 --- /dev/null +++ b/frontend/src/features/results/lib/planTree.ts @@ -0,0 +1,144 @@ +import type { PlanNode, QueryPlan } from '@/types'; + +export type PlanMetric = 'time' | 'cost' | 'rows'; + +export const PLAN_METRICS: PlanMetric[] = ['time', 'cost', 'rows']; + +/** An estimate off by this much means the planner picked blind. */ +export const PLAN_ESTIMATE_WARN_FACTOR = 10; + +export interface PlanRow { + node: PlanNode; + /** Path key ("0.1.2"), stable across renders; keys both React and the collapse set. */ + key: string; + depth: number; + hasChildren: boolean; + /** 0..1 share of the plan's largest self-metric. */ + heat: number; + hottest: boolean; + estimateFactor?: number; +} + +/** The node's own share, so a parent isn't hot merely because its children are. */ +function heatValue(node: PlanNode, metric: PlanMetric): number | undefined { + const value = + metric === 'time' ? node.selfTimeMs : metric === 'cost' ? node.costSelf : (node.rowsActual ?? node.rowsPlanned); + return value == null ? undefined : value; +} + +function hasMetric(nodes: PlanNode[], metric: PlanMetric): boolean { + return nodes.some((node) => heatValue(node, metric) != null || hasMetric(node.children ?? [], metric)); +} + +/** SQLite reports no metrics; EXPLAIN without ANALYZE reports no timings. */ +export function availableMetrics(plan: QueryPlan): PlanMetric[] { + return PLAN_METRICS.filter((metric) => hasMetric(plan.nodes ?? [], metric)); +} + +export function defaultMetric(plan: QueryPlan): PlanMetric | null { + return availableMetrics(plan)[0] ?? null; +} + +function maxHeat(nodes: PlanNode[], metric: PlanMetric): number { + let max = 0; + for (const node of nodes) { + const value = heatValue(node, metric); + if (value != null && value > max) max = value; + max = Math.max(max, maxHeat(node.children ?? [], metric)); + } + return max; +} + +export function estimateFactor(node: PlanNode): number | undefined { + const { rowsActual, rowsPlanned } = node; + if (rowsActual == null || rowsPlanned == null || rowsPlanned <= 0) return undefined; + return rowsActual / rowsPlanned; +} + +/** A node the executor never reached produced no rows by definition, so its factor says nothing. */ +export function isEstimateOff(factor: number | undefined, neverRun = false): boolean { + if (factor == null || neverRun) return false; + return factor >= PLAN_ESTIMATE_WARN_FACTOR || factor <= 1 / PLAN_ESTIMATE_WARN_FACTOR; +} + +export function collectKeys(nodes: PlanNode[], prefix = ''): string[] { + const keys: string[] = []; + nodes.forEach((node, i) => { + const key = prefix ? `${prefix}.${i}` : String(i); + keys.push(key); + keys.push(...collectKeys(node.children ?? [], key)); + }); + return keys; +} + +/** Only the keys that can be collapsed. */ +export function collectParentKeys(nodes: PlanNode[], prefix = ''): string[] { + const keys: string[] = []; + nodes.forEach((node, i) => { + const key = prefix ? `${prefix}.${i}` : String(i); + const children = node.children ?? []; + if (children.length > 0) { + keys.push(key); + keys.push(...collectParentKeys(children, key)); + } + }); + return keys; +} + +/** The visible rows. Heat scales against the whole plan, so collapsing never recolours a row. */ +export function flattenPlan(plan: QueryPlan, metric: PlanMetric | null, collapsed: Set): PlanRow[] { + const nodes = plan.nodes ?? []; + const max = metric ? maxHeat(nodes, metric) : 0; + const rows: PlanRow[] = []; + + const walk = (siblings: PlanNode[], depth: number, prefix: string) => { + siblings.forEach((node, i) => { + const key = prefix ? `${prefix}.${i}` : String(i); + const children = node.children ?? []; + const value = metric ? heatValue(node, metric) : undefined; + rows.push({ + node, + key, + depth, + hasChildren: children.length > 0, + heat: value != null && max > 0 ? value / max : 0, + hottest: value != null && max > 0 && value === max, + estimateFactor: estimateFactor(node), + }); + if (children.length > 0 && !collapsed.has(key)) { + walk(children, depth + 1, key); + } + }); + }; + walk(nodes, 0, ''); + return rows; +} + +/** Sub-millisecond timings stay readable; slow nodes switch to seconds. */ +export function formatPlanMs(ms: number | null | undefined): string { + if (ms == null || !Number.isFinite(ms)) return ''; + if (ms >= 1000) return `${(ms / 1000).toLocaleString(undefined, { maximumFractionDigits: 2 })} s`; + if (ms >= 1) return `${ms.toLocaleString(undefined, { maximumFractionDigits: 1 })} ms`; + if (ms === 0) return '0 ms'; + return `${ms.toLocaleString(undefined, { maximumFractionDigits: 3 })} ms`; +} + +/** Whole rows stay exact - a bad estimate is the point of the column. */ +export function formatPlanRows(rows: number | null | undefined): string { + if (rows == null || !Number.isFinite(rows)) return ''; + if (Number.isInteger(rows)) return rows.toLocaleString(); + return rows.toLocaleString(undefined, { maximumFractionDigits: 1 }); +} + +export function formatPlanCost(cost: number | null | undefined): string { + if (cost == null || !Number.isFinite(cost)) return ''; + if (cost >= 1000) return cost.toLocaleString(undefined, { maximumFractionDigits: 0 }); + return cost.toLocaleString(undefined, { maximumFractionDigits: 2 }); +} + +/** How far off the estimate was, as a multiplier ("12x", "0.1x"). */ +export function formatEstimateFactor(factor: number | undefined): string { + if (factor == null || !Number.isFinite(factor)) return ''; + if (factor >= 10) return `${factor.toLocaleString(undefined, { maximumFractionDigits: 0 })}x`; + return `${factor.toLocaleString(undefined, { maximumFractionDigits: factor < 1 ? 2 : 1 })}x`; +} diff --git a/frontend/src/features/results/lib/resultTabLabels.test.ts b/frontend/src/features/results/lib/resultTabLabels.test.ts new file mode 100644 index 0000000..fe58430 --- /dev/null +++ b/frontend/src/features/results/lib/resultTabLabels.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { resultTabLabels } from '@/features/results/lib/resultTabLabels'; +import type { QueryPlan, QueryResult, ResultSet } from '@/types'; + +const grid = (rowCount: number): QueryResult => ({ + columns: ['id'], + columnTypes: ['INTEGER'], + rows: [], + rowCount, + affectedRows: 0, + durationMs: 1, +}); + +const plan = (): QueryPlan => ({ + driver: 'postgres', + statement: 'SELECT 1', + explainSql: 'EXPLAIN (FORMAT JSON) SELECT 1', + analyzed: false, + nodes: [{ label: 'Seq Scan' }], + durationMs: 1, + raw: '[]', +}); + +const set = (over: Partial): ResultSet => ({ result: null, error: null, ...over }); + +describe('resultTabLabels', () => { + it('numbers grids and plans in separate sequences', () => { + const labels = resultTabLabels([ + set({ result: grid(5) }), + set({ plan: plan() }), + set({ result: grid(2) }), + set({ plan: plan() }), + ]); + expect(labels).toEqual([ + { key: 'results.resultLabel', n: 1, count: 5 }, + { key: 'results.planLabel', n: 1, count: null }, + { key: 'results.resultLabel', n: 2, count: 2 }, + { key: 'results.planLabel', n: 2, count: null }, + ]); + }); + + it('counts affected rows for a statement with no columns', () => { + const updated: QueryResult = { ...grid(0), columns: [], columnTypes: [], affectedRows: 7 }; + expect(resultTabLabels([set({ result: updated })])).toEqual([{ key: 'results.resultLabel', n: 1, count: 7 }]); + }); + + it('shows no count for a failed statement', () => { + expect(resultTabLabels([set({ error: 'boom' })])).toEqual([{ key: 'results.resultLabel', n: 1, count: null }]); + }); + + // A failed EXPLAIN has no plan, so it counts as a grid - that's where its error renders. + it('treats a failed explain as a grid', () => { + const labels = resultTabLabels([set({ plan: plan() }), set({ error: 'syntax error' })]); + expect(labels.map((l) => l.key)).toEqual(['results.planLabel', 'results.resultLabel']); + }); + + it('handles an empty run', () => { + expect(resultTabLabels([])).toEqual([]); + }); +}); diff --git a/frontend/src/features/results/lib/resultTabLabels.ts b/frontend/src/features/results/lib/resultTabLabels.ts new file mode 100644 index 0000000..ff5f26d --- /dev/null +++ b/frontend/src/features/results/lib/resultTabLabels.ts @@ -0,0 +1,23 @@ +import type { ResultSet } from '@/types'; + +export interface ResultTabLabel { + key: 'results.resultLabel' | 'results.planLabel'; + /** Ordinal within its own kind. */ + n: number; + count: number | null; +} + +/** Grids and plans count separately, so a mixed script reads "Result 1 Β· Plan 1 Β· Result 2". */ +export function resultTabLabels(results: ResultSet[]): ResultTabLabel[] { + let grids = 0; + let plans = 0; + return results.map((rs) => { + if (rs.plan) { + plans += 1; + return { key: 'results.planLabel', n: plans, count: null }; + } + grids += 1; + const count = rs.error ? null : rs.result?.columns?.length ? rs.result.rowCount : (rs.result?.affectedRows ?? 0); + return { key: 'results.resultLabel', n: grids, count }; + }); +} diff --git a/frontend/src/i18n/locales/bg.json b/frontend/src/i18n/locales/bg.json index e030d3b..8981d23 100644 --- a/frontend/src/i18n/locales/bg.json +++ b/frontend/src/i18n/locales/bg.json @@ -116,6 +116,8 @@ "stopQuery": "Π‘ΠΏΡ€ΠΈ заявката", "runSelection": "Изпълни ΠΈΠ·Π±Ρ€Π°Π½ΠΎΡ‚ΠΎ ({{shortcut}})", "runAll": "Изпълни всичко ({{shortcut}})", + "explain": "ПокаТи ΠΏΠ»Π°Π½Π° Π½Π° заявката ({{shortcut}})", + "explainOptions": "План с ΠΈΠ·ΠΌΠ΅Ρ€Π΅Π½ΠΈ Ρ€Π΅Π΄ΠΎΠ²Π΅ ΠΈ Π²Ρ€Π΅ΠΌΠ΅Π½Π° ({{shortcut}})", "saveQuery": "Π—Π°ΠΏΠ°Π·ΠΈ Π² Π±ΠΈΠ±Π»ΠΈΠΎΡ‚Π΅ΠΊΠ°Ρ‚Π° ({{shortcut}})", "updateSavedQuery": "Обнови Π·Π°ΠΏΠ°Π·Π΅Π½Π°Ρ‚Π° заявка ({{shortcut}})", "renameSavedQuery": "ΠŸΡ€Π΅ΠΈΠΌΠ΅Π½ΡƒΠ²Π°ΠΉ Π·Π°ΠΏΠ°Π·Π΅Π½Π°Ρ‚Π° заявка ({{shortcut}})", @@ -169,6 +171,9 @@ "resetFontSize": "Нулирай Ρ€Π°Π·ΠΌΠ΅Ρ€Π° Π½Π° ΡˆΡ€ΠΈΡ„Ρ‚Π° Π² Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΎΡ€Π°", "run": "Изпълни", "runAll": "Изпълни всичко", + "explain": "Обясни", + "explainAnalyze": "Обясни с ΠΈΠ·ΠΌΠ΅Ρ€Π²Π°Π½Π΅", + "explainOptions": "ΠžΠΏΡ†ΠΈΠΈ Π·Π° обяснСниС", "stop": "Π‘ΠΏΡ€ΠΈ", "save": "Π—Π°ΠΏΠ°Π·ΠΈ", "update": "Обнови", @@ -195,6 +200,8 @@ "contextFormat": "Π€ΠΎΡ€ΠΌΠ°Ρ‚ΠΈΡ€Π°ΠΉ заявката", "actionRunSelection": "Изпълни ΠΈΠ·Π±Ρ€Π°Π½ΠΎΡ‚ΠΎ", "actionRunAll": "Изпълни всичко", + "actionExplain": "ПокаТи ΠΏΠ»Π°Π½Π° Π½Π° заявката", + "actionExplainAnalyze": "ПокаТи ΠΏΠ»Π°Π½Π° Π½Π° заявката (с ΠΈΠ·ΠΌΠ΅Ρ€Π²Π°Π½Π΅)", "actionSaveQuery": "Π—Π°ΠΏΠ°Π·ΠΈ заявка", "actionRenameSaved": "ΠŸΡ€Π΅ΠΈΠΌΠ΅Π½ΡƒΠ²Π°ΠΉ Π·Π°ΠΏΠ°Π·Π΅Π½Π° заявка", "sql": { @@ -226,6 +233,7 @@ "noResults": "Няма Ρ€Π΅Π·ΡƒΠ»Ρ‚Π°Ρ‚ΠΈ", "runQueryHint": "Π˜Π·ΠΏΡŠΠ»Π½Π΅Ρ‚Π΅ заявка, Π·Π° Π΄Π° Π²ΠΈΠ΄ΠΈΡ‚Π΅ Ρ€Π΅Π·ΡƒΠ»Ρ‚Π°Ρ‚ΠΈ", "resultLabel": "Π Π΅Π·ΡƒΠ»Ρ‚Π°Ρ‚ {{n}}", + "planLabel": "План {{n}}", "txnBegin": "Вранзакцията Π΅ стартирана", "txnCommit": "Вранзакцията Π΅ ΠΏΠΎΡ‚Π²ΡŠΡ€Π΄Π΅Π½Π°", "txnRollback": "Вранзакцията Π΅ ΠΎΡ‚ΠΌΠ΅Π½Π΅Π½Π°", @@ -246,7 +254,44 @@ "metaRows": "{{count}} Ρ€Π΅Π΄(Π°) Β· {{ms}}ms", "metaRowsShort": "{{ms}}ms Β· {{count}} Ρ€Π΅Π΄Π°", "rowHeader": "Π Π΅Π΄", - "copyFormatSr": "Π€ΠΎΡ€ΠΌΠ°Ρ‚ Π·Π° ΠΊΠΎΠΏΠΈΡ€Π°Π½Π΅" + "copyFormatSr": "Π€ΠΎΡ€ΠΌΠ°Ρ‚ Π·Π° ΠΊΠΎΠΏΠΈΡ€Π°Π½Π΅", + "planTitle": "План Π½Π° заявката", + "planAnalyzed": "Π˜Π·ΠΌΠ΅Ρ€Π΅Π½ΠΎ", + "planEstimated": "ΠŸΡ€ΠΈΠ±Π»ΠΈΠ·ΠΈΡ‚Π΅Π»Π½ΠΎ", + "planPlanning": "ΠŸΠ»Π°Π½ΠΈΡ€Π°Π½Π΅", + "planExecution": "ИзпълнСниС", + "planTotalCost": "ΠžΠ±Ρ‰Π° Ρ†Π΅Π½Π°", + "planNode": "Π’ΡŠΠ·Π΅Π»", + "planColRows": "Π Π΅Π΄ΠΎΠ²Π΅", + "planColTime": "Π’Ρ€Π΅ΠΌΠ΅", + "planColCost": "Π¦Π΅Π½Π°", + "planHeatBy": "ΠœΠ΅Ρ‚Ρ€ΠΈΠΊΠ° Π½Π° Ρ‚ΠΎΠΏΠ»ΠΈΠ½Π½Π°Ρ‚Π° ΠΊΠ°Ρ€Ρ‚Π°", + "planHeatByMetric": "Π’ΠΎΠΏΠ»ΠΈΠ½Π½Π° ΠΊΠ°Ρ€Ρ‚Π° ΠΏΠΎ {{metric}}", + "planMetric": { + "time": "Π’Ρ€Π΅ΠΌΠ΅", + "cost": "Π¦Π΅Π½Π°", + "rows": "Π Π΅Π΄ΠΎΠ²Π΅" + }, + "planExpandAll": "Разгъни всички", + "planCollapseAll": "Бгъни всички", + "planExpandNode": "Разгъни възСла", + "planCollapseNode": "Бгъни възСла", + "planRaw": "Π‘ΡƒΡ€ΠΎΠ² ΠΏΠ»Π°Π½", + "planCopy": "ΠšΠΎΠΏΠΈΡ€Π°ΠΉ суровия ΠΏΠ»Π°Π½", + "planNeverRun": "Π½Π΅ Π΅ изпълнСн", + "planEstimateOffHint": "Π˜Π·ΠΌΠ΅Ρ€Π΅Π½ΠΈΡΡ‚ Π±Ρ€ΠΎΠΉ Ρ€Π΅Π΄ΠΎΠ²Π΅ сС Ρ€Π°Π·Π»ΠΈΡ‡Π°Π²Π° силно ΠΎΡ‚ очаквания - планиращият Π΅ ΠΈΠ·Π±Ρ€Π°Π» Ρ‚ΠΎΠ·ΠΈ ΠΏΡŠΡ‚ наслуки", + "planRowsTitle": "{{actual}} ΠΈΠ·ΠΌΠ΅Ρ€Π΅Π½ΠΈ, {{planned}} ΠΎΡ‡Π°ΠΊΠ²Π°Π½ΠΈ", + "planRowsActual": "Π Π΅Π΄ΠΎΠ²Π΅ (ΠΈΠ·ΠΌΠ΅Ρ€Π΅Π½ΠΈ)", + "planRowsPlanned": "Π Π΅Π΄ΠΎΠ²Π΅ (ΠΎΡ‡Π°ΠΊΠ²Π°Π½ΠΈ)", + "planLoops": "ΠŸΠΎΠ²Ρ‚ΠΎΡ€Π΅Π½ΠΈΡ", + "planSelfTime": "БобствСно Π²Ρ€Π΅ΠΌΠ΅", + "planTotalTime": "ΠžΠ±Ρ‰ΠΎ Π²Ρ€Π΅ΠΌΠ΅", + "planSelfCost": "БобствСна Ρ†Π΅Π½Π°", + "planNote": { + "noMetrics": "SQLite Π΄Π°Π²Π° само структурата Π½Π° ΠΏΠ»Π°Π½Π° - Π±Π΅Π· Ρ†Π΅Π½Π°, Ρ€Π΅Π΄ΠΎΠ²Π΅ ΠΈΠ»ΠΈ Π²Ρ€Π΅ΠΌΠ΅Π½Π°", + "rolledBack": "Заявката записва, Π·Π°Ρ‚ΠΎΠ²Π° сС изпълни Π² транзакция, която Π±Π΅ Π²ΡŠΡ€Π½Π°Ρ‚Π° Π½Π°Π·Π°Π΄", + "tabTransaction": "ИзпълнСно Π² ΠΎΡ‚Π²ΠΎΡ€Π΅Π½Π°Ρ‚Π° транзакция Π½Π° Ρ‚ΠΎΠ·ΠΈ Ρ‚Π°Π±" + } }, "export": { "title": "Експорт Π½Π° Ρ€Π΅Π·ΡƒΠ»Ρ‚Π°Ρ‚ΠΈ", @@ -549,6 +594,8 @@ "quickSearch": "Π‘ΡŠΡ€Π·ΠΎ Ρ‚ΡŠΡ€ΡΠ΅Π½Π΅", "runSelection": "Изпълни ΠΈΠ·Π±Ρ€Π°Π½ΠΎΡ‚ΠΎ", "runAll": "Изпълни всичко", + "explainQuery": "ПокаТи ΠΏΠ»Π°Π½Π° Π½Π° заявката", + "explainAnalyze": "ПокаТи ΠΏΠ»Π°Π½Π° Π½Π° заявката (с ΠΈΠ·ΠΌΠ΅Ρ€Π²Π°Π½Π΅)", "saveQuery": "Π—Π°ΠΏΠ°Π·ΠΈ заявка", "renameSavedQuery": "ΠŸΡ€Π΅ΠΈΠΌΠ΅Π½ΡƒΠ²Π°ΠΉ Π·Π°ΠΏΠ°Π·Π΅Π½Π° заявка", "newTab": "Нов Ρ‚Π°Π± със заявка", diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 57e2273..246c7f9 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -116,6 +116,8 @@ "stopQuery": "Abfrage stoppen", "runSelection": "Auswahl ausfΓΌhren ({{shortcut}})", "runAll": "Alles ausfΓΌhren ({{shortcut}})", + "explain": "Abfrageplan anzeigen ({{shortcut}})", + "explainOptions": "Plan mit gemessenen Zeilen und Zeiten ({{shortcut}})", "saveQuery": "In Bibliothek speichern ({{shortcut}})", "updateSavedQuery": "Gespeicherte Abfrage aktualisieren ({{shortcut}})", "renameSavedQuery": "Gespeicherte Abfrage umbenennen ({{shortcut}})", @@ -169,6 +171,9 @@ "resetFontSize": "Editorschrift zurΓΌcksetzen", "run": "AusfΓΌhren", "runAll": "Alles ausfΓΌhren", + "explain": "ErklΓ€ren", + "explainAnalyze": "ErklΓ€ren mit Messung", + "explainOptions": "Optionen zum ErklΓ€ren", "stop": "Stopp", "save": "Speichern", "update": "Aktualisieren", @@ -195,6 +200,8 @@ "contextFormat": "Abfrage formatieren", "actionRunSelection": "Auswahl ausfΓΌhren", "actionRunAll": "Alles ausfΓΌhren", + "actionExplain": "Abfrageplan anzeigen", + "actionExplainAnalyze": "Abfrageplan anzeigen (mit Messung)", "actionSaveQuery": "Abfrage speichern", "actionRenameSaved": "Gespeicherte Abfrage umbenennen", "sql": { @@ -226,6 +233,7 @@ "noResults": "Keine Ergebnisse", "runQueryHint": "Abfrage ausfΓΌhren, um Ergebnisse zu sehen", "resultLabel": "Ergebnis {{n}}", + "planLabel": "Plan {{n}}", "txnBegin": "Transaktion gestartet", "txnCommit": "Transaktion bestΓ€tigt", "txnRollback": "Transaktion zurΓΌckgerollt", @@ -246,7 +254,44 @@ "metaRows": "{{count}} Zeile(n) Β· {{ms}}ms", "metaRowsShort": "{{ms}}ms Β· {{count}} Zeilen", "rowHeader": "Zeile", - "copyFormatSr": "Kopierformat" + "copyFormatSr": "Kopierformat", + "planTitle": "Abfrageplan", + "planAnalyzed": "Gemessen", + "planEstimated": "GeschΓ€tzt", + "planPlanning": "Planung", + "planExecution": "AusfΓΌhrung", + "planTotalCost": "Gesamtkosten", + "planNode": "Knoten", + "planColRows": "Zeilen", + "planColTime": "Zeit", + "planColCost": "Kosten", + "planHeatBy": "Metrik der Heatmap", + "planHeatByMetric": "Heatmap nach {{metric}}", + "planMetric": { + "time": "Zeit", + "cost": "Kosten", + "rows": "Zeilen" + }, + "planExpandAll": "Alle ausklappen", + "planCollapseAll": "Alle einklappen", + "planExpandNode": "Knoten ausklappen", + "planCollapseNode": "Knoten einklappen", + "planRaw": "Rohplan", + "planCopy": "Rohplan kopieren", + "planNeverRun": "nie ausgefΓΌhrt", + "planEstimateOffHint": "Die gemessene Zeilenzahl weicht stark von der SchΓ€tzung ab - der Planer hat diesen Weg blind gewΓ€hlt", + "planRowsTitle": "{{actual}} gemessen, {{planned}} geschΓ€tzt", + "planRowsActual": "Zeilen (gemessen)", + "planRowsPlanned": "Zeilen (geschΓ€tzt)", + "planLoops": "DurchlΓ€ufe", + "planSelfTime": "Eigene Zeit", + "planTotalTime": "Gesamtzeit", + "planSelfCost": "Eigene Kosten", + "planNote": { + "noMetrics": "SQLite liefert nur die Planstruktur - keine Kosten-, Zeilen- oder ZeitschΓ€tzungen", + "rolledBack": "Die Anweisung schreibt, daher lief sie in einer Transaktion, die zurΓΌckgerollt wurde", + "tabTransaction": "Lief in der offenen Transaktion dieses Tabs" + } }, "export": { "title": "Ergebnisse exportieren", @@ -549,6 +594,8 @@ "quickSearch": "Schnellsuche", "runSelection": "Auswahl ausfΓΌhren", "runAll": "Alles ausfΓΌhren", + "explainQuery": "Abfrageplan anzeigen", + "explainAnalyze": "Abfrageplan anzeigen (mit Messung)", "saveQuery": "Abfrage speichern", "renameSavedQuery": "Gespeicherte Abfrage umbenennen", "newTab": "Neuer Abfrage-Tab", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index bf362b1..c5d75bb 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -116,6 +116,8 @@ "stopQuery": "Stop query", "runSelection": "Run selection ({{shortcut}})", "runAll": "Run all ({{shortcut}})", + "explain": "Show the query plan ({{shortcut}})", + "explainOptions": "Plan with measured rows and timings ({{shortcut}})", "saveQuery": "Save to library ({{shortcut}})", "updateSavedQuery": "Update saved query ({{shortcut}})", "renameSavedQuery": "Rename saved query ({{shortcut}})", @@ -169,6 +171,9 @@ "resetFontSize": "Reset editor font size", "run": "Run", "runAll": "Run All", + "explain": "Explain", + "explainAnalyze": "Explain analyze", + "explainOptions": "Explain options", "stop": "Stop", "save": "Save", "update": "Update", @@ -195,6 +200,8 @@ "contextFormat": "Format query", "actionRunSelection": "Run Selection", "actionRunAll": "Run All", + "actionExplain": "Explain query plan", + "actionExplainAnalyze": "Explain query plan (analyze)", "actionSaveQuery": "Save Query", "actionRenameSaved": "Rename Saved Query", "sql": { @@ -226,6 +233,7 @@ "noResults": "No results", "runQueryHint": "Run a query to see results", "resultLabel": "Result {{n}}", + "planLabel": "Plan {{n}}", "txnBegin": "Transaction started", "txnCommit": "Transaction committed", "txnRollback": "Transaction rolled back", @@ -246,7 +254,44 @@ "metaRows": "{{count}} row(s) Β· {{ms}}ms", "metaRowsShort": "{{ms}}ms Β· {{count}} rows", "rowHeader": "Row", - "copyFormatSr": "Copy format" + "copyFormatSr": "Copy format", + "planTitle": "Query plan", + "planAnalyzed": "Measured", + "planEstimated": "Estimated", + "planPlanning": "Planning", + "planExecution": "Execution", + "planTotalCost": "Total cost", + "planNode": "Node", + "planColRows": "Rows", + "planColTime": "Time", + "planColCost": "Cost", + "planHeatBy": "Heat map metric", + "planHeatByMetric": "Heat map by {{metric}}", + "planMetric": { + "time": "Time", + "cost": "Cost", + "rows": "Rows" + }, + "planExpandAll": "Expand all", + "planCollapseAll": "Collapse all", + "planExpandNode": "Expand node", + "planCollapseNode": "Collapse node", + "planRaw": "Raw plan", + "planCopy": "Copy raw plan", + "planNeverRun": "never run", + "planEstimateOffHint": "The measured row count is far from the estimate, so the planner chose this path blind", + "planRowsTitle": "{{actual}} measured, {{planned}} estimated", + "planRowsActual": "Rows (measured)", + "planRowsPlanned": "Rows (estimated)", + "planLoops": "Loops", + "planSelfTime": "Own time", + "planTotalTime": "Total time", + "planSelfCost": "Own cost", + "planNote": { + "noMetrics": "SQLite reports the plan shape only - no cost, row or time estimates", + "rolledBack": "The statement writes, so it ran inside a transaction that was rolled back", + "tabTransaction": "Ran on this tab's open transaction" + } }, "export": { "title": "Export results", @@ -549,6 +594,8 @@ "quickSearch": "Quick search", "runSelection": "Run selection", "runAll": "Run all", + "explainQuery": "Explain query plan", + "explainAnalyze": "Explain query plan (analyze)", "saveQuery": "Save query", "renameSavedQuery": "Rename saved query", "newTab": "New query tab", diff --git a/frontend/src/shared/lib/api.ts b/frontend/src/shared/lib/api.ts index e1bccd5..40156ca 100644 --- a/frontend/src/shared/lib/api.ts +++ b/frontend/src/shared/lib/api.ts @@ -14,6 +14,7 @@ import { DeleteSavedQuery, Disconnect, ExecuteQueryStream, + ExplainQuery, ExportResult, FormatSQL, GetAppInfo, @@ -61,6 +62,7 @@ import type { ConnectionFolder, EditorTab, HistoryEntry, + QueryPlan, QueryResult, SavedQuery, TableDataRequest, @@ -93,6 +95,8 @@ export const api = { normalizeColumns(await ListColumns(connId, schema, table)), executeQueryStream: (connId: string, tabId: string, sql: string): Promise => cast(ExecuteQueryStream(connId, tabId, sql)), + explainQuery: (connId: string, tabId: string, sql: string, analyze: boolean): Promise => + cast(ExplainQuery(connId, tabId, sql, analyze)), queryTableStream: (connId: string, tabId: string, req: TableDataRequest): Promise => cast(QueryTableStream(connId, tabId, req as never)), updateRow: ( diff --git a/frontend/src/shared/lib/shortcuts.test.ts b/frontend/src/shared/lib/shortcuts.test.ts index 6104821..4e1668f 100644 --- a/frontend/src/shared/lib/shortcuts.test.ts +++ b/frontend/src/shared/lib/shortcuts.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { bindingFromKeyboardEvent, bindingKey, matchesBinding } from '@/shared/lib/shortcuts'; +import { APP_SHORTCUTS, bindingFromKeyboardEvent, bindingKey, matchesBinding } from '@/shared/lib/shortcuts'; const ev = (init: Partial) => ({ ctrlKey: false, metaKey: false, shiftKey: false, altKey: false, ...init }) as KeyboardEvent; @@ -67,3 +67,21 @@ describe('bindingFromKeyboardEvent on non-Latin layouts', () => { }); }); }); + +describe('default bindings', () => { + it('are all distinct', () => { + const seen = new Map(); + for (const def of APP_SHORTCUTS) { + const key = bindingKey(def.defaultBinding); + const clash = seen.get(key); + expect(clash, `${def.id} shares its default binding with ${clash}`).toBeUndefined(); + seen.set(key, def.id); + } + }); + + // Windows reports AltGr as Ctrl+Alt, so such a default fires while typing (German AltGr+E is €). + it('never default to a Ctrl+Alt chord', () => { + const altGr = APP_SHORTCUTS.filter((def) => def.defaultBinding.ctrl && def.defaultBinding.alt); + expect(altGr.map((def) => def.id)).toEqual([]); + }); +}); diff --git a/frontend/src/shared/lib/shortcuts.ts b/frontend/src/shared/lib/shortcuts.ts index 1ff590f..2fd7e46 100644 --- a/frontend/src/shared/lib/shortcuts.ts +++ b/frontend/src/shared/lib/shortcuts.ts @@ -36,6 +36,19 @@ export const APP_SHORTCUTS: ShortcutDef[] = [ scope: 'editor', defaultBinding: { key: 'Enter', ctrl: true, shift: true }, }, + { + id: 'explainQuery', + category: 'query', + scope: 'editor', + defaultBinding: { key: 'e', ctrl: true, shift: true }, + }, + { + // Not a Ctrl+Alt chord: Windows reports AltGr as Ctrl+Alt (German AltGr+E types €). + id: 'explainAnalyze', + category: 'query', + scope: 'editor', + defaultBinding: { key: 'a', ctrl: true, shift: true }, + }, { id: 'saveQuery', category: 'query', diff --git a/frontend/src/store/appStore.test.ts b/frontend/src/store/appStore.test.ts index b8595d2..27eab83 100644 --- a/frontend/src/store/appStore.test.ts +++ b/frontend/src/store/appStore.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { type EditorTab, tableViewStateFrom } from '@/types'; +import { type EditorTab, type QueryPlan, tableViewStateFrom } from '@/types'; vi.mock('@/shared/lib/api', () => ({ api: {} })); @@ -72,3 +72,90 @@ describe('appStore table-view tab lifecycle', () => { }); }); }); + +const queryTab = (): EditorTab => ({ + id: 'tab-q', + connectionId: 'conn-1', + title: 'Query 1', + sql: 'SELECT 1', + color: '#4f8cc9', +}); + +const samplePlan = (label: string): QueryPlan => ({ + driver: 'postgres', + statement: 'SELECT 1', + explainSql: 'EXPLAIN (FORMAT JSON) SELECT 1', + analyzed: false, + nodes: [{ label }], + durationMs: 1, + raw: '[]', +}); + +describe('appStore query plans as result sets', () => { + beforeEach(() => { + useAppStore.setState({ tabs: [], activeTabId: null, tabSession: {}, closedTabs: [], runningTabId: null }); + useAppStore.getState().addTab(queryTab()); + }); + + it('finalizes a plan statement as its own result set, carrying no grid', () => { + const store = useAppStore.getState(); + store.startResultSet('tab-q', { + streamId: '1', + resultIndex: 0, + columns: ['id'], + columnTypes: ['INTEGER'], + }); + store.appendResultRows('tab-q', '1', 0, [[1]]); + store.finalizeResultSet('tab-q', '1', 0, null, null, 'SELECT 1', null); + store.finalizeResultSet('tab-q', '1', 1, null, samplePlan('Seq Scan'), 'EXPLAIN SELECT 1', null); + + const session = useAppStore.getState().getTabSession('tab-q'); + expect(session.results).toHaveLength(2); + expect(session.results[0].plan).toBeUndefined(); + expect(session.results[0].result?.rows).toEqual([[1]]); + expect(session.results[1].plan?.nodes[0].label).toBe('Seq Scan'); + expect(session.results[1].result).toBeNull(); + }); + + it('reports a failed explain as an error set, not a plan', () => { + const store = useAppStore.getState(); + store.finalizeResultSet('tab-q', '1', 0, null, null, 'EXPLAIN SELECT 1', 'relation does not exist'); + + const session = useAppStore.getState().getTabSession('tab-q'); + expect(session.results[0].plan).toBeUndefined(); + expect(session.results[0].error).toBe('relation does not exist'); + expect(session.resultError).toBe('relation does not exist'); + }); + + it('showPlan replaces the tab output with the one plan', () => { + const store = useAppStore.getState(); + store.updateTabSession('tab-q', { dataBrowser: { schema: 'main', table: 't' } }); + store.showPlan('tab-q', samplePlan('Nested Loop')); + + const session = useAppStore.getState().getTabSession('tab-q'); + expect(session.results).toHaveLength(1); + expect(session.results[0].plan?.nodes[0].label).toBe('Nested Loop'); + expect(session.activeResultIndex).toBe(0); + // A plan is not table data, so the browse toolbar must go. + expect(session.dataBrowser).toBeNull(); + expect(session.result).toBeNull(); + expect(session.resultError).toBeNull(); + }); + + it('a new run clears a plan from the previous one', () => { + const store = useAppStore.getState(); + store.showPlan('tab-q', samplePlan('Seq Scan')); + store.updateTabSession('tab-q', { result: null, resultError: null, dataBrowser: null }); + + expect(useAppStore.getState().getTabSession('tab-q').results).toEqual([]); + }); + + it('ignores a plan from a superseded run', () => { + const store = useAppStore.getState(); + store.startResultSet('tab-q', { streamId: '2', resultIndex: 0, columns: ['id'], columnTypes: ['INTEGER'] }); + store.finalizeResultSet('tab-q', '1', 0, null, samplePlan('Stale'), 'EXPLAIN SELECT 1', null); + + const session = useAppStore.getState().getTabSession('tab-q'); + expect(session.results[0]?.plan).toBeUndefined(); + }); +}); diff --git a/frontend/src/store/appStore.ts b/frontend/src/store/appStore.ts index ced0b42..e9aa39a 100644 --- a/frontend/src/store/appStore.ts +++ b/frontend/src/store/appStore.ts @@ -7,6 +7,7 @@ import type { EditorTab, HistoryEntry, QueryError, + QueryPlan, QueryResult, ResultSet, SavedQuery, @@ -107,16 +108,19 @@ interface AppState { ) => void; /** Appends streamed rows to one result set; drops events from a superseded run. */ appendResultRows: (tabId: string, streamId: string, resultIndex: number, rows: unknown[][]) => void; - /** Finalizes one result set (result event) with summary metadata or a per-statement error. */ + /** Finalizes one result set: summary metadata, a query plan, or a per-statement error. */ finalizeResultSet: ( tabId: string, streamId: string, resultIndex: number, result: QueryResult | null, + plan: QueryPlan | null, statement: string | null, error: string | null, errorInfo?: QueryError | null, ) => void; + /** Shows a plan as the tab's lone output; the Explain action runs outside a stream. */ + showPlan: (tabId: string, plan: QueryPlan) => void; /** Terminates a run (done event): clears the running indicator and surfaces a batch-level error. */ finishRun: ( tabId: string, @@ -323,7 +327,7 @@ export const useAppStore = create((set, get) => ({ tabSession: { ...s.tabSession, [tabId]: withActiveMirror({ ...session, results }) }, }; }), - finalizeResultSet: (tabId, streamId, resultIndex, result, statement, error, errorInfo) => + finalizeResultSet: (tabId, streamId, resultIndex, result, plan, statement, error, errorInfo) => set((s) => { const tabStillOpen = s.tabs.some((t) => t.id === tabId); const sessionExists = s.tabSession[tabId] != null; @@ -335,6 +339,9 @@ export const useAppStore = create((set, get) => ({ let set_: ResultSet; if (error) { set_ = { result: null, error, errorInfo: errorInfo ?? null, statement: label }; + } else if (plan) { + // A plan statement streamed no rows. + set_ = { result: null, error: null, plan, statement: label }; } else if (existing && existing.streamId === streamId) { // Streamed result set: merge backend metadata; rows stay as-is. set_ = { @@ -396,6 +403,24 @@ export const useAppStore = create((set, get) => ({ runningTabId: nextRunning, }; }), + showPlan: (tabId, plan) => + set((s) => { + const tabStillOpen = s.tabs.some((t) => t.id === tabId); + const sessionExists = s.tabSession[tabId] != null; + if (!tabStillOpen && !sessionExists) return s; + const session = s.tabSession[tabId] ?? emptyTabSession(); + return { + tabSession: { + ...s.tabSession, + [tabId]: withActiveMirror({ + ...session, + results: [{ result: null, error: null, plan }], + activeResultIndex: 0, + dataBrowser: null, + }), + }, + }; + }), setActiveResultIndex: (tabId, index) => set((s) => { const session = s.tabSession[tabId]; diff --git a/frontend/src/store/selectors.ts b/frontend/src/store/selectors.ts index 6cbe78d..872d75d 100644 --- a/frontend/src/store/selectors.ts +++ b/frontend/src/store/selectors.ts @@ -50,6 +50,7 @@ export const useStoreActions = () => closeTab: s.closeTab, reopenClosedTab: s.reopenClosedTab, updateTabSession: s.updateTabSession, + showPlan: s.showPlan, setRunningTab: s.setRunningTab, setHistory: s.setHistory, setSavedQueries: s.setSavedQueries, diff --git a/frontend/src/styles/buttons.css b/frontend/src/styles/buttons.css index 26cd44a..3ce85cd 100644 --- a/frontend/src/styles/buttons.css +++ b/frontend/src/styles/buttons.css @@ -90,3 +90,20 @@ padding: var(--space-3) var(--space-8); font-size: var(--text-xs); } + +/* Split button: the pair reads as one control, so the seam collapses to a single border. */ +.btn-split { + display: inline-flex; +} + +.btn-split-main { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.btn-split-more { + margin-left: calc(-1 * var(--space-1)); + padding-inline: var(--space-4); + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index 72f28f1..4cc5e0c 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -10,6 +10,7 @@ @import "./json-viewer.css"; @import "./editor.css"; @import "./results.css"; +@import "./plan.css"; @import "./table-view.css"; @import "./modal.css"; @import "./quick-search.css"; diff --git a/frontend/src/styles/plan.css b/frontend/src/styles/plan.css new file mode 100644 index 0000000..d2aa4a3 --- /dev/null +++ b/frontend/src/styles/plan.css @@ -0,0 +1,429 @@ +/* Query plan viewer: heat-mapped plan tree, node details, raw engine output. */ + +.result-tab-icon { + flex-shrink: 0; + opacity: 0.75; +} + +.plan-view { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + overflow: hidden; + background: var(--bg-panel); +} + +.plan-toolbar { + display: flex; + align-items: center; + gap: var(--space-8); + flex-shrink: 0; + padding: var(--space-4) var(--space-8); + border-bottom: 1px solid var(--border); + background: var(--bg-panel); + overflow: hidden; +} + +.plan-toolbar-spacer { + flex: 1; + min-width: var(--space-4); +} + +.plan-badge { + flex-shrink: 0; + padding: var(--space-2) var(--space-7); + border: 1px solid var(--border); + border-radius: var(--radius-pill); + font-size: var(--text-2xs); + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; + color: var(--text-muted); + white-space: nowrap; +} + +.plan-badge-analyzed { + color: var(--success); + border-color: color-mix(in srgb, var(--success) 45%, transparent); + background: color-mix(in srgb, var(--success) 12%, transparent); +} + +.plan-summary { + display: flex; + align-items: center; + gap: var(--space-12); + min-width: 0; + overflow: hidden; +} + +.plan-stat { + display: flex; + align-items: baseline; + gap: var(--space-4); + font-size: var(--text-sm); + font-variant-numeric: tabular-nums; + color: var(--text); + white-space: nowrap; +} + +.plan-stat-label { + color: var(--text-muted); + font-size: var(--text-xs); +} + +.plan-metric-switch { + display: flex; + flex-shrink: 0; + border: 1px solid var(--border); + border-radius: var(--radius-md); + overflow: hidden; +} + +.plan-metric-btn { + padding: var(--space-3) var(--space-8); + border: none; + border-right: 1px solid var(--border); + background: transparent; + color: var(--text-muted); + font-size: var(--text-xs); + cursor: pointer; + white-space: nowrap; +} + +.plan-metric-btn:last-child { + border-right: none; +} + +.plan-metric-btn:hover { + color: var(--text); + background: var(--bg-hover); +} + +.plan-metric-btn.active { + color: var(--on-accent); + background: var(--accent); +} + +.plan-icon-btn { + flex-shrink: 0; + padding: var(--space-4) var(--space-6); +} + +.plan-icon-btn.active { + color: var(--accent); + border-color: var(--accent-dim); +} + +.plan-notes { + display: flex; + flex-wrap: wrap; + gap: var(--space-6); + flex-shrink: 0; + padding: var(--space-5) var(--space-10); + border-bottom: 1px solid var(--border); + background: var(--bg-base); +} + +.plan-note { + font-size: var(--text-xs); + color: var(--text-muted); +} + +.plan-note::before { + content: "β€’"; + margin-right: var(--space-5); + color: var(--warning); +} + +.plan-body { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.plan-tree-scroll { + flex: 1; + min-width: 0; + overflow: auto; + scrollbar-width: thin; +} + +.plan-tree { + min-width: min-content; + font-size: var(--text-sm); +} + +/* The tree supplies the template, dropping columns the engine reported nothing for. */ +.plan-head, +.plan-row { + display: grid; + grid-template-columns: var(--plan-cols, minmax(18rem, 1fr) 10rem 10rem 10rem); + align-items: stretch; +} + +.plan-head { + position: sticky; + top: 0; + z-index: 1; + border-bottom: 1px solid var(--border); + background: var(--bg-panel); + font-size: var(--text-xs); + color: var(--text-muted); + user-select: none; +} + +.plan-head-node { + padding: var(--space-5) var(--space-8); +} + +.plan-head-metric { + padding: var(--space-5) var(--space-8); + text-align: right; +} + +.plan-row { + position: relative; + border-bottom: 1px solid color-mix(in srgb, var(--border) 45%, transparent); +} + +.plan-row:hover { + background: var(--bg-hover); +} + +.plan-row-selected { + background: color-mix(in srgb, var(--accent) 14%, transparent); +} + +.plan-row-selected:hover { + background: color-mix(in srgb, var(--accent) 20%, transparent); +} + +/* Width and intensity both track --plan-heat, so cheap nodes stay quiet. */ +.plan-heat { + position: absolute; + inset-block: 0; + inset-inline-start: 0; + width: calc(var(--plan-heat, 0) * 100%); + background: var(--danger); + opacity: calc(var(--plan-heat, 0) * 0.28); + pointer-events: none; +} + +.plan-row-hottest { + box-shadow: inset var(--space-2) 0 0 var(--danger); +} + +.plan-row-never .plan-node-label { + color: var(--text-muted); +} + +.plan-row-main { + position: relative; + display: flex; + align-items: center; + gap: var(--space-2); + min-width: 0; + padding: 0 var(--space-8) 0 var(--space-4); +} + +.plan-twisty { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: var(--space-16); + height: var(--space-16); + padding: 0; + border: none; + border-radius: var(--radius-xs); + background: transparent; + color: var(--text-muted); + cursor: pointer; +} + +.plan-twisty:hover { + color: var(--text); + background: var(--bg-elevated); +} + +.plan-twisty-spacer { + flex-shrink: 0; + width: var(--space-16); +} + +.plan-node-btn { + display: flex; + align-items: center; + gap: var(--space-6); + flex: 1; + min-width: 0; + padding: var(--space-5) var(--space-4); + border: none; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + white-space: nowrap; +} + +.plan-node-label { + flex-shrink: 0; + font-weight: 600; + color: var(--text); +} + +.plan-node-relation, +.plan-node-index { + flex-shrink: 0; + padding: 0 var(--space-5); + border-radius: var(--radius-xs); + font-family: var(--font-mono); + font-size: var(--text-xs); +} + +.plan-node-relation { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 14%, transparent); +} + +.plan-node-index { + color: var(--success); + background: color-mix(in srgb, var(--success) 14%, transparent); +} + +.plan-node-detail { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--text-muted); +} + +.plan-chip { + display: inline-flex; + align-items: center; + gap: var(--space-2); + flex-shrink: 0; + padding: 0 var(--space-5); + border-radius: var(--radius-xs); + font-size: var(--text-2xs); + font-weight: 600; + color: var(--text-muted); + background: var(--bg-elevated); +} + +.plan-chip-warn { + color: var(--warning); + background: color-mix(in srgb, var(--warning) 16%, transparent); +} + +.plan-cell { + display: flex; + align-items: baseline; + justify-content: flex-end; + gap: var(--space-5); + min-width: 0; + padding: var(--space-5) var(--space-8); + overflow: hidden; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* Estimated rows next to measured, inclusive time next to own. */ +.plan-cell-sub { + font-size: var(--text-xs); + color: var(--text-muted); +} + +.plan-cell-sub::before { + content: "/"; + margin-right: var(--space-3); +} + +.plan-details { + flex: 0 0 21rem; + min-width: 0; + overflow: auto; + padding: var(--space-10); + border-left: 1px solid var(--border); + background: var(--bg-base); + scrollbar-width: thin; +} + +.plan-details-head { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-6); + margin-bottom: var(--space-8); +} + +.plan-details-title { + font-size: var(--text-md); + font-weight: 600; +} + +.plan-details-detail { + margin-bottom: var(--space-10); + padding: var(--space-6); + border-radius: var(--radius-sm); + background: var(--bg-panel); + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--text); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.plan-details-grid { + display: grid; + grid-template-columns: minmax(0, auto) minmax(0, 1fr); + gap: var(--space-3) var(--space-8); + margin: 0; + font-size: var(--text-xs); +} + +.plan-details-entry { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; +} + +.plan-details-grid dt { + color: var(--text-muted); + white-space: nowrap; +} + +.plan-details-grid dd { + margin: 0; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} + +.plan-details-fields { + margin-top: var(--space-10); + padding-top: var(--space-10); + border-top: 1px solid var(--border); +} + +.plan-details-fields dd { + font-family: var(--font-mono); +} + +.plan-raw { + flex: 1; + min-height: 0; + margin: 0; + padding: var(--space-10); + overflow: auto; + font-family: var(--font-mono); + font-size: var(--text-xs); + line-height: 1.5; + color: var(--text); + white-space: pre; + scrollbar-width: thin; +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 9278bdf..c1ff55b 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -98,6 +98,49 @@ export interface QueryResult { streaming?: boolean; } +// See database.PlanField. +export interface PlanField { + key: string; + value: string; +} + +// See database.PlanNode. Metrics are optional: a metric the engine never reported renders blank, +// not as 0. +export interface PlanNode { + label: string; + detail?: string; + relation?: string; + index?: string; + costTotal?: number | null; + costSelf?: number | null; + rowsPlanned?: number | null; + /** Totals across every loop, not the per-loop averages Postgres and MariaDB report. */ + rowsActual?: number | null; + loops?: number | null; + timeMs?: number | null; + selfTimeMs?: number | null; + neverRun?: boolean; + fields?: PlanField[]; + children?: PlanNode[]; +} + +// Translated via results.planNote.. +export type PlanNote = 'noMetrics' | 'rolledBack' | 'tabTransaction'; + +export interface QueryPlan { + driver: DriverType; + statement: string; + explainSql: string; + analyzed: boolean; + nodes: PlanNode[]; + totalCost?: number | null; + planningMs?: number | null; + executionMs?: number | null; + durationMs: number; + notes?: string[]; + raw: string; +} + // Structured form of a failed query (see database.QueryError). export interface QueryError { message: string; @@ -133,6 +176,7 @@ export interface QueryStreamRowsPayload { } // Finalizes one result set within a run (result carries metadata only; rows arrived via rows events). +// A plan statement carries plan instead and streamed no rows. export interface QueryStreamResultPayload { seq: number; tabId: string; @@ -140,6 +184,7 @@ export interface QueryStreamResultPayload { connectionId: string; resultIndex: number; result?: QueryResult | null; + plan?: QueryPlan | null; statement?: string; error?: string; errorInfo?: QueryError | null; @@ -204,13 +249,14 @@ export interface TableViewSessionState { export type TxnState = 'idle' | 'active' | 'error'; -// One result set produced by a run: either a grid (result) or a failure (error). A run can produce -// several - multiple statements in a script, or a stored procedure returning more than one set. +// One output of a run: a grid, a query plan or a failure. A run can produce several, each its own tab. export interface ResultSet { result: QueryResult | null; error: string | null; errorInfo?: QueryError | null; statement?: string; + /** Set for a plan statement; the set renders as the plan viewer, not a grid. */ + plan?: QueryPlan | null; } export interface TabSessionState { diff --git a/go.mod b/go.mod index 8856ef5..cac781d 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,8 @@ require ( github.com/go-sql-driver/mysql v1.10.0 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.10.0 - github.com/wailsapp/wails/v3 v3.0.0-beta.3 - golang.org/x/crypto v0.53.0 + github.com/wailsapp/wails/v3 v3.0.0-beta.4 + golang.org/x/crypto v0.54.0 modernc.org/sqlite v1.56.0 ) diff --git a/go.sum b/go.sum index d606031..63a69ef 100644 --- a/go.sum +++ b/go.sum @@ -52,10 +52,10 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/wailsapp/wails/v3 v3.0.0-beta.3 h1:BrcZunEBVucncRx+xgkk9TzlXU4qc0ygJuEhKAAGaeA= -github.com/wailsapp/wails/v3 v3.0.0-beta.3/go.mod h1:BzATbK71VFikMMMCo434wAi0QcaI03P+xeaWgDvQvjw= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +github.com/wailsapp/wails/v3 v3.0.0-beta.4 h1:Kv5ywwZDMB0SgA1zhLM4fImf5ZsGWATTkoAQ3DS/pB8= +github.com/wailsapp/wails/v3 v3.0.0-beta.4/go.mod h1:BzATbK71VFikMMMCo434wAi0QcaI03P+xeaWgDvQvjw= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= @@ -64,8 +64,8 @@ golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= diff --git a/internal/app/app_explain.go b/internal/app/app_explain.go new file mode 100644 index 0000000..da02912 --- /dev/null +++ b/internal/app/app_explain.go @@ -0,0 +1,74 @@ +package app + +import ( + "context" + + "xensql/internal/database" +) + +// ExplainQuery returns a normalized plan for one statement. analyze executes the statement, so a +// write runs inside a transaction that is always rolled back; a tab with an open transaction runs +// there instead, matching where a plain Run would have. +func (a *App) ExplainQuery(connectionID, tabID, sql string, analyze bool) (*database.QueryPlan, error) { + cfg, err := a.getConnection(connectionID) + if err != nil { + return nil, err + } + stmt, err := database.SingleStatement(cfg.Driver, sql) + if err != nil { + return nil, err + } + if cfg.ReadOnly { + if err := database.AssertReadOnlySQLFor(cfg.Driver, stmt); err != nil { + return nil, err + } + } + + _, queryCtx, end := a.queryContext(connectionID) + defer end() + + conn, note, release, err := a.explainConn(queryCtx, tabID, connectionID, cfg.Driver, stmt, analyze) + if err != nil { + return nil, err + } + if release != nil { + defer release() + } + plan, err := database.ExplainPlan(queryCtx, conn, cfg.Driver, stmt, analyze) + if err != nil { + return nil, queryErr(err) + } + if note != "" { + plan.AddNote(note) + } + return plan, nil +} + +// explainConn picks the connection and the note recording which: the tab's transaction, a throwaway +// one when a measured plan would otherwise execute a write, or a plain pinned connection. +func (a *App) explainConn(ctx context.Context, tabID, connectionID string, driver database.DriverType, stmt string, analyze bool) (database.PinnedConn, string, func(), error) { + if txn, ok := a.txns.Get(tabID); ok { + return txn, database.PlanNoteTabTransaction, nil, nil + } + s, err := a.sessionFor(connectionID) + if err != nil { + return nil, "", nil, err + } + if analyze && !database.IsReadOnlySQLFor(driver, stmt) { + txn, err := s.BeginTxn(ctx) + if err != nil { + return nil, "", nil, err + } + release := func() { + // Detached from ctx: a cancelled plan must still roll back before the conn returns to the pool. + _ = txn.Rollback(context.WithoutCancel(ctx)) + txn.Close() + } + return txn, database.PlanNoteRolledBack, release, nil + } + pc, err := s.PinnedConn(ctx) + if err != nil { + return nil, "", nil, err + } + return pc, "", pc.Close, nil +} diff --git a/internal/app/app_explain_test.go b/internal/app/app_explain_test.go new file mode 100644 index 0000000..a12e417 --- /dev/null +++ b/internal/app/app_explain_test.go @@ -0,0 +1,132 @@ +package app + +import ( + "strings" + "testing" + + "xensql/internal/database" +) + +func explainTestConn(t *testing.T, readOnly bool) (*App, string) { + t.Helper() + a := appForTest(t) + cfg := sqliteConn(t) + saved, err := a.SaveConnection(cfg) + if err != nil { + t.Fatalf("save: %v", err) + } + for _, stmt := range []string{ + "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT, age INTEGER)", + "CREATE INDEX users_email ON users (email)", + "INSERT INTO users (email, age) VALUES ('a@example.com', 30), ('b@example.com', 41)", + } { + if _, err := a.ExecuteQuery(saved.ID, stmt); err != nil { + t.Fatalf("setup %q: %v", stmt, err) + } + } + if readOnly { + cfg = saved + cfg.ReadOnly = true + if _, err := a.SaveConnection(cfg); err != nil { + t.Fatalf("mark read-only: %v", err) + } + a.Disconnect(saved.ID) + } + return a, saved.ID +} + +func TestExplainQuerySQLite(t *testing.T) { + a, connID := explainTestConn(t, false) + + plan, err := a.ExplainQuery(connID, "", "SELECT * FROM users WHERE email = 'a@example.com'", false) + if err != nil { + t.Fatalf("explain: %v", err) + } + if plan.Driver != database.DriverSQLite { + t.Errorf("driver = %q", plan.Driver) + } + if plan.Analyzed { + t.Error("plan-only run must not report itself as analyzed") + } + if plan.ExplainSQL != "EXPLAIN QUERY PLAN SELECT * FROM users WHERE email = 'a@example.com'" { + t.Errorf("explain sql = %q", plan.ExplainSQL) + } + if len(plan.Nodes) == 0 { + t.Fatal("expected at least one plan node") + } + root := plan.Nodes[0] + if root.Label != "SEARCH" || root.Relation != "users" || root.Index != "users_email" { + t.Errorf("root = %+v, expected a SEARCH of users using users_email", root) + } + if plan.Raw == "" { + t.Error("expected the engine's raw output to be kept") + } +} + +func TestExplainQuerySQLiteFullScan(t *testing.T) { + a, connID := explainTestConn(t, false) + + plan, err := a.ExplainQuery(connID, "", "SELECT * FROM users WHERE age > 20", false) + if err != nil { + t.Fatalf("explain: %v", err) + } + if root := plan.Nodes[0]; root.Label != "SCAN" || root.Relation != "users" { + t.Errorf("root = %+v, expected a SCAN of users", root) + } +} + +func TestExplainQueryAnalyzeUnsupportedOnSQLite(t *testing.T) { + a, connID := explainTestConn(t, false) + + _, err := a.ExplainQuery(connID, "", "SELECT * FROM users", true) + if err == nil { + t.Fatal("expected an error: SQLite has no EXPLAIN ANALYZE") + } + if !strings.Contains(err.Error(), "EXPLAIN ANALYZE") { + t.Errorf("error should name the missing feature, got %v", err) + } +} + +func TestExplainQueryRejectsMultipleStatements(t *testing.T) { + a, connID := explainTestConn(t, false) + + if _, err := a.ExplainQuery(connID, "", "SELECT 1; SELECT 2", false); err == nil { + t.Fatal("expected an error for more than one statement") + } + if _, err := a.ExplainQuery(connID, "", " ", false); err == nil { + t.Fatal("expected an error for an empty statement") + } +} + +// A read-only connection blocks planning a write, as it blocks running one. +func TestExplainQueryReadOnlyBlocksWrites(t *testing.T) { + a, connID := explainTestConn(t, true) + + if _, err := a.ExplainQuery(connID, "", "DELETE FROM users", false); err == nil { + t.Fatal("expected a read-only error") + } + if _, err := a.ExplainQuery(connID, "", "SELECT * FROM users", false); err != nil { + t.Fatalf("planning a read on a read-only connection should work: %v", err) + } +} + +func TestExplainQuerySkipsHistory(t *testing.T) { + a, connID := explainTestConn(t, false) + if err := a.ClearQueryHistory(connID); err != nil { + t.Fatalf("clear history: %v", err) + } + + if _, err := a.ExplainQuery(connID, "", "SELECT * FROM users", false); err != nil { + t.Fatalf("explain: %v", err) + } + if entries := a.GetQueryHistory(connID, 10); len(entries) != 0 { + t.Errorf("expected no history entries, got %v", entries) + } +} + +func TestExplainQueryUnknownConnection(t *testing.T) { + a := appForTest(t) + if _, err := a.ExplainQuery("nope", "", "SELECT 1", false); err == nil { + t.Fatal("expected an error for an unknown connection") + } +} diff --git a/internal/app/app_query.go b/internal/app/app_query.go index e3312bf..08cc100 100644 --- a/internal/app/app_query.go +++ b/internal/app/app_query.go @@ -52,7 +52,8 @@ type QueryStreamRowsEvent struct { } // QueryStreamResultEvent finalizes one result set within a run. Result carries metadata only; rows -// were delivered via stream batches. Statement is the SQL that produced it (for labeling). +// were delivered via stream batches. Statement is the SQL that produced it (for labeling). A plan +// statement carries Plan instead, and delivered no rows. type QueryStreamResultEvent struct { Seq int `json:"seq"` TabID string `json:"tabId"` @@ -60,6 +61,7 @@ type QueryStreamResultEvent struct { ConnectionID string `json:"connectionId"` ResultIndex int `json:"resultIndex"` Result *database.QueryResult `json:"result,omitempty"` + Plan *database.QueryPlan `json:"plan,omitempty"` Statement string `json:"statement,omitempty"` Error string `json:"error,omitempty"` ErrorInfo *database.QueryError `json:"errorInfo,omitempty"` @@ -120,7 +122,7 @@ func (e *streamEmitter) rows(resultIndex int, rows [][]any) { }) } -func (e *streamEmitter) result(resultIndex int, result *database.QueryResult, statement string, err error) { +func (e *streamEmitter) result(resultIndex int, result *database.QueryResult, plan *database.QueryPlan, statement string, err error) { payload := QueryStreamResultEvent{ Seq: e.nextSeq(), TabID: e.tabID, @@ -128,6 +130,7 @@ func (e *streamEmitter) result(resultIndex int, result *database.QueryResult, st ConnectionID: e.connectionID, ResultIndex: resultIndex, Result: result, + Plan: plan, Statement: statement, } if err != nil { @@ -215,11 +218,11 @@ func (a *App) runBatchStream(tabID, connectionID string, statements []string) { em.rows(idx, rows) return nil }, - OnResult: func(idx int, summary *database.QueryResult, statement string, err error) { + OnResult: func(idx int, summary *database.QueryResult, plan *database.QueryPlan, statement string, err error) { resultCount = idx + 1 err = queryErr(err) hist = append(hist, histEntry{statement, summary, err}) - em.result(idx, summary, statement, err) + em.result(idx, summary, plan, statement, err) }, } // Per-statement errors are reported via the result event above, so the terminal done carries @@ -290,7 +293,7 @@ func (a *App) QueryTableStream(connectionID, tabID string, req database.TableDat } // A table browse is always a single result set (index 0). result, err := s.QueryTableStream(queryCtx, req, opts) - em.result(0, result, "", queryErr(err)) + em.result(0, result, nil, "", queryErr(err)) em.done(1, nil) }) return nil diff --git a/internal/app/e2e_explain_test.go b/internal/app/e2e_explain_test.go new file mode 100644 index 0000000..de61c2d --- /dev/null +++ b/internal/app/e2e_explain_test.go @@ -0,0 +1,321 @@ +//go:build e2e + +package app + +import ( + "fmt" + "regexp" + "strings" + "testing" + + "xensql/internal/database" +) + +// Word-bounded, so the fixture's own explain_* table name isn't mistaken for the keyword. +var explainKeywordRe = regexp.MustCompile(`(?i)\bexplain\b`) + +// An indexed table with rows, so the planner has a choice to make. +func explainFixture(t *testing.T, a *App, e engine, connID string) string { + t.Helper() + table := uniqueTable("explain") + createTempTable(t, a, e, connID, e.autoPKTable(table), table) + mustExec(t, a, connID, fmt.Sprintf("CREATE INDEX %s_name ON %s (name)", + strings.ReplaceAll(table, ".", "_"), qualified(e, table))) + for _, name := range []string{"alpha", "beta", "gamma", "delta"} { + mustExec(t, a, connID, fmt.Sprintf("INSERT INTO %s (name) VALUES ('%s')", qualified(e, table), name)) + } + return table +} + +func countNodes(nodes []database.PlanNode) int { + total := 0 + for _, n := range nodes { + total += 1 + countNodes(n.Children) + } + return total +} + +func findNode(nodes []database.PlanNode, match func(database.PlanNode) bool) (database.PlanNode, bool) { + for _, n := range nodes { + if match(n) { + return n, true + } + if found, ok := findNode(n.Children, match); ok { + return found, true + } + } + return database.PlanNode{}, false +} + +func anyNode(nodes []database.PlanNode, match func(database.PlanNode) bool) bool { + _, ok := findNode(nodes, match) + return ok +} + +func TestE2EExplainPlanOnly(t *testing.T) { + forEachEngine(t, func(t *testing.T, a *App, e engine, connID string) { + table := explainFixture(t, a, e, connID) + + plan, err := a.ExplainQuery(connID, "", "SELECT * FROM "+qualified(e, table)+" WHERE name = 'beta'", false) + if err != nil { + t.Fatalf("explain: %v", err) + } + if plan.Analyzed { + t.Error("a plan-only run must not report itself as analyzed") + } + if len(plan.Nodes) == 0 { + t.Fatal("expected at least one plan node") + } + if plan.Raw == "" { + t.Error("expected the engine's raw output to be kept") + } + if !anyNode(plan.Nodes, func(n database.PlanNode) bool { return n.CostTotal != nil }) { + t.Errorf("no node carried a cost estimate: %+v", plan.Nodes) + } + if !anyNode(plan.Nodes, func(n database.PlanNode) bool { return n.RowsPlanned != nil }) { + t.Errorf("no node carried a row estimate: %+v", plan.Nodes) + } + // Nothing ran. + if anyNode(plan.Nodes, func(n database.PlanNode) bool { return n.RowsActual != nil || n.TimeMs != nil }) { + t.Error("a plan-only run reported measured values") + } + if len(plan.Notes) != 0 { + t.Errorf("expected no notes, got %v", plan.Notes) + } + }) +} + +func TestE2EExplainAnalyzeMeasures(t *testing.T) { + forEachEngine(t, func(t *testing.T, a *App, e engine, connID string) { + table := explainFixture(t, a, e, connID) + + plan, err := a.ExplainQuery(connID, "", "SELECT * FROM "+qualified(e, table), true) + if err != nil { + t.Fatalf("explain analyze: %v", err) + } + if !plan.Analyzed { + t.Error("expected the plan to report itself as analyzed") + } + if !anyNode(plan.Nodes, func(n database.PlanNode) bool { return n.RowsActual != nil }) { + t.Errorf("no node carried measured rows: %+v", plan.Nodes) + } + if !anyNode(plan.Nodes, func(n database.PlanNode) bool { return n.TimeMs != nil }) { + t.Errorf("no node carried a measured time: %+v", plan.Nodes) + } + // Self time drives the heat map, so it must be derivable everywhere. + if !anyNode(plan.Nodes, func(n database.PlanNode) bool { return n.SelfTimeMs != nil }) { + t.Errorf("no node carried a self time: %+v", plan.Nodes) + } + }) +} + +func TestE2EExplainAnalyzeRowCountsAreTotals(t *testing.T) { + forEachEngine(t, func(t *testing.T, a *App, e engine, connID string) { + table := explainFixture(t, a, e, connID) + + plan, err := a.ExplainQuery(connID, "", "SELECT * FROM "+qualified(e, table), true) + if err != nil { + t.Fatalf("explain analyze: %v", err) + } + if !anyNode(plan.Nodes, func(n database.PlanNode) bool { return n.RowsActual != nil && *n.RowsActual == 4 }) { + t.Errorf("expected a node reporting the 4 rows scanned: %s", planSummary(plan)) + } + }) +} + +func TestE2EExplainIndexIsNamed(t *testing.T) { + forEachEngine(t, func(t *testing.T, a *App, e engine, connID string) { + table := explainFixture(t, a, e, connID) + plan, err := a.ExplainQuery(connID, "", "SELECT * FROM "+qualified(e, table)+" WHERE id = 1", false) + if err != nil { + t.Fatalf("explain: %v", err) + } + if !anyNode(plan.Nodes, func(n database.PlanNode) bool { return n.Index != "" }) { + t.Errorf("expected some node to name the index it used: %s", planSummary(plan)) + } + }) +} + +func TestE2EExplainAnalyzeRollsBackWrites(t *testing.T) { + forEachEngine(t, func(t *testing.T, a *App, e engine, connID string) { + table := explainFixture(t, a, e, connID) + + plan, err := a.ExplainQuery(connID, "", "DELETE FROM "+qualified(e, table), true) + if err != nil { + t.Fatalf("explain analyze delete: %v", err) + } + if !planHasNote(plan, database.PlanNoteRolledBack) { + t.Errorf("expected the rolled-back note, got %v", plan.Notes) + } + + result, err := a.ExecuteQuery(connID, "SELECT COUNT(*) FROM "+qualified(e, table)) + if err != nil { + t.Fatalf("count: %v", err) + } + if got := countValue(t, result); got != 4 { + t.Fatalf("EXPLAIN ANALYZE of a DELETE left %d of 4 rows behind", got) + } + }) +} + +func TestE2EExplainWriteWithoutAnalyzeKeepsRows(t *testing.T) { + forEachEngine(t, func(t *testing.T, a *App, e engine, connID string) { + table := explainFixture(t, a, e, connID) + + if _, err := a.ExplainQuery(connID, "", "DELETE FROM "+qualified(e, table), false); err != nil { + t.Fatalf("explain delete: %v", err) + } + result, err := a.ExecuteQuery(connID, "SELECT COUNT(*) FROM "+qualified(e, table)) + if err != nil { + t.Fatalf("count: %v", err) + } + if got := countValue(t, result); got != 4 { + t.Fatalf("EXPLAIN of a DELETE left %d of 4 rows behind", got) + } + }) +} + +func TestE2EExplainOfAnExplain(t *testing.T) { + forEachEngine(t, func(t *testing.T, a *App, e engine, connID string) { + table := explainFixture(t, a, e, connID) + + plan, err := a.ExplainQuery(connID, "", "EXPLAIN SELECT * FROM "+qualified(e, table), false) + if err != nil { + t.Fatalf("explain of an explain: %v", err) + } + if len(plan.Nodes) == 0 { + t.Fatal("expected a plan for the underlying statement") + } + if got := len(explainKeywordRe.FindAllString(plan.ExplainSQL, -1)); got != 1 { + t.Errorf("explain sql nests %d EXPLAINs: %q", got, plan.ExplainSQL) + } + }) +} + +func TestE2EExplainReportsSyntaxErrors(t *testing.T) { + forEachEngine(t, func(t *testing.T, a *App, e engine, connID string) { + if _, err := a.ExplainQuery(connID, "", "SELECT * FROM table_that_is_not_there_xensql", false); err == nil { + t.Fatal("expected an error for a missing table") + } + }) +} + +func TestE2EExplainPostgresTimings(t *testing.T) { + a := appForTest(t) + e := pgEngine() + connID := requireEngine(t, a, e) + table := explainFixture(t, a, e, connID) + + plan, err := a.ExplainQuery(connID, "", "SELECT * FROM "+qualified(e, table), true) + if err != nil { + t.Fatalf("explain analyze: %v", err) + } + if plan.PlanningMs == nil || *plan.PlanningMs <= 0 { + t.Errorf("planning time = %v", plan.PlanningMs) + } + if plan.ExecutionMs == nil || *plan.ExecutionMs <= 0 { + t.Errorf("execution time = %v", plan.ExecutionMs) + } + if plan.TotalCost == nil { + t.Error("expected a total cost") + } +} + +func TestE2EExplainTreeHasDepth(t *testing.T) { + forEachEngine(t, func(t *testing.T, a *App, e engine, connID string) { + table := explainFixture(t, a, e, connID) + ref := qualified(e, table) + query := fmt.Sprintf( + "SELECT a.name FROM %s a JOIN %s b ON a.id = b.id JOIN %s c ON b.id = c.id ORDER BY a.name", + ref, ref, ref) + + plan, err := a.ExplainQuery(connID, "", query, false) + if err != nil { + t.Fatalf("explain join: %v", err) + } + if got := countNodes(plan.Nodes); got < 3 { + t.Errorf("expected a multi-node tree for a 3-way join, got %d: %s", got, planSummary(plan)) + } + if !anyNode(plan.Nodes, func(n database.PlanNode) bool { return len(n.Children) > 0 }) { + t.Errorf("plan tree is flat: %s", planSummary(plan)) + } + }) +} + +func TestE2EExplainReadOnlyConnection(t *testing.T) { + forEachEngine(t, func(t *testing.T, a *App, e engine, connID string) { + table := explainFixture(t, a, e, connID) + + cfg, err := a.getConnection(connID) + if err != nil { + t.Fatalf("get connection: %v", err) + } + cfg.ReadOnly = true + if _, err := a.SaveConnection(cfg); err != nil { + t.Fatalf("mark read-only: %v", err) + } + a.Disconnect(connID) + + if _, err := a.ExplainQuery(connID, "", "SELECT * FROM "+qualified(e, table), false); err != nil { + t.Fatalf("planning a read on a read-only connection should work: %v", err) + } + if _, err := a.ExplainQuery(connID, "", "DELETE FROM "+qualified(e, table), true); err == nil { + t.Fatal("expected a read-only error for a measured plan of a delete") + } + }) +} + +func planHasNote(plan *database.QueryPlan, code string) bool { + for _, n := range plan.Notes { + if n == code { + return true + } + } + return false +} + +func planSummary(plan *database.QueryPlan) string { + var b strings.Builder + fmt.Fprintf(&b, "\n%s\n", plan.ExplainSQL) + writePlanNodes(&b, plan.Nodes, 0) + return b.String() +} + +func writePlanNodes(b *strings.Builder, nodes []database.PlanNode, depth int) { + for _, n := range nodes { + fmt.Fprintf(b, "%s%s relation=%q index=%q cost=%s rows=%s actual=%s time=%s\n", + strings.Repeat(" ", depth), n.Label, n.Relation, n.Index, + floatOrDash(n.CostTotal), floatOrDash(n.RowsPlanned), floatOrDash(n.RowsActual), floatOrDash(n.TimeMs)) + writePlanNodes(b, n.Children, depth+1) + } +} + +func floatOrDash(v *float64) string { + if v == nil { + return "-" + } + return fmt.Sprintf("%g", *v) +} + +// Drivers hand a COUNT(*) back as int64, or as a string when it would lose precision in JS. +func countValue(t *testing.T, result *database.QueryResult) int64 { + t.Helper() + if result == nil || len(result.Rows) == 0 || len(result.Rows[0]) == 0 { + t.Fatal("count returned no rows") + } + switch v := result.Rows[0][0].(type) { + case int64: + return v + case float64: + return int64(v) + case string: + var n int64 + if _, err := fmt.Sscanf(v, "%d", &n); err != nil { + t.Fatalf("count value %q: %v", v, err) + } + return n + default: + t.Fatalf("unexpected count type %T", v) + return 0 + } +} diff --git a/internal/app/e2e_stream_test.go b/internal/app/e2e_stream_test.go index c6e00df..5611b23 100644 --- a/internal/app/e2e_stream_test.go +++ b/internal/app/e2e_stream_test.go @@ -145,7 +145,7 @@ func (c *scriptCapture) sink(batchSize int) database.ScriptSink { c.rows[idx] = append(c.rows[idx], rows...) return nil }, - OnResult: func(idx int, _ *database.QueryResult, _ string, err error) { + OnResult: func(idx int, _ *database.QueryResult, _ *database.QueryPlan, _ string, err error) { c.mu.Lock() defer c.mu.Unlock() c.results = append(c.results, scriptResult{idx, err}) diff --git a/internal/database/exec.go b/internal/database/exec.go index 3dab596..98f43cd 100644 --- a/internal/database/exec.go +++ b/internal/database/exec.go @@ -69,11 +69,13 @@ func streamQueryRows(ctx context.Context, conn *sql.Conn, query string, startMs // set fires OnMeta once, then OnBatch zero or more times; every set ends with OnResult carrying its // summary (columns/counts/message) and any error. resultIndex is global across all statements in // the script, so one statement that yields several result sets advances it several times. +// +// A statement asking for a query plan delivers no rows: its set is a single OnResult carrying plan. type ScriptSink struct { BatchSize int OnMeta func(resultIndex int, columns, columnTypes []string) OnBatch func(resultIndex int, rows [][]any) error - OnResult func(resultIndex int, summary *QueryResult, statement string, err error) + OnResult func(resultIndex int, summary *QueryResult, plan *QueryPlan, statement string, err error) } // RunScript executes statements in order on a single connection, streaming every result set @@ -90,6 +92,9 @@ func RunScript(ctx context.Context, conn *sql.Conn, driver DriverType, statement } func runScriptStatement(ctx context.Context, conn *sql.Conn, driver DriverType, stmt string, resultIndex *int, sink ScriptSink) error { + if req, ok := DetectPlanRequest(driver, stmt); ok { + return runScriptPlan(ctx, conn, driver, stmt, req, resultIndex, sink) + } start := NowMs() upper := strings.ToUpper(StripLeadingComments(stmt)) if statementReturnsRows(driver, upper) { @@ -99,13 +104,58 @@ func runScriptStatement(ctx context.Context, conn *sql.Conn, driver DriverType, idx := *resultIndex *resultIndex++ if err != nil { - sink.OnResult(idx, nil, stmt, err) + sink.OnResult(idx, nil, nil, stmt, err) + return err + } + sink.OnResult(idx, execSummary(res, start), nil, stmt, nil) + return nil +} + +// runScriptPlan delivers a normalized plan in place of rows. Output the parsers don't recognize +// falls back to the engine's rows rather than erroring. +func runScriptPlan(ctx context.Context, conn *sql.Conn, driver DriverType, stmt string, req PlanRequest, resultIndex *int, sink ScriptSink) error { + start := NowMs() + idx := *resultIndex + *resultIndex++ + + res, err := bufferedQuery(ctx, conn, req.SQL) + if err != nil { + sink.OnResult(idx, nil, nil, stmt, err) return err } - sink.OnResult(idx, execSummary(res, start), stmt, nil) + plan, parseErr := ParsePlan(driver, stmt, req.SQL, req.Analyze, res) + if parseErr != nil { + if sink.OnMeta != nil { + sink.OnMeta(idx, res.Columns, res.ColumnTypes) + } + if sink.OnBatch != nil && len(res.Rows) > 0 { + if err := sink.OnBatch(idx, res.Rows); err != nil { + sink.OnResult(idx, res, nil, stmt, err) + return err + } + } + sink.OnResult(idx, res, nil, stmt, nil) + return nil + } + plan.DurationMs = NowMs() - start + sink.OnResult(idx, nil, plan, stmt, nil) return nil } +// Plan output is always small enough to buffer. +func bufferedQuery(ctx context.Context, conn *sql.Conn, query string) (*QueryResult, error) { + var rows [][]any + opts := StreamOpts{OnBatch: func(batch [][]any) error { + rows = append(rows, batch...) + return nil + }} + result, err := streamQueryRows(ctx, conn, query, NowMs(), opts, &QueryResult{}) + if result != nil { + result.Rows = rows + } + return result, err +} + // streamResultSets runs a row-returning statement and streams each of its result sets (the first // plus any from NextResultSet) to sink, one OnResult per set. func streamResultSets(ctx context.Context, conn *sql.Conn, query string, start int64, resultIndex *int, sink ScriptSink) error { @@ -113,7 +163,7 @@ func streamResultSets(ctx context.Context, conn *sql.Conn, query string, start i if err != nil { idx := *resultIndex *resultIndex++ - sink.OnResult(idx, nil, query, err) + sink.OnResult(idx, nil, nil, query, err) return err } defer rows.Close() @@ -124,10 +174,10 @@ func streamResultSets(ctx context.Context, conn *sql.Conn, query string, start i summary, scanErr := streamOneResultSet(ctx, rows, sink.BatchSize, idx, sink) summary.DurationMs = NowMs() - start if scanErr != nil { - sink.OnResult(idx, summary, query, scanErr) + sink.OnResult(idx, summary, nil, query, scanErr) return scanErr } - sink.OnResult(idx, summary, query, nil) + sink.OnResult(idx, summary, nil, query, nil) if !rows.NextResultSet() { break } @@ -164,6 +214,10 @@ func statementReturnsRows(driver DriverType, upper string) bool { if IsSelectLike(driver, upper) || hasReturningClause(upper) { return true } + // MySQL ANALYZE always answers with rows: a status table, or MariaDB's measured plan. + if driver == DriverMySQL && strings.HasPrefix(upper, "ANALYZE ") { + return true + } return strings.HasPrefix(upper, "CALL") || strings.HasPrefix(upper, "EXEC") || strings.HasPrefix(upper, "VALUES") || diff --git a/internal/database/explain.go b/internal/database/explain.go new file mode 100644 index 0000000..e22fd46 --- /dev/null +++ b/internal/database/explain.go @@ -0,0 +1,412 @@ +package database + +import ( + "context" + "errors" + "fmt" + "regexp" + "strconv" + "strings" +) + +type PlanField struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// PlanNode is one operation in a normalized plan tree. +type PlanNode struct { + Label string `json:"label"` + Detail string `json:"detail,omitempty"` + Relation string `json:"relation,omitempty"` + Index string `json:"index,omitempty"` + CostTotal *float64 `json:"costTotal,omitempty"` + CostSelf *float64 `json:"costSelf,omitempty"` + RowsPlanned *float64 `json:"rowsPlanned,omitempty"` + // Totals across every loop, not the per-loop averages Postgres and MariaDB report. + RowsActual *float64 `json:"rowsActual,omitempty"` + Loops *float64 `json:"loops,omitempty"` + TimeMs *float64 `json:"timeMs,omitempty"` + SelfTimeMs *float64 `json:"selfTimeMs,omitempty"` + NeverRun bool `json:"neverRun,omitempty"` + Fields []PlanField `json:"fields,omitempty"` + Children []PlanNode `json:"children,omitempty"` +} + +// Note codes the frontend translates. +const ( + PlanNoteNoMetrics = "noMetrics" + PlanNoteRolledBack = "rolledBack" + PlanNoteTabTransaction = "tabTransaction" +) + +type QueryPlan struct { + Driver DriverType `json:"driver"` + Statement string `json:"statement"` + ExplainSQL string `json:"explainSql"` + Analyzed bool `json:"analyzed"` + Nodes []PlanNode `json:"nodes"` + TotalCost *float64 `json:"totalCost,omitempty"` + PlanningMs *float64 `json:"planningMs,omitempty"` + ExecutionMs *float64 `json:"executionMs,omitempty"` + DurationMs int64 `json:"durationMs"` + Notes []string `json:"notes,omitempty"` + Raw string `json:"raw"` +} + +func (p *QueryPlan) AddNote(code string) { + if p.HasNote(code) { + return + } + p.Notes = append(p.Notes, code) +} + +func (p *QueryPlan) HasNote(code string) bool { + for _, existing := range p.Notes { + if existing == code { + return true + } + } + return false +} + +// ServerVersion picks the EXPLAIN syntax: MariaDB and MySQL disagree on how to ask for a +// measured plan, and MySQL only learned to at all in 8.0.18. +type ServerVersion struct { + MariaDB bool + Major int + Minor int + Patch int +} + +func (v ServerVersion) atLeast(major, minor, patch int) bool { + if v.Major != major { + return v.Major > major + } + if v.Minor != minor { + return v.Minor > minor + } + return v.Patch >= patch +} + +func (v ServerVersion) String() string { + return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) +} + +var ( + versionNumberRe = regexp.MustCompile(`(\d+)\.(\d+)(?:\.(\d+))?`) + // Old MariaDB builds prefix "5.5.5-" so clients gating on 5.x keep working. + mariaCompatPrefix = "5.5.5-" +) + +func ParseServerVersion(raw string) ServerVersion { + raw = strings.TrimSpace(raw) + v := ServerVersion{MariaDB: strings.Contains(strings.ToLower(raw), "mariadb")} + if v.MariaDB { + raw = strings.TrimPrefix(raw, mariaCompatPrefix) + } + m := versionNumberRe.FindStringSubmatch(raw) + if m == nil { + return v + } + v.Major, _ = strconv.Atoi(m[1]) + v.Minor, _ = strconv.Atoi(m[2]) + v.Patch, _ = strconv.Atoi(m[3]) + return v +} + +func SingleStatement(driver DriverType, sql string) (string, error) { + stmts := SplitStatements(driver, sql) + switch len(stmts) { + case 0: + return "", errors.New("no statement to explain") + case 1: + return stmts[0], nil + default: + return "", errors.New("select a single statement to explain") + } +} + +var ( + pgExplainPrefixRe = regexp.MustCompile(`(?is)^explain\s*(?:\(.*?\)\s*)?(?:analyze\s+)?(?:verbose\s+)?`) + mysqlExplainPrefixRe = regexp.MustCompile(`(?is)^(?:explain|analyze)\s+(?:analyze\s+)?(?:format\s*=\s*\w+\s+)?`) + sqliteExplainPrefixRe = regexp.MustCompile(`(?is)^explain\s+(?:query\s+plan\s+)?`) +) + +// Explaining a plan query plans the underlying statement instead of nesting the EXPLAIN. +func stripExplainPrefix(driver DriverType, stmt string) string { + var re *regexp.Regexp + switch driver { + case DriverPostgres: + re = pgExplainPrefixRe + case DriverMySQL: + re = mysqlExplainPrefixRe + case DriverSQLite: + re = sqliteExplainPrefixRe + default: + return stmt + } + prefix := re.FindString(stmt) + if prefix == "" { + return stmt + } + rest := strings.TrimSpace(stmt[len(prefix):]) + if !explainsAStatement(strings.ToUpper(strings.TrimSpace(prefix)), rest) { + return stmt + } + return rest +} + +func isExplainableStart(stmt string) bool { + switch firstKeyword(stmt) { + case "SELECT", "WITH", "INSERT", "UPDATE", "DELETE", "REPLACE", "MERGE", "VALUES", "TABLE": + return true + } + return false +} + +// `ANALYZE TABLE t` is MySQL's statistics command, not a plan of a TABLE statement. +func explainsAStatement(keyword, rest string) bool { + if !isExplainableStart(rest) { + return false + } + return !(keyword == "ANALYZE" && firstKeyword(rest) == "TABLE") +} + +type PlanRequest struct { + SQL string + Analyze bool +} + +type explainIntent struct { + analyze bool + format string + inner string + // SQLite's bare EXPLAIN lists bytecode instead, with nothing in common with a plan. + queryPlan bool +} + +var ( + pgExplainHeadRe = regexp.MustCompile(`(?is)^explain\s*(?:\(([^)]*)\)\s*)?((?:(?:analyze|verbose)\s+)*)`) + pgAnalyzeOptRe = regexp.MustCompile(`(?is)\banalyze\b(?:\s+(\w+))?`) + pgFormatOptRe = regexp.MustCompile(`(?is)\bformat\s+(\w+)`) + mysqlExplainHeadRe = regexp.MustCompile(`(?is)^(explain|analyze)\s+(analyze\s+)?(?:format\s*=\s*(\w+)\s+)?`) + sqliteExplainHeadRe = regexp.MustCompile(`(?is)^explain\s+(query\s+plan\s+)?`) +) + +func isFalsey(word string) bool { + switch strings.ToLower(word) { + case "false", "off", "0": + return true + } + return false +} + +func parseExplainIntent(driver DriverType, stmt string) (explainIntent, bool) { + switch driver { + case DriverPostgres: + m := pgExplainHeadRe.FindStringSubmatch(stmt) + if m == nil { + return explainIntent{}, false + } + intent := explainIntent{inner: strings.TrimSpace(stmt[len(m[0]):])} + if options := m[1]; options != "" { + if am := pgAnalyzeOptRe.FindStringSubmatch(options); am != nil { + intent.analyze = !isFalsey(am[1]) + } + if fm := pgFormatOptRe.FindStringSubmatch(options); fm != nil { + intent.format = strings.ToLower(fm[1]) + } + } + // Legacy syntax has no parentheses: EXPLAIN ANALYZE VERBOSE . + if strings.Contains(strings.ToLower(m[2]), "analyze") { + intent.analyze = true + } + return intent, true + case DriverMySQL: + m := mysqlExplainHeadRe.FindStringSubmatch(stmt) + if m == nil { + return explainIntent{}, false + } + return explainIntent{ + analyze: strings.EqualFold(m[1], "analyze") || m[2] != "", + format: strings.ToLower(m[3]), + inner: strings.TrimSpace(stmt[len(m[0]):]), + }, true + case DriverSQLite: + m := sqliteExplainHeadRe.FindStringSubmatch(stmt) + if m == nil { + return explainIntent{}, false + } + return explainIntent{inner: strings.TrimSpace(stmt[len(m[0]):]), queryPlan: m[1] != ""}, true + default: + return explainIntent{}, false + } +} + +// DetectPlanRequest reports whether a typed statement asks for a plan, and which EXPLAIN to run. +// Output that already carries structure (any FORMAT JSON, MySQL's EXPLAIN ANALYZE tree, SQLite's +// EXPLAIN QUERY PLAN) runs as typed; Postgres text and MySQL's traditional table are asked again +// in JSON. ok is false where the user wants raw output: a named text/XML/YAML/traditional format, +// or SQLite's bytecode EXPLAIN. +func DetectPlanRequest(driver DriverType, stmt string) (PlanRequest, bool) { + stmt = strings.TrimSpace(StripLeadingComments(stmt)) + first := firstKeyword(stmt) + if first != "EXPLAIN" && !(driver == DriverMySQL && first == "ANALYZE") { + return PlanRequest{}, false + } + intent, ok := parseExplainIntent(driver, stmt) + if !ok || !explainsAStatement(first, intent.inner) { + return PlanRequest{}, false + } + + switch driver { + case DriverPostgres: + switch intent.format { + case "json": + return PlanRequest{SQL: stmt, Analyze: intent.analyze}, true + case "": + sql, err := BuildExplainSQL(driver, ServerVersion{}, intent.inner, intent.analyze) + if err != nil { + return PlanRequest{}, false + } + return PlanRequest{SQL: sql, Analyze: intent.analyze}, true + default: + return PlanRequest{}, false + } + case DriverMySQL: + switch intent.format { + case "json", "tree": + return PlanRequest{SQL: stmt, Analyze: intent.analyze}, true + case "": + // EXPLAIN ANALYZE already answers with the tree; other bare forms are the traditional table. + if intent.analyze && first == "EXPLAIN" { + return PlanRequest{SQL: stmt, Analyze: true}, true + } + if intent.analyze { + return PlanRequest{SQL: "ANALYZE FORMAT=JSON " + intent.inner, Analyze: true}, true + } + return PlanRequest{SQL: "EXPLAIN FORMAT=JSON " + intent.inner, Analyze: false}, true + default: + return PlanRequest{}, false + } + case DriverSQLite: + if !intent.queryPlan { + return PlanRequest{}, false + } + return PlanRequest{SQL: stmt, Analyze: false}, true + default: + return PlanRequest{}, false + } +} + +// BuildExplainSQL renders the EXPLAIN for driver. analyze means the statement actually runs, so +// callers must isolate a write themselves. +func BuildExplainSQL(driver DriverType, sv ServerVersion, stmt string, analyze bool) (string, error) { + stmt = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(stmt), ";")) + if stmt == "" { + return "", errors.New("no statement to explain") + } + stmt = stripExplainPrefix(driver, stmt) + switch driver { + case DriverPostgres: + if analyze { + return "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) " + stmt, nil + } + return "EXPLAIN (FORMAT JSON) " + stmt, nil + case DriverMySQL: + switch { + case !analyze: + return "EXPLAIN FORMAT=JSON " + stmt, nil + case sv.MariaDB: + return "ANALYZE FORMAT=JSON " + stmt, nil + case sv.atLeast(8, 0, 18): + // MySQL only accepts FORMAT=JSON on EXPLAIN ANALYZE from 8.3; TREE works from 8.0.18. + return "EXPLAIN ANALYZE " + stmt, nil + default: + return "", fmt.Errorf("EXPLAIN ANALYZE needs MySQL 8.0.18 or newer (server reports %s)", sv) + } + case DriverSQLite: + if analyze { + return "", errors.New("SQLite has no EXPLAIN ANALYZE; explain without it for the plan shape") + } + return "EXPLAIN QUERY PLAN " + stmt, nil + default: + return "", fmt.Errorf("unsupported driver: %s", driver) + } +} + +// ExplainPlan runs the driver's EXPLAIN for stmt on conn and normalizes the output. conn is pinned +// so an ANALYZE the caller wrapped in a transaction stays inside it. +func ExplainPlan(ctx context.Context, conn PinnedConn, driver DriverType, stmt string, analyze bool) (*QueryPlan, error) { + var sv ServerVersion + if driver == DriverMySQL && analyze { + detected, err := detectServerVersion(ctx, conn) + if err != nil { + return nil, err + } + sv = detected + } + explainSQL, err := BuildExplainSQL(driver, sv, stmt, analyze) + if err != nil { + return nil, err + } + start := NowMs() + res, err := runBufferedOn(ctx, conn, explainSQL) + if err != nil { + return nil, err + } + plan, err := ParsePlan(driver, stmt, explainSQL, analyze, res) + if err != nil { + return nil, err + } + plan.DurationMs = NowMs() - start + return plan, nil +} + +func runBufferedOn(ctx context.Context, conn PinnedConn, sql string) (*QueryResult, error) { + return collectStream(func(opts StreamOpts) (*QueryResult, error) { + return conn.ExecuteStream(ctx, sql, opts) + }) +} + +func detectServerVersion(ctx context.Context, conn PinnedConn) (ServerVersion, error) { + res, err := runBufferedOn(ctx, conn, "SELECT VERSION()") + if err != nil { + return ServerVersion{}, err + } + return ParseServerVersion(planString(firstCell(res))), nil +} + +func ParsePlan(driver DriverType, stmt, explainSQL string, analyze bool, res *QueryResult) (*QueryPlan, error) { + if res == nil || len(res.Rows) == 0 { + return nil, errors.New("the server returned no plan") + } + plan := &QueryPlan{ + Driver: driver, + Statement: stmt, + ExplainSQL: explainSQL, + Analyzed: analyze, + // One text column for both JSON formats and the tree text; parseSQLitePlan re-renders its own. + Raw: joinFirstColumn(res), + } + var err error + switch driver { + case DriverPostgres: + err = parsePostgresPlan(plan) + case DriverMySQL: + err = parseMySQLPlan(plan) + case DriverSQLite: + parseSQLitePlan(plan, res) + default: + err = fmt.Errorf("unsupported driver: %s", driver) + } + if err != nil { + return nil, err + } + if len(plan.Nodes) == 0 { + return nil, errors.New("the server returned no plan") + } + // Notes carry only what the viewer can't show otherwise; the badge already reports estimates. + return plan, nil +} diff --git a/internal/database/explain_parse.go b/internal/database/explain_parse.go new file mode 100644 index 0000000..08b0616 --- /dev/null +++ b/internal/database/explain_parse.go @@ -0,0 +1,701 @@ +package database + +import ( + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" +) + +// planBuilder builds a tree whose shape is only known while scanning (MySQL indented text, +// SQLite's id/parent rows). finish() flattens bottom-up, so a node is finalized after its children. +type planBuilder struct { + node PlanNode + children []*planBuilder +} + +func (b *planBuilder) finish() PlanNode { + for _, child := range b.children { + b.node.Children = append(b.node.Children, child.finish()) + } + finalizeNode(&b.node) + return b.node +} + +func finishAll(builders []*planBuilder) []PlanNode { + nodes := make([]PlanNode, 0, len(builders)) + for _, b := range builders { + nodes = append(nodes, b.finish()) + } + return nodes +} + +// finalizeNode derives whichever of the inclusive/exclusive metrics the engine left out. Clamped at +// zero: Postgres leaves InitPlan/SubPlan children out of a parent's total. +func finalizeNode(n *PlanNode) { + var childCost, childTime float64 + var haveChildCost, haveChildTime bool + for i := range n.Children { + if c := n.Children[i].CostTotal; c != nil { + childCost += *c + haveChildCost = true + } + if t := n.Children[i].TimeMs; t != nil { + childTime += *t + haveChildTime = true + } + } + n.CostTotal, n.CostSelf = deriveTotals(n.CostTotal, n.CostSelf, childCost, haveChildCost) + n.TimeMs, n.SelfTimeMs = deriveTotals(n.TimeMs, n.SelfTimeMs, childTime, haveChildTime) +} + +// Postgres reports the inclusive value, MariaDB an exclusive one, and either can be missing. +func deriveTotals(total, self *float64, childSum float64, haveChildren bool) (*float64, *float64) { + switch { + case total != nil && self == nil: + return total, ptrFloat(max(0, *total-childSum)) + case total == nil && self != nil: + return ptrFloat(*self + childSum), self + case total == nil && self == nil && haveChildren: + return ptrFloat(childSum), ptrFloat(0) + default: + return total, self + } +} + +// ---------- Postgres: EXPLAIN (FORMAT JSON) ---------- + +// Already first-class metrics. +var pgFirstClassKeys = map[string]bool{ + "Node Type": true, "Plans": true, "Relation Name": true, "Alias": true, "Index Name": true, + "Total Cost": true, "Plan Rows": true, "Join Type": true, "Strategy": true, + "Subplan Name": true, "Parallel Aware": true, + "Actual Total Time": true, "Actual Rows": true, "Actual Loops": true, +} + +// Most specific clause first. +var pgDetailKeys = []string{ + "Index Cond", "Recheck Cond", "TID Cond", "Hash Cond", "Merge Cond", "Join Filter", + "Filter", "One-Time Filter", "Sort Key", "Group Key", "Presorted Key", "Cache Key", + "Function Call", "Table Function Name", "CTE Name", "Conflict Filter", +} + +func parsePostgresPlan(plan *QueryPlan) error { + var envelopes []map[string]any + if err := json.Unmarshal([]byte(plan.Raw), &envelopes); err != nil { + return fmt.Errorf("could not read the Postgres plan: %w", err) + } + for _, env := range envelopes { + root, ok := env["Plan"].(map[string]any) + if !ok { + continue + } + plan.Nodes = append(plan.Nodes, pgNode(root)) + if v := optFloat(env["Planning Time"]); v != nil { + plan.PlanningMs = v + } + if v := optFloat(env["Execution Time"]); v != nil { + plan.ExecutionMs = v + } + } + if len(plan.Nodes) > 0 { + plan.TotalCost = plan.Nodes[0].CostTotal + } + return nil +} + +func pgNode(raw map[string]any) PlanNode { + detailKey, detail := pgDetail(raw) + n := PlanNode{ + Label: pgLabel(raw), + Detail: detail, + Relation: pgRelation(raw), + Index: planString(raw["Index Name"]), + CostTotal: optFloat(raw["Total Cost"]), + RowsPlanned: optFloat(raw["Plan Rows"]), + Fields: planFields(raw, pgFirstClassKeys, detailKey), + } + // Postgres reports per-loop averages; scale by loops so nested-loop nodes compare fairly. + loops := 1.0 + if v, ok := planFloat(raw["Actual Loops"]); ok { + n.Loops = ptrFloat(v) + n.NeverRun = v == 0 + loops = v + } + if v, ok := planFloat(raw["Actual Total Time"]); ok { + n.TimeMs = ptrFloat(v * loops) + } + if v, ok := planFloat(raw["Actual Rows"]); ok { + n.RowsActual = ptrFloat(v * loops) + } + for _, child := range asObjectSlice(raw["Plans"]) { + n.Children = append(n.Children, pgNode(child)) + } + finalizeNode(&n) + return n +} + +func pgLabel(raw map[string]any) string { + label := planString(raw["Node Type"]) + if label == "" { + label = "Node" + } + if join := planString(raw["Join Type"]); join != "" && join != "Inner" { + label += " (" + join + ")" + } + if strategy := planString(raw["Strategy"]); strategy != "" && strategy != "Plain" { + label += " (" + strategy + ")" + } + if raw["Parallel Aware"] == true { + label = "Parallel " + label + } + if name := planString(raw["Subplan Name"]); name != "" { + label = name + ": " + label + } + return label +} + +func pgRelation(raw map[string]any) string { + relation := planString(raw["Relation Name"]) + alias := planString(raw["Alias"]) + if alias == "" || alias == relation { + return relation + } + if relation == "" { + return alias + } + return relation + " " + alias +} + +// pgDetail returns the clause and its key, so planFields can skip what's already displayed. +func pgDetail(raw map[string]any) (string, string) { + for _, key := range pgDetailKeys { + if v := planString(raw[key]); v != "" { + return key, v + } + } + return "", "" +} + +// ---------- MySQL / MariaDB ---------- + +func parseMySQLPlan(plan *QueryPlan) error { + if strings.HasPrefix(strings.TrimSpace(plan.Raw), "{") { + return parseMySQLJSONPlan(plan) + } + parseMySQLTreePlan(plan) + return nil +} + +// Operation keys MySQL and MariaDB use; anything unlisted falls back to the humanized key. +var mysqlOpLabels = map[string]string{ + "query_block": "Query block", + "table": "Table", + "nested_loop": "Nested loop", + "ordering_operation": "Ordering", + "grouping_operation": "Grouping", + "duplicates_removal": "Duplicates removal", + "materialized_from_subquery": "Materialized subquery", + "union_result": "Union result", + "query_specifications": "Query specification", + "buffer_result": "Buffer result", + "block-nl-join": "Block nested loop join", + "read_sorted_file": "Read sorted file", + "temporary_table": "Temporary table", + "attached_subqueries": "Attached subquery", + "select_list_subqueries": "Select list subquery", + "having_subqueries": "Having subquery", + "optimized_away_subqueries": "Optimized-away subquery", + "update_value_subqueries": "Update value subquery", + "subqueries": "Subquery", + "insert_from": "Insert from", +} + +// Folded into the parent instead of becoming a child node. +var mysqlFoldedObjects = map[string]bool{"cost_info": true} + +var mysqlFirstClassKeys = map[string]bool{ + "table_name": true, "access_type": true, "key": true, "cost_info": true, "cost": true, + "rows_produced_per_join": true, "rows": true, + "r_rows": true, "r_loops": true, "r_total_time_ms": true, + "r_table_time_ms": true, "r_other_time_ms": true, +} + +// Most specific clause first. +var mysqlDetailKeys = []string{"attached_condition", "index_condition"} + +func parseMySQLJSONPlan(plan *QueryPlan) error { + var root map[string]any + if err := json.Unmarshal([]byte(plan.Raw), &root); err != nil { + return fmt.Errorf("could not read the MySQL plan: %w", err) + } + for _, key := range sortedKeys(root) { + obj, ok := root[key].(map[string]any) + if !ok { + continue + } + plan.Nodes = append(plan.Nodes, mysqlNode(key, obj)) + } + if len(plan.Nodes) > 0 { + plan.TotalCost = plan.Nodes[0].CostTotal + plan.ExecutionMs = plan.Nodes[0].TimeMs + } + return nil +} + +func mysqlNode(key string, raw map[string]any) PlanNode { + detailKey, detail := firstStringField(raw, mysqlDetailKeys) + n := PlanNode{ + Label: mysqlLabel(key, raw), + Detail: detail, + Relation: planString(raw["table_name"]), + Index: planString(raw["key"]), + Fields: planFields(raw, mysqlFirstClassKeys, detailKey), + } + mysqlCost(&n, raw) + mysqlRowsAndTime(&n, raw) + n.Children = mysqlChildren(raw) + finalizeNode(&n) + return n +} + +// MySQL nests cost under cost_info, MariaDB 11 reports a flat per-node cost. prefix_cost is the +// running cost of the join prefix, so summing it double-counts; read+eval is the node's own. +func mysqlCost(n *PlanNode, raw map[string]any) { + info, _ := raw["cost_info"].(map[string]any) + n.Fields = append(n.Fields, planFields(info, nil, "")...) + if flat := optFloat(raw["cost"]); flat != nil { + n.CostSelf = flat + return + } + if info == nil { + return + } + n.CostTotal = optFloat(info["query_cost"]) + read, hasRead := planFloat(info["read_cost"]) + eval, hasEval := planFloat(info["eval_cost"]) + if hasRead || hasEval { + n.CostSelf = ptrFloat(read + eval) + } +} + +func mysqlRowsAndTime(n *PlanNode, raw map[string]any) { + n.RowsPlanned = firstFloat(raw["rows_produced_per_join"], raw["rows_examined_per_scan"], raw["rows"]) + // MariaDB's r_rows is per-loop like Postgres; its time counters are already totals. + loops := 1.0 + if v, ok := planFloat(raw["r_loops"]); ok { + n.Loops = ptrFloat(v) + n.NeverRun = v == 0 + loops = v + } + if v, ok := planFloat(raw["r_rows"]); ok { + n.RowsActual = ptrFloat(v * loops) + } + if v := optFloat(raw["r_total_time_ms"]); v != nil { + n.TimeMs = v + return + } + table, hasTable := planFloat(raw["r_table_time_ms"]) + other, hasOther := planFloat(raw["r_other_time_ms"]) + if hasTable || hasOther { + n.SelfTimeMs = ptrFloat(table + other) + } +} + +func mysqlLabel(key string, raw map[string]any) string { + label := mysqlOpLabels[key] + if label == "" { + label = humanizeKey(key) + } + if access := planString(raw["access_type"]); access != "" { + label += " (" + access + ")" + } + if key == "query_block" { + if id := planString(raw["select_id"]); id != "" { + label += " #" + id + } + } + return label +} + +// Every nested object is a child; an array of objects becomes one grouping node holding them. +func mysqlChildren(raw map[string]any) []PlanNode { + var children []PlanNode + for _, key := range sortedKeys(raw) { + if mysqlFoldedObjects[key] { + continue + } + switch v := raw[key].(type) { + case map[string]any: + children = append(children, mysqlNode(key, v)) + case []any: + if group, ok := mysqlGroupNode(key, v); ok { + children = append(children, group) + } + } + } + return children +} + +// ok=false for arrays of scalars (possible_keys, used_columns), which planFields renders instead. +func mysqlGroupNode(key string, arr []any) (PlanNode, bool) { + objects := asObjectSlice(arr) + if len(objects) == 0 || len(objects) != len(arr) { + return PlanNode{}, false + } + group := PlanNode{Label: firstNonEmpty(mysqlOpLabels[key], humanizeKey(key))} + for _, obj := range objects { + // Unwrap the single-key envelopes MySQL uses so the child is labelled by its operation. + if innerKey, inner, ok := soleObjectEntry(obj); ok { + group.Children = append(group.Children, mysqlNode(innerKey, inner)) + continue + } + group.Children = append(group.Children, mysqlNode(key, obj)) + } + finalizeNode(&group) + return group, true +} + +// ---------- MySQL: EXPLAIN ANALYZE (tree text) ---------- + +var ( + treeLineRe = regexp.MustCompile(`^(\s*)->\s*(.*)$`) + treeCostRe = regexp.MustCompile(`\(cost=([0-9.eE+-]+)(?:\.\.([0-9.eE+-]+))?\s+rows=([0-9.eE+-]+)\)`) + treeActualRe = regexp.MustCompile(`\(actual time=([0-9.eE+-]+)\.\.([0-9.eE+-]+)\s+rows=([0-9.eE+-]+)\s+loops=([0-9.eE+-]+)\)`) + treeNeverRe = regexp.MustCompile(`\(never executed\)`) + treeOnRe = regexp.MustCompile(`\bon\s+([^\s(]+)`) + treeUsingRe = regexp.MustCompile(`\busing\s+([^\s(]+)`) +) + +func parseMySQLTreePlan(plan *QueryPlan) { + var roots []*planBuilder + type frame struct { + indent int + builder *planBuilder + } + var stack []frame + for _, line := range strings.Split(plan.Raw, "\n") { + if strings.TrimSpace(line) == "" { + continue + } + indent, text := treeLineParts(line) + builder := &planBuilder{node: treeNode(text)} + for len(stack) > 0 && stack[len(stack)-1].indent >= indent { + stack = stack[:len(stack)-1] + } + if len(stack) == 0 { + roots = append(roots, builder) + } else { + parent := stack[len(stack)-1].builder + parent.children = append(parent.children, builder) + } + stack = append(stack, frame{indent: indent, builder: builder}) + } + plan.Nodes = finishAll(roots) + if len(plan.Nodes) > 0 { + plan.TotalCost = plan.Nodes[0].CostTotal + plan.ExecutionMs = plan.Nodes[0].TimeMs + } +} + +// A line without the "-> " marker belongs to the root, at indent 0. +func treeLineParts(line string) (int, string) { + if m := treeLineRe.FindStringSubmatch(line); m != nil { + return len(m[1]), strings.TrimSpace(m[2]) + } + return 0, strings.TrimSpace(line) +} + +func treeNode(text string) PlanNode { + n := PlanNode{} + if m := treeCostRe.FindStringSubmatch(text); m != nil { + n.CostTotal = parseFloatPtr(m[1]) + n.RowsPlanned = parseFloatPtr(m[3]) + text = strings.Replace(text, m[0], "", 1) + } + if m := treeActualRe.FindStringSubmatch(text); m != nil { + loops := 1.0 + if v, err := strconv.ParseFloat(m[4], 64); err == nil { + n.Loops = ptrFloat(v) + loops = v + } + // first-row and last-row, both per loop. + if v, err := strconv.ParseFloat(m[2], 64); err == nil { + n.TimeMs = ptrFloat(v * loops) + } + if v, err := strconv.ParseFloat(m[3], 64); err == nil { + n.RowsActual = ptrFloat(v * loops) + } + text = strings.Replace(text, m[0], "", 1) + } + if loc := treeNeverRe.FindString(text); loc != "" { + n.NeverRun = true + text = strings.Replace(text, loc, "", 1) + } + n.Label, n.Detail = splitLabelDetail(strings.TrimSpace(text)) + // Read these off the operation only: a filter clause can contain "on" or "using". + if m := treeOnRe.FindStringSubmatch(n.Label); m != nil { + n.Relation = m[1] + } + if m := treeUsingRe.FindStringSubmatch(n.Label); m != nil { + n.Index = m[1] + } + return n +} + +func splitLabelDetail(text string) (string, string) { + idx := strings.Index(text, ": ") + if idx <= 0 { + return text, "" + } + return strings.TrimSpace(text[:idx]), strings.TrimSpace(text[idx+2:]) +} + +// ---------- SQLite: EXPLAIN QUERY PLAN ---------- + +var ( + sqliteScanRe = regexp.MustCompile(`(?i)^(SCAN|SEARCH)\s+(?:TABLE\s+|SUBQUERY\s+)?(\S+)\s*(.*)$`) + sqliteIndexRe = regexp.MustCompile(`(?i)\bUSING\s+(?:COVERING\s+)?(?:AUTOMATIC\s+)?(?:PARTIAL\s+)?INDEX\s+([^\s(]+)`) +) + +// parseSQLitePlan rebuilds the tree from the id/parent columns. SQLite reports plan shape only. +func parseSQLitePlan(plan *QueryPlan, res *QueryResult) { + idID := columnIndex(res, "id", 0) + idParent := columnIndex(res, "parent", 1) + idDetail := columnIndex(res, "detail", 3) + + byID := map[int64]*planBuilder{} + var roots []*planBuilder + var raw strings.Builder + for _, row := range res.Rows { + id, _ := planFloat(cellAt(row, idID)) + parent, _ := planFloat(cellAt(row, idParent)) + detail := planString(cellAt(row, idDetail)) + builder := &planBuilder{node: sqliteNode(detail)} + byID[int64(id)] = builder + if p, ok := byID[int64(parent)]; ok && int64(parent) != int64(id) { + p.children = append(p.children, builder) + } else { + roots = append(roots, builder) + } + fmt.Fprintf(&raw, "%d|%d|%s\n", int64(id), int64(parent), detail) + } + plan.Nodes = finishAll(roots) + plan.Raw = strings.TrimRight(raw.String(), "\n") + plan.AddNote(PlanNoteNoMetrics) +} + +func sqliteNode(detail string) PlanNode { + m := sqliteScanRe.FindStringSubmatch(strings.TrimSpace(detail)) + if m == nil { + return PlanNode{Label: detail} + } + n := PlanNode{ + Label: strings.ToUpper(m[1]), + Relation: m[2], + Detail: strings.TrimSpace(m[3]), + } + if idx := sqliteIndexRe.FindStringSubmatch(n.Detail); idx != nil { + n.Index = idx[1] + } + return n +} + +// ---------- value helpers ---------- + +// planFields renders the engine's remaining scalars, skipping skip and alsoSkip (the detail key). +// Nested objects and arrays of objects are children, not fields. +func planFields(raw map[string]any, skip map[string]bool, alsoSkip string) []PlanField { + var fields []PlanField + for _, key := range sortedKeys(raw) { + if skip[key] || (alsoSkip != "" && key == alsoSkip) { + continue + } + switch v := raw[key].(type) { + case nil, map[string]any: + continue + case []any: + if len(asObjectSlice(v)) > 0 { + continue + } + if s := planString(v); s != "" { + fields = append(fields, PlanField{Key: humanizeKey(key), Value: s}) + } + default: + if s := planString(v); s != "" { + fields = append(fields, PlanField{Key: humanizeKey(key), Value: s}) + } + } + } + return fields +} + +func planString(v any) string { + switch val := v.(type) { + case nil: + return "" + case string: + return val + case bool: + return strconv.FormatBool(val) + case float64: + return strconv.FormatFloat(val, 'g', -1, 64) + case float32: + return strconv.FormatFloat(float64(val), 'g', -1, 32) + case int64: + return strconv.FormatInt(val, 10) + case int: + return strconv.Itoa(val) + case json.Number: + return val.String() + case []any: + parts := make([]string, 0, len(val)) + for _, item := range val { + if s := planString(item); s != "" { + parts = append(parts, s) + } + } + return strings.Join(parts, ", ") + default: + return fmt.Sprintf("%v", val) + } +} + +// The engines mix JSON numbers, quoted numbers ("1.35") and driver integer types. +func planFloat(v any) (float64, bool) { + switch val := v.(type) { + case float64: + return val, true + case float32: + return float64(val), true + case int64: + return float64(val), true + case int: + return float64(val), true + case json.Number: + f, err := val.Float64() + return f, err == nil + case string: + f, err := strconv.ParseFloat(strings.TrimSpace(val), 64) + return f, err == nil + default: + return 0, false + } +} + +func optFloat(v any) *float64 { + if f, ok := planFloat(v); ok { + return &f + } + return nil +} + +func firstFloat(values ...any) *float64 { + for _, v := range values { + if f := optFloat(v); f != nil { + return f + } + } + return nil +} + +func firstStringField(raw map[string]any, keys []string) (string, string) { + for _, key := range keys { + if v := planString(raw[key]); v != "" { + return key, v + } + } + return "", "" +} + +func parseFloatPtr(s string) *float64 { + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return nil + } + return &f +} + +func ptrFloat(f float64) *float64 { return &f } + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} + +func asObjectSlice(v any) []map[string]any { + arr, ok := v.([]any) + if !ok { + return nil + } + var out []map[string]any + for _, item := range arr { + if obj, ok := item.(map[string]any); ok { + out = append(out, obj) + } + } + return out +} + +func soleObjectEntry(obj map[string]any) (string, map[string]any, bool) { + if len(obj) != 1 { + return "", nil, false + } + for key, value := range obj { + if inner, ok := value.(map[string]any); ok { + return key, inner, true + } + } + return "", nil, false +} + +func humanizeKey(key string) string { + if key == "" { + return "" + } + spaced := strings.NewReplacer("_", " ", "-", " ").Replace(key) + return strings.ToUpper(spaced[:1]) + spaced[1:] +} + +func firstCell(res *QueryResult) any { + if res == nil || len(res.Rows) == 0 || len(res.Rows[0]) == 0 { + return nil + } + return res.Rows[0][0] +} + +// Both JSON formats arrive as one row; MySQL tree text as one row of newline-separated lines. +func joinFirstColumn(res *QueryResult) string { + if res == nil { + return "" + } + parts := make([]string, 0, len(res.Rows)) + for _, row := range res.Rows { + if len(row) == 0 { + continue + } + parts = append(parts, planString(row[0])) + } + return strings.Join(parts, "\n") +} + +func columnIndex(res *QueryResult, name string, fallback int) int { + for i, col := range res.Columns { + if strings.EqualFold(col, name) { + return i + } + } + return fallback +} + +func cellAt(row []any, index int) any { + if index < 0 || index >= len(row) { + return nil + } + return row[index] +} diff --git a/internal/database/explain_test.go b/internal/database/explain_test.go new file mode 100644 index 0000000..e5ec24f --- /dev/null +++ b/internal/database/explain_test.go @@ -0,0 +1,784 @@ +package database + +import ( + "math" + "strings" + "testing" +) + +func TestParseServerVersion(t *testing.T) { + tests := []struct { + raw string + mariaDB bool + major int + minor int + patch int + }{ + {"8.0.36", false, 8, 0, 36}, + {"8.0.18-log", false, 8, 0, 18}, + {"9.1.0", false, 9, 1, 0}, + {"5.7.44-log", false, 5, 7, 44}, + {"11.4.2-MariaDB-ubu2404", true, 11, 4, 2}, + {"5.5.5-10.6.12-MariaDB-1:10.6.12+maria~ubu2004", true, 10, 6, 12}, + {"", false, 0, 0, 0}, + } + for _, tc := range tests { + got := ParseServerVersion(tc.raw) + if got.MariaDB != tc.mariaDB || got.Major != tc.major || got.Minor != tc.minor || got.Patch != tc.patch { + t.Errorf("ParseServerVersion(%q) = %+v, want mariadb=%v %d.%d.%d", + tc.raw, got, tc.mariaDB, tc.major, tc.minor, tc.patch) + } + } +} + +func TestServerVersionAtLeast(t *testing.T) { + tests := []struct { + raw string + want bool + }{ + {"8.0.18", true}, + {"8.0.36", true}, + {"8.4.0", true}, + {"9.0.0", true}, + {"8.0.17", false}, + {"5.7.44", false}, + {"8.0.2", false}, + } + for _, tc := range tests { + if got := ParseServerVersion(tc.raw).atLeast(8, 0, 18); got != tc.want { + t.Errorf("%s atLeast(8.0.18) = %v, want %v", tc.raw, got, tc.want) + } + } +} + +func TestBuildExplainSQL(t *testing.T) { + mysql8 := ParseServerVersion("8.0.36") + mysql57 := ParseServerVersion("5.7.44") + maria := ParseServerVersion("11.4.2-MariaDB") + + tests := []struct { + name string + driver DriverType + sv ServerVersion + stmt string + analyze bool + want string + wantErr bool + }{ + {name: "postgres plan", driver: DriverPostgres, stmt: "SELECT 1", want: "EXPLAIN (FORMAT JSON) SELECT 1"}, + { + name: "postgres analyze", driver: DriverPostgres, stmt: "SELECT 1", analyze: true, + want: "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT 1", + }, + {name: "mysql plan", driver: DriverMySQL, sv: mysql8, stmt: "SELECT 1", want: "EXPLAIN FORMAT=JSON SELECT 1"}, + { + name: "mysql analyze uses tree", driver: DriverMySQL, sv: mysql8, stmt: "SELECT 1", analyze: true, + want: "EXPLAIN ANALYZE SELECT 1", + }, + { + name: "mariadb analyze", driver: DriverMySQL, sv: maria, stmt: "SELECT 1", analyze: true, + want: "ANALYZE FORMAT=JSON SELECT 1", + }, + {name: "mysql 5.7 cannot analyze", driver: DriverMySQL, sv: mysql57, stmt: "SELECT 1", analyze: true, wantErr: true}, + {name: "sqlite plan", driver: DriverSQLite, stmt: "SELECT 1", want: "EXPLAIN QUERY PLAN SELECT 1"}, + {name: "sqlite cannot analyze", driver: DriverSQLite, stmt: "SELECT 1", analyze: true, wantErr: true}, + {name: "empty statement", driver: DriverPostgres, stmt: " ", wantErr: true}, + {name: "unknown driver", driver: DriverType("oracle"), stmt: "SELECT 1", wantErr: true}, + { + name: "trailing semicolon dropped", driver: DriverPostgres, stmt: "SELECT 1; ", + want: "EXPLAIN (FORMAT JSON) SELECT 1", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := BuildExplainSQL(tc.driver, tc.sv, tc.stmt, tc.analyze) + if tc.wantErr { + if err == nil { + t.Fatalf("expected an error, got %q", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} + +// A typed EXPLAIN is replaced, not nested. +func TestBuildExplainSQLStripsExistingExplain(t *testing.T) { + tests := []struct { + driver DriverType + stmt string + want string + }{ + {DriverPostgres, "EXPLAIN SELECT 1", "EXPLAIN (FORMAT JSON) SELECT 1"}, + {DriverPostgres, "EXPLAIN ANALYZE SELECT 1", "EXPLAIN (FORMAT JSON) SELECT 1"}, + {DriverPostgres, "explain (analyze, buffers) select 1", "EXPLAIN (FORMAT JSON) select 1"}, + {DriverPostgres, "EXPLAIN (FORMAT TEXT) WITH x AS (SELECT 1) SELECT * FROM x", "EXPLAIN (FORMAT JSON) WITH x AS (SELECT 1) SELECT * FROM x"}, + {DriverMySQL, "EXPLAIN FORMAT=JSON SELECT 1", "EXPLAIN FORMAT=JSON SELECT 1"}, + {DriverMySQL, "EXPLAIN ANALYZE SELECT 1", "EXPLAIN FORMAT=JSON SELECT 1"}, + {DriverMySQL, "ANALYZE FORMAT=JSON SELECT 1", "EXPLAIN FORMAT=JSON SELECT 1"}, + {DriverSQLite, "EXPLAIN QUERY PLAN SELECT 1", "EXPLAIN QUERY PLAN SELECT 1"}, + // Not an explain of a statement: left alone. + {DriverMySQL, "ANALYZE TABLE t", "EXPLAIN FORMAT=JSON ANALYZE TABLE t"}, + {DriverPostgres, "EXPLAIN", "EXPLAIN (FORMAT JSON) EXPLAIN"}, + } + for _, tc := range tests { + got, err := BuildExplainSQL(tc.driver, ServerVersion{}, tc.stmt, false) + if err != nil { + t.Fatalf("%s %q: %v", tc.driver, tc.stmt, err) + } + if got != tc.want { + t.Errorf("%s %q: got %q, want %q", tc.driver, tc.stmt, got, tc.want) + } + } +} + +func TestSingleStatement(t *testing.T) { + if got, err := SingleStatement(DriverPostgres, " SELECT 1; "); err != nil || got != "SELECT 1" { + t.Errorf("single: got %q, %v", got, err) + } + if _, err := SingleStatement(DriverPostgres, "SELECT 1; SELECT 2"); err == nil { + t.Error("expected an error for two statements") + } + if _, err := SingleStatement(DriverPostgres, " -- just a comment\n"); err == nil { + t.Error("expected an error for no statement") + } +} + +// ---------- Postgres ---------- + +const pgAnalyzeJSON = `[ + { + "Plan": { + "Node Type": "Nested Loop", + "Parallel Aware": false, + "Join Type": "Inner", + "Startup Cost": 0.29, + "Total Cost": 42.58, + "Plan Rows": 10, + "Plan Width": 68, + "Actual Startup Time": 0.021, + "Actual Total Time": 0.185, + "Actual Rows": 9, + "Actual Loops": 1, + "Plans": [ + { + "Node Type": "Seq Scan", + "Parent Relationship": "Outer", + "Relation Name": "orders", + "Alias": "o", + "Startup Cost": 0.00, + "Total Cost": 18.10, + "Plan Rows": 10, + "Plan Width": 36, + "Actual Startup Time": 0.010, + "Actual Total Time": 0.032, + "Actual Rows": 9, + "Actual Loops": 1, + "Filter": "(total > 100)", + "Rows Removed by Filter": 3, + "Shared Hit Blocks": 5 + }, + { + "Node Type": "Index Scan", + "Parent Relationship": "Inner", + "Relation Name": "customers", + "Alias": "c", + "Index Name": "customers_pkey", + "Startup Cost": 0.29, + "Total Cost": 2.44, + "Plan Rows": 1, + "Plan Width": 32, + "Actual Startup Time": 0.003, + "Actual Total Time": 0.004, + "Actual Rows": 1, + "Actual Loops": 9, + "Index Cond": "(id = o.customer_id)" + } + ] + }, + "Planning Time": 0.153, + "Execution Time": 0.221 + } +]` + +func TestParsePostgresAnalyzePlan(t *testing.T) { + plan := parsePlanFixture(t, DriverPostgres, true, pgAnalyzeJSON) + + if len(plan.Nodes) != 1 { + t.Fatalf("expected 1 root, got %d", len(plan.Nodes)) + } + wantFloat(t, "planning ms", plan.PlanningMs, 0.153) + wantFloat(t, "execution ms", plan.ExecutionMs, 0.221) + wantFloat(t, "total cost", plan.TotalCost, 42.58) + if plan.Notes != nil { + t.Errorf("an analyzed plan needs no notes, got %v", plan.Notes) + } + + root := plan.Nodes[0] + if root.Label != "Nested Loop" { + t.Errorf("root label = %q", root.Label) + } + wantFloat(t, "root time", root.TimeMs, 0.185) + wantFloat(t, "root rows", root.RowsActual, 9) + wantFloat(t, "root self time", root.SelfTimeMs, 0.185-(0.032+0.036)) + wantFloat(t, "root self cost", root.CostSelf, 42.58-(18.10+2.44)) + if len(root.Children) != 2 { + t.Fatalf("expected 2 children, got %d", len(root.Children)) + } + + seq := root.Children[0] + if seq.Label != "Seq Scan" || seq.Relation != "orders o" { + t.Errorf("seq scan = %q on %q", seq.Label, seq.Relation) + } + if seq.Detail != "(total > 100)" { + t.Errorf("seq scan detail = %q", seq.Detail) + } + wantFloat(t, "seq scan cost", seq.CostTotal, 18.10) + wantFloat(t, "seq scan self cost", seq.CostSelf, 18.10) + if hasField(seq.Fields, "Filter") { + t.Error("the detail clause should not repeat in the field list") + } + if got := fieldValue(seq.Fields, "Rows Removed by Filter"); got != "3" { + t.Errorf("rows removed by filter = %q", got) + } + if got := fieldValue(seq.Fields, "Shared Hit Blocks"); got != "5" { + t.Errorf("shared hit blocks = %q", got) + } + + idx := root.Children[1] + if idx.Index != "customers_pkey" || idx.Detail != "(id = o.customer_id)" { + t.Errorf("index scan = %+v", idx) + } + // Per-loop averages: 9 loops of 1 row at 0.004ms is 9 rows in 0.036ms. + wantFloat(t, "index scan loops", idx.Loops, 9) + wantFloat(t, "index scan rows", idx.RowsActual, 9) + wantFloat(t, "index scan time", idx.TimeMs, 0.036) +} + +func TestParsePostgresPlanOnly(t *testing.T) { + const raw = `[{"Plan":{"Node Type":"Seq Scan","Relation Name":"t","Alias":"t","Total Cost":12.5,"Plan Rows":420,"Plan Width":8}}]` + plan := parsePlanFixture(t, DriverPostgres, false, raw) + + root := plan.Nodes[0] + if root.TimeMs != nil || root.RowsActual != nil || root.Loops != nil { + t.Errorf("a plan-only run must carry no measurements: %+v", root) + } + wantFloat(t, "rows planned", root.RowsPlanned, 420) + if len(plan.Notes) != 0 { + t.Errorf("expected no notes, got %v", plan.Notes) + } +} + +func TestPostgresLabels(t *testing.T) { + tests := []struct { + raw string + want string + }{ + {`{"Node Type":"Hash Join","Join Type":"Left"}`, "Hash Join (Left)"}, + {`{"Node Type":"Hash Join","Join Type":"Inner"}`, "Hash Join"}, + {`{"Node Type":"Aggregate","Strategy":"Hashed"}`, "Aggregate (Hashed)"}, + {`{"Node Type":"Aggregate","Strategy":"Plain"}`, "Aggregate"}, + {`{"Node Type":"Seq Scan","Parallel Aware":true}`, "Parallel Seq Scan"}, + {`{"Node Type":"Aggregate","Subplan Name":"InitPlan 1 (returns $0)"}`, "InitPlan 1 (returns $0): Aggregate"}, + {`{}`, "Node"}, + } + for _, tc := range tests { + plan := parsePlanFixture(t, DriverPostgres, false, `[{"Plan":`+tc.raw+`}]`) + if got := plan.Nodes[0].Label; got != tc.want { + t.Errorf("%s β†’ %q, want %q", tc.raw, got, tc.want) + } + } +} + +// A subplan's time is outside its parent's total, so the subtraction must not go negative. +func TestPostgresSelfTimeNeverNegative(t *testing.T) { + const raw = `[{"Plan":{ + "Node Type":"Result","Total Cost":1.0,"Actual Total Time":0.05,"Actual Loops":1, + "Plans":[{"Node Type":"Aggregate","Subplan Name":"InitPlan 1","Total Cost":9.0,"Actual Total Time":0.4,"Actual Loops":1}] + }}]` + plan := parsePlanFixture(t, DriverPostgres, true, raw) + wantFloat(t, "self time", plan.Nodes[0].SelfTimeMs, 0) + wantFloat(t, "self cost", plan.Nodes[0].CostSelf, 0) +} + +func TestPostgresNeverExecuted(t *testing.T) { + const raw = `[{"Plan":{"Node Type":"Result","Total Cost":1,"Actual Total Time":0,"Actual Rows":0,"Actual Loops":0}}]` + plan := parsePlanFixture(t, DriverPostgres, true, raw) + if !plan.Nodes[0].NeverRun { + t.Error("expected the node to be marked as never run") + } +} + +func TestParsePostgresPlanRejectsGarbage(t *testing.T) { + res := &QueryResult{Columns: []string{"QUERY PLAN"}, Rows: [][]any{{"not json"}}} + if _, err := ParsePlan(DriverPostgres, "SELECT 1", "EXPLAIN …", false, res); err == nil { + t.Fatal("expected an error for non-JSON output") + } +} + +// ---------- MySQL ---------- + +const mysqlJSON = `{ + "query_block": { + "select_id": 1, + "cost_info": {"query_cost": "3.60"}, + "nested_loop": [ + { + "table": { + "table_name": "o", + "access_type": "ALL", + "rows_examined_per_scan": 10, + "rows_produced_per_join": 3, + "filtered": "33.33", + "cost_info": {"read_cost": "1.25", "eval_cost": "0.33", "prefix_cost": "1.58", "data_read_per_join": "160"}, + "used_columns": ["id", "customer_id", "total"], + "attached_condition": "(` + "`shop`.`o`.`total`" + ` > 100)" + } + }, + { + "table": { + "table_name": "c", + "access_type": "eq_ref", + "possible_keys": ["PRIMARY"], + "key": "PRIMARY", + "used_key_parts": ["id"], + "key_length": "4", + "ref": ["shop.o.customer_id"], + "rows_examined_per_scan": 1, + "rows_produced_per_join": 3, + "filtered": "100.00", + "cost_info": {"read_cost": "1.69", "eval_cost": "0.33", "prefix_cost": "3.60", "data_read_per_join": "192"} + } + } + ] + } +}` + +func TestParseMySQLJSONPlan(t *testing.T) { + plan := parsePlanFixture(t, DriverMySQL, false, mysqlJSON) + + if len(plan.Nodes) != 1 { + t.Fatalf("expected 1 root, got %d", len(plan.Nodes)) + } + root := plan.Nodes[0] + if root.Label != "Query block #1" { + t.Errorf("root label = %q", root.Label) + } + wantFloat(t, "total cost", plan.TotalCost, 3.60) + + if len(root.Children) != 1 { + t.Fatalf("expected the nested loop group, got %d children", len(root.Children)) + } + group := root.Children[0] + if group.Label != "Nested loop" { + t.Errorf("group label = %q", group.Label) + } + // Sum of the tables' own costs, not of MySQL's cumulative prefix_cost. + wantFloat(t, "join cost", group.CostTotal, 3.60) + if len(group.Children) != 2 { + t.Fatalf("expected 2 tables, got %d", len(group.Children)) + } + + outer := group.Children[0] + if outer.Label != "Table (ALL)" || outer.Relation != "o" { + t.Errorf("outer = %q on %q", outer.Label, outer.Relation) + } + if !strings.Contains(outer.Detail, "> 100") { + t.Errorf("outer detail = %q", outer.Detail) + } + wantFloat(t, "outer rows", outer.RowsPlanned, 3) + wantFloat(t, "outer self cost", outer.CostSelf, 1.58) + // cost_info's other numbers survive the fold. + if got := fieldValue(outer.Fields, "Prefix cost"); got != "1.58" { + t.Errorf("prefix cost field = %q", got) + } + if got := fieldValue(outer.Fields, "Rows examined per scan"); got != "10" { + t.Errorf("rows examined field = %q", got) + } + if got := fieldValue(outer.Fields, "Used columns"); got != "id, customer_id, total" { + t.Errorf("used columns field = %q", got) + } + + inner := group.Children[1] + if inner.Label != "Table (eq_ref)" || inner.Index != "PRIMARY" { + t.Errorf("inner = %q using %q", inner.Label, inner.Index) + } + wantFloat(t, "inner self cost", inner.CostSelf, 2.02) + if inner.TimeMs != nil { + t.Errorf("EXPLAIN without ANALYZE has no timings, got %v", *inner.TimeMs) + } +} + +const mariaAnalyzeJSON = `{ + "query_block": { + "select_id": 1, + "r_loops": 1, + "r_total_time_ms": 0.4521, + "nested_loop": [ + { + "table": { + "table_name": "o", + "access_type": "ALL", + "r_loops": 1, + "rows": 10, + "r_rows": 9, + "r_table_time_ms": 0.0521, + "r_other_time_ms": 0.0129, + "filtered": 100, + "r_filtered": 90, + "attached_condition": "o.total > 100" + } + }, + { + "table": { + "table_name": "c", + "access_type": "eq_ref", + "possible_keys": ["PRIMARY"], + "key": "PRIMARY", + "r_loops": 9, + "rows": 1, + "r_rows": 1, + "r_table_time_ms": 0.1521, + "r_other_time_ms": 0.0221 + } + } + ] + } +}` + +func TestParseMariaDBAnalyzePlan(t *testing.T) { + plan := parsePlanFixture(t, DriverMySQL, true, mariaAnalyzeJSON) + + root := plan.Nodes[0] + wantFloat(t, "root time", root.TimeMs, 0.4521) + wantFloat(t, "execution ms", plan.ExecutionMs, 0.4521) + + group := root.Children[0] + outer := group.Children[0] + // MariaDB splits table and other time; together they are the node's own. + wantFloat(t, "outer self time", outer.SelfTimeMs, 0.065) + wantFloat(t, "outer rows planned", outer.RowsPlanned, 10) + wantFloat(t, "outer rows actual", outer.RowsActual, 9) + + inner := group.Children[1] + wantFloat(t, "inner loops", inner.Loops, 9) + wantFloat(t, "inner rows actual", inner.RowsActual, 9) + wantFloat(t, "inner self time", inner.SelfTimeMs, 0.1742) + + wantFloat(t, "join time", group.TimeMs, 0.065+0.1742) + wantFloat(t, "root self time", root.SelfTimeMs, 0.4521-(0.065+0.1742)) +} + +const mysqlTree = `-> Nested loop inner join (cost=3.60 rows=3) (actual time=0.0451..0.0912 rows=9 loops=1) + -> Filter: (o.total > 100) (cost=1.58 rows=3) (actual time=0.0312..0.0451 rows=9 loops=1) + -> Table scan on o (cost=1.58 rows=10) (actual time=0.0221..0.0356 rows=10 loops=1) + -> Single-row index lookup on c using PRIMARY (id=o.customer_id) (cost=0.67 rows=1) (actual time=0.0021..0.0024 rows=1 loops=9) +` + +func TestParseMySQLTreePlan(t *testing.T) { + plan := parsePlanFixture(t, DriverMySQL, true, mysqlTree) + + if len(plan.Nodes) != 1 { + t.Fatalf("expected 1 root, got %d", len(plan.Nodes)) + } + root := plan.Nodes[0] + if root.Label != "Nested loop inner join" { + t.Errorf("root label = %q", root.Label) + } + wantFloat(t, "root cost", root.CostTotal, 3.60) + wantFloat(t, "root rows planned", root.RowsPlanned, 3) + wantFloat(t, "root time", root.TimeMs, 0.0912) + wantFloat(t, "root rows actual", root.RowsActual, 9) + if len(root.Children) != 2 { + t.Fatalf("expected 2 children, got %d", len(root.Children)) + } + + filter := root.Children[0] + if filter.Label != "Filter" || filter.Detail != "(o.total > 100)" { + t.Errorf("filter = %q / %q", filter.Label, filter.Detail) + } + if len(filter.Children) != 1 { + t.Fatalf("expected the scan under the filter, got %d children", len(filter.Children)) + } + if scan := filter.Children[0]; scan.Relation != "o" { + t.Errorf("scan relation = %q", scan.Relation) + } + + lookup := root.Children[1] + if lookup.Relation != "c" || lookup.Index != "PRIMARY" { + t.Errorf("lookup = %q using %q", lookup.Relation, lookup.Index) + } + wantFloat(t, "lookup time", lookup.TimeMs, 0.0216) + wantFloat(t, "lookup rows", lookup.RowsActual, 9) + wantFloat(t, "root self time", root.SelfTimeMs, 0.0912-(0.0451+0.0216)) +} + +func TestParseMySQLTreeNeverExecuted(t *testing.T) { + const raw = `-> Limit: 1 row(s) (cost=0.35 rows=1) (actual time=0.01..0.02 rows=1 loops=1) + -> Index lookup on t using ix (a=1) (cost=0.35 rows=1) (never executed) +` + plan := parsePlanFixture(t, DriverMySQL, true, raw) + child := plan.Nodes[0].Children[0] + if !child.NeverRun { + t.Error("expected the child to be marked as never run") + } + if !strings.HasPrefix(child.Label, "Index lookup on t using ix") { + t.Errorf("label kept the marker: %q", child.Label) + } +} + +func TestParseMySQLTreeIgnoresClauseKeywords(t *testing.T) { + const raw = `-> Filter: (t.status = 'on' and t.mode = 'using idx') (cost=1.0 rows=1) +` + node := parsePlanFixture(t, DriverMySQL, false, raw).Nodes[0] + if node.Relation != "" || node.Index != "" { + t.Errorf("relation = %q, index = %q; both should be empty", node.Relation, node.Index) + } +} + +// ---------- SQLite ---------- + +func TestParseSQLitePlan(t *testing.T) { + res := &QueryResult{ + Columns: []string{"id", "parent", "notused", "detail"}, + Rows: [][]any{ + {int64(4), int64(0), int64(0), "CO-ROUTINE t1"}, + {int64(8), int64(4), int64(0), "SCAN x"}, + {int64(20), int64(0), int64(0), "SEARCH c USING INDEX idx_c (a=?)"}, + }, + } + plan, err := ParsePlan(DriverSQLite, "SELECT 1", "EXPLAIN QUERY PLAN SELECT 1", false, res) + if err != nil { + t.Fatalf("ParsePlan: %v", err) + } + if len(plan.Nodes) != 2 { + t.Fatalf("expected 2 roots, got %d", len(plan.Nodes)) + } + + coroutine := plan.Nodes[0] + if coroutine.Label != "CO-ROUTINE t1" { + t.Errorf("coroutine label = %q", coroutine.Label) + } + if len(coroutine.Children) != 1 || coroutine.Children[0].Relation != "x" { + t.Fatalf("expected SCAN x nested under the coroutine, got %+v", coroutine.Children) + } + if coroutine.Children[0].Label != "SCAN" { + t.Errorf("child label = %q", coroutine.Children[0].Label) + } + + search := plan.Nodes[1] + if search.Label != "SEARCH" || search.Relation != "c" || search.Index != "idx_c" { + t.Errorf("search = %+v", search) + } + if search.Detail != "USING INDEX idx_c (a=?)" { + t.Errorf("search detail = %q", search.Detail) + } + if search.CostTotal != nil || search.RowsPlanned != nil || search.TimeMs != nil { + t.Error("SQLite reports no cost, row or time estimates") + } + if !hasNote(plan, PlanNoteNoMetrics) { + t.Errorf("expected the no-metrics note, got %v", plan.Notes) + } + if plan.Raw != "4|0|CO-ROUTINE t1\n8|4|SCAN x\n20|0|SEARCH c USING INDEX idx_c (a=?)" { + t.Errorf("raw = %q", plan.Raw) + } +} + +func TestParseSQLiteScanVariants(t *testing.T) { + tests := []struct { + detail string + label string + relation string + index string + }{ + {"SCAN t", "SCAN", "t", ""}, + {"SCAN TABLE t", "SCAN", "t", ""}, + {"SEARCH t USING COVERING INDEX ix_t (a=?)", "SEARCH", "t", "ix_t"}, + {"SEARCH t USING AUTOMATIC PARTIAL COVERING INDEX ix (a=?)", "SEARCH", "t", ""}, + {"SEARCH t USING INTEGER PRIMARY KEY (rowid=?)", "SEARCH", "t", ""}, + {"USE TEMP B-TREE FOR ORDER BY", "USE TEMP B-TREE FOR ORDER BY", "", ""}, + } + for _, tc := range tests { + got := sqliteNode(tc.detail) + if got.Label != tc.label || got.Relation != tc.relation || got.Index != tc.index { + t.Errorf("%q β†’ label %q relation %q index %q; want %q/%q/%q", + tc.detail, got.Label, got.Relation, got.Index, tc.label, tc.relation, tc.index) + } + } +} + +func TestParsePlanRejectsEmptyResult(t *testing.T) { + if _, err := ParsePlan(DriverPostgres, "SELECT 1", "EXPLAIN …", false, nil); err == nil { + t.Error("expected an error for a nil result") + } + empty := &QueryResult{Columns: []string{"QUERY PLAN"}} + if _, err := ParsePlan(DriverPostgres, "SELECT 1", "EXPLAIN …", false, empty); err == nil { + t.Error("expected an error for a result with no rows") + } +} + +// ---------- helpers ---------- + +func parsePlanFixture(t *testing.T, driver DriverType, analyze bool, raw string) *QueryPlan { + t.Helper() + res := &QueryResult{Columns: []string{"EXPLAIN"}, Rows: [][]any{{raw}}} + plan, err := ParsePlan(driver, "SELECT 1", "EXPLAIN …", analyze, res) + if err != nil { + t.Fatalf("ParsePlan(%s): %v", driver, err) + } + if len(plan.Nodes) == 0 { + t.Fatalf("ParsePlan(%s) produced no nodes", driver) + } + return plan +} + +func wantFloat(t *testing.T, what string, got *float64, want float64) { + t.Helper() + if got == nil { + t.Errorf("%s is missing, want %g", what, want) + return + } + if math.Abs(*got-want) > 1e-9 { + t.Errorf("%s = %g, want %g", what, *got, want) + } +} + +func fieldValue(fields []PlanField, key string) string { + for _, f := range fields { + if f.Key == key { + return f.Value + } + } + return "" +} + +func hasField(fields []PlanField, key string) bool { + for _, f := range fields { + if f.Key == key { + return true + } + } + return false +} + +func hasNote(plan *QueryPlan, code string) bool { + for _, n := range plan.Notes { + if n == code { + return true + } + } + return false +} + +// ---------- detecting a typed EXPLAIN ---------- + +func TestDetectPlanRequestPostgres(t *testing.T) { + tests := []struct { + stmt string + wantSQL string + analyze bool + wantOK bool + }{ + // Text output: asked again in JSON. + {stmt: "EXPLAIN SELECT 1", wantSQL: "EXPLAIN (FORMAT JSON) SELECT 1", wantOK: true}, + {stmt: "EXPLAIN ANALYZE SELECT 1", wantSQL: "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT 1", analyze: true, wantOK: true}, + {stmt: "explain analyze verbose select 1", wantSQL: "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) select 1", analyze: true, wantOK: true}, + {stmt: "EXPLAIN (ANALYZE, BUFFERS) SELECT 1", wantSQL: "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT 1", analyze: true, wantOK: true}, + // Already JSON: run as typed. + {stmt: "EXPLAIN (FORMAT JSON) SELECT 1", wantSQL: "EXPLAIN (FORMAT JSON) SELECT 1", wantOK: true}, + {stmt: "EXPLAIN (ANALYZE, FORMAT JSON) SELECT 1", wantSQL: "EXPLAIN (ANALYZE, FORMAT JSON) SELECT 1", analyze: true, wantOK: true}, + {stmt: "EXPLAIN (ANALYZE false, FORMAT JSON) SELECT 1", wantSQL: "EXPLAIN (ANALYZE false, FORMAT JSON) SELECT 1", wantOK: true}, + // A named format means raw output. + {stmt: "EXPLAIN (FORMAT TEXT) SELECT 1", wantOK: false}, + {stmt: "EXPLAIN (FORMAT YAML) SELECT 1", wantOK: false}, + {stmt: "EXPLAIN (ANALYZE, FORMAT XML) SELECT 1", wantOK: false}, + {stmt: "SELECT 1", wantOK: false}, + {stmt: "EXPLAIN", wantOK: false}, + {stmt: "ANALYZE my_table", wantOK: false}, + {stmt: "-- a comment\nEXPLAIN SELECT 1", wantSQL: "EXPLAIN (FORMAT JSON) SELECT 1", wantOK: true}, + } + for _, tc := range tests { + got, ok := DetectPlanRequest(DriverPostgres, tc.stmt) + if ok != tc.wantOK { + t.Errorf("DetectPlanRequest(%q) ok = %v, want %v", tc.stmt, ok, tc.wantOK) + continue + } + if !ok { + continue + } + if got.SQL != tc.wantSQL || got.Analyze != tc.analyze { + t.Errorf("DetectPlanRequest(%q) = %q analyze=%v, want %q analyze=%v", + tc.stmt, got.SQL, got.Analyze, tc.wantSQL, tc.analyze) + } + } +} + +func TestDetectPlanRequestMySQL(t *testing.T) { + tests := []struct { + stmt string + wantSQL string + analyze bool + wantOK bool + }{ + // Traditional table: asked again in JSON. + {stmt: "EXPLAIN SELECT 1", wantSQL: "EXPLAIN FORMAT=JSON SELECT 1", wantOK: true}, + // Tree output parses as-is. + {stmt: "EXPLAIN ANALYZE SELECT 1", wantSQL: "EXPLAIN ANALYZE SELECT 1", analyze: true, wantOK: true}, + {stmt: "EXPLAIN FORMAT=JSON SELECT 1", wantSQL: "EXPLAIN FORMAT=JSON SELECT 1", wantOK: true}, + {stmt: "EXPLAIN FORMAT=TREE SELECT 1", wantSQL: "EXPLAIN FORMAT=TREE SELECT 1", wantOK: true}, + {stmt: "EXPLAIN ANALYZE FORMAT=JSON SELECT 1", wantSQL: "EXPLAIN ANALYZE FORMAT=JSON SELECT 1", analyze: true, wantOK: true}, + {stmt: "ANALYZE FORMAT=JSON SELECT 1", wantSQL: "ANALYZE FORMAT=JSON SELECT 1", analyze: true, wantOK: true}, + // Tabular: asked again in JSON, still measured. + {stmt: "ANALYZE SELECT 1", wantSQL: "ANALYZE FORMAT=JSON SELECT 1", analyze: true, wantOK: true}, + {stmt: "EXPLAIN FORMAT=TRADITIONAL SELECT 1", wantOK: false}, + // The statistics command. + {stmt: "ANALYZE TABLE users", wantOK: false}, + {stmt: "EXPLAIN users", wantOK: false}, + {stmt: "DESCRIBE users", wantOK: false}, + } + for _, tc := range tests { + got, ok := DetectPlanRequest(DriverMySQL, tc.stmt) + if ok != tc.wantOK { + t.Errorf("DetectPlanRequest(%q) ok = %v, want %v", tc.stmt, ok, tc.wantOK) + continue + } + if !ok { + continue + } + if got.SQL != tc.wantSQL || got.Analyze != tc.analyze { + t.Errorf("DetectPlanRequest(%q) = %q analyze=%v, want %q analyze=%v", + tc.stmt, got.SQL, got.Analyze, tc.wantSQL, tc.analyze) + } + } +} + +func TestDetectPlanRequestSQLite(t *testing.T) { + got, ok := DetectPlanRequest(DriverSQLite, "EXPLAIN QUERY PLAN SELECT 1") + if !ok || got.SQL != "EXPLAIN QUERY PLAN SELECT 1" || got.Analyze { + t.Errorf("EXPLAIN QUERY PLAN β†’ %+v, ok=%v", got, ok) + } + // Bytecode, not a plan. + if _, ok := DetectPlanRequest(DriverSQLite, "EXPLAIN SELECT 1"); ok { + t.Error("SQLite's bytecode EXPLAIN must not be treated as a plan request") + } +} + +// Run executes what you typed; the classifier only picks how to ask for the plan. +func TestDetectPlanRequestKeepsWrites(t *testing.T) { + got, ok := DetectPlanRequest(DriverPostgres, "EXPLAIN ANALYZE DELETE FROM t") + if !ok { + t.Fatal("expected a plan request") + } + if !got.Analyze { + t.Error("expected the request to report that it executes") + } + if !strings.Contains(got.SQL, "DELETE FROM t") { + t.Errorf("inner statement lost: %q", got.SQL) + } +} diff --git a/internal/database/runscript_test.go b/internal/database/runscript_test.go index f288049..9cf3617 100644 --- a/internal/database/runscript_test.go +++ b/internal/database/runscript_test.go @@ -29,6 +29,7 @@ type capturedSet struct { cols []string rows [][]any summary *QueryResult + plan *QueryPlan stmt string err error } @@ -51,9 +52,10 @@ func captureSink(sets *[]*capturedSet) ScriptSink { s.rows = append(s.rows, rows...) return nil }, - OnResult: func(idx int, summary *QueryResult, stmt string, err error) { + OnResult: func(idx int, summary *QueryResult, plan *QueryPlan, stmt string, err error) { s := get(idx) s.summary = summary + s.plan = plan s.stmt = stmt s.err = err *sets = append(*sets, s) @@ -122,3 +124,75 @@ func TestRunScriptStopsAtFirstError(t *testing.T) { t.Error("second set should carry the syntax error") } } + +// A typed EXPLAIN yields a plan; its neighbours stay grids. +func TestRunScriptMixesPlansAndGrids(t *testing.T) { + conn, cleanup := memConn(t) + defer cleanup() + + var sets []*capturedSet + script := []string{ + "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)", + "INSERT INTO t VALUES (1, 'a'), (2, 'b')", + "SELECT * FROM t", + "EXPLAIN QUERY PLAN SELECT * FROM t WHERE id = 1", + "SELECT COUNT(*) FROM t", + } + if err := RunScript(context.Background(), conn, DriverSQLite, script, captureSink(&sets)); err != nil { + t.Fatalf("RunScript: %v", err) + } + if len(sets) != 5 { + t.Fatalf("expected one result set per statement, got %d", len(sets)) + } + + plan := sets[3] + if plan.plan == nil { + t.Fatalf("the EXPLAIN statement produced no plan: %+v", plan) + } + if len(plan.rows) != 0 { + t.Errorf("a plan set must deliver no rows, got %d", len(plan.rows)) + } + if plan.summary != nil { + t.Errorf("a plan set carries no row summary, got %+v", plan.summary) + } + if len(plan.plan.Nodes) == 0 { + t.Fatal("expected plan nodes") + } + if got := plan.plan.Nodes[0]; got.Label != "SEARCH" || got.Relation != "t" { + t.Errorf("plan root = %+v, expected a SEARCH of t", got) + } + if plan.plan.Analyzed { + t.Error("EXPLAIN QUERY PLAN measures nothing") + } + + for _, i := range []int{2, 4} { + if sets[i].plan != nil { + t.Errorf("set %d should be a grid, got a plan", i) + } + if len(sets[i].rows) == 0 { + t.Errorf("set %d should have rows", i) + } + } +} + +// Bytecode, not a plan: it stays an ordinary query. +func TestRunScriptKeepsSQLiteBytecodeExplainAsRows(t *testing.T) { + conn, cleanup := memConn(t) + defer cleanup() + + var sets []*capturedSet + script := []string{ + "CREATE TABLE t (id INTEGER PRIMARY KEY)", + "EXPLAIN SELECT * FROM t", + } + if err := RunScript(context.Background(), conn, DriverSQLite, script, captureSink(&sets)); err != nil { + t.Fatalf("RunScript: %v", err) + } + last := sets[len(sets)-1] + if last.plan != nil { + t.Error("bytecode EXPLAIN must not become a plan") + } + if len(last.rows) == 0 { + t.Error("expected the bytecode listing as rows") + } +}