From 575749f2ac0a90ef57aab6a1dfabf0c758714f93 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:31:29 +0800 Subject: [PATCH] feat(database): Add query plans, history, data tools and schema objects (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/ide-db/src/conn.rs | 15 +- crates/ide-db/src/history.rs | 137 ++++++ crates/ide-db/src/lib.rs | 221 ++++++++- crates/ide-db/src/plan.rs | 268 +++++++++++ crates/ide-db/src/source.rs | 19 +- crates/ide-db/src/sql.rs | 161 ++++++- crates/ide-db/tests/sqlite.rs | 53 ++- docs/plugins/themes.md | 8 +- src-tauri/src/commands/db.rs | 71 ++- src-tauri/src/commands/mod.rs | 5 + src-tauri/src/state.rs | 6 +- src/bindings.ts | 78 +++- src/features/database/ChartView.tsx | 192 ++++++++ src/features/database/CodePreview.tsx | 36 ++ src/features/database/DataDialogs.tsx | 247 ++++++++++ src/features/database/DataView.tsx | 412 ++++++++++++++++ src/features/database/Database.module.css | 442 +++++++++++++++++- src/features/database/DatabaseView.tsx | 105 ++++- src/features/database/Dialogs.tsx | 10 + src/features/database/ExportDialog.tsx | 169 +++++++ src/features/database/Grid.tsx | 85 +++- src/features/database/ObjectDialogs.tsx | 39 +- src/features/database/ResultsView.tsx | 215 ++++++--- src/features/database/SourceDialog.tsx | 99 +++- src/features/database/TableEditor.tsx | 158 +++++-- src/features/database/console.tsx | 71 ++- src/features/database/export.ts | 53 ++- src/features/database/index.ts | 42 +- src/features/database/model.ts | 133 +++++- src/features/database/nodes.ts | 56 ++- src/features/database/objects.ts | 77 ++- src/features/database/resultView.test.ts | 221 +++++++++ src/features/database/resultView.ts | 194 ++++++++ src/features/database/sql/complete.ts | 31 +- src/features/database/sqlSupport.ts | 25 +- src/features/database/store.ts | 182 +++++++- src/features/database/treeActions.ts | 86 +++- src/features/database/treeMenu.ts | 81 +++- src/styles/tokens.css | 6 + src/ui/icons.tsx | 17 +- src/ui/openidea/database/chart.svg | 4 + src/ui/openidea/database/matview.svg | 4 + src/ui/openidea/database/plan.svg | 4 + src/ui/openidea/database/routine.svg | 4 + src/ui/openidea/database/sequence.svg | 4 + src/ui/openidea/database/sort.svg | 4 + src/ui/openidea/database/trigger.svg | 4 + .../themes/harbor-night.theme.json | 3 + 48 files changed, 4268 insertions(+), 289 deletions(-) create mode 100644 crates/ide-db/src/history.rs create mode 100644 crates/ide-db/src/plan.rs create mode 100644 src/features/database/ChartView.tsx create mode 100644 src/features/database/CodePreview.tsx create mode 100644 src/features/database/DataDialogs.tsx create mode 100644 src/features/database/DataView.tsx create mode 100644 src/features/database/ExportDialog.tsx create mode 100644 src/features/database/resultView.test.ts create mode 100644 src/features/database/resultView.ts create mode 100644 src/ui/openidea/database/chart.svg create mode 100644 src/ui/openidea/database/matview.svg create mode 100644 src/ui/openidea/database/plan.svg create mode 100644 src/ui/openidea/database/routine.svg create mode 100644 src/ui/openidea/database/sequence.svg create mode 100644 src/ui/openidea/database/sort.svg create mode 100644 src/ui/openidea/database/trigger.svg diff --git a/crates/ide-db/src/conn.rs b/crates/ide-db/src/conn.rs index 95b3ff606..83f32315d 100644 --- a/crates/ide-db/src/conn.rs +++ b/crates/ide-db/src/conn.rs @@ -40,6 +40,8 @@ pub struct QueryResult { /// More rows exist beyond the limit. pub has_more: bool, pub elapsed_ms: f64, + /// Until the server's first answer; the rest of `elapsed_ms` is fetching. + pub execution_ms: f64, } pub enum Pool { @@ -130,7 +132,9 @@ macro_rules! fetch { let mut affected: Option = None; let mut has_more = false; let mut failure = None; + let mut first: Option = None; while let Some(item) = stream.next().await { + first.get_or_insert_with(Instant::now); match item { Ok(sqlx::Either::Left(done)) => affected = Some(affected.unwrap_or(0) + done.rows_affected()), Ok(sqlx::Either::Right(row)) => { @@ -152,12 +156,13 @@ macro_rules! fetch { drop(stream); match failure { Some(e) => Err(e), - None => Ok((columns, rows, affected, has_more)), + None => Ok((columns, rows, affected, has_more, first)), } }}; } -type Fetched = (Vec, Vec>>, Option, bool); +/// Columns, rows, rows affected, more rows beyond the limit, time of the first answer. +type Fetched = (Vec, Vec>>, Option, bool, Option); /// The server-side id of a connection (to cancel its statement from another one). macro_rules! backend_id { @@ -552,14 +557,16 @@ impl Held { } Held::Mysql(c) => run!(c, Some("SELECT CONNECTION_ID()"), |b: String| format!("KILL QUERY {b}")), }; - let (columns, rows, affected, has_more) = fetched; + let (columns, rows, affected, has_more, first) = fetched; + let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0; Ok(QueryResult { statement: sql.to_owned(), affected: if columns.is_empty() { Some(affected.unwrap_or(0) as f64) } else { None }, columns, rows, has_more, - elapsed_ms: started.elapsed().as_secs_f64() * 1000.0, + elapsed_ms, + execution_ms: first.map_or(elapsed_ms, |t| t.duration_since(started).as_secs_f64() * 1000.0), }) } } diff --git a/crates/ide-db/src/history.rs b/crates/ide-db/src/history.rs new file mode 100644 index 000000000..21213d3c2 --- /dev/null +++ b/crates/ide-db/src/history.rs @@ -0,0 +1,137 @@ +//! 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>>; + +/// 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 { + 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| v.into_iter().map(|e| e.text).collect::>(); + 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)); + } +} diff --git a/crates/ide-db/src/lib.rs b/crates/ide-db/src/lib.rs index b115e629e..1a7f120aa 100644 --- a/crates/ide-db/src/lib.rs +++ b/crates/ide-db/src/lib.rs @@ -3,6 +3,8 @@ //! and statement splitting ([`split`]), for SQLite, PostgreSQL and MySQL/MariaDB. pub mod conn; +pub mod history; +pub mod plan; pub mod secrets; pub mod source; pub mod split; @@ -10,11 +12,14 @@ pub mod sql; pub mod tunnel; pub use conn::{Held, Pool, QueryResult, ResultColumn}; +pub use history::HistoryEntry; +pub use plan::{PlanNode, PlanProperty}; pub use source::{ DataSource, DbColor, DbDriver, DbIntrospection, DbOptions, DbProperty, DbSsh, DbSshAuth, DbSslMode, DbTxMode, }; pub use sql::{CellValue, DbRowEdit, SortKey}; +use history::HistoryStore; use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind}; use parking_lot::Mutex; use secrets::SecretStore; @@ -31,6 +36,67 @@ use tunnel::{OpenError, Tunnel, TunnelSpec, UnknownHostKey}; pub enum DbTableKind { Table, View, + MaterializedView, +} + +impl DbTableKind { + fn from_catalog(kind: Option<&str>) -> Self { + match kind { + Some("view") => Self::View, + Some("materialized view") => Self::MaterializedView, + _ => Self::Table, + } + } +} + +/// Schema objects other than tables and columns (Go to DDL, Drop). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum DbObjectKind { + Function, + Procedure, + Trigger, + Sequence, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[serde(rename = "DbTriggerInfo")] +pub struct TriggerInfo { + pub name: String, + /// `before`, `after`, `instead of`. + pub timing: String, + /// `insert`, `update or delete`… + pub events: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[serde(rename = "DbRoutineInfo")] +pub struct RoutineInfo { + pub name: String, + /// `Function` or `Procedure`. + pub kind: DbObjectKind, + pub arguments: String, + /// Result type of a function. + pub returns: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[serde(rename = "DbSequenceInfo")] +pub struct SequenceInfo { + pub name: String, + pub data_type: String, +} + +/// A schema's routines and sequences (the tree's `routines` / `sequences` folders). +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[serde(rename = "DbSchemaObjects")] +pub struct SchemaObjects { + pub routines: Vec, + pub sequences: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -68,6 +134,7 @@ pub struct TableDetails { pub columns: Vec, pub indexes: Vec, pub foreign_keys: Vec, + pub triggers: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -148,6 +215,7 @@ pub struct DbManager { known_hosts: PathBuf, ssh_config: Option, saved_passwords: SecretStore, + history: Arc, /// Passwords entered this session (not saved, or not yet), by credential key. session_passwords: Mutex>, /// Manual-commit data sources, each with the connection of its open @@ -251,21 +319,24 @@ async fn end_session(session: &Session) { } impl DbManager { - /// `passwords` is the app-wide file of saved passwords ([`SecretStore`]). - pub fn new(root: PathBuf, passwords: PathBuf) -> Self { + /// `passwords` / `history` are the app-wide files of saved passwords + /// ([`SecretStore`]) and query history ([`HistoryStore`]). + pub fn new(root: PathBuf, passwords: PathBuf, history: PathBuf) -> Self { let ssh_dir = dirs::home_dir().unwrap_or_default().join(".ssh"); - Self::with_ssh_files(root, passwords, ssh_dir.join("known_hosts"), Some(ssh_dir.join("config"))) + Self::with_ssh_files(root, passwords, history, ssh_dir.join("known_hosts"), Some(ssh_dir.join("config"))) } /// [`DbManager::new`] with other `known_hosts` / `~/.ssh/config` files (tests). pub fn with_ssh_files( root: PathBuf, passwords: PathBuf, + history: PathBuf, known_hosts: PathBuf, ssh_config: Option, ) -> Self { Self { saved_passwords: SecretStore::new(passwords, &root), + history: Arc::new(HistoryStore::new(history, &root)), root, pools: Mutex::default(), tunnels: Mutex::default(), @@ -529,14 +600,15 @@ impl DbManager { /// The databases of the server of data source `id` (PostgreSQL), the connected one first. pub async fn databases(&self, id: &str) -> IdeResult { let id = split_target(id).0; - let system = self.source(id)?.introspection.show_system; + let introspection = self.source(id)?.introspection; + let system = introspection.show_system; let pool = self.pool(id).await?; if pool.driver() != DbDriver::Postgres { return Err(IdeError::invalid("only PostgreSQL data sources list databases")); } let first = |rows: Vec>>| rows.into_iter().filter_map(|r| r.into_iter().next().flatten()); let current = first(pool.rows("SELECT current_database()", 1).await?).next().unwrap_or_default(); - let rows = pool.rows(sql::databases(system), MAX_META_ROWS).await?; + let rows = pool.rows(sql::databases(introspection.show_templates), MAX_META_ROWS).await?; let mut names: Vec = first(rows).filter(|n| system || *n == current || !sql::INTERNAL_DATABASES.contains(&n.as_str())).collect(); if !names.contains(¤t) { @@ -562,9 +634,7 @@ impl DbManager { .filter_map(|r| { let mut it = r.into_iter(); let name = it.next().flatten()?; - let kind = - if it.next().flatten().as_deref() == Some("view") { DbTableKind::View } else { DbTableKind::Table }; - Some(TableInfo { name, kind }) + Some(TableInfo { name, kind: DbTableKind::from_catalog(it.next().flatten().as_deref()) }) }) .collect()) } @@ -612,7 +682,85 @@ impl DbManager { .collect(); } foreign_keys.retain(|k| k.columns.len() == k.ref_columns.len()); - Ok(TableDetails { columns, indexes, foreign_keys }) + let triggers = pool + .rows(&sql::triggers(driver, schema, table), MAX_META_ROWS) + .await + .unwrap_or_else(|e| { + tracing::warn!("triggers: {}", e.message); + Vec::new() + }) + .into_iter() + .filter_map(|r| { + let mut it = r.into_iter(); + let name = it.next().flatten()?; + let (timing, events) = if driver == DbDriver::Sqlite { + sql::sqlite_trigger_event(&it.next().flatten().unwrap_or_default()) + } else { + (it.next().flatten().unwrap_or_default(), it.next().flatten().unwrap_or_default()) + }; + Some(TriggerInfo { name, timing, events }) + }) + .collect(); + Ok(TableDetails { columns, indexes, foreign_keys, triggers }) + } + + /// The routines and sequences of `schema`. + pub async fn schema_objects(&self, id: &str, schema: &str) -> IdeResult { + let pool = self.pool(id).await?; + let driver = pool.driver(); + let mut out = SchemaObjects::default(); + if let Some(q) = sql::routines(driver, schema) { + out.routines = pool + .rows(&q, MAX_META_ROWS) + .await? + .into_iter() + .filter_map(|r| { + let mut it = r.into_iter(); + let name = it.next().flatten()?; + let kind = if it.next().flatten().as_deref() == Some("procedure") { + DbObjectKind::Procedure + } else { + DbObjectKind::Function + }; + let arguments = it.next().flatten().unwrap_or_default(); + let returns = it.next().flatten().filter(|r| !r.is_empty() && kind == DbObjectKind::Function); + Some(RoutineInfo { name, kind, arguments, returns }) + }) + .collect(); + } + if let Some(q) = sql::sequences(driver, schema) { + out.sequences = pool + .rows(&q, MAX_META_ROWS) + .await? + .into_iter() + .filter_map(|r| { + let mut it = r.into_iter(); + Some(SequenceInfo { + name: it.next().flatten()?, + data_type: it.next().flatten().unwrap_or_default(), + }) + }) + .collect(); + } + Ok(out) + } + + /// The definition of a routine (every overload), trigger or sequence. + pub async fn object_ddl(&self, id: &str, schema: &str, kind: DbObjectKind, name: &str) -> IdeResult { + let pool = self.pool(id).await?; + let (q, column) = sql::object_ddl(pool.driver(), schema, kind, name) + .ok_or_else(|| IdeError::invalid(format!("no {kind:?} objects in this database")))?; + let defs: Vec = pool + .rows(&q, MAX_META_ROWS) + .await? + .into_iter() + .filter_map(|r| r.into_iter().nth(column).flatten()) + .map(|d| format!("{};\n", d.trim_end().trim_end_matches(';'))) + .collect(); + if defs.is_empty() { + return Err(IdeError::not_found(format!("{schema}.{name}"))); + } + Ok(defs.join("\n")) } /// The DDL of table or view `name`: SQLite's stored statements, MySQL's @@ -708,7 +856,7 @@ impl DbManager { _ => tables.push(CompletionTable { schema, name, - kind: if kind == "view" { DbTableKind::View } else { DbTableKind::Table }, + kind: DbTableKind::from_catalog(Some(&kind)), columns: vec![column], }), } @@ -732,12 +880,65 @@ impl DbManager { Ok(CompletionModel { default_schema, tables, foreign_keys }) } + /// Runs `statement` on `id` (in its open transaction in manual-commit mode), + /// recording it in the data source's query history. pub async fn execute( &self, id: &str, statement: &str, max_rows: usize, cancel: &CancellationToken, + ) -> IdeResult { + let r = self.run(id, statement, max_rows, cancel).await; + let ran = match &r { + Ok(_) => true, + Err(e) => !matches!(e.kind, ErrorKind::Cancelled | ErrorKind::PermissionDenied), + }; + if ran { + self.remember_statement(id, statement); + } + r + } + + fn remember_statement(&self, id: &str, statement: &str) { + let (history, source, text) = (self.history.clone(), split_target(id).0.to_owned(), statement.to_owned()); + let time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0.0, |d| d.as_secs_f64() * 1000.0); + tokio::task::spawn_blocking(move || { + if let Err(e) = history.add(&source, &text, time) { + tracing::warn!("{}", e.message); + } + }); + } + + /// The query history of data source `id`, newest first. + pub async fn history(&self, id: &str) -> IdeResult> { + let (history, source) = (self.history.clone(), split_target(id).0.to_owned()); + tokio::task::spawn_blocking(move || history.list(&source)).await.map_err(|e| IdeError::internal(e.to_string())) + } + + /// Forgets statements of the query history of `id` (all of them for `None`). + pub async fn remove_history(&self, id: &str, texts: Option>) -> IdeResult<()> { + let (history, source) = (self.history.clone(), split_target(id).0.to_owned()); + tokio::task::spawn_blocking(move || history.remove(&source, texts.as_deref())) + .await + .map_err(|e| IdeError::internal(e.to_string()))? + } + + /// The execution plan of `statement` (explained, not run). + pub async fn explain(&self, id: &str, statement: &str, cancel: &CancellationToken) -> IdeResult> { + let driver = self.pool(id).await?.driver(); + let result = self.run(id, &plan::explain_statement(driver, statement), MAX_META_ROWS, cancel).await?; + plan::parse(driver, &result) + } + + async fn run( + &self, + id: &str, + statement: &str, + max_rows: usize, + cancel: &CancellationToken, ) -> IdeResult { let pool = self.pool(id).await?; let session = self.sessions.lock().get(id).cloned(); diff --git a/crates/ide-db/src/plan.rs b/crates/ide-db/src/plan.rs new file mode 100644 index 000000000..6f0022d69 --- /dev/null +++ b/crates/ide-db/src/plan.rs @@ -0,0 +1,268 @@ +//! Execution plans (IDEA's Explain Plan): the `EXPLAIN` statement of each +//! dialect and its output read into one flat tree of operations. + +use crate::DbDriver; +use crate::conn::QueryResult; +use ide_core::{IdeError, IdeResult}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use specta::Type; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[serde(rename = "DbPlanProperty")] +pub struct PlanProperty { + pub name: String, + pub value: String, +} + +/// One operation of a plan; `parent` indexes the plan's node list (roots: `None`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[serde(rename = "DbPlanNode")] +pub struct PlanNode { + pub parent: Option, + /// `Full Scan`, `Index Scan`, `Hash Join`… + pub operation: String, + /// The object it works on and its key details (`table: o;`). + pub params: String, + pub properties: Vec, +} + +/// The statement that explains `statement` without running it. +pub fn explain_statement(driver: DbDriver, statement: &str) -> String { + let s = statement.trim().trim_end_matches(';'); + match driver { + DbDriver::Sqlite => format!("EXPLAIN QUERY PLAN {s}"), + DbDriver::Postgres => format!("EXPLAIN (FORMAT JSON) {s}"), + DbDriver::Mysql => format!("EXPLAIN {s}"), + } +} + +/// Reads the result of [`explain_statement`]. +pub fn parse(driver: DbDriver, result: &QueryResult) -> IdeResult> { + match driver { + DbDriver::Sqlite => Ok(sqlite(&result.rows)), + DbDriver::Postgres => { + let text: String = + result.rows.iter().filter_map(|r| r.first().cloned().flatten()).collect::>().join("\n"); + let json: Value = serde_json::from_str(&text).map_err(|e| IdeError::invalid(format!("plan: {e}")))?; + let mut out = Vec::new(); + for root in json.as_array().into_iter().flatten() { + if let Some(plan) = root.get("Plan") { + postgres(plan, None, &mut out); + } + } + Ok(out) + } + DbDriver::Mysql => { + let names: Vec<&str> = result.columns.iter().map(|c| c.name.as_str()).collect(); + Ok(mysql(&names, &result.rows)) + } + } +} + +fn prop(name: &str, value: impl Into) -> PlanProperty { + PlanProperty { name: name.to_owned(), value: value.into() } +} + +/// SQLite rows: `id, parent, notused, detail` (`SCAN o`, `SEARCH c USING INTEGER PRIMARY KEY (rowid=?)`). +fn sqlite(rows: &[Vec>]) -> Vec { + let mut ids: Vec = Vec::new(); + let mut out = Vec::new(); + for r in rows { + let cell = |i: usize| r.get(i).cloned().flatten().unwrap_or_default(); + let (id, parent, detail) = (cell(0), cell(1), cell(3)); + let parent = ids.iter().position(|x| *x == parent).map(|i| i as u32); + let (operation, params) = sqlite_operation(&detail); + out.push(PlanNode { parent, operation, params, properties: vec![prop("Detail", detail.clone())] }); + ids.push(id); + } + out +} + +fn sqlite_operation(detail: &str) -> (String, String) { + let words: Vec<&str> = detail.split_whitespace().collect(); + let object = |i: usize| words.get(i).map(|t| format!("table: {t};")).unwrap_or_default(); + let using = || detail.find(" USING ").map(|i| detail[i + 1..].to_owned()).unwrap_or_default(); + match words.first().copied() { + Some("SCAN") if detail.contains(" USING ") => ("Index Scan".into(), format!("{} {}", object(1), using())), + Some("SCAN") => ("Full Scan".into(), object(1)), + Some("SEARCH") => ("Index Scan".into(), format!("{} {}", object(1), using()).trim().to_owned()), + Some("USE") if detail.contains("ORDER BY") => ("Order By".into(), String::new()), + Some("USE") if detail.contains("GROUP BY") => ("Group By".into(), String::new()), + Some("USE") if detail.contains("DISTINCT") => ("Distinct".into(), String::new()), + _ => (detail.to_owned(), String::new()), + } +} + +/// PostgreSQL JSON plan node: `Node Type`, `Relation Name`, nested `Plans`. +fn postgres(node: &Value, parent: Option, out: &mut Vec) { + let text = |k: &str| node.get(k).and_then(Value::as_str).unwrap_or_default(); + let mut operation = text("Node Type").to_owned(); + if let Some(j) = node.get("Join Type").and_then(Value::as_str).filter(|j| *j != "Inner") { + operation = format!("{operation} ({j})"); + } + let mut params = Vec::new(); + if let Some(r) = node.get("Relation Name").and_then(Value::as_str) { + let alias = text("Alias"); + params.push(if alias.is_empty() || alias == r { + format!("table: {r};") + } else { + format!("table: {r} {alias};") + }); + } + if let Some(i) = node.get("Index Name").and_then(Value::as_str) { + params.push(format!("index: {i};")); + } + for k in ["Index Cond", "Hash Cond", "Merge Cond", "Filter", "Join Filter"] { + if let Some(c) = node.get(k).and_then(Value::as_str) { + params.push(format!("{}: {c};", k.to_lowercase())); + } + } + let properties = node + .as_object() + .into_iter() + .flatten() + .filter(|(k, _)| k.as_str() != "Plans") + .map(|(k, v)| { + let value = match v { + Value::String(s) => s.clone(), + Value::Array(a) => a + .iter() + .map(|x| x.as_str().map_or_else(|| x.to_string(), str::to_owned)) + .collect::>() + .join(", "), + other => other.to_string(), + }; + prop(k, value) + }) + .collect(); + let me = out.len() as u32; + out.push(PlanNode { parent, operation, params: params.join(" "), properties }); + for child in node.get("Plans").and_then(Value::as_array).into_iter().flatten() { + postgres(child, Some(me), out); + } +} + +/// MySQL's tabular `EXPLAIN`: one row per table access, flat. +fn mysql(names: &[&str], rows: &[Vec>]) -> Vec { + let col = |r: &[Option], n: &str| { + names.iter().position(|c| c.eq_ignore_ascii_case(n)).and_then(|i| r.get(i).cloned().flatten()) + }; + rows.iter() + .map(|r| { + let access = col(r, "type").unwrap_or_default(); + let operation = match access.as_str() { + "ALL" => "Full Scan".to_owned(), + "index" => "Full Index Scan".to_owned(), + "range" => "Index Range Scan".to_owned(), + "ref" | "eq_ref" | "ref_or_null" => "Index Lookup".to_owned(), + "const" | "system" => "Constant Lookup".to_owned(), + "" => col(r, "Extra").unwrap_or_else(|| "Select".to_owned()), + other => other.to_owned(), + }; + let mut params = Vec::new(); + if let Some(t) = col(r, "table") { + params.push(format!("table: {t};")); + } + if let Some(k) = col(r, "key") { + params.push(format!("key: {k};")); + } + let properties = names.iter().zip(r).filter_map(|(n, v)| v.as_ref().map(|v| prop(n, v.clone()))).collect(); + PlanNode { parent: None, operation, params: params.join(" "), properties } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::conn::ResultColumn; + + fn result(columns: &[&str], rows: Vec>>) -> QueryResult { + QueryResult { + statement: String::new(), + columns: columns.iter().map(|c| ResultColumn { name: (*c).into(), type_name: String::new() }).collect(), + rows: rows.into_iter().map(|r| r.into_iter().map(|v| v.map(str::to_owned)).collect()).collect(), + affected: None, + has_more: false, + elapsed_ms: 0.0, + execution_ms: 0.0, + } + } + + #[test] + fn explains_per_dialect() { + assert_eq!(explain_statement(DbDriver::Sqlite, "select 1;"), "EXPLAIN QUERY PLAN select 1"); + assert_eq!(explain_statement(DbDriver::Postgres, " select 1 "), "EXPLAIN (FORMAT JSON) select 1"); + assert_eq!(explain_statement(DbDriver::Mysql, "select 1"), "EXPLAIN select 1"); + } + + #[test] + fn reads_sqlite_plans_like_idea() { + let r = result( + &["id", "parent", "notused", "detail"], + vec![ + vec![Some("3"), Some("0"), Some("0"), Some("SCAN o")], + vec![Some("5"), Some("0"), Some("0"), Some("SEARCH c USING INTEGER PRIMARY KEY (rowid=?)")], + vec![Some("9"), Some("0"), Some("0"), Some("USE TEMP B-TREE FOR ORDER BY")], + ], + ); + let plan = parse(DbDriver::Sqlite, &r).unwrap(); + let ops: Vec<(&str, &str)> = plan.iter().map(|n| (n.operation.as_str(), n.params.as_str())).collect(); + assert_eq!( + ops, + [ + ("Full Scan", "table: o;"), + ("Index Scan", "table: c; USING INTEGER PRIMARY KEY (rowid=?)"), + ("Order By", "") + ] + ); + assert!(plan.iter().all(|n| n.parent.is_none())); + } + + #[test] + fn nests_sqlite_children_under_their_parent() { + let r = result( + &["id", "parent", "notused", "detail"], + vec![ + vec![Some("2"), Some("0"), Some("0"), Some("CO-ROUTINE sub")], + vec![Some("4"), Some("2"), Some("0"), Some("SCAN t")], + ], + ); + let plan = parse(DbDriver::Sqlite, &r).unwrap(); + assert_eq!(plan[1].parent, Some(0)); + } + + #[test] + fn reads_postgres_json_trees() { + let json = r#"[{"Plan": {"Node Type": "Hash Join", "Join Type": "Inner", "Hash Cond": "(o.customer_id = c.id)", + "Total Cost": 12.5, "Plans": [ + {"Node Type": "Seq Scan", "Relation Name": "orders", "Alias": "o", "Filter": "(status <> 'x')"}, + {"Node Type": "Hash", "Plans": [{"Node Type": "Seq Scan", "Relation Name": "customers", "Alias": "c"}]} + ]}}]"#; + let plan = parse(DbDriver::Postgres, &result(&["QUERY PLAN"], vec![vec![Some(json)]])).unwrap(); + assert_eq!(plan.len(), 4); + assert_eq!(plan[0].operation, "Hash Join"); + assert_eq!(plan[0].params, "hash cond: (o.customer_id = c.id);"); + assert!(plan[0].properties.iter().any(|p| p.name == "Total Cost" && p.value == "12.5")); + assert_eq!((plan[1].parent, plan[1].params.as_str()), (Some(0), "table: orders o; filter: (status <> 'x');")); + assert_eq!((plan[3].parent, plan[3].operation.as_str()), (Some(2), "Seq Scan")); + } + + #[test] + fn reads_mysql_rows() { + let r = result( + &["id", "select_type", "table", "type", "key", "rows", "Extra"], + vec![ + vec![Some("1"), Some("SIMPLE"), Some("o"), Some("ALL"), None, Some("20"), Some("Using where")], + vec![Some("1"), Some("SIMPLE"), Some("c"), Some("eq_ref"), Some("PRIMARY"), Some("1"), None], + ], + ); + let plan = parse(DbDriver::Mysql, &r).unwrap(); + assert_eq!((plan[0].operation.as_str(), plan[0].params.as_str()), ("Full Scan", "table: o;")); + assert_eq!((plan[1].operation.as_str(), plan[1].params.as_str()), ("Index Lookup", "table: c; key: PRIMARY;")); + assert!(plan[0].properties.iter().any(|p| p.name == "rows" && p.value == "20")); + } +} diff --git a/crates/ide-db/src/source.rs b/crates/ide-db/src/source.rs index 1036995bf..a4e56300c 100644 --- a/crates/ide-db/src/source.rs +++ b/crates/ide-db/src/source.rs @@ -77,6 +77,8 @@ pub struct DbOptions { pub keep_alive_secs: Option, /// Disconnect after this long without statements (enforced by the UI, which sees the activity). pub auto_disconnect_secs: Option, + /// PostgreSQL: show only the configured database, its schemas right under the data source. + pub single_database: bool, } /// Schemas tab: the databases / schemas the tree shows. Names may use `*` and `?`. @@ -87,13 +89,26 @@ pub struct DbIntrospection { pub databases: Vec, pub all_schemas: bool, pub schemas: Vec, - /// `pg_catalog`, `information_schema`, `mysql`…, and PostgreSQL template databases. + /// `pg_catalog`, `information_schema`, `mysql`…, and service databases (`rdsadmin`…). pub show_system: bool, + /// PostgreSQL template databases (`template0`, `template1`). + pub show_templates: bool, + /// Object filter: the tree lists only tables, views, routines and sequences matching + /// one of these patterns (`*`, `?`; separated by commas or spaces). Empty: all. + pub object_filter: String, } impl Default for DbIntrospection { fn default() -> Self { - Self { all_databases: true, databases: Vec::new(), all_schemas: true, schemas: Vec::new(), show_system: false } + Self { + all_databases: true, + databases: Vec::new(), + all_schemas: true, + schemas: Vec::new(), + show_system: false, + show_templates: false, + object_filter: String::new(), + } } } diff --git a/crates/ide-db/src/sql.rs b/crates/ide-db/src/sql.rs index 17373158a..0f57823ae 100644 --- a/crates/ide-db/src/sql.rs +++ b/crates/ide-db/src/sql.rs @@ -32,10 +32,9 @@ pub fn qualified(driver: DbDriver, schema: &str, table: &str) -> String { } } -/// Databases one can connect to (PostgreSQL). -/// PostgreSQL databases one can connect to; templates only with `system`. -pub fn databases(system: bool) -> &'static str { - if system { +/// PostgreSQL databases one can connect to; template databases only with `templates`. +pub fn databases(templates: bool) -> &'static str { + if templates { "SELECT datname FROM pg_database WHERE datallowconn ORDER BY datname" } else { "SELECT datname FROM pg_database WHERE NOT datistemplate AND datallowconn ORDER BY datname" @@ -65,7 +64,7 @@ pub fn schemas(driver: DbDriver, system: bool) -> &'static str { } } -/// Rows: `name, kind` where kind is `table` or `view`. +/// Rows: `name, kind` where kind is `table`, `view` or `materialized view`. pub fn tables(driver: DbDriver, schema: &str) -> String { match driver { DbDriver::Sqlite => format!( @@ -73,7 +72,13 @@ pub fn tables(driver: DbDriver, schema: &str) -> String { AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY name", quote_ident(driver, schema) ), - _ => format!( + DbDriver::Postgres => format!( + "SELECT c.relname, CASE c.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' ELSE 'table' END \ + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \ + WHERE n.nspname = {} AND c.relkind IN ('r', 'v', 'm', 'p', 'f') ORDER BY 1", + quote_literal(driver, schema) + ), + DbDriver::Mysql => format!( "SELECT table_name, CASE WHEN table_type LIKE '%VIEW%' THEN 'view' ELSE 'table' END \ FROM information_schema.tables WHERE table_schema = {} ORDER BY 1", quote_literal(driver, schema) @@ -146,7 +151,7 @@ pub fn completion_columns(driver: DbDriver) -> &'static str { WHERE m.type IN ('table', 'view') AND m.name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY m.name, p.cid" } DbDriver::Postgres => { - "SELECT n.nspname, c.relname, CASE WHEN c.relkind IN ('v', 'm') THEN 'view' ELSE 'table' END, a.attname, \ + "SELECT n.nspname, c.relname, CASE c.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' ELSE 'table' END, a.attname, \ format_type(a.atttypid, a.atttypmod), CASE WHEN a.attnotnull THEN 'NO' ELSE 'YES' END, \ CASE WHEN EXISTS (SELECT 1 FROM pg_index i WHERE i.indrelid = c.oid AND i.indisprimary \ AND a.attnum = ANY(i.indkey)) THEN 1 ELSE 0 END, \ @@ -375,6 +380,123 @@ pub fn postgres_ddl_indexes(oid: u32) -> String { ) } +/// Rows: `name, timing, events` of the triggers of a table (SQLite: `name, sql`). +pub fn triggers(driver: DbDriver, schema: &str, table: &str) -> String { + let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table)); + match driver { + DbDriver::Sqlite => format!( + "SELECT name, sql FROM {}.sqlite_master WHERE type = 'trigger' AND tbl_name = {t} ORDER BY name", + quote_ident(driver, schema) + ), + DbDriver::Postgres => format!( + "SELECT t.tgname, CASE WHEN t.tgtype & 2 <> 0 THEN 'before' WHEN t.tgtype & 64 <> 0 THEN 'instead of' \ + ELSE 'after' END, concat_ws(' or ', CASE WHEN t.tgtype & 4 <> 0 THEN 'insert' END, \ + CASE WHEN t.tgtype & 16 <> 0 THEN 'update' END, CASE WHEN t.tgtype & 8 <> 0 THEN 'delete' END, \ + CASE WHEN t.tgtype & 32 <> 0 THEN 'truncate' END) \ + FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid JOIN pg_namespace n ON n.oid = c.relnamespace \ + WHERE NOT t.tgisinternal AND n.nspname = {s} AND c.relname = {t} ORDER BY 1" + ), + DbDriver::Mysql => format!( + "SELECT trigger_name, lower(action_timing), lower(event_manipulation) FROM information_schema.triggers \ + WHERE event_object_schema = {s} AND event_object_table = {t} ORDER BY 1" + ), + } +} + +/// `before`, `insert` of SQLite's `CREATE TRIGGER name BEFORE INSERT ON …` (its catalog keeps only the text). +pub fn sqlite_trigger_event(sql: &str) -> (String, String) { + let upper = sql.to_ascii_uppercase(); + let words: Vec<&str> = upper.split_whitespace().collect(); + let timing = if words.contains(&"BEFORE") { + "before" + } else if words.windows(2).any(|w| w == ["INSTEAD", "OF"]) { + "instead of" + } else { + "after" + }; + let event = ["INSERT", "UPDATE", "DELETE"].into_iter().find(|e| words.contains(e)).unwrap_or_default(); + (timing.to_owned(), event.to_ascii_lowercase()) +} + +/// Rows: `name, kind (function/procedure), arguments, result` of a schema's routines (none in SQLite). +pub fn routines(driver: DbDriver, schema: &str) -> Option { + let s = quote_literal(driver, schema); + match driver { + DbDriver::Sqlite => None, + // Functions of extensions are left out, as in IDEA. + DbDriver::Postgres => Some(format!( + "SELECT p.proname, CASE WHEN p.prokind = 'p' THEN 'procedure' ELSE 'function' END, \ + pg_get_function_identity_arguments(p.oid), pg_get_function_result(p.oid) \ + FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace \ + WHERE n.nspname = {s} AND p.prokind IN ('f', 'p') \ + AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = p.oid AND d.deptype = 'e') ORDER BY 1, 3" + )), + DbDriver::Mysql => Some(format!( + "SELECT r.routine_name, lower(r.routine_type), \ + (SELECT GROUP_CONCAT(CONCAT(p.parameter_name, ' ', p.dtd_identifier) ORDER BY p.ordinal_position \ + SEPARATOR ', ') FROM information_schema.parameters p WHERE p.specific_schema = r.routine_schema \ + AND p.specific_name = r.specific_name AND p.ordinal_position > 0), r.dtd_identifier \ + FROM information_schema.routines r WHERE r.routine_schema = {s} ORDER BY 1" + )), + } +} + +/// Rows: `name, data type` of a schema's sequences (PostgreSQL). +pub fn sequences(driver: DbDriver, schema: &str) -> Option { + (driver == DbDriver::Postgres).then(|| { + format!( + "SELECT sequence_name, data_type FROM information_schema.sequences WHERE sequence_schema = {} ORDER BY 1", + quote_literal(driver, schema) + ) + }) +} + +/// The statement reading the definition of a routine (a row per overload), trigger +/// or sequence, and the column that holds it. +pub fn object_ddl(driver: DbDriver, schema: &str, kind: crate::DbObjectKind, name: &str) -> Option<(String, usize)> { + use crate::DbObjectKind as K; + let (s, n) = (quote_literal(driver, schema), quote_literal(driver, name)); + let target = qualified(driver, schema, name); + Some(match (driver, kind) { + (DbDriver::Sqlite, K::Trigger) => ( + format!( + "SELECT sql FROM {}.sqlite_master WHERE type = 'trigger' AND name = {n}", + quote_ident(driver, schema) + ), + 0, + ), + (DbDriver::Postgres, K::Function | K::Procedure) => ( + format!( + "SELECT pg_get_functiondef(p.oid) FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace \ + WHERE n.nspname = {s} AND p.proname = {n} ORDER BY pg_get_function_identity_arguments(p.oid)" + ), + 0, + ), + (DbDriver::Postgres, K::Trigger) => ( + format!( + "SELECT pg_get_triggerdef(t.oid, true) FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid \ + JOIN pg_namespace n ON n.oid = c.relnamespace \ + WHERE NOT t.tgisinternal AND n.nspname = {s} AND t.tgname = {n}" + ), + 0, + ), + (DbDriver::Postgres, K::Sequence) => ( + format!( + "SELECT format(E'create sequence %s\\n as %s\\n increment by %s\\n minvalue %s\\n \ + maxvalue %s\\n start with %s%s', {}, data_type, increment_by, min_value, max_value, start_value, \ + CASE WHEN cycle THEN E'\\n cycle' ELSE '' END) \ + FROM pg_sequences WHERE schemaname = {s} AND sequencename = {n}", + quote_literal(driver, &target) + ), + 0, + ), + (DbDriver::Mysql, K::Function) => (format!("SHOW CREATE FUNCTION {target}"), 2), + (DbDriver::Mysql, K::Procedure) => (format!("SHOW CREATE PROCEDURE {target}"), 2), + (DbDriver::Mysql, K::Trigger) => (format!("SHOW CREATE TRIGGER {target}"), 2), + _ => return None, + }) +} + /// A column of [`postgres_table_ddl`]. pub struct DdlColumn { pub name: String, @@ -464,6 +586,31 @@ mod tests { assert!(edit_statement(DbDriver::Postgres, "s", "t", &DbRowEdit::Delete { key: vec![] }).is_err()); } + #[test] + fn sqlite_trigger_events() { + let ev = sqlite_trigger_event; + assert_eq!( + ev("CREATE TRIGGER t BEFORE UPDATE OF a ON x BEGIN SELECT 1; END"), + ("before".into(), "update".into()) + ); + assert_eq!(ev("create trigger t instead of insert on v begin end"), ("instead of".into(), "insert".into())); + assert_eq!(ev("CREATE TRIGGER t DELETE ON x BEGIN END"), ("after".into(), "delete".into())); + } + + #[test] + fn schema_objects_per_dialect() { + use crate::DbObjectKind as K; + assert!(routines(DbDriver::Sqlite, "main").is_none()); + assert!(sequences(DbDriver::Mysql, "db").is_none()); + assert!(routines(DbDriver::Postgres, "public").unwrap().contains("n.nspname = 'public'")); + assert_eq!( + object_ddl(DbDriver::Mysql, "db", K::Procedure, "p"), + Some(("SHOW CREATE PROCEDURE `db`.`p`".into(), 2)) + ); + assert!(object_ddl(DbDriver::Sqlite, "main", K::Function, "f").is_none()); + assert!(object_ddl(DbDriver::Postgres, "s", K::Sequence, "q").unwrap().0.contains("sequencename = 'q'")); + } + #[test] fn postgres_ddl() { let cols = [ diff --git a/crates/ide-db/tests/sqlite.rs b/crates/ide-db/tests/sqlite.rs index 03d12dac7..315ecff54 100644 --- a/crates/ide-db/tests/sqlite.rs +++ b/crates/ide-db/tests/sqlite.rs @@ -7,7 +7,7 @@ async fn setup() -> (tempfile::TempDir, DbManager) { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().to_path_buf(); std::fs::File::create(root.join("app.db")).unwrap(); - let db = DbManager::new(root.clone(), root.join("passwords.json")); + let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json")); db.save_sources(vec![DataSource { id: "s1".into(), name: "app".into(), @@ -247,6 +247,57 @@ async fn ddl_of_tables_and_views() -> IdeResult<()> { Ok(()) } +#[tokio::test] +async fn explains_queries_without_running_them() -> IdeResult<()> { + let (_tmp, db) = setup().await; + let c = CancellationToken::new(); + let plan = db.explain("s1", "SELECT * FROM users WHERE id > 1 ORDER BY note;", &c).await?; + let ops: Vec<_> = plan.iter().map(|n| n.operation.as_str()).collect(); + assert_eq!(ops, vec!["Index Scan", "Order By"]); + assert!(plan[0].params.starts_with("table: users;"), "{}", plan[0].params); + db.explain("s1", "DELETE FROM users", &c).await?; + assert_eq!(count(&db).await, "3"); + Ok(()) +} + +#[tokio::test] +async fn records_executed_statements_in_the_query_history() -> IdeResult<()> { + let (_tmp, db) = setup().await; + let c = CancellationToken::new(); + db.execute("s1", "SELECT 1", 1, &c).await?; + assert!(db.execute("s1", "SELECT nope", 1, &c).await.is_err()); + // Recorded off the async threads. + let mut texts = Vec::new(); + for _ in 0..50 { + texts = db.history("s1").await?.into_iter().map(|e| e.text).collect::>(); + if texts.first().map(String::as_str) == Some("SELECT nope") { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + assert_eq!(texts[..2], ["SELECT nope", "SELECT 1"]); + db.remove_history("s1", Some(vec!["SELECT 1".into()])).await?; + assert!(!db.history("s1").await?.iter().any(|e| e.text == "SELECT 1")); + db.remove_history("s1", None).await?; + assert!(db.history("s1").await?.is_empty()); + Ok(()) +} + +#[tokio::test] +async fn lists_triggers_and_their_ddl() -> IdeResult<()> { + let (_tmp, db) = setup().await; + let c = CancellationToken::new(); + db.execute("s1", "CREATE TRIGGER users_touch AFTER UPDATE ON users BEGIN SELECT 1; END", 1, &c).await?; + let d = db.table_details("s1", "main", "users").await?; + let triggers: Vec<_> = d.triggers.iter().map(|t| (t.name.as_str(), t.timing.as_str(), t.events.as_str())).collect(); + assert_eq!(triggers, vec![("users_touch", "after", "update")]); + let ddl = db.object_ddl("s1", "main", ide_db::DbObjectKind::Trigger, "users_touch").await?; + assert!(ddl.starts_with("CREATE TRIGGER users_touch"), "{ddl}"); + // SQLite has neither routines nor sequences. + assert_eq!(db.schema_objects("s1", "main").await?, ide_db::SchemaObjects::default()); + Ok(()) +} + #[tokio::test] async fn applies_driver_properties_and_the_startup_script_on_connect() -> IdeResult<()> { let (tmp, db) = setup().await; diff --git a/docs/plugins/themes.md b/docs/plugins/themes.md index cde05ac35..ea7caa480 100644 --- a/docs/plugins/themes.md +++ b/docs/plugins/themes.md @@ -294,7 +294,7 @@ a token. -202 themeable tokens: 112 in the UI theme file, 90 in the editor colour scheme file. +203 themeable tokens: 113 in the UI theme file, 90 in the editor colour scheme file. ### Surfaces (UI theme) @@ -343,6 +343,12 @@ a token. | `--link` | `#71a1fe` | `#2f5eb9` | | `--link-hover` | `#92b7ff` | `#2e4d89` | +### Charts (UI theme) + +| Token | Islands Dark | Islands Light | +|---|---|---| +| `--chart-series` | `#e07a7c` | `#d9686a` | + ### Status (UI theme) | Token | Islands Dark | Islands Light | diff --git a/src-tauri/src/commands/db.rs b/src-tauri/src/commands/db.rs index 1ccd51264..93f20ff66 100644 --- a/src-tauri/src/commands/db.rs +++ b/src-tauri/src/commands/db.rs @@ -1,7 +1,10 @@ use crate::state::AppState; use ide_core::{IdeError, IdeResult, IpcPath, RequestId}; use ide_db::split::SqlStatement; -use ide_db::{CompletionModel, DataSource, DbRowEdit, QueryResult, SortKey, TableDetails, TableInfo, TablePage}; +use ide_db::{ + CompletionModel, DataSource, DbObjectKind, DbRowEdit, HistoryEntry, PlanNode, QueryResult, SchemaObjects, SortKey, + TableDetails, TableInfo, TablePage, +}; use serde::{Deserialize, Serialize}; use specta::Type; use tauri::{State, Window}; @@ -175,6 +178,72 @@ pub async fn db_ddl( db.ddl(&id, &schema, &table).await } +/// The routines and sequences of `schema` (the tree's `routines` / `sequences`). +#[tauri::command] +#[specta::specta] +pub async fn db_schema_objects( + window: Window, + state: State<'_, AppState>, + id: String, + schema: String, +) -> IdeResult { + let db = state.project(window.label())?.db.clone(); + db.schema_objects(&id, &schema).await +} + +/// The definition of a routine, trigger or sequence (Go to DDL, Quick Documentation). +#[tauri::command] +#[specta::specta] +pub async fn db_object_ddl( + window: Window, + state: State<'_, AppState>, + id: String, + schema: String, + kind: DbObjectKind, + name: String, +) -> IdeResult { + let db = state.project(window.label())?.db.clone(); + db.object_ddl(&id, &schema, kind, &name).await +} + +/// Explain Plan: the execution plan of `statement` on `id`, without running it. +#[tauri::command] +#[specta::specta] +pub async fn db_explain( + window: Window, + state: State<'_, AppState>, + request: RequestId, + id: String, + statement: String, +) -> IdeResult> { + let db = state.project(window.label())?.db.clone(); + let cancel = state.begin_request(request); + let result = db.explain(&id, &statement, &cancel).await; + state.end_request(request); + result +} + +/// The statements data source `id` ran, newest first (Browse Query History). +#[tauri::command] +#[specta::specta] +pub async fn db_history(window: Window, state: State<'_, AppState>, id: String) -> IdeResult> { + let db = state.project(window.label())?.db.clone(); + db.history(&id).await +} + +/// Removes statements from the query history of `id` (`None`: all of them). +#[tauri::command] +#[specta::specta] +pub async fn db_remove_history( + window: Window, + state: State<'_, AppState>, + id: String, + texts: Option>, +) -> IdeResult<()> { + let db = state.project(window.label())?.db.clone(); + db.remove_history(&id, texts).await +} + /// Writes exported result data (CSV, JSON, SQL…) to `path`. #[tauri::command] #[specta::specta] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 7dec66ab6..8a9f04df1 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -365,6 +365,11 @@ pub fn builder() -> tauri_specta::Builder { db::db_tables, db::db_table_details, db::db_ddl, + db::db_schema_objects, + db::db_object_ddl, + db::db_explain, + db::db_history, + db::db_remove_history, db::db_completion, db::db_export_file, db::db_set_manual_commit, diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index e0e436ce2..f8dd64db4 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -397,7 +397,11 @@ impl AppState { mark("recent"); tracing::info!("opened {} in window {window} in {} ms {laps:?}", root.display(), started.elapsed().as_millis()); - let db = Arc::new(ide_db::DbManager::new(root.clone(), self.data_dir.join("db-passwords.json"))); + let db = Arc::new(ide_db::DbManager::new( + root.clone(), + self.data_dir.join("db-passwords.json"), + self.data_dir.join("db-history.json"), + )); let http_history = Arc::new(ide_http::History::new(self.data_dir.join("http-history").join(&project_key))); let project = Arc::new(Project { root, diff --git a/src/bindings.ts b/src/bindings.ts index a4eebe9f7..765fcc442 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -768,6 +768,16 @@ export const commands = { dbTableDetails: (id: string, schema: string, table: string) => typedError(__TAURI_INVOKE("db_table_details", { id, schema, table })), /** The DDL of table or view `table` (Go to DDL, Generate DDL). */ dbDdl: (id: string, schema: string, table: string) => typedError(__TAURI_INVOKE("db_ddl", { id, schema, table })), + /** The routines and sequences of `schema` (the tree's `routines` / `sequences`). */ + dbSchemaObjects: (id: string, schema: string) => typedError(__TAURI_INVOKE("db_schema_objects", { id, schema })), + /** The definition of a routine, trigger or sequence (Go to DDL, Quick Documentation). */ + dbObjectDdl: (id: string, schema: string, kind: DbObjectKind, name: string) => typedError(__TAURI_INVOKE("db_object_ddl", { id, schema, kind, name })), + /** Explain Plan: the execution plan of `statement` on `id`, without running it. */ + dbExplain: (request: RequestId, id: string, statement: string) => typedError(__TAURI_INVOKE("db_explain", { request, id, statement })), + /** The statements data source `id` ran, newest first (Browse Query History). */ + dbHistory: (id: string) => typedError(__TAURI_INVOKE("db_history", { id })), + /** Removes statements from the query history of `id` (`None`: all of them). */ + dbRemoveHistory: (id: string, texts: string[] | null) => typedError(__TAURI_INVOKE("db_remove_history", { id, texts })), /** Tables, columns and foreign keys for SQL completion. */ dbCompletion: (id: string) => typedError(__TAURI_INVOKE("db_completion", { id })), /** Writes exported result data (CSV, JSON, SQL…) to `path`. */ @@ -1545,6 +1555,12 @@ export type DbForeignKey = { refColumns: string[], }; +export type DbHistoryEntry = { + text: string, + /** Milliseconds since the Unix epoch. */ + time: number | null, +}; + export type DbIndexInfo = { name: string, unique: boolean, @@ -1557,10 +1573,20 @@ export type DbIntrospection = { databases?: string[], allSchemas?: boolean, schemas?: string[], - /** `pg_catalog`, `information_schema`, `mysql`…, and PostgreSQL template databases. */ + /** `pg_catalog`, `information_schema`, `mysql`…, and service databases (`rdsadmin`…). */ showSystem?: boolean, + /** PostgreSQL template databases (`template0`, `template1`). */ + showTemplates?: boolean, + /** + * Object filter: the tree lists only tables, views, routines and sequences matching + * one of these patterns (`*`, `?`; separated by commas or spaces). Empty: all. + */ + objectFilter?: string, }; +/** Schema objects other than tables and columns (Go to DDL, Drop). */ +export type DbObjectKind = "function" | "procedure" | "trigger" | "sequence"; + /** Options tab: session settings applied to every connection. */ export type DbOptions = { transaction?: DbTxMode, @@ -1574,6 +1600,23 @@ export type DbOptions = { keepAliveSecs?: number | null, /** Disconnect after this long without statements (enforced by the UI, which sees the activity). */ autoDisconnectSecs?: number | null, + /** PostgreSQL: show only the configured database, its schemas right under the data source. */ + singleDatabase?: boolean, +}; + +/** One operation of a plan; `parent` indexes the plan's node list (roots: `None`). */ +export type DbPlanNode = { + parent: number | null, + /** `Full Scan`, `Index Scan`, `Hash Join`… */ + operation: string, + /** The object it works on and its key details (`table: o;`). */ + params: string, + properties: DbPlanProperty[], +}; + +export type DbPlanProperty = { + name: string, + value: string, }; /** @@ -1596,6 +1639,8 @@ export type DbQueryResult = { /** More rows exist beyond the limit. */ hasMore: boolean, elapsedMs: number | null, + /** Until the server's first answer; the rest of `elapsed_ms` is fetching. */ + executionMs: number | null, }; export type DbResultColumn = { @@ -1603,12 +1648,32 @@ export type DbResultColumn = { typeName: string, }; +export type DbRoutineInfo = { + name: string, + /** `Function` or `Procedure`. */ + kind: DbObjectKind, + arguments: string, + /** Result type of a function. */ + returns: string | null, +}; + /** * A pending change of the table editor. `key` identifies the row: its primary * key columns with their original values. */ export type DbRowEdit = { kind: "update"; key: DbCellValue[]; values: DbCellValue[] } | { kind: "insert"; values: DbCellValue[] } | { kind: "delete"; key: DbCellValue[] }; +/** A schema's routines and sequences (the tree's `routines` / `sequences` folders). */ +export type DbSchemaObjects = { + routines: DbRoutineInfo[], + sequences: DbSequenceInfo[], +}; + +export type DbSequenceInfo = { + name: string, + dataType: string, +}; + export type DbSortKey = { column: string, descending: boolean, @@ -1651,6 +1716,7 @@ export type DbTableDetails = { columns: DbColumnInfo[], indexes: DbIndexInfo[], foreignKeys: DbForeignKey[], + triggers: DbTriggerInfo[], }; export type DbTableInfo = { @@ -1658,7 +1724,7 @@ export type DbTableInfo = { kind: DbTableKind, }; -export type DbTableKind = "table" | "view"; +export type DbTableKind = "table" | "view" | "materializedView"; export type DbTablePage = { result: DbQueryResult, @@ -1666,6 +1732,14 @@ export type DbTablePage = { offset: number | null, }; +export type DbTriggerInfo = { + name: string, + /** `before`, `after`, `instead of`. */ + timing: string, + /** `insert`, `update or delete`… */ + events: string, +}; + /** Transaction control of the data source's consoles. */ export type DbTxMode = "auto" | "manual"; diff --git a/src/features/database/ChartView.tsx b/src/features/database/ChartView.tsx new file mode 100644 index 000000000..e0ae83cc5 --- /dev/null +++ b/src/features/database/ChartView.tsx @@ -0,0 +1,192 @@ +/** + * 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 }) { + 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("avg"); + const [settings, setSettings] = useState(false); + const points = useMemo(() => chartSeries(rows, x, y, aggregate), [rows, x, y, aggregate]); + + const host = useRef(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 ( +
+ {settings && ( +
+ + X: + + setY(Number(v))} /> + setKind(v as "bar" | "line")} + /> +
+ )} +
+ setSettings(!settings)} + /> + {!points.length ? ( +
+ No numeric values to plot +
+ ) : ( + + {ticks.map((t) => ( + + + + {grouped.format(t)} + + + ))} + {points.map((_, i) => ( + + ))} + {kind === "bar" + ? points.map((p, i) => ( + + {`${p.label}: ${formatNumber(p.value)}`} + + )) + : [ + `${xAt(i)},${yAt(p.value)}`).join(" ")} + />, + ...points.map((p, i) => ( + + {`${p.label}: ${formatNumber(p.value)}`} + + )), + ]} + + {points.map((p, i) => + i % labelEvery ? null : ( + + {p.label.length > 14 ? `${p.label.slice(0, 13)}…` : p.label} + + ), + )} + + {columns[x]?.name} + + + {yName} + + + )} +
+
+ ); +} diff --git a/src/features/database/CodePreview.tsx b/src/features/database/CodePreview.tsx new file mode 100644 index 000000000..c7981bd35 --- /dev/null +++ b/src/features/database/CodePreview.tsx @@ -0,0 +1,36 @@ +/** Read-only text with line numbers (IDEA's preview editors), SQL in the editor's syntax colours. */ +import { useEffect, useRef } from "react"; +import clsx from "clsx"; +import { highlightInto } from "@/editor/lsp/markdown"; +import styles from "./Database.module.css"; + +export function CodePreview({ + text, + sql = false, + className, + "aria-label": ariaLabel, +}: { + text: string; + sql?: boolean; + className?: string; + "aria-label"?: string; +}) { + const ref = useRef(null); + useEffect(() => { + const el = ref.current; + if (!el) return; + el.textContent = text; + if (sql) void highlightInto(el, text, "SQL"); + }, [text, sql]); + const lines = text.split("\n").length; + return ( +
+
+ {Array.from({ length: lines }, (_, i) => ( +
{i + 1}
+ ))} +
+
+    
+ ); +} diff --git a/src/features/database/DataDialogs.tsx b/src/features/database/DataDialogs.tsx new file mode 100644 index 000000000..4a9720826 --- /dev/null +++ b/src/features/database/DataDialogs.tsx @@ -0,0 +1,247 @@ +/** Query History (browse / paste / delete) and Import Data from File dialogs. */ +import { useEffect, useMemo, useState } from "react"; +import { call, commands, type DbHistoryEntry } from "@/platform/ipc"; +import { editorsFor } from "@/editor/api"; +import { Checkbox, Dialog, EmptyState, IconButton, Input, List, Select, T } from "@/ui"; +import { notifications } from "@/workbench/contributions"; +import { CodePreview } from "./CodePreview"; +import { guessSeparator, importEdits, parseDelimited } from "./model"; +import { closeDialog, detailsKey, loadDetails, openConsoleWith, targetLabel, useDatabase } from "./store"; +import styles from "./Database.module.css"; + +function when(t: number): string { + const d = new Date(t); + const p = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; +} + +/** Pastes `text` over the selection of console `path` when it is open, else appends it to the data source's console. */ +async function paste(id: string, path: string | null, text: string) { + const view = path ? editorsFor(path)[0]?.view : undefined; + if (!view) { + await openConsoleWith(id, text); + return; + } + const sel = view.state.selection.main; + view.dispatch({ + changes: { from: sel.from, to: sel.to, insert: text }, + selection: { anchor: sel.from, head: sel.from + text.length }, + scrollIntoView: true, + }); + view.focus(); +} + +/** IDEA's ` Query History`: statements newest first, a preview, Paste into the console. */ +export function HistoryDialog({ id, path }: { id: string; path: string | null }) { + const [entries, setEntries] = useState(null); + const [query, setQuery] = useState(""); + const [active, setActive] = useState(0); + useEffect(() => { + void call(commands.dbHistory(id)) + .then(setEntries) + .catch(() => setEntries([])); + }, [id]); + const shown = useMemo(() => { + const q = query.trim().toLowerCase(); + return (entries ?? []).filter((e) => !q || e.text.toLowerCase().includes(q)); + }, [entries, query]); + const current = shown[Math.min(active, shown.length - 1)]; + + const remove = async (texts: string[] | null) => { + await call(commands.dbRemoveHistory(id, texts)); + setEntries((e) => (texts ? (e ?? []).filter((x) => !texts.includes(x.text)) : [])); + }; + const done = (text: string | undefined) => { + closeDialog(); + if (text) void paste(id, path, text); + }; + + return ( + void remove(null)} /> + } + buttons={[ + { label: "Close", role: "cancel" }, + { label: "Paste", role: "default", disabled: !current, onClick: () => done(current?.text) }, + ]} + > +
+
+
+ { + setQuery(e.target.value); + setActive(0); + }} + onKeyDown={(e) => { + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + setActive((a) => Math.max(0, Math.min(shown.length - 1, a + (e.key === "ArrowDown" ? 1 : -1)))); + } else if (e.key === "Enter") done(current?.text); + }} + /> + current && void remove([current.text])} + /> +
+ e.text} + activeIndex={Math.min(active, shown.length - 1)} + onActiveChange={setActive} + onActivate={(e) => done(e.text)} + renderItem={(e) => ( + + {e.text.replace(/\s+/g, " ")} + + )} + empty={} + /> +
+ +
+
+ ); +} + +const SEPARATORS = [ + { value: ",", label: "Comma" }, + { value: "\t", label: "Tab" }, + { value: ";", label: "Semicolon" }, + { value: "|", label: "Pipe" }, +]; + +/** Import Data from File: a CSV / TSV file inserted into a table in one transaction. */ +export function ImportDialog({ id, schema, table }: { id: string; schema: string; table: string }) { + const [file, setFile] = useState(null); + const [text, setText] = useState(""); + const [sep, setSep] = useState(","); + const [header, setHeader] = useState(true); + const [emptyAsNull, setEmptyAsNull] = useState(true); + const [busy, setBusy] = useState(false); + const details = useDatabase((s) => s.details[detailsKey(id, schema, table)]); + useEffect(() => { + if (details?.status !== "ok") void loadDetails(id, schema, table); + // Once per table. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, schema, table]); + const columns = useMemo(() => (details?.status === "ok" ? details.value.columns.map((c) => c.name) : []), [details]); + const rows = useMemo(() => (text ? parseDelimited(text, sep) : []), [text, sep]); + const { edits, mapped } = useMemo( + () => importEdits(rows, columns, header, emptyAsNull), + [rows, columns, header, emptyAsNull], + ); + + const choose = async () => { + try { + const { open } = await import("@tauri-apps/plugin-dialog"); + const f = await open({ + multiple: false, + directory: false, + title: "Import Data from File", + filters: [{ name: "CSV / TSV", extensions: ["csv", "tsv", "txt"] }], + }); + if (typeof f !== "string") return; + const content = await call(commands.fsReadText(f)); + setFile(f); + setText(content); + setSep(guessSeparator(content)); + } catch (e) { + notifications().notify({ + level: "error", + title: "Cannot read the file", + body: e instanceof Error ? e.message : String(e), + }); + } + }; + + const run = async () => { + setBusy(true); + try { + const n = await call(commands.dbApplyEdits(id, schema, table, edits)); + notifications().notify({ level: "info", title: `${table}: ${n} row(s) imported`, key: "db-import" }); + closeDialog(); + } catch (e) { + notifications().notify({ + level: "error", + title: "Nothing was imported", + body: e instanceof Error ? e.message : String(e), + key: "db-import", + }); + } finally { + setBusy(false); + } + }; + + const preview = rows.slice(0, 8); + return ( + void run(), + }, + ]} + > +
+ + File: + +
+ + void choose()} /> +
+ + Separator: + + ({ value: f.id, label: f.label }))} + onChange={(v) => onChange(v as ExportFormat)} + /> + ); +} + +/** The Record / Value / Aggregates panel's toggle (IDEA's "Show Value Editor"). */ +export function PanelToggle({ panel, onChange }: { panel: DataPanel | null; onChange: (p: DataPanel | null) => void }) { + return ( + onChange(panel ? null : "record")} + /> + ); +} + +/** Local filter and sort of the column set `key`. */ +interface Shaping { + key: string; + filter: LocalFilter; + sort: LocalSort | null; +} + +export interface DataViewProps extends Pick< + GridProps, + "rowState" | "isModified" | "editable" | "onEdit" | "sort" | "onSort" | "rowMenu" +> { + columns: readonly GridColumn[]; + rowCount: number; + value: (row: number, col: number) => Cell; + /** Rows from here on (the table editor's added rows) are never filtered or sorted. */ + fixedRows?: number; + firstRowNumber?: number; + /** The side panel shown (`null`: hidden). */ + panel: DataPanel | null; + onPanelChange: (panel: DataPanel | null) => void; + "aria-label"?: string; +} + +export function DataView(props: DataViewProps) { + const { columns, rowCount, value, fixedRows = rowCount, panel, onPanelChange } = props; + const columnKey = columns.map((c) => c.name).join("\u0000"); + // Filter and sort belong to a column set: another one (a re-run query) starts without them. + const [shaping, setShaping] = useState({ key: columnKey, filter: new Map(), sort: null }); + const current: Shaping = shaping.key === columnKey ? shaping : { key: columnKey, filter: new Map(), sort: null }; + const { filter, sort: localSort } = current; + const setFilter = (f: LocalFilter | ((prev: LocalFilter) => LocalFilter)) => + setShaping({ ...current, filter: typeof f === "function" ? f(current.filter) : f }); + const setLocalSort = (s: (prev: LocalSort | null) => LocalSort | null) => + setShaping({ ...current, sort: s(current.sort) }); + const [range, setRange] = useState(null); + const [filterAt, setFilterAt] = useState<{ col: number; anchor: HTMLElement } | null>(null); + + const local = !props.onSort; + // Display row → loaded row; `null` keeps the identity (no filter, no local sort). + const order = useMemo( + () => + filter.size || (local && localSort) + ? visibleRows(rowCount, value, filter, local ? localSort : null, fixedRows) + : null, + [rowCount, value, filter, local, localSort, fixedRows], + ); + const at = (r: number) => (order ? order[r]! : r); + const shown = order ? order.length : rowCount; + + const onSort = local + ? (column: string) => { + const col = columns.findIndex((c) => c.name === column); + // asc → desc → none, like the table editor's. + setLocalSort((s) => + s?.column !== col ? { column: col, descending: false } : !s.descending ? { ...s, descending: true } : null, + ); + } + : props.onSort; + const sort = local + ? localSort + ? [{ column: columns[localSort.column]?.name ?? "", descending: localSort.descending }] + : [] + : props.sort; + + return ( +
+
+ value(at(r), c)} + rowState={props.rowState && ((r) => props.rowState!(at(r)))} + isModified={props.isModified && ((r, c) => props.isModified!(at(r), c))} + editable={props.editable} + onEdit={props.onEdit && ((r, c, v) => props.onEdit!(at(r), c, v))} + sort={sort} + onSort={onSort} + rowMenu={props.rowMenu && ((rows) => props.rowMenu!(rows.map(at)))} + rowNumber={(r) => (props.firstRowNumber ?? 1) + at(r)} + onSelectionChange={setRange} + onFilter={(col, anchor) => setFilterAt({ col, anchor })} + filtered={(col) => filter.has(col)} + /> + {filter.size > 0 && ( +
+ {shown} of {rowCount} rows + +
+ )} +
+ {filterAt && ( + setFilter((f) => toggleFilterValue(f, filterAt.col, all, v, on))} + onClear={() => + setFilter((f) => { + const next = new Map(f); + next.delete(filterAt.col); + return next; + }) + } + onClearAll={() => setFilter(new Map())} + onClose={() => setFilterAt(null)} + /> + )} + {panel && ( + value(at(r), c)} + editable={!!props.editable && !!props.onEdit && !!range && props.rowState?.(at(range.lead.row)) !== "deleted"} + onEdit={(r, c, v) => props.onEdit?.(at(r), c, v)} + /> + )} +
+ ); +} + +function FilterPopup(props: { + anchor: HTMLElement; + column: string; + values: Array<{ value: Cell; count: number }>; + checked: ReadonlySet | null; + onToggle: (all: Cell[], v: Cell, on: boolean) => void; + onClear: () => void; + onClearAll: () => void; + onClose: () => void; +}) { + const [query, setQuery] = useState(""); + const all = props.values.map((v) => v.value); + const q = query.trim().toLowerCase(); + const list = q ? props.values.filter((v) => (v.value ?? "").toLowerCase().includes(q)) : props.values; + return ( + + setQuery(e.target.value)} + /> +
+ {list.map(({ value: v, count }) => ( +
+ props.onToggle(all, v, on)} + label={v === null ? <null> : v} + /> + {count} +
+ ))} + {!list.length && ( +
+ No matching values +
+ )} +
+
+ + +
+
+ ); +} + +const AGGREGATE_ROWS: ReadonlyArray<[label: string, key: keyof ReturnType]> = [ + ["Count", "count"], + ["Distinct", "distinct"], + ["Nulls", "nulls"], + ["Sum", "sum"], + ["Avg", "avg"], + ["Median", "median"], + ["Min", "min"], + ["Max", "max"], +]; + +function SidePanel(props: { + panel: DataPanel; + onPanelChange: (p: DataPanel | null) => void; + columns: readonly GridColumn[]; + range: GridRange | null; + value: (row: number, col: number) => Cell; + editable: boolean; + onEdit: (row: number, col: number, v: Cell) => void; +}) { + const { range, value, columns } = props; + return ( + + ); +} + +/** One field per column of the lead row; edits apply on Enter / leaving the field. */ +function RecordView(props: { + columns: readonly GridColumn[]; + row: number; + value: (row: number, col: number) => Cell; + editable: boolean; + onEdit: (row: number, col: number, v: Cell) => void; +}) { + return ( +
+ {props.columns.map((c, i) => ( + props.onEdit(props.row, i, v)} + /> + ))} +
+ ); +} + +function RecordField(props: { column: GridColumn; cell: Cell; editable: boolean; onCommit: (v: Cell) => void }) { + const [text, setText] = useState(props.cell ?? ""); + const commit = () => { + if (text !== (props.cell ?? "") || (props.cell === null && text !== "")) props.onCommit(text); + }; + const c = props.column; + return ( + + ); +} + +function ValueView(props: { column: string; cell: Cell; editable: boolean; onCommit: (v: Cell) => void }) { + const [text, setText] = useState(props.cell ?? ""); + const dirty = text !== (props.cell ?? "") || (props.cell === null && text !== ""); + return ( +
+