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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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` |
Expand Down
10 changes: 9 additions & 1 deletion docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ <h3>🧠 SQL Editor</h3>
</div>
<div class="feature-card">
<h3>πŸ“œ Multi-statement scripts</h3>
<p>Run several <code>;</code>-separated statements at once on a single connection - temp tables, <code>SET</code> and scripted transactions hold. Each result set gets its own tab.</p>
<p>Run several <code>;</code>-separated statements at once on a single connection - temp tables, <code>SET</code> and scripted transactions hold. Every output gets its own tab, query plans included.</p>
</div>
<div class="feature-card">
<h3>πŸ“Œ Pinned transactions</h3>
Expand All @@ -176,6 +176,14 @@ <h3>πŸ“Œ Pinned transactions</h3>
<h3>🌊 Streaming results</h3>
<p>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.</p>
</div>
<div class="feature-card">
<h3>🧭 Query plan viewer</h3>
<p>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.</p>
</div>
<div class="feature-card">
<h3>πŸ“Š Estimated or measured</h3>
<p><code>Ctrl+Shift+E</code> plans without running. <code>Ctrl+Shift+A</code> executes the statement for real rows and timings - and rolls a write back so nothing lands. Type <code>EXPLAIN</code> yourself and Run opens the same viewer instead of a table of raw output.</p>
</div>
<div class="feature-card">
<h3>πŸ—ƒοΈ Schema Explorer</h3>
<p>Tree view of schemas, tables and columns with instant search. Double-click a table for a <code>SELECT</code>, Ctrl+double-click to browse and edit its data.</p>
Expand Down
105 changes: 105 additions & 0 deletions e2e/pages/plan-page.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await this.page.getByRole('button', { name: 'Explain', exact: true }).click();
}

async explain(): Promise<void> {
await this.clickExplain();
await this.waitForPlan();
}

async explainAnalyze(): Promise<void> {
await this.page.getByRole('button', { name: 'Explain options' }).click();
await this.page.getByRole('menuitem', { name: 'Explain analyze' }).click();
await this.waitForPlan();
}

async explainByShortcut(): Promise<void> {
await this.page.keyboard.press('ControlOrMeta+Shift+E');
await this.waitForPlan();
}

/** Left unbound on engines that can't measure. */
async explainAnalyzeByShortcut(): Promise<void> {
await this.page.keyboard.press('ControlOrMeta+Shift+A');
await this.waitForPlan();
}

async waitForPlan(): Promise<void> {
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<boolean> {
return (await this.page.getByRole('button', { name: 'Explain options' }).count()) > 0;
}

get badge(): Locator {
return this.view.locator('.plan-badge');
}

nodeLabels(): Promise<string[]> {
return this.view.locator('.plan-node-label').allInnerTexts();
}

metricColumns(): Promise<string[]> {
return this.view.locator('.plan-head-metric').allInnerTexts();
}

async heats(): Promise<number[]> {
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<string | null> {
return this.view.locator('.plan-row-hottest .plan-node-label').first().textContent();
}

async selectNode(index: number): Promise<void> {
await this.view.locator('.plan-node-btn').nth(index).click();
}

get detailsTitle(): Locator {
return this.view.locator('.plan-details-title');
}

detailFields(): Promise<string[]> {
return this.view.locator('.plan-details-grid dt').allInnerTexts();
}

async heatBy(metric: 'Time' | 'Cost' | 'Rows'): Promise<void> {
await this.view.getByRole('button', { name: metric, exact: true }).click();
}

async toggleRaw(): Promise<void> {
await this.view.getByRole('button', { name: 'Raw plan', exact: true }).click();
}

get raw(): Locator {
return this.view.locator('.plan-raw');
}

async toggleFirstNode(): Promise<void> {
await this.view.locator('.plan-twisty').first().click();
}
}
190 changes: 190 additions & 0 deletions e2e/specs/editor/query-plan.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading