Files
l0ng-ai 575749f2ac 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
2026-10-27 13:31:29 +08:00

100 lines
3.8 KiB
TypeScript

/**
* Result-set export formats (IDEA's data extractors): CSV, TSV, JSON, SQL
* INSERT statements and a Markdown table. Values arrive as text; numeric
* columns are written unquoted where the format distinguishes them.
*/
import type { DbDriver } from "@/platform/ipc";
import { isNumericColumn } from "./Grid";
import { toCsv, toTsv, type Cell } from "./model";
import { quoteIdent } from "./sql/complete";
export type ExportFormat = "csv" | "tsv" | "json" | "sql" | "markdown";
export const EXPORT_FORMATS: ReadonlyArray<{ id: ExportFormat; label: string; extension: string }> = [
{ id: "csv", label: "CSV", extension: "csv" },
{ id: "tsv", label: "TSV", extension: "tsv" },
{ id: "json", label: "JSON", extension: "json" },
{ id: "sql", label: "SQL Inserts", extension: "sql" },
{ id: "markdown", label: "Markdown", extension: "md" },
];
export interface ExportData {
columns: ReadonlyArray<{ name: string; type: string }>;
rows: ReadonlyArray<readonly Cell[]>;
/** Target of SQL inserts (the query's table, when it reads one). */
table: string;
driver: DbDriver;
}
/** The Export Data dialog's options (CSV, TSV and Markdown; JSON and SQL keep their shape). */
export interface ExportOptions {
columnHeader: boolean;
rowHeader: boolean;
transpose: boolean;
}
export const DEFAULT_EXPORT_OPTIONS: ExportOptions = { columnHeader: true, rowHeader: false, transpose: false };
/** Header and rows of a delimited export after the row header / transpose options. */
function laidOut(d: ExportData, o: ExportOptions): { head: string[] | null; rows: Cell[][] } {
let head = d.columns.map((c) => c.name);
let rows = d.rows.map((r) => [...r]);
if (o.rowHeader) {
head = ["#", ...head];
rows = rows.map((r, i) => [String(i + 1), ...r]);
}
if (!o.transpose) return { head: o.columnHeader ? head : null, rows };
return { head: null, rows: head.map((h, c) => [...(o.columnHeader ? [h] : []), ...rows.map((r) => r[c] ?? null)]) };
}
export function exportData(
format: ExportFormat,
d: ExportData,
options: ExportOptions = DEFAULT_EXPORT_OPTIONS,
): string {
const names = d.columns.map((c) => c.name);
const numeric = d.columns.map((c, i) =>
isNumericColumn(
c.type,
d.rows.slice(0, 50).map((r) => r[i] ?? null),
),
);
switch (format) {
case "csv": {
const t = laidOut(d, options);
return toCsv(t.head, t.rows);
}
case "tsv": {
const t = laidOut(d, options);
return toTsv(t.head, t.rows);
}
case "json": {
const value = (v: Cell, i: number) =>
v === null ? null : numeric[i] && Number.isFinite(Number(v)) ? Number(v) : v;
return JSON.stringify(
d.rows.map((r) => Object.fromEntries(names.map((n, i) => [n, value(r[i] ?? null, i)]))),
null,
2,
);
}
case "sql": {
const literal = (v: Cell, i: number) =>
v === null ? "NULL" : numeric[i] && Number.isFinite(Number(v)) ? v : `'${v.replace(/'/g, "''")}'`;
const head = `INSERT INTO ${quoteIdent(d.table, d.driver)} (${names.map((n) => quoteIdent(n, d.driver)).join(", ")}) VALUES`;
return d.rows.map((r) => `${head} (${r.map(literal).join(", ")});`).join("\n");
}
case "markdown": {
const cell = (v: Cell) => (v ?? "").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
const t = laidOut(d, options);
const width = t.head?.length ?? t.rows[0]?.length ?? 0;
// A Markdown table always has a header row: blank when left out.
const head = t.head ?? Array.from({ length: width }, () => "");
const align =
options.transpose || t.head === null
? head.map(() => "---")
: [...(options.rowHeader ? ["--:"] : []), ...numeric.map((n) => (n ? "--:" : "---"))];
return [head.map(cell), align, ...t.rows.map((r) => r.map(cell))].map((r) => `| ${r.join(" | ")} |`).join("\n");
}
}
}