Files
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 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;",
);
});
});