feat(database): Add query plans, history, data tools and schema objects (#63)

New query, data and schema tools for the Database feature (SQLite, PostgreSQL, MySQL).

## Query console
- **Explain Plan** tab: SQLite `EXPLAIN QUERY PLAN`, PostgreSQL `EXPLAIN (FORMAT JSON)`, MySQL `EXPLAIN`, parsed in `ide-db::plan` into an operation tree (Full Scan / Index Scan / Group By / Order By …) under a statement root, with a properties panel.
- **Query history**: `ide-db::history` keeps executed statements per project and source in `db-history.json` (deduplicated, newest first, 500 max). The Browse Query History dialog has search, a highlighted preview, Paste and Delete.
- Output reports `(execution: X ms, fetching: Y ms)`.

## Results and table editor
- Local column filter (value list with counts) and local sort.
- Record / Value / Aggregates side panel.
- Chart view (bar / line) with X / Y / aggregation settings.
- Export Data dialog: extractor, Transpose, headers, preview, file or clipboard. An extractor drop-down sits on the data toolbars.
- Import Data from a CSV / TSV file.
- Table editor gets a WHERE | ORDER BY row. Column headers fit their names, with filter and sort indicators. Whole SQLite REALs display as `3108`, not `3108.0`.

## Database tree
- Triggers under tables. Routines, sequences and materialized views under schemas, with DDL, Quick Documentation and Drop.
- Data source options: object filter, show template databases, single database mode (PostgreSQL).
- New ▸ Import from Clipboard (connection URLs).

## IPC
New commands: `db_schema_objects`, `db_object_ddl`, `db_explain`, `db_history`, `db_remove_history`. Bindings are regenerated.

## Testing
- `cargo test -p ide-db`: plan parsing, history store, trigger listing and DDL, explain without executing.
- `cargo clippy -p ide-db -p openidea --all-targets -- -D warnings` is clean.
- `pnpm typecheck` and eslint pass. `pnpm vitest run src/features/database src/ui`: 226 passed.
- Checked in the running app against a SQLite database:
  - tree with triggers
  - result grid, local filter, side panel
  - chart
  - Explain Plan
  - history dialog
  - Export Data dialog
  - table editor ORDER BY
  - CSV import




Merge follow-up: grouped the new `--chart-series` token under a section comment and added it to the theme plugin template (theme.test.ts on main), regenerated docs/plugins/themes.md. Re-verified after merging main on macOS with a SQLite source: query + result grid with filter/sort headers, Explain Plan tab (Full Scan / Index Scan / Group By / Order By), Query History dialog; `cargo nextest run -p ide-db` 45 passed, bindings unchanged after regeneration.

https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-27 13:31:29 +08:00
1 джерело 580f0efc28
коміт 575749f2ac
48 files changed
+4268 -289

No files matched your search

+24 -7
Переглянути файл
@@ -130,7 +130,7 @@ export function sqlCandidates({ model, analysis: a, driver, sourceName }: Comple
eq(t.schema, defaultSchema) || driver === "sqlite" ? q(t.name) : `${q(t.schema)}.${q(t.name)}`;
const tableCandidate = (t: DbCompletionTable, boost: number, qualified = false): SqlCandidate => ({
label: t.name,
kind: t.kind === "view" ? "view" : "table",
kind: t.kind !== "table" ? "view" : "table",
tail: t.schema ? `(${t.schema})` : undefined,
type: sourceName,
insert: qualified ? `${q(t.schema)}.${q(t.name)}` : tableText(t),
@@ -192,7 +192,7 @@ export function sqlCandidates({ model, analysis: a, driver, sourceName }: Comple
for (const t of tables) {
schemas.add(t.schema);
const local = eq(t.schema, defaultSchema) || driver === "sqlite";
out.push(tableCandidate(t, local ? (t.kind === "view" ? 28 : 30) : 0, !local));
out.push(tableCandidate(t, local ? (t.kind !== "table" ? 28 : 30) : 0, !local));
}
for (const s of schemas)
if (!eq(s, defaultSchema) || schemas.size > 1)
@@ -204,7 +204,9 @@ export function sqlCandidates({ model, analysis: a, driver, sourceName }: Comple
case "afterTable": {
if (!ctx.ref.alias && ctx.ref.kind === "table" && a.verb !== "INSERT" && a.verb !== "REPLACE") {
// IDEA suggests an alias first.
const taken = new Set(a.refs.filter((r) => r !== ctx.ref).flatMap((r) => [refName(r).toLowerCase(), r.name.toLowerCase()]));
const taken = new Set(
a.refs.filter((r) => r !== ctx.ref).flatMap((r) => [refName(r).toLowerCase(), r.name.toLowerCase()]),
);
out.push({ label: aliasFor(ctx.ref.name, taken), kind: "alias", boost: 45 });
}
if (a.verb === "UPDATE") keywords(["SET", "AS"], 20);
@@ -237,7 +239,15 @@ export function sqlCandidates({ model, analysis: a, driver, sourceName }: Comple
// The columns an INSERT needs (no default), then all of them.
const required = all.filter((c) => !c.hasDefault).map((c) => c.name);
if (required.length > 1) out.push(columnsCandidate(ctx.target, required, 60, false));
if (all.length > 1 && all.length !== required.length) out.push(columnsCandidate(ctx.target, all.map((c) => c.name), 55, false));
if (all.length > 1 && all.length !== required.length)
out.push(
columnsCandidate(
ctx.target,
all.map((c) => c.name),
55,
false,
),
);
out.push(...all.map((c) => columnCandidate(c, ownerOf(ctx.target), t, 50)));
keywords(["SELECT", "VALUES"], 5);
return out;
@@ -271,14 +281,21 @@ export function sqlCandidates({ model, analysis: a, driver, sourceName }: Comple
seen.add(key);
const ambiguous = r.depth > 0 || (count.get(c.name.toLowerCase()) ?? 0) > 1;
out.push(
columnCandidate(c, owner, t, r.depth === 0 ? 40 : 20, ambiguous ? `${q(refName(r))}.${q(c.name)}` : undefined),
columnCandidate(
c,
owner,
t,
r.depth === 0 ? 40 : 20,
ambiguous ? `${q(refName(r))}.${q(c.name)}` : undefined,
),
);
}
if (assigning) continue;
out.push({ label: refName(r), kind: "alias", boost: 35 });
// The whole column list (IDEA's "expand *" item in the select list).
const names = columnsOf(r).map((c) => c.name);
if (clause === "select" && r.depth === 0 && names.length > 1) out.push(columnsCandidate(r, names, 30, inner.length > 1));
if (clause === "select" && r.depth === 0 && names.length > 1)
out.push(columnsCandidate(r, names, 30, inner.length > 1));
}
if (assigning) return;
if (!inner.length && a.fromInsert !== null && clause === "select") {
@@ -478,5 +495,5 @@ function tableDoc(t: DbCompletionTable): string {
const cols = t.columns.map(
(c) => ` ${c.name.padEnd(width)} ${c.dataType}${c.nullable ? "" : " NOT NULL"}${c.primaryKey ? " PK" : ""}`,
);
return `${t.kind === "view" ? "VIEW" : "TABLE"} ${t.schema}.${t.name}\n${cols.join("\n")}`;
return `${t.kind === "materializedView" ? "MATERIALIZED VIEW" : t.kind === "view" ? "VIEW" : "TABLE"} ${t.schema}.${t.name}\n${cols.join("\n")}`;
}