Files
openidea/src/features/database/ChartView.tsx
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

193 lines
7.4 KiB
TypeScript

/**
* IDEA's result chart: one numeric column (Y) against a category column (X)
* as bars or a line, with the Y values of equal X combined (sum, average…).
*/
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { IconButton, Select, T } from "@/ui";
import { isNumericColumn, type GridColumn } from "./Grid";
import type { Cell } from "./model";
import { chartSeries, formatNumber, niceTicks, type ChartAggregate } from "./resultView";
import styles from "./Database.module.css";
const AGGREGATES: ReadonlyArray<{ value: ChartAggregate; label: string }> = [
{ value: "none", label: "No aggregation" },
{ value: "sum", label: "Sum" },
{ value: "avg", label: "Mean" },
{ value: "count", label: "Count" },
{ value: "min", label: "Min" },
{ value: "max", label: "Max" },
];
const PAD = { left: 64, right: 16, top: 12, bottom: 44 };
const grouped = new Intl.NumberFormat("en-US", { maximumFractionDigits: 6 });
export function ChartView({ columns, rows }: { columns: readonly GridColumn[]; rows: ReadonlyArray<readonly Cell[]> }) {
const numeric = useMemo(
() =>
columns.map((c, i) =>
isNumericColumn(
c.type,
rows.slice(0, 50).map((r) => r[i] ?? null),
),
),
[columns, rows],
);
const firstText = numeric.findIndex((n) => !n);
const [x, setX] = useState(Math.max(0, firstText));
const [y, setY] = useState(() => {
const n = numeric.findLastIndex((isNum, i) => isNum && i !== Math.max(0, firstText));
return n >= 0 ? n : Math.min(1, columns.length - 1);
});
const [kind, setKind] = useState<"bar" | "line">("bar");
// IDEA combines equal X values with their mean unless told otherwise.
const [aggregate, setAgg] = useState<ChartAggregate>("avg");
const [settings, setSettings] = useState(false);
const points = useMemo(() => chartSeries(rows, x, y, aggregate), [rows, x, y, aggregate]);
const host = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ w: 600, h: 300 });
useLayoutEffect(() => {
const el = host.current;
if (!el) return;
const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight }));
ro.observe(el);
setSize({ w: el.clientWidth, h: el.clientHeight });
return () => ro.disconnect();
}, []);
const options = columns.map((c, i) => ({ value: String(i), label: c.name }));
const values = points.map((p) => p.value);
const ticks = niceTicks(
Math.min(0, ...values),
Math.max(0, ...values),
Math.max(2, Math.floor((size.h - PAD.top - PAD.bottom) / 32)),
);
const lo = ticks[0] ?? 0;
const hi = ticks.at(-1) ?? 1;
const plotW = Math.max(1, size.w - PAD.left - PAD.right);
const plotH = Math.max(1, size.h - PAD.top - PAD.bottom);
const yAt = (v: number) => PAD.top + plotH - ((v - lo) / (hi - lo || 1)) * plotH;
const band = plotW / Math.max(1, points.length);
const xAt = (i: number) => PAD.left + band * i + band / 2;
// Every n-th label so they do not overlap.
const labelEvery = Math.max(1, Math.ceil(points.length / Math.max(1, Math.floor(plotW / 72))));
const yName = `${columns[y]?.name ?? ""}${aggregate === "none" ? "" : ` (${AGGREGATES.find((a) => a.value === aggregate)!.label.toLowerCase()})`}`;
return (
<div className={styles.view}>
{settings && (
<div className={styles.chartBar}>
<span className={styles.formLabel}>
<T>X:</T>
</span>
<Select aria-label="X axis" value={String(x)} options={options} onChange={(v) => setX(Number(v))} />
<span className={styles.formLabel}>
<T>Y:</T>
</span>
<Select aria-label="Y axis" value={String(y)} options={options} onChange={(v) => setY(Number(v))} />
<Select
aria-label="Aggregation"
value={aggregate}
options={AGGREGATES.map((a) => ({ ...a }))}
onChange={(v) => setAgg(v as ChartAggregate)}
/>
<Select
aria-label="Chart type"
value={kind}
options={[
{ value: "bar", label: "Bar" },
{ value: "line", label: "Line" },
]}
onChange={(v) => setKind(v as "bar" | "line")}
/>
</div>
)}
<div ref={host} className={styles.chartHost}>
<IconButton
className={styles.chartSettings}
icon="settings"
title="Chart Settings"
aria-pressed={settings}
onClick={() => setSettings(!settings)}
/>
{!points.length ? (
<div className={styles.filterEmpty}>
<T>No numeric values to plot</T>
</div>
) : (
<svg width={size.w} height={size.h} role="img" aria-label={`${yName} by ${columns[x]?.name ?? ""}`}>
{ticks.map((t) => (
<g key={t}>
<line className={styles.chartGrid} x1={PAD.left} x2={PAD.left + plotW} y1={yAt(t)} y2={yAt(t)} />
<text
className={styles.chartTick}
x={PAD.left - 6}
y={yAt(t)}
textAnchor="end"
dominantBaseline="middle"
>
{grouped.format(t)}
</text>
</g>
))}
{points.map((_, i) => (
<line
key={i}
className={styles.chartGrid}
x1={PAD.left + band * (i + 1)}
x2={PAD.left + band * (i + 1)}
y1={PAD.top}
y2={PAD.top + plotH}
/>
))}
{kind === "bar"
? points.map((p, i) => (
<rect
key={i}
className={styles.chartBarMark}
x={PAD.left + band * i + band * 0.05}
width={band * 0.9}
y={Math.min(yAt(p.value), yAt(0))}
height={Math.abs(yAt(0) - yAt(p.value))}
>
<title>{`${p.label}: ${formatNumber(p.value)}`}</title>
</rect>
))
: [
<polyline
key="line"
className={styles.chartLine}
points={points.map((p, i) => `${xAt(i)},${yAt(p.value)}`).join(" ")}
/>,
...points.map((p, i) => (
<circle key={i} className={styles.chartDot} cx={xAt(i)} cy={yAt(p.value)} r={3}>
<title>{`${p.label}: ${formatNumber(p.value)}`}</title>
</circle>
)),
]}
<line className={styles.chartAxis} x1={PAD.left} x2={PAD.left + plotW} y1={yAt(0)} y2={yAt(0)} />
{points.map((p, i) =>
i % labelEvery ? null : (
<text key={i} className={styles.chartTick} x={xAt(i)} y={PAD.top + plotH + 14} textAnchor="middle">
{p.label.length > 14 ? `${p.label.slice(0, 13)}…` : p.label}
</text>
),
)}
<text className={styles.chartAxisName} x={PAD.left + plotW / 2} y={size.h - 6} textAnchor="middle">
{columns[x]?.name}
</text>
<text
className={styles.chartAxisName}
transform={`translate(12 ${PAD.top + plotH / 2}) rotate(-90)`}
textAnchor="middle"
>
{yName}
</text>
</svg>
)}
</div>
</div>
);
}