Files
openidea/crates/ide-db/src/history.rs
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

138 lines
4.9 KiB
Rust

//! Query history (IDEA's Browse Query History): the statements each data
//! source ran, newest first. One JSON file in the app's data directory for all
//! projects, keyed by project root, then data source id — never committed.
use ide_core::{IdeError, IdeResult};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// Statements kept per data source.
pub const MAX_ENTRIES: usize = 500;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[serde(rename = "DbHistoryEntry")]
pub struct HistoryEntry {
pub text: String,
/// Milliseconds since the Unix epoch.
pub time: f64,
}
/// Project root → data source id → entries, newest first.
type Contents = BTreeMap<String, BTreeMap<String, Vec<HistoryEntry>>>;
/// Serialises read-modify-write across the projects sharing the file.
static LOCK: Mutex<()> = Mutex::new(());
pub struct HistoryStore {
path: PathBuf,
project: String,
}
impl HistoryStore {
pub fn new(path: PathBuf, root: &Path) -> Self {
Self { path, project: root.to_string_lossy().into_owned() }
}
pub fn list(&self, source: &str) -> Vec<HistoryEntry> {
let _g = LOCK.lock();
self.read().remove(&self.project).and_then(|mut p| p.remove(source)).unwrap_or_default()
}
/// Records `text`; running a statement again moves it to the top.
pub fn add(&self, source: &str, text: &str, time: f64) -> IdeResult<()> {
let text = text.trim();
if text.is_empty() {
return Ok(());
}
let _g = LOCK.lock();
let mut all = self.read();
let entries = all.entry(self.project.clone()).or_default().entry(source.to_owned()).or_default();
entries.retain(|e| e.text != text);
entries.insert(0, HistoryEntry { text: text.to_owned(), time });
entries.truncate(MAX_ENTRIES);
self.write(&all)
}
/// Removes the entries with these texts (all of them for `None`).
pub fn remove(&self, source: &str, texts: Option<&[String]>) -> IdeResult<()> {
let _g = LOCK.lock();
let mut all = self.read();
let Some(project) = all.get_mut(&self.project) else { return Ok(()) };
match texts {
None => {
project.remove(source);
}
Some(texts) => {
if let Some(entries) = project.get_mut(source) {
entries.retain(|e| !texts.contains(&e.text));
}
}
}
self.write(&all)
}
fn read(&self) -> Contents {
match std::fs::read(&self.path) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
tracing::warn!("query history: {}: {e}", self.path.display());
Contents::new()
}),
Err(_) => Contents::new(),
}
}
fn write(&self, all: &Contents) -> IdeResult<()> {
let err = |e: std::io::Error| IdeError::unavailable(format!("query history: {e}"));
if let Some(dir) = self.path.parent() {
std::fs::create_dir_all(dir).map_err(err)?;
}
let tmp = self.path.with_extension("json.tmp");
let bytes = serde_json::to_vec(all).map_err(|e| IdeError::internal(e.to_string()))?;
std::fs::write(&tmp, bytes).map_err(err)?;
std::fs::rename(&tmp, &self.path).map_err(err)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_newest_first_without_duplicates_per_project_and_source() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("h.json");
let a = HistoryStore::new(file.clone(), Path::new("/p/a"));
let b = HistoryStore::new(file, Path::new("/p/b"));
a.add("s1", "select 1", 1.0).unwrap();
a.add("s1", " select 2 ", 2.0).unwrap();
a.add("s1", "select 1", 3.0).unwrap();
a.add("s2", "select 3", 4.0).unwrap();
b.add("s1", "select 4", 5.0).unwrap();
let texts = |v: Vec<HistoryEntry>| v.into_iter().map(|e| e.text).collect::<Vec<_>>();
assert_eq!(texts(a.list("s1")), ["select 1", "select 2"]);
assert_eq!(texts(a.list("s2")), ["select 3"]);
assert_eq!(texts(b.list("s1")), ["select 4"]);
a.remove("s1", Some(&["select 2".to_owned()])).unwrap();
assert_eq!(texts(a.list("s1")), ["select 1"]);
a.remove("s1", None).unwrap();
assert!(a.list("s1").is_empty());
assert_eq!(texts(b.list("s1")), ["select 4"]);
}
#[test]
fn caps_entries() {
let dir = tempfile::tempdir().unwrap();
let h = HistoryStore::new(dir.path().join("h.json"), Path::new("/p"));
for i in 0..MAX_ENTRIES + 5 {
h.add("s", &format!("select {i}"), i as f64).unwrap();
}
let list = h.list("s");
assert_eq!(list.len(), MAX_ENTRIES);
assert_eq!(list[0].text, format!("select {}", MAX_ENTRIES + 4));
}
}