Файли
openidea/src/features/database/resultView.test.ts
T
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

222 рядки
7.1 KiB
TypeScript
Неформатований Анотація Історія

Цей файл містить невидимі символи Юнікоду
Цей файл містить невидимі символи Юнікоду, які не розрізняються людиною, але можуть по-різному оброблятися комп'ютером. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
import { describe, expect, it } from "vitest";
import { exportData, type ExportData } from "./export";
import {
formatOrderBy,
guessSeparator,
importEdits,
parseDelimited,
parseOrderBy,
passesObjectFilter,
type Cell,
} from "./model";
import { dropSql } from "./objects";
import {
aggregates,
chartSeries,
compareCells,
distinctValues,
niceTicks,
toggleFilterValue,
visibleRows,
type LocalFilter,
} from "./resultView";
const rows: Cell[][] = [
["1", "Shanghai", "10"],
["2", "Beijing", null],
["3", "Shanghai", "2.5"],
["4", null, "7"],
];
const value = (r: number, c: number) => rows[r]?.[c] ?? null;
describe("local filter and sort", () => {
it("compares numbers by value, NULL first", () => {
expect(["10", "9", null, "abc"].sort(compareCells)).toEqual([null, "9", "10", "abc"]);
});
it("keeps the rows whose values are checked, then sorts them", () => {
const filter: LocalFilter = new Map([[1, new Set<Cell>(["Shanghai", null])]]);
expect(visibleRows(4, value, filter, null)).toEqual([0, 2, 3]);
expect(visibleRows(4, value, filter, { column: 2, descending: true })).toEqual([0, 3, 2]);
expect(visibleRows(4, value, new Map(), { column: 2, descending: false })).toEqual([1, 2, 3, 0]);
});
it("leaves rows past `fixed` (added rows) last and unfiltered", () => {
const filter: LocalFilter = new Map([[1, new Set<Cell>(["Beijing"])]]);
expect(visibleRows(4, value, filter, null, 3)).toEqual([1, 3]);
});
it("lists distinct values with counts and toggles them", () => {
const d = distinctValues(4, value, 1);
expect(d).toEqual([
{ value: null, count: 1 },
{ value: "Beijing", count: 1 },
{ value: "Shanghai", count: 2 },
]);
const all = d.map((x) => x.value);
let f = toggleFilterValue(new Map(), 1, all, "Beijing", false);
expect([...f.get(1)!]).toEqual([null, "Shanghai"]);
f = toggleFilterValue(f, 1, all, "Beijing", true);
expect(f.has(1)).toBe(false);
});
});
describe("aggregates", () => {
it("sums numbers and reports text min / max", () => {
expect(aggregates(["10", null, "2.5", "7"])).toMatchObject({
count: 4,
distinct: 4,
nulls: 1,
sum: 19.5,
avg: 6.5,
median: 7,
min: "2.5",
max: "10",
});
expect(aggregates(["b", "a", "b"])).toMatchObject({ distinct: 2, sum: null, avg: null, min: "a", max: "b" });
});
});
describe("chart series", () => {
it("combines Y per X", () => {
expect(chartSeries(rows, 1, 2, "sum")).toEqual([
{ label: "Shanghai", value: 12.5 },
{ label: "Beijing", value: 0 },
{ label: "<null>", value: 7 },
]);
expect(chartSeries(rows, 1, 2, "count").map((p) => p.value)).toEqual([2, 1, 1]);
expect(chartSeries(rows, 0, 2, "none").map((p) => p.label)).toEqual(["1", "3", "4"]);
});
it("picks round ticks from zero", () => {
expect(niceTicks(0, 3100)).toEqual([0, 1000, 2000, 3000, 4000]);
expect(niceTicks(-5, 5)).toEqual([-6, -4, -2, 0, 2, 4, 6]);
});
});
describe("export options", () => {
const data: ExportData = {
columns: [
{ name: "id", type: "INTEGER" },
{ name: "city", type: "TEXT" },
],
rows: [
["1", "Shanghai"],
["2", null],
],
table: "customers",
driver: "sqlite",
};
it("leaves the header out, adds row numbers and transposes", () => {
const opts = { columnHeader: false, rowHeader: false, transpose: false };
expect(exportData("csv", data, opts)).toBe("1,Shanghai\r\n2,");
expect(exportData("csv", data, { ...opts, rowHeader: true, columnHeader: true })).toBe(
"#,id,city\r\n1,1,Shanghai\r\n2,2,",
);
expect(exportData("tsv", data, { ...opts, columnHeader: true, transpose: true })).toBe(
"id\t1\t2\ncity\tShanghai\t",
);
expect(exportData("csv", data)).toBe("id,city\r\n1,Shanghai\r\n2,");
});
});
describe("import from file", () => {
it("parses quoted CSV and guesses the separator", () => {
expect(parseDelimited('a,"b ""q""",c\r\n1,"x,y",\n', ",")).toEqual([
["a", 'b "q"', "c"],
["1", "x,y", ""],
]);
expect(guessSeparator("a\tb\tc\n1\t2\t3")).toBe("\t");
expect(guessSeparator("a;b;c")).toBe(";");
});
it("maps columns by header name or position", () => {
const parsed = [
["CITY", "nope", "id"],
["Shanghai", "x", "1"],
["", "", ""],
["", "y", "2"],
];
const byName = importEdits(parsed, ["id", "city"], true, true);
expect(byName.mapped).toEqual(["city", "id"]);
expect(byName.edits).toEqual([
{
kind: "insert",
values: [
{ column: "city", value: "Shanghai" },
{ column: "id", value: "1" },
],
},
{
kind: "insert",
values: [
{ column: "city", value: null },
{ column: "id", value: "2" },
],
},
]);
const byPosition = importEdits([["1", "a"]], ["id", "city", "extra"], false, false);
expect(byPosition.edits).toEqual([
{
kind: "insert",
values: [
{ column: "id", value: "1" },
{ column: "city", value: "a" },
],
},
]);
});
});
describe("ORDER BY field", () => {
it("round-trips the sort and rejects unknown columns", () => {
const cols = ["id", "Name"];
expect(parseOrderBy('name desc, "id" ASC', cols)).toEqual([
{ column: "Name", descending: true },
{ column: "id", descending: false },
]);
expect(
formatOrderBy([
{ column: "Name", descending: true },
{ column: "id", descending: false },
]),
).toBe("Name DESC, id");
expect(parseOrderBy("", cols)).toEqual([]);
expect(parseOrderBy("nope", cols)).toBeNull();
});
});
describe("object filter", () => {
it("matches any of its patterns, ignoring case", () => {
expect(passesObjectFilter("orders", "")).toBe(true);
expect(passesObjectFilter("Order_Items", "order*, customers")).toBe(true);
expect(passesObjectFilter("customers", "order* customers")).toBe(true);
expect(passesObjectFilter("products", "order*,customers")).toBe(false);
});
});
describe("dropping schema objects", () => {
const opts = { ifExists: true, cascade: false };
it("drops routines, triggers, sequences and materialized views per dialect", () => {
expect(dropSql("postgres", { kind: "function", schema: "public", name: "f", arguments: "a integer" }, opts)).toBe(
"drop function if exists public.f(a integer);",
);
expect(dropSql("mysql", { kind: "procedure", schema: "db", name: "p" }, opts)).toBe(
"drop procedure if exists db.p;",
);
expect(dropSql("postgres", { kind: "trigger", schema: "s", name: "t", table: "x" }, opts)).toBe(
"drop trigger if exists t on s.x;",
);
expect(dropSql("sqlite", { kind: "trigger", schema: "main", name: "t", table: "x" }, opts)).toBe(
"drop trigger if exists t;",
);
expect(dropSql("postgres", { kind: "sequence", schema: "s", name: "q" }, opts)).toBe(
"drop sequence if exists s.q;",
);
expect(dropSql("postgres", { kind: "materializedView", schema: "s", name: "m" }, opts)).toBe(
"drop materialized view if exists s.m;",
);
});
});