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
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-27 13:31:29 +08:00
1 джерело 580f0efc28
коміт 575749f2ac
48 files changed
+4268 -289

No files matched your search

+11 -4
Переглянути файл
@@ -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<u64> = None;
let mut has_more = false;
let mut failure = None;
let mut first: Option<Instant> = 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<ResultColumn>, Vec<Vec<Option<String>>>, Option<u64>, bool);
/// Columns, rows, rows affected, more rows beyond the limit, time of the first answer.
type Fetched = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, Option<u64>, bool, Option<Instant>);
/// 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),
})
}
}
+137
Переглянути файл
@@ -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<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));
}
}
+211 -10
Переглянути файл
@@ -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<String>,
}
#[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<RoutineInfo>,
pub sequences: Vec<SequenceInfo>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -68,6 +134,7 @@ pub struct TableDetails {
pub columns: Vec<ColumnInfo>,
pub indexes: Vec<IndexInfo>,
pub foreign_keys: Vec<ForeignKey>,
pub triggers: Vec<TriggerInfo>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -148,6 +215,7 @@ pub struct DbManager {
known_hosts: PathBuf,
ssh_config: Option<PathBuf>,
saved_passwords: SecretStore,
history: Arc<HistoryStore>,
/// Passwords entered this session (not saved, or not yet), by credential key.
session_passwords: Mutex<HashMap<String, String>>,
/// 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<PathBuf>,
) -> 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<Databases> {
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<Vec<Option<String>>>| 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<String> =
first(rows).filter(|n| system || *n == current || !sql::INTERNAL_DATABASES.contains(&n.as_str())).collect();
if !names.contains(&current) {
@@ -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<SchemaObjects> {
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<String> {
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<String> = 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<QueryResult> {
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<Vec<HistoryEntry>> {
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<Vec<String>>) -> 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<Vec<PlanNode>> {
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<QueryResult> {
let pool = self.pool(id).await?;
let session = self.sessions.lock().get(id).cloned();
+268
Переглянути файл
@@ -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<u32>,
/// `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<PlanProperty>,
}
/// 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<Vec<PlanNode>> {
match driver {
DbDriver::Sqlite => Ok(sqlite(&result.rows)),
DbDriver::Postgres => {
let text: String =
result.rows.iter().filter_map(|r| r.first().cloned().flatten()).collect::<Vec<_>>().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<String>) -> 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<Option<String>>]) -> Vec<PlanNode> {
let mut ids: Vec<String> = 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<u32>, out: &mut Vec<PlanNode>) {
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::<Vec<_>>()
.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<Option<String>>]) -> Vec<PlanNode> {
let col = |r: &[Option<String>], 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<Vec<Option<&str>>>) -> 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"));
}
}
+17 -2
Переглянути файл
@@ -77,6 +77,8 @@ pub struct DbOptions {
pub keep_alive_secs: Option<u32>,
/// Disconnect after this long without statements (enforced by the UI, which sees the activity).
pub auto_disconnect_secs: Option<u32>,
/// 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<String>,
pub all_schemas: bool,
pub schemas: Vec<String>,
/// `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(),
}
}
}
+154 -7
Переглянути файл
@@ -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<String> {
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<String> {
(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 = [
+52 -1
Переглянути файл
@@ -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::<Vec<_>>();
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;
+7 -1
Переглянути файл
@@ -294,7 +294,7 @@ a token.
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
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 |
+70 -1
Переглянути файл
@@ -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<SchemaObjects> {
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<String> {
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<Vec<PlanNode>> {
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<Vec<HistoryEntry>> {
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<Vec<String>>,
) -> 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]
+5
Переглянути файл
@@ -365,6 +365,11 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+5 -1
Переглянути файл
@@ -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,
+76 -2
Переглянути файл
@@ -768,6 +768,16 @@ export const commands = {
dbTableDetails: (id: string, schema: string, table: string) => typedError<DbTableDetails, IdeError>(__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<string, IdeError>(__TAURI_INVOKE("db_ddl", { id, schema, table })),
/** The routines and sequences of `schema` (the tree's `routines` / `sequences`). */
dbSchemaObjects: (id: string, schema: string) => typedError<DbSchemaObjects, IdeError>(__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<string, IdeError>(__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<DbPlanNode[], IdeError>(__TAURI_INVOKE("db_explain", { request, id, statement })),
/** The statements data source `id` ran, newest first (Browse Query History). */
dbHistory: (id: string) => typedError<DbHistoryEntry[], IdeError>(__TAURI_INVOKE("db_history", { id })),
/** Removes statements from the query history of `id` (`None`: all of them). */
dbRemoveHistory: (id: string, texts: string[] | null) => typedError<null, IdeError>(__TAURI_INVOKE("db_remove_history", { id, texts })),
/** Tables, columns and foreign keys for SQL completion. */
dbCompletion: (id: string) => typedError<DbCompletion, IdeError>(__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";
+192
Переглянути файл
@@ -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<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>
);
}
+36
Переглянути файл
@@ -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<HTMLPreElement>(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 (
<div className={clsx(styles.codePreview, className)}>
<div className={styles.codeGutter} aria-hidden>
{Array.from({ length: lines }, (_, i) => (
<div key={i}>{i + 1}</div>
))}
</div>
<pre ref={ref} className={styles.codeText} aria-label={ariaLabel} />
</div>
);
}
+247
Переглянути файл
@@ -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 `<data source> 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<DbHistoryEntry[] | null>(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 (
<Dialog
open
onClose={closeDialog}
title={`${targetLabel(id)} Query History`}
width={820}
footerStart={
<IconButton icon="trash" title="Clear History" disabled={!entries?.length} onClick={() => void remove(null)} />
}
buttons={[
{ label: "Close", role: "cancel" },
{ label: "Paste", role: "default", disabled: !current, onClick: () => done(current?.text) },
]}
>
<div className={styles.historyDialog}>
<div className={styles.historyList}>
<div className={styles.formRow}>
<Input
autoFocus
aria-label="Search history"
placeholder="Search"
value={query}
onChange={(e) => {
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);
}}
/>
<IconButton
icon="minus"
title="Delete"
disabled={!current}
onClick={() => current && void remove([current.text])}
/>
</div>
<List
className={styles.fill}
aria-label="Statements"
items={shown}
getKey={(e) => e.text}
activeIndex={Math.min(active, shown.length - 1)}
onActiveChange={setActive}
onActivate={(e) => done(e.text)}
renderItem={(e) => (
<span className={styles.historyItem} title={when(e.time ?? 0)}>
<span className={styles.historyText}>{e.text.replace(/\s+/g, " ")}</span>
</span>
)}
empty={<EmptyState title={entries === null ? "Loading…" : "No statements"} />}
/>
</div>
<CodePreview sql className={styles.historyPreview} aria-label="Statement" text={current?.text ?? ""} />
</div>
</Dialog>
);
}
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<string | null>(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 (
<Dialog
open
onClose={closeDialog}
title={`Import Data into ${schema}.${table}`}
width={720}
buttons={[
{ label: "Cancel", role: "cancel" },
{
label: `Import ${edits.length} Rows`,
role: "default",
disabled: !edits.length || !mapped.length || busy,
onClick: () => void run(),
},
]}
>
<div className={styles.form}>
<span className={styles.formLabel}>
<T>File:</T>
</span>
<div className={styles.formRow}>
<Input readOnly aria-label="File" value={file ?? ""} placeholder="Choose a CSV or TSV file" />
<IconButton icon="folder" title="Choose File…" onClick={() => void choose()} />
</div>
<span className={styles.formLabel}>
<T>Separator:</T>
</span>
<Select aria-label="Separator" value={sep} options={SEPARATORS} onChange={setSep} />
<span />
<Checkbox checked={header} onChange={setHeader} label="First row is header (columns matched by name)" />
<span />
<Checkbox checked={emptyAsNull} onChange={setEmptyAsNull} label="Treat empty values as NULL" />
<span className={styles.formLabel}>
<T>Columns:</T>
</span>
<span className={styles.formHint}>
{mapped.length ? mapped.join(", ") : "No column of the file matches the table"}
</span>
</div>
{preview.length > 0 && (
<div className={styles.importPreview}>
<table>
<tbody>
{preview.map((r, i) => (
<tr key={i} className={header && i === 0 ? styles.importHeader : undefined}>
{r.map((v, j) => (
<td key={j}>{v}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</Dialog>
);
}
+412
Переглянути файл
@@ -0,0 +1,412 @@
/**
* A data grid with IDEA's result-view extras, shared by console results and
* the table editor: the header's local filter (values of the loaded rows),
* client-side sorting when the caller does not sort on the server, and the
* Record / Value / Aggregates side panel following the selection.
*/
import { useMemo, useState } from "react";
import clsx from "clsx";
import { Button, Checkbox, Icon, IconButton, Input, Popup, Select, Tabs, T, type MenuItem } from "@/ui";
import { EXPORT_FORMATS, exportData, type ExportData, type ExportFormat } from "./export";
import { Grid, type GridColumn, type GridProps, type GridRange } from "./Grid";
import type { Cell } from "./model";
import {
aggregates,
distinctValues,
formatNumber,
toggleFilterValue,
visibleRows,
type LocalFilter,
type LocalSort,
} from "./resultView";
import { openDialog } from "./store";
import styles from "./Database.module.css";
export type DataPanel = "record" | "value" | "aggregates";
/** The Export button's menu: the Export Data dialog (with the toolbar's extractor), then IDEA's quick Copy as… extractors. */
export function exportMenu(data: ExportData, source: string, format?: ExportFormat): MenuItem[] {
return [
{ label: "Export Data…", icon: "export", run: () => openDialog({ kind: "export", data, source, format }) },
{ kind: "separator" },
...EXPORT_FORMATS.map((f): MenuItem => ({
label: `Copy as ${f.label}`,
icon: "copy",
run: () => void navigator.clipboard?.writeText(exportData(f.id, data)),
})),
];
}
/** IDEA's extractor drop-down at the right of a data toolbar ("CSV ▾"). */
export function ExtractorSelect({ value, onChange }: { value: ExportFormat; onChange: (f: ExportFormat) => void }) {
return (
<Select
aria-label="Data extractor"
value={value}
options={EXPORT_FORMATS.map((f) => ({ 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 (
<IconButton
icon="split"
title={panel ? "Hide Record / Value / Aggregates" : "Show Record / Value / Aggregates"}
aria-pressed={!!panel}
onClick={() => 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<Shaping>({ 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<GridRange | null>(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 (
<div className={styles.dataView}>
<div className={clsx(styles.fill, styles.gridHost)}>
<Grid
aria-label={props["aria-label"]}
columns={columns}
rowCount={shown}
value={(r, c) => 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 && (
<div className={styles.filterPill} role="status">
{shown} of {rowCount} rows
<button type="button" className={styles.filterPillClear} onClick={() => setFilter(new Map())}>
<T>Clear local filter</T>
</button>
</div>
)}
</div>
{filterAt && (
<FilterPopup
anchor={filterAt.anchor}
column={columns[filterAt.col]?.name ?? ""}
values={distinctValues(Math.min(rowCount, fixedRows), value, filterAt.col)}
checked={filter.get(filterAt.col) ?? null}
onToggle={(all, v, on) => 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 && (
<SidePanel
panel={panel}
onPanelChange={onPanelChange}
columns={columns}
range={range}
value={(r, c) => 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)}
/>
)}
</div>
);
}
function FilterPopup(props: {
anchor: HTMLElement;
column: string;
values: Array<{ value: Cell; count: number }>;
checked: ReadonlySet<Cell> | 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 ?? "<null>").toLowerCase().includes(q)) : props.values;
return (
<Popup
open
anchor={props.anchor}
placement="bottom-start"
onClose={props.onClose}
aria-label={`Local filter of ${props.column}`}
className={styles.filterPopup}
trapFocus
>
<Input
autoFocus
aria-label="Search values"
placeholder={`Values of ${props.column}`}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<div className={styles.filterValues} role="group" aria-label="Values">
{list.map(({ value: v, count }) => (
<div key={v ?? "\u0000null"} className={styles.filterValue}>
<Checkbox
checked={props.checked ? props.checked.has(v) : true}
onChange={(on) => props.onToggle(all, v, on)}
label={v === null ? <span className={styles.null}>&lt;null&gt;</span> : v}
/>
<span className={styles.filterCount}>{count}</span>
</div>
))}
{!list.length && (
<div className={styles.filterEmpty}>
<T>No matching values</T>
</div>
)}
</div>
<div className={styles.filterButtons}>
<Button size="small" disabled={!props.checked} onClick={props.onClear}>
<T>Clear Filter</T>
</Button>
<Button size="small" onClick={props.onClearAll}>
<T>Clear Local Filter For All Columns</T>
</Button>
</div>
</Popup>
);
}
const AGGREGATE_ROWS: ReadonlyArray<[label: string, key: keyof ReturnType<typeof aggregates>]> = [
["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 (
<aside className={styles.sidePanel} aria-label="Value panel">
<div className={styles.sidePanelHead}>
<Tabs
variant="compact"
aria-label="Value panel"
activeId={props.panel}
onSelect={(id) => props.onPanelChange(id as DataPanel)}
items={[
{ id: "record", label: "Record" },
{ id: "value", label: "Value" },
{ id: "aggregates", label: "Aggregates" },
]}
/>
<span className={styles.spacer} />
<IconButton icon="close" title="Hide" size="small" onClick={() => props.onPanelChange(null)} />
</div>
<div className={styles.sidePanelBody}>
{!range ? (
<div className={styles.filterEmpty}>
<T>Select a cell</T>
</div>
) : props.panel === "record" ? (
<RecordView key={range.lead.row} {...props} row={range.lead.row} />
) : props.panel === "value" ? (
<ValueView
key={`${range.lead.row}:${range.lead.col}`}
column={columns[range.lead.col]?.name ?? ""}
cell={value(range.lead.row, range.lead.col)}
editable={props.editable}
onCommit={(v) => props.onEdit(range.lead.row, range.lead.col, v)}
/>
) : (
<AggregatesView range={range} value={value} />
)}
</div>
</aside>
);
}
/** 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 (
<div className={styles.recordFields}>
{props.columns.map((c, i) => (
<RecordField
// A new value (another row, an edit elsewhere) starts the field over.
key={`${c.name}\u0000${props.value(props.row, i) ?? "\u0001"}`}
column={c}
cell={props.value(props.row, i)}
editable={props.editable}
onCommit={(v) => props.onEdit(props.row, i, v)}
/>
))}
</div>
);
}
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 (
<label className={styles.recordField}>
<span className={styles.recordLabel}>
<Icon name={c.primaryKey ? "db-column-key" : c.foreignKey ? "db-column-fk" : "db-column"} size={16} />
{c.name}
{c.type && c.type !== "NULL" && <span className={styles.treeType}>{c.type}</span>}
</span>
<Input
mono
value={text}
readOnly={!props.editable}
placeholder={props.cell === null ? "<null>" : undefined}
onChange={(e) => setText(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
}}
/>
</label>
);
}
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 (
<div className={styles.valueView}>
<textarea
className={styles.valueText}
aria-label={`Value of ${props.column}`}
value={text}
readOnly={!props.editable}
placeholder={props.cell === null ? "<null>" : undefined}
spellCheck={false}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && dirty) props.onCommit(text);
}}
/>
{props.editable && (
<div className={styles.filterButtons}>
<Button size="small" disabled={!dirty} onClick={() => props.onCommit(text)}>
<T>Apply</T>
</Button>
<Button size="small" onClick={() => props.onCommit(null)}>
<T>Set NULL</T>
</Button>
</div>
)}
</div>
);
}
function AggregatesView({ range, value }: { range: GridRange; value: (row: number, col: number) => Cell }) {
const cells: Cell[] = [];
for (let r = range.top; r <= range.bottom; r++)
for (let c = range.left; c <= range.right; c++) cells.push(value(r, c));
const a = aggregates(cells);
const show = (v: number | string | null) => (v === null ? "—" : typeof v === "number" ? formatNumber(v) : v);
return (
<table className={styles.aggregates}>
<tbody>
{AGGREGATE_ROWS.map(([label, key]) => (
<tr key={key}>
<th scope="row">{label}</th>
<td>{show(a[key])}</td>
</tr>
))}
</tbody>
</table>
);
}
+434 -8
Переглянути файл
@@ -141,9 +141,15 @@
flex: 1;
min-height: 0;
}
/* Gives way before the name does. */
.treeDetail {
color: var(--fg-muted);
margin-left: 6px;
min-width: 0;
flex-shrink: 100;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* IDEA: counts after folders, column types in a smaller grey face, key rows in grey. */
.treeCount {
@@ -291,9 +297,28 @@
}
/* Table editor. */
.filter {
/* The table editor's WHERE | ORDER BY row (IDEA). */
.filterBar {
display: flex;
flex: none;
height: var(--h-header);
border-bottom: 1px solid var(--border);
}
.filterField {
flex: 1;
min-width: 120px;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
color: var(--fg-muted);
}
.filterField + .filterField {
border-left: 1px solid var(--border);
}
.filterField > input {
flex: 1;
min-width: 0;
}
.pager {
display: flex;
@@ -663,27 +688,38 @@
user-select: text;
}
.sqlPreview :global(.tok-keyword),
.sqlPreview :global(.tok-operatorKeyword) {
.codeText :global(.tok-keyword),
.sqlPreview :global(.tok-operatorKeyword),
.codeText :global(.tok-operatorKeyword) {
color: var(--syn-keyword);
}
.sqlPreview :global(.tok-string),
.sqlPreview :global(.tok-string2) {
.codeText :global(.tok-string),
.sqlPreview :global(.tok-string2),
.codeText :global(.tok-string2) {
color: var(--syn-string);
}
.sqlPreview :global(.tok-number),
.codeText :global(.tok-number),
.sqlPreview :global(.tok-bool),
.sqlPreview :global(.tok-atom) {
.codeText :global(.tok-bool),
.sqlPreview :global(.tok-atom),
.codeText :global(.tok-atom) {
color: var(--syn-number);
}
.sqlPreview :global(.tok-typeName) {
.sqlPreview :global(.tok-typeName),
.codeText :global(.tok-typeName) {
color: var(--syn-keyword);
}
.sqlPreview :global(.tok-comment) {
.sqlPreview :global(.tok-comment),
.codeText :global(.tok-comment) {
color: var(--syn-comment);
font-style: italic;
}
.sqlPreview :global(.tok-punctuation),
.sqlPreview :global(.tok-operator) {
.codeText :global(.tok-punctuation),
.sqlPreview :global(.tok-operator),
.codeText :global(.tok-operator) {
color: var(--syn-punct);
}
.modifyBody {
@@ -776,3 +812,393 @@
display: flex;
justify-content: flex-start;
}
/* Result views: grid + Record / Value / Aggregates panel. */
.dataView {
display: flex;
height: 100%;
min-height: 0;
}
.dataView > .fill {
min-width: 0;
}
.headerFilter {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
border: 0;
border-radius: var(--radius-small);
background: transparent;
color: var(--fg-muted);
cursor: pointer;
}
.headerFilter:hover {
background: var(--bg-hover);
}
.headerFilterOn {
color: var(--accent);
}
.filterPill {
position: absolute;
right: 12px;
bottom: 12px;
display: flex;
gap: 8px;
align-items: center;
padding: 3px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-small);
background: var(--bg-popup);
color: var(--fg-secondary);
font-size: var(--fs-small);
box-shadow: var(--shadow-popup);
}
.filterPillClear {
padding: 0;
border: 0;
background: none;
color: var(--link);
font: inherit;
cursor: pointer;
}
.filterPopup {
display: flex;
flex-direction: column;
gap: 6px;
width: 280px;
padding: 8px;
}
.filterValues {
max-height: 260px;
overflow: auto;
}
.filterValue {
display: flex;
align-items: center;
gap: 6px;
min-height: var(--h-row);
}
.filterValue > :first-child {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filterCount {
color: var(--fg-muted);
font-size: var(--fs-small);
}
.filterEmpty {
padding: 12px;
color: var(--fg-muted);
text-align: center;
}
.filterButtons {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.sidePanel {
flex: none;
display: flex;
flex-direction: column;
width: 320px;
min-height: 0;
border-left: 1px solid var(--border);
}
.sidePanelHead {
display: flex;
align-items: center;
padding-right: 4px;
border-bottom: 1px solid var(--border);
}
.sidePanelBody {
flex: 1;
min-height: 0;
overflow: auto;
}
.recordFields {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
}
.recordField {
display: flex;
flex-direction: column;
gap: 4px;
}
.recordLabel {
display: flex;
align-items: center;
gap: 4px;
color: var(--fg-secondary);
}
.valueView {
display: flex;
flex-direction: column;
gap: 6px;
height: 100%;
padding: 8px;
box-sizing: border-box;
}
.valueText {
flex: 1;
min-height: 120px;
resize: none;
padding: 6px;
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-input);
color: var(--fg-strong);
font-family: var(--openidea-editor-font, var(--font-mono));
font-size: var(--fs-code);
}
.valueText:focus {
outline: none;
border-color: var(--focus-ring);
}
.aggregates {
width: 100%;
border-collapse: collapse;
}
.aggregates th,
.aggregates td {
padding: 4px 10px;
border-bottom: 1px solid var(--border);
text-align: left;
font-weight: normal;
}
.aggregates th {
color: var(--fg-secondary);
width: 40%;
}
.aggregates td {
font-family: var(--openidea-editor-font, var(--font-mono));
word-break: break-all;
}
/* Chart. */
.chartBar {
display: flex;
align-items: center;
gap: 6px;
height: var(--h-header);
padding: 0 8px;
flex: none;
border-bottom: 1px solid var(--border);
}
.chartHost {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
}
.chartSettings {
position: absolute;
top: 4px;
right: 8px;
}
.chartGrid {
stroke: var(--border);
}
.chartAxis {
stroke: var(--fg-muted);
}
.chartTick {
fill: var(--fg-muted);
font-size: 10px;
}
.chartAxisName {
fill: var(--fg-secondary);
font-size: var(--fs-small);
}
.chartBarMark {
fill: var(--chart-series);
fill-opacity: 0.9;
}
.chartBarMark:hover {
fill-opacity: 1;
}
.chartLine {
fill: none;
stroke: var(--chart-series);
stroke-width: 2;
}
.chartDot {
fill: var(--chart-series);
}
/* Query plan. */
.planView {
display: flex;
min-height: 0;
}
.planTree {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.planHeader,
.planRow {
display: grid;
grid-template-columns: minmax(160px, 1fr) 1fr;
gap: 12px;
width: 100%;
}
.planHeader {
padding: 4px 8px;
border-bottom: 1px solid var(--border);
color: var(--fg-secondary);
}
.planOperation {
display: flex;
align-items: center;
gap: 4px;
overflow: hidden;
text-overflow: ellipsis;
}
.planParams {
overflow: hidden;
text-overflow: ellipsis;
color: var(--fg-secondary);
}
.planProperties {
flex: none;
width: 320px;
align-self: flex-start;
border-collapse: collapse;
border-left: 1px solid var(--border);
}
.planProperties th,
.planProperties td {
padding: 4px 8px;
text-align: left;
font-weight: normal;
border-bottom: 1px solid var(--border);
word-break: break-word;
}
.planProperties th {
color: var(--fg-secondary);
}
/* Export / history / import dialogs. */
.exportDialog {
display: flex;
gap: 16px;
height: 420px;
padding: 14px;
}
.exportOptions {
display: flex;
flex-direction: column;
gap: 8px;
width: 300px;
flex: none;
}
.exportOptions .formLabel,
.exportPreview .formLabel {
text-align: left;
}
.exportPreview {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.exportPreviewText,
.historyPreview {
flex: 1;
min-height: 0;
}
.codePreview {
display: flex;
align-items: flex-start;
overflow: auto;
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-editor);
font-family: var(--openidea-editor-font, var(--font-mono));
font-size: var(--fs-code);
line-height: 1.6;
}
.codeGutter {
position: sticky;
left: 0;
flex: none;
min-width: 36px;
min-height: 100%;
padding: 8px 10px 8px 8px;
border-right: 1px solid var(--border);
background: var(--bg-editor);
color: var(--fg-muted);
text-align: right;
user-select: none;
}
.codeText {
flex: 1;
margin: 0;
padding: 8px 10px;
color: var(--fg-editor, var(--fg-strong));
font: inherit;
white-space: pre;
user-select: text;
}
.historyDialog {
display: flex;
gap: 12px;
height: 440px;
padding: 14px;
}
.historyList {
display: flex;
flex-direction: column;
gap: 6px;
width: 300px;
flex: none;
min-height: 0;
}
.historyItem {
display: flex;
gap: 8px;
align-items: baseline;
width: 100%;
min-width: 0;
}
.historyText {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--openidea-editor-font, var(--font-mono));
}
.importPreview {
max-height: 200px;
margin: 0 14px 14px;
overflow: auto;
border: 1px solid var(--border);
border-radius: var(--radius-control);
}
.importPreview table {
border-collapse: collapse;
font-family: var(--openidea-editor-font, var(--font-mono));
font-size: var(--fs-code);
}
.importPreview td {
padding: 2px 8px;
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.importHeader td {
color: var(--fg-strong);
font-weight: 600;
}
+89 -16
Переглянути файл
@@ -1,6 +1,7 @@
/**
* Database tool window (IDEA's layout): data source → database (PostgreSQL) → schema → `tables` /
* `views` folders → table → `columns` / `keys` / `foreign keys` / `indexes`,
* `views` / `materialized views` / `routines` / `sequences` folders → table → `columns` / `keys` /
* `foreign keys` / `indexes` / `triggers`,
* with counts and column types, loaded lazily. Double-click a table to open its data, a data
* source to open its query console.
*/
@@ -21,8 +22,8 @@ import {
type TreeHandle,
T,
} from "@/ui";
import { colorVar, databaseKey, shownNames, sourceIcon, splitTarget, targetOf } from "./model";
import { nodeTarget, type DbNode as Node, type Group } from "./nodes";
import { colorVar, databaseKey, passesObjectFilter, shownNames, sourceIcon, splitTarget, targetOf } from "./model";
import { nodeTarget, schemaObjectOf, tableFolder, type DbNode as Node, type Folder, type Group } from "./nodes";
import {
connect,
detailsKey,
@@ -33,6 +34,7 @@ import {
loadSources,
loadTables,
openConsole,
openObjectDdl,
tablesKey,
useDatabase,
type Loaded,
@@ -46,8 +48,19 @@ const GROUP_LABELS: Record<Group, string> = {
keys: "keys",
foreignKeys: "foreign keys",
indexes: "indexes",
triggers: "triggers",
};
const FOLDER_LABELS: Record<Folder, string> = {
tables: "tables",
views: "views",
materializedViews: "materialized views",
routines: "routines",
sequences: "sequences",
};
const FOLDERS: readonly Folder[] = ["tables", "views", "materializedViews", "routines", "sequences"];
function status<T>(key: string, l: Loaded<T> | undefined, map: (v: T) => Node[]): Node[] | undefined {
if (!l) return undefined;
if (l.status === "loading") return undefined;
@@ -64,6 +77,7 @@ function groupsOf(d: DbTableDetails): Group[] {
if (primaryKey(d).length) g.push("keys");
if (d.foreignKeys.length) g.push("foreignKeys");
if (d.indexes.length) g.push("indexes");
if (d.triggers.length) g.push("triggers");
return g;
}
@@ -71,6 +85,7 @@ function groupSize(d: DbTableDetails, g: Group): number {
if (g === "columns") return d.columns.length;
if (g === "keys") return primaryKey(d).length ? 1 : 0;
if (g === "foreignKeys") return d.foreignKeys.length;
if (g === "triggers") return d.triggers.length;
return d.indexes.length;
}
@@ -96,9 +111,24 @@ export function DatabaseView() {
[st.sources],
);
/** Schemas tab › Object filter of the data source of target `id`. */
const shown = (id: string) => {
const filter = sourceOf(id)?.introspection?.objectFilter;
return (o: { name: string }) => passesObjectFilter(o.name, filter);
};
const tablesOf = (id: string, schema: string) => {
const t = st.tables[tablesKey(id, schema)];
return t?.status === "ok" ? t.value : null;
return t?.status === "ok" ? t.value.filter(shown(id)) : null;
};
const routinesOf = (id: string, schema: string) =>
(st.objects[tablesKey(id, schema)]?.routines ?? []).filter(shown(id));
const sequencesOf = (id: string, schema: string) =>
(st.objects[tablesKey(id, schema)]?.sequences ?? []).filter(shown(id));
/** Members of a schema folder: tables / views / materialized views, routines or sequences. */
const folderSize = (id: string, schema: string, folder: Folder): number => {
if (folder === "routines") return routinesOf(id, schema).length;
if (folder === "sequences") return sequencesOf(id, schema).length;
return (tablesOf(id, schema) ?? []).filter((t) => tableFolder(t.kind) === folder).length;
};
const detailsOf = (id: string, schema: string, table: string) => {
const d = st.details[detailsKey(id, schema, table)];
@@ -140,15 +170,36 @@ export function DatabaseView() {
case "database":
return schemasOf(n.key, n.id);
case "schema":
return status(n.key, st.tables[tablesKey(n.id, n.schema)], (v) =>
(["tables", "views"] as const)
.filter((f) => v.some((t) => (t.kind === "view") === (f === "views")))
.map((folder) => ({ kind: "folder", key: `${n.key}/${folder}`, id: n.id, schema: n.schema, folder })),
return status(n.key, st.tables[tablesKey(n.id, n.schema)], () =>
FOLDERS.filter((f) => folderSize(n.id, n.schema, f) > 0).map((folder): Node => ({
kind: "folder",
key: `${n.key}/${folder}`,
id: n.id,
schema: n.schema,
folder,
})),
);
case "folder":
case "folder": {
const at = { id: n.id, schema: n.schema };
if (n.folder === "routines")
// Overloads share a name: the key tells them apart by their arguments.
return routinesOf(n.id, n.schema).map((routine) => ({
kind: "routine",
key: `${n.key}/${routine.name}(${routine.arguments})`,
routine,
...at,
}));
if (n.folder === "sequences")
return sequencesOf(n.id, n.schema).map((sequence) => ({
kind: "sequence",
key: `${n.key}/${sequence.name}`,
sequence,
...at,
}));
return (tablesOf(n.id, n.schema) ?? [])
.filter((t) => (t.kind === "view") === (n.folder === "views"))
.map((table) => ({ kind: "table", key: `${n.key}/${table.name}`, id: n.id, schema: n.schema, table }));
.filter((t) => tableFolder(t.kind) === n.folder)
.map((table) => ({ kind: "table", key: `${n.key}/${table.name}`, table, ...at }));
}
case "table": {
const d = st.details[detailsKey(n.id, n.schema, n.table.name)];
if (!d || d.status === "loading") return undefined;
@@ -182,6 +233,8 @@ export function DatabaseView() {
return d.foreignKeys.map((fk, i) => ({ kind: "foreignKey", key: `${n.key}/${i}`, fk, ...at }));
case "indexes":
return d.indexes.map((index) => ({ kind: "index", key: `${n.key}/${index.name}`, index, ...at }));
case "triggers":
return d.triggers.map((trigger) => ({ kind: "trigger", key: `${n.key}/${trigger.name}`, trigger, ...at }));
}
return undefined;
}
@@ -211,7 +264,13 @@ export function DatabaseView() {
case "schema":
return n.schema;
case "folder":
return n.folder;
return FOLDER_LABELS[n.folder];
case "routine":
return n.routine.name;
case "sequence":
return n.sequence.name;
case "trigger":
return n.trigger.name;
case "table":
return n.table.name;
case "group":
@@ -247,9 +306,13 @@ export function DatabaseView() {
return s?.status === "ok" ? String(shownSchemas(n.id, s.value).length) : undefined;
}
case "folder":
return String(
(tablesOf(n.id, n.schema) ?? []).filter((t) => (t.kind === "view") === (n.folder === "views")).length,
);
return String(folderSize(n.id, n.schema, n.folder));
case "routine":
return `(${n.routine.arguments})${n.routine.returns ? `: ${n.routine.returns}` : ""}`;
case "sequence":
return n.sequence.dataType;
case "trigger":
return `${n.trigger.timing} ${n.trigger.events}`.trim();
case "group": {
const d = detailsOf(n.id, n.schema, n.table);
return d ? String(groupSize(d, n.group)) : undefined;
@@ -279,7 +342,13 @@ export function DatabaseView() {
case "group":
return "db-folder";
case "table":
return n.table.kind === "view" ? "db-view" : "db-table";
return n.table.kind === "view" ? "db-view" : n.table.kind === "materializedView" ? "db-matview" : "db-table";
case "routine":
return "db-routine";
case "sequence":
return "db-sequence";
case "trigger":
return "db-trigger";
case "column":
return n.column.primaryKey ? "db-column-key" : n.foreignKey ? "db-column-fk" : "db-column";
case "key":
@@ -387,6 +456,10 @@ export function DatabaseView() {
if (n.kind === "table") openTableData(n.id, n.schema, n.table.name);
else if (n.kind === "source") void openConsole(n.source.id ?? "");
else if (n.kind === "database") void openConsole(n.id);
else {
const o = schemaObjectOf(n);
if (o) void openObjectDdl(o.id, o.schema, o.kind, o.name);
}
}}
activateExpandable={(n) => n.kind === "table"}
onContextMenu={(n, _sel, e) => {
+10
Переглянути файл
@@ -8,6 +8,9 @@ import { closeDialog, useDatabase } from "./store";
import styles from "./Database.module.css";
const ObjectDialogs = lazyComponent(() => import("./ObjectDialogs").then((m) => m.ObjectDialogs));
const ExportDialog = lazyComponent(() => import("./ExportDialog").then((m) => m.ExportDialog));
const HistoryDialog = lazyComponent(() => import("./DataDialogs").then((m) => m.HistoryDialog));
const ImportDialog = lazyComponent(() => import("./DataDialogs").then((m) => m.ImportDialog));
function PasswordDialog({ source, resolve }: { source: DataSource; resolve: (ok: boolean) => void }) {
const [password, setPassword] = useState("");
@@ -178,6 +181,13 @@ export function DatabaseDialogs() {
/>
);
if (dialog.kind === "params") return <ParamsDialog names={dialog.names} resolve={dialog.resolve} />;
if (dialog.kind === "export")
return <ExportDialog data={dialog.data} source={dialog.source} initialFormat={dialog.format} />;
if (dialog.kind === "history") return <HistoryDialog key={dialog.id} id={dialog.id} path={dialog.path} />;
if (dialog.kind === "import")
return (
<ImportDialog key={`${dialog.id}.${dialog.table}`} id={dialog.id} schema={dialog.schema} table={dialog.table} />
);
if (dialog.kind === "sshSecret")
return (
<SshSecretDialog key={dialog.source.id} source={dialog.source} prompt={dialog.prompt} resolve={dialog.resolve} />
+169
Переглянути файл
@@ -0,0 +1,169 @@
/**
* IDEA's Export Data dialog: extractor (format) and its options on the left,
* a live preview on the right; copies to the clipboard or writes a file.
*/
import { useEffect, useMemo, useState } from "react";
import { call, commands } from "@/platform/ipc";
import { Checkbox, Dialog, IconButton, Input, Select, T } from "@/ui";
import { notifications } from "@/workbench/contributions";
import {
DEFAULT_EXPORT_OPTIONS,
EXPORT_FORMATS,
exportData,
type ExportData,
type ExportFormat,
type ExportOptions,
} from "./export";
import { CodePreview } from "./CodePreview";
import { closeDialog } from "./store";
import styles from "./Database.module.css";
const PREVIEW_ROWS = 100;
/** Formats with a tabular layout (the options apply). */
const TABULAR: ReadonlySet<ExportFormat> = new Set(["csv", "tsv", "markdown"]);
function joinPath(dir: string, name: string): string {
return `${dir.replace(/[\\/]+$/, "")}/${name}`;
}
export function ExportDialog({
data,
source,
initialFormat = "csv",
}: {
data: ExportData;
source: string;
initialFormat?: ExportFormat;
}) {
const [format, setFormat] = useState<ExportFormat>(initialFormat);
const [options, setOptions] = useState<ExportOptions>(DEFAULT_EXPORT_OPTIONS);
const ext = EXPORT_FORMATS.find((f) => f.id === format)!.extension;
const [file, setFile] = useState(`${data.table}.${ext}`);
const [dir, setDir] = useState<string | null>(null);
useEffect(() => {
void import("@tauri-apps/api/path")
.then((p) => p.homeDir())
.then((home) => {
setDir(home);
setFile((f) => (f.includes("/") || f.includes("\\") ? f : joinPath(home, f)));
})
.catch(() => {});
}, []);
const changeFormat = (f: ExportFormat) => {
setFormat(f);
// The file follows the format's extension.
const next = EXPORT_FORMATS.find((x) => x.id === f)!.extension;
setFile((path) => path.replace(/\.[^./\\]*$/, `.${next}`));
};
const tabular = TABULAR.has(format);
const effective = tabular ? options : DEFAULT_EXPORT_OPTIONS;
const preview = useMemo(
() => exportData(format, { ...data, rows: data.rows.slice(0, PREVIEW_ROWS) }, effective),
[format, data, effective],
);
const text = () => exportData(format, data, effective);
const set = (k: keyof ExportOptions) => (v: boolean) => setOptions((o) => ({ ...o, [k]: v }));
const browse = async () => {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const f = EXPORT_FORMATS.find((x) => x.id === format)!;
const path = await save({ defaultPath: file, filters: [{ name: f.label, extensions: [f.extension] }] });
if (path) setFile(path);
} catch {
// No dialog (tests): keep typing the path.
}
};
const toFile = async () => {
const path = file.trim();
if (!path) return;
const target = /^([a-zA-Z]:)?[\\/]/.test(path) || !dir ? path : joinPath(dir, path);
try {
await call(commands.dbExportFile(target, text()));
notifications().notify({
level: "info",
title: `${data.rows.length} rows exported`,
body: target,
key: "db-export",
});
closeDialog();
} catch (e) {
notifications().notify({
level: "error",
title: "Cannot export data",
body: e instanceof Error ? e.message : String(e),
});
}
};
return (
<Dialog
open
onClose={closeDialog}
title="Export Data"
width={860}
buttons={[
{ label: "Cancel", role: "cancel" },
{
label: "Copy to Clipboard",
onClick: () => {
void navigator.clipboard?.writeText(text());
closeDialog();
},
},
{ label: "Export to File", role: "default", disabled: !file.trim(), onClick: () => void toFile() },
]}
>
<div className={styles.exportDialog}>
<div className={styles.exportOptions}>
<span className={styles.formLabel}>
<T>Source:</T>
</span>
<Input readOnly value={source} aria-label="Source" />
<span className={styles.formLabel}>
<T>Extractor:</T>
</span>
<Select
aria-label="Extractor"
value={format}
options={EXPORT_FORMATS.map((f) => ({ value: f.id, label: f.label }))}
onChange={(v) => changeFormat(v as ExportFormat)}
/>
<Checkbox checked={effective.transpose} disabled={!tabular} onChange={set("transpose")} label="Transpose" />
<Checkbox
checked={effective.columnHeader}
disabled={!tabular}
onChange={set("columnHeader")}
label="Add column header"
/>
<Checkbox
checked={effective.rowHeader}
disabled={!tabular}
onChange={set("rowHeader")}
label="Add row header"
/>
<span className={styles.formLabel}>
<T>Output file:</T>
</span>
<div className={styles.formRow}>
<Input mono aria-label="Output file" value={file} onChange={(e) => setFile(e.target.value)} />
<IconButton icon="folder" title="Browse…" onClick={() => void browse()} />
</div>
</div>
<div className={styles.exportPreview}>
<span className={styles.formLabel}>
<T>Export preview:</T>
</span>
<CodePreview
className={styles.exportPreviewText}
aria-label="Export preview"
sql={format === "sql"}
text={preview + (data.rows.length > PREVIEW_ROWS ? `\n… ${data.rows.length - PREVIEW_ROWS} more rows` : "")}
/>
</div>
</div>
</Dialog>
);
}
+74 -11
Переглянути файл
@@ -42,9 +42,25 @@ export interface GridProps {
rowMenu?: (rows: number[]) => MenuItem[];
/** Row number shown for row 0 (paging). */
firstRowNumber?: number;
/** Row number shown for a row (local sort / filter reorder them); `firstRowNumber + row` otherwise. */
rowNumber?: (row: number) => number;
/** The selected range changed (the Record / Value / Aggregates panel follows it). */
onSelectionChange?: (range: GridRange | null) => void;
/** The header's filter button (IDEA's local filter); `filtered` marks the columns with one. */
onFilter?: (col: number, anchor: HTMLElement) => void;
filtered?: (col: number) => boolean;
"aria-label"?: string;
}
/** A selected block of cells, inclusive; `lead` is the cell last moved to. */
export interface GridRange {
top: number;
bottom: number;
left: number;
right: number;
lead: { row: number; col: number };
}
interface Sel {
r0: number;
c0: number;
@@ -55,11 +71,16 @@ interface Sel {
const ROW_H = 24;
const NUM_W = 48;
function initialWidth(c: GridColumn, sample: Cell[]): number {
const longest = Math.max(c.name.length + 2, ...sample.map((v) => Math.min((v ?? "<null>").length, 60)));
return Math.max(64, Math.min(320, longest * 7.5 + 20));
/** Wide enough for the whole header (icon, name, filter, sort) and the longest sampled value. */
function initialWidth(c: GridColumn, sample: Cell[], decorations: number): number {
const header = c.name.length * 8 + 32 + decorations;
const longest = Math.max(...sample.map((v) => Math.min((v ?? "<null>").length, 60)), 0) * 7.5 + 20;
return Math.max(64, Math.min(360, Math.max(header, longest)));
}
/** SQLite renders whole REALs as `3108.0`; IDEA shows `3108`. */
const displayed = (v: string, numeric: boolean) => (numeric && /^-?\d+\.0$/.test(v) ? v.slice(0, -2) : v);
function norm(s: Sel) {
return {
top: Math.min(s.r0, s.r1),
@@ -80,7 +101,8 @@ export function Grid(props: GridProps) {
useEffect(() => {
const sampleRows = Math.min(rowCount, 50);
const samples = columns.map((_, ci) => Array.from({ length: sampleRows }, (_, r) => value(r, ci)));
setWidths(columns.map((c, ci) => initialWidth(c, samples[ci]!)));
const decorations = (props.onFilter ? 20 : 0) + (onSort ? 20 : 0);
setWidths(columns.map((c, ci) => initialWidth(c, samples[ci]!, decorations)));
setNumeric(columns.map((c, ci) => isNumericColumn(c.type, samples[ci]!)));
setSel(null);
setEditing(null);
@@ -88,6 +110,13 @@ export function Grid(props: GridProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [columnKey]);
const { onSelectionChange } = props;
useEffect(() => {
onSelectionChange?.(
sel && rowCount ? { ...norm(sel), lead: { row: Math.min(sel.r1, rowCount - 1), col: sel.c1 } } : null,
);
}, [sel, rowCount, onSelectionChange]);
const virtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => scrollRef.current,
@@ -112,7 +141,9 @@ export function Grid(props: GridProps) {
const copy = (format: "tsv" | "csv", header: boolean, wholeRows = false) => {
if (!sel) return;
const n = norm(sel);
const cols = wholeRows ? columns.map((_, i) => i) : Array.from({ length: n.right - n.left + 1 }, (_, i) => n.left + i);
const cols = wholeRows
? columns.map((_, i) => i)
: Array.from({ length: n.right - n.left + 1 }, (_, i) => n.left + i);
const rows = selectedRows().map((r) => cols.map((c) => value(r, c)));
const head = header ? cols.map((c) => columns[c]!.name) : null;
const text = format === "tsv" ? toTsv(head, rows) : toCsv(head, rows);
@@ -153,7 +184,12 @@ export function Grid(props: GridProps) {
startEdit(sel.r1, sel.c1);
return;
}
const moves: Record<string, [number, number]> = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1] };
const moves: Record<string, [number, number]> = {
ArrowUp: [-1, 0],
ArrowDown: [1, 0],
ArrowLeft: [0, -1],
ArrowRight: [0, 1],
};
const m = moves[e.key];
if (!m) return;
e.preventDefault();
@@ -250,8 +286,31 @@ export function Grid(props: GridProps) {
className={styles.headerIcon}
/>
<span className={styles.headerName}>{c.name}</span>
{s && <Icon name={s.descending ? "arrow-down" : "arrow-up"} size={12} className={styles.headerSort} />}
<span className={styles.resizer} onMouseDown={(e) => startResize(e, ci)} onClick={(e) => e.stopPropagation()} />
{props.onFilter && (
<button
type="button"
className={clsx(styles.headerFilter, props.filtered?.(ci) && styles.headerFilterOn)}
aria-label={`Filter ${c.name}`}
title="Local filter"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
props.onFilter?.(ci, e.currentTarget);
}}
>
<Icon name="filter" size={12} />
</button>
)}
{s ? (
<Icon name={s.descending ? "arrow-down" : "arrow-up"} size={12} className={styles.headerSort} />
) : (
onSort && <Icon name="db-sort" size={16} className={styles.headerSort} />
)}
<span
className={styles.resizer}
onMouseDown={(e) => startResize(e, ci)}
onClick={(e) => e.stopPropagation()}
/>
</div>
);
})}
@@ -263,11 +322,15 @@ export function Grid(props: GridProps) {
<div
key={v.key}
role="row"
className={clsx(styles.gridRow, state === "deleted" && styles.rowDeleted, state === "inserted" && styles.rowInserted)}
className={clsx(
styles.gridRow,
state === "deleted" && styles.rowDeleted,
state === "inserted" && styles.rowInserted,
)}
style={{ gridTemplateColumns: template, transform: `translateY(${v.start + ROW_H}px)` }}
>
<div className={styles.rowNumber} onMouseDown={(e) => onRowNumberMouseDown(e, r)}>
{state === "inserted" ? "+" : firstRowNumber + r}
{state === "inserted" ? "+" : (props.rowNumber?.(r) ?? firstRowNumber + r)}
</div>
{columns.map((_, c) => {
if (editing && editing.row === r && editing.col === c) {
@@ -306,7 +369,7 @@ export function Grid(props: GridProps) {
onDoubleClick={() => startEdit(r, c)}
title={val != null && val.length > 40 ? val.slice(0, 2000) : undefined}
>
{val === null ? <span className={styles.null}>&lt;null&gt;</span> : val}
{val === null ? <span className={styles.null}>&lt;null&gt;</span> : displayed(val, !!numeric[c])}
</div>
);
})}
+34 -5
Переглянути файл
@@ -8,7 +8,7 @@ import { call, commands, type DbColumnInfo, type DbDriver } from "@/platform/ipc
import { highlightInto } from "@/editor/lsp/markdown";
import { Button, Checkbox, Dialog, IconButton, Input, Popup, Icon, T } from "@/ui";
import { parseSourceUrl, sourceLabel, splitTarget } from "./model";
import { nodeTarget, tableOf, type DbNode } from "./nodes";
import { nodeTarget, schemaObjectOf, tableOf, type DbNode } from "./nodes";
import {
alterTableSql,
canAlterColumn,
@@ -61,9 +61,14 @@ const driverOf = (id: string): DbDriver => sourceById(id)?.driver ?? "sqlite";
const KIND_LABEL: Record<DbObjectRef["kind"], string> = {
table: "table",
view: "view",
materializedView: "materialized view",
column: "column",
index: "index",
schema: "schema",
function: "function",
procedure: "procedure",
trigger: "trigger",
sequence: "sequence",
};
function objectName(o: DbObjectRef): string {
@@ -530,12 +535,18 @@ function QuickDoc({ node, anchor }: { node: DbNode; anchor: { x: number; y: numb
const source = sourceById(id);
const t = tableOf(node);
const [ddl, setDdl] = useState<string | null>(null);
const showDdl = node.kind === "table";
const object = schemaObjectOf(node);
const showDdl = node.kind === "table" || !!object;
useEffect(() => {
if (!showDdl || !t) return;
if (!showDdl || (!t && !object)) return;
let live = true;
withConnection(t.id, () => call(commands.dbDdl(t.id, t.schema, t.table)))
.then((d) => live && setDdl(readableDdl(d, driverOf(t.id))))
const read = object
? withConnection(object.id, () => call(commands.dbObjectDdl(object.id, object.schema, object.kind, object.name)))
: withConnection(t!.id, () => call(commands.dbDdl(t!.id, t!.schema, t!.table))).then((d) =>
readableDdl(d, driverOf(t!.id)),
);
read
.then((d) => live && setDdl(d))
.catch((e: unknown) => live && setDdl(`-- ${e instanceof Error ? e.message : String(e)}`));
return () => {
live = false;
@@ -568,6 +579,24 @@ function QuickDoc({ node, anchor }: { node: DbNode; anchor: { x: number; y: numb
case "folder":
rows.push(["Schema", node.schema]);
break;
case "routine":
rows.push(
["Schema", node.schema],
[node.routine.kind === "procedure" ? "Procedure" : "Function", node.routine.name],
);
if (node.routine.arguments) rows.push(["Arguments", node.routine.arguments]);
if (node.routine.returns) rows.push(["Returns", node.routine.returns]);
break;
case "sequence":
rows.push(["Schema", node.schema], ["Sequence", node.sequence.name], ["Type", node.sequence.dataType]);
break;
case "trigger":
rows.push(
["Table", node.table],
["Trigger", node.trigger.name],
["Fires", `${node.trigger.timing} ${node.trigger.events}`],
);
break;
default:
if (t) rows.push(["Schema", t.schema], [t.view ? "View" : "Table", t.table]);
}
+155 -60
Переглянути файл
@@ -3,13 +3,13 @@
* executed statement with its outcome, and a `Result N` tab per result set —
* a toolbar (re-run, export / copy as CSV, JSON, SQL…), the grid and a floating row count.
*/
import { useEffect, useRef } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import clsx from "clsx";
import { call, commands } from "@/platform/ipc";
import { EmptyState, IconButton, Spinner, Tabs, showContextMenu, type MenuItem, T } from "@/ui";
import { notifications } from "@/workbench/contributions";
import { EXPORT_FORMATS, exportData, type ExportData, type ExportFormat } from "./export";
import { Grid } from "./Grid";
import type { DbPlanNode } from "@/platform/ipc";
import { EmptyState, Icon, IconButton, Spinner, Tabs, Tree, showContextMenu, T } from "@/ui";
import { ChartView } from "./ChartView";
import { DataView, ExtractorSelect, PanelToggle, exportMenu, type DataPanel } from "./DataView";
import type { ExportData, ExportFormat } from "./export";
import { analyze } from "./sql/analyze";
import {
OUTPUT_TAB,
@@ -63,52 +63,17 @@ function queryTable(text: string, data: Pick<ExportData, "driver">): string {
return a.refs.find((r) => r.depth === 0 && r.kind === "table")?.name ?? "MY_TABLE";
}
async function saveExport(format: ExportFormat, data: ExportData) {
const f = EXPORT_FORMATS.find((x) => x.id === format)!;
let path: string | null;
try {
const { save } = await import("@tauri-apps/plugin-dialog");
path = await save({
defaultPath: `${data.table}.${f.extension}`,
filters: [{ name: f.label, extensions: [f.extension] }],
});
} catch {
return;
}
if (!path) return;
try {
await call(commands.dbExportFile(path, exportData(format, data)));
notifications().notify({ level: "info", title: `${data.rows.length} rows exported`, body: path, key: "db-export" });
} catch (e) {
notifications().notify({
level: "error",
title: "Cannot export data",
body: e instanceof Error ? e.message : String(e),
});
}
}
function exportMenu(data: ExportData): MenuItem[] {
return [
...EXPORT_FORMATS.map((f): MenuItem => ({
label: `Copy as ${f.label}`,
icon: "copy",
run: () => void navigator.clipboard?.writeText(exportData(f.id, data)),
})),
{ kind: "separator" },
...EXPORT_FORMATS.map((f): MenuItem => ({
label: `Export to ${f.label} File…`,
run: () => void saveExport(f.id, data),
})),
];
}
function Result({ tab }: { tab: ResultTab }) {
const [view, setView] = useState<"table" | "chart">("table");
const [panel, setPanel] = useState<DataPanel | null>(null);
const [extractor, setExtractor] = useState<ExportFormat>("csv");
const r = tab.outcome.result;
const columns = useMemo(() => (r ? r.columns.map((c) => ({ name: c.name, type: c.typeName })) : []), [r]);
const value = useCallback((row: number, col: number) => r?.rows[row]?.[col] ?? null, [r]);
if (!r) return null;
const driver = sourceById(tab.sourceId)?.driver ?? "sqlite";
const data = (): ExportData => ({
columns: r.columns.map((c) => ({ name: c.name, type: c.typeName })),
columns,
rows: r.rows,
driver,
table: queryTable(tab.outcome.statement.text, { driver }),
@@ -116,25 +81,153 @@ function Result({ tab }: { tab: ResultTab }) {
return (
<div className={styles.view}>
<div className={styles.toolbar}>
<IconButton icon="refresh" title="Rerun Query" disabled={tab.busy} onClick={() => void rerunResult(tab.id)} />
<IconButton icon="db-table" title="Table" aria-pressed={view === "table"} onClick={() => setView("table")} />
<IconButton icon="db-chart" title="Chart" aria-pressed={view === "chart"} onClick={() => setView("chart")} />
<span className={styles.toolbarSeparator} />
<IconButton icon="export" title="Export Data" onClick={(e) => showContextMenu(e, exportMenu(data()))} />
<IconButton icon="refresh" title="Rerun Query" disabled={tab.busy} onClick={() => void rerunResult(tab.id)} />
{tab.busy && <Spinner size={12} />}
<span className={styles.spacer} />
<span className={styles.resultSource} title={tab.outcome.statement.text}>
{targetLabel(tab.sourceId)}
</span>
<ExtractorSelect value={extractor} onChange={setExtractor} />
<IconButton
icon="export"
title="Export Data"
onClick={(e) =>
showContextMenu(e, exportMenu(data(), queryTable(tab.outcome.statement.text, { driver }), extractor))
}
/>
{view === "table" && <PanelToggle panel={panel} onChange={setPanel} />}
</div>
{view === "chart" ? (
<div className={styles.fill}>
<ChartView columns={columns} rows={r.rows} />
</div>
) : (
<div className={clsx(styles.fill, styles.gridHost)}>
<DataView
columns={columns}
rowCount={r.rows.length}
value={value}
panel={panel}
onPanelChange={setPanel}
aria-label={tab.title}
/>
<div className={styles.rowPill} role="status" title={outcomeSummary(tab.outcome)}>
{r.rows.length} row{r.rows.length === 1 ? "" : "s"}
{r.hasMore ? " (limit reached)" : ""}
</div>
</div>
)}
</div>
);
}
interface PlanRow {
node: DbPlanNode;
index: number;
key: string;
}
/** IDEA's operation icons: a table for scans of one, a key for index lookups, an arrow for the statement. */
function planIcon(r: PlanRow): string | null {
if (r.index < 0) return "arrow-left";
if (/index/i.test(r.node.operation)) return "db-key";
if (/scan/i.test(r.node.operation)) return "db-table";
return null;
}
/** IDEA's Query Plan tab: the operation tree with its parameters, the selected operation's properties beside it. */
function PlanResult({ tab }: { tab: ResultTab }) {
const plan = useMemo(() => tab.plan ?? [], [tab.plan]);
const rows = useMemo(() => plan.map((node, index): PlanRow => ({ node, index, key: String(index) })), [plan]);
// IDEA roots the operations in the statement itself ("Select").
const statement = useMemo((): PlanRow => {
const kind = /^\s*(\w+)/.exec(tab.outcome.statement.text)?.[1]?.toLowerCase() ?? "statement";
const operation = kind[0]!.toUpperCase() + kind.slice(1);
return {
node: { parent: null, operation, params: "", properties: [{ name: "Operation", value: kind }] },
index: -1,
key: "statement",
};
}, [tab.outcome.statement.text]);
const [selected, setSelected] = useState(-1);
// Everything starts expanded, a re-explained plan too.
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set());
const keys = useMemo(() => [statement.key, ...rows.map((r) => r.key)], [statement, rows]);
const expanded = useMemo(() => new Set(keys.filter((k) => !collapsed.has(k))), [keys, collapsed]);
const setExpanded = (next: ReadonlySet<string>) => setCollapsed(new Set(keys.filter((k) => !next.has(k))));
const children = (r: PlanRow) =>
r === statement ? rows.filter((c) => c.node.parent === null) : rows.filter((c) => c.node.parent === r.index);
const props = (selected < 0 ? statement : rows[selected])?.node.properties ?? [];
const selectedKey = selected < 0 ? statement.key : String(selected);
return (
<div className={styles.view}>
<div className={styles.toolbar}>
<IconButton icon="refresh" title="Explain Again" disabled={tab.busy} onClick={() => void rerunResult(tab.id)} />
{tab.busy && <Spinner size={12} />}
<span className={styles.spacer} />
<span className={styles.resultSource} title={tab.outcome.statement.text}>
{targetLabel(tab.sourceId)}
</span>
</div>
<div className={clsx(styles.fill, styles.gridHost)}>
<Grid
columns={r.columns.map((c) => ({ name: c.name, type: c.typeName }))}
rowCount={r.rows.length}
value={(row, col) => r.rows[row]?.[col] ?? null}
/>
<div className={styles.rowPill} role="status" title={outcomeSummary(tab.outcome)}>
{r.rows.length} row{r.rows.length === 1 ? "" : "s"}
{r.hasMore ? " (limit reached)" : ""}
<div className={clsx(styles.fill, styles.planView)}>
<div className={styles.planTree}>
<div className={styles.planHeader}>
<span>
<T>Operation</T>
</span>
<span>
<T>Params</T>
</span>
</div>
{plan.length ? (
<Tree<PlanRow>
aria-label="Query plan"
roots={[statement]}
selected={new Set([selectedKey])}
expanded={expanded}
onExpandedChange={setExpanded}
getKey={(r) => r.key}
getLabel={(r) => r.node.operation}
getChildren={(r) => children(r)}
isExpandable={(r) => children(r).length > 0}
onSelectionChange={(_, lead) => lead && setSelected(lead.index)}
renderNode={(r) => (
<span className={styles.planRow}>
<span className={styles.planOperation}>
{planIcon(r) && <Icon name={planIcon(r)!} size={16} />}
{r.node.operation}
</span>
<span className={styles.planParams}>{r.node.params}</span>
</span>
)}
/>
) : (
<EmptyState title="Empty plan" />
)}
</div>
<table className={styles.planProperties} aria-label="Plan node properties">
<thead>
<tr>
<th>
<T>Property</T>
</th>
<th>
<T>Value</T>
</th>
</tr>
</thead>
<tbody>
{props.map((p) => (
<tr key={p.name}>
<td>{p.name}</td>
<td>{p.value}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
@@ -164,13 +257,15 @@ export function ResultsView() {
...results.map((r) => ({
id: String(r.id),
label: r.title,
icon: "db-table",
icon: r.plan ? "db-plan" : "db-table",
closable: true,
tooltip: r.outcome.statement.text,
})),
]}
/>
<div className={styles.fill}>{tab ? <Result key={tab.id} tab={tab} /> : <Output />}</div>
<div className={styles.fill}>
{!tab ? <Output /> : tab.plan ? <PlanResult key={tab.id} tab={tab} /> : <Result key={tab.id} tab={tab} />}
</div>
</div>
);
}
+86 -13
Переглянути файл
@@ -60,7 +60,15 @@ const SSH_AUTH: Array<SelectOption<DbSshAuth>> = [
/** Driver property names the Advanced tab suggests, and what the backend does with them. */
const PROPERTIES: Record<string, { names: string[]; hint: string }> = {
postgres: {
names: ["statement_timeout", "lock_timeout", "idle_in_transaction_session_timeout", "search_path", "work_mem", "application_name", "connect_timeout"],
names: [
"statement_timeout",
"lock_timeout",
"idle_in_transaction_session_timeout",
"search_path",
"work_mem",
"application_name",
"connect_timeout",
],
hint: "Sent as run-time parameters when connecting (any server setting, e.g. statement_timeout). application_name names the session; connect_timeout is in seconds.",
},
mysql: {
@@ -90,6 +98,7 @@ const blankOptions = (): DbOptions => ({
startupScript: "",
keepAliveSecs: null,
autoDisconnectSecs: null,
singleDatabase: false,
});
const blankIntrospection = (): DbIntrospection => ({
@@ -98,6 +107,8 @@ const blankIntrospection = (): DbIntrospection => ({
allSchemas: true,
schemas: [],
showSystem: false,
showTemplates: false,
objectFilter: "",
});
const parsePort = (v: string) => {
@@ -326,7 +337,8 @@ export function SourceDialog({
const ssh: DbSsh = { ...blankSsh(), ...s.ssh };
const setSsh = (patch: Partial<DbSsh>) => setS((x) => ({ ...x, ssh: { ...blankSsh(), ...x.ssh, ...patch } }));
const options: DbOptions = { ...blankOptions(), ...s.options };
const setOptions = (patch: Partial<DbOptions>) => setS((x) => ({ ...x, options: { ...blankOptions(), ...x.options, ...patch } }));
const setOptions = (patch: Partial<DbOptions>) =>
setS((x) => ({ ...x, options: { ...blankOptions(), ...x.options, ...patch } }));
const intro: DbIntrospection = { ...blankIntrospection(), ...s.introspection };
const setIntro = (patch: Partial<DbIntrospection>) =>
setS((x) => ({ ...x, introspection: { ...blankIntrospection(), ...x.introspection, ...patch } }));
@@ -342,7 +354,8 @@ export function SourceDialog({
const urlValid = !!parsedUrl && parsedUrl.source.driver === s.driver;
const host = urlOnly ? (parsedUrl?.source.host ?? "") : (s.host ?? "");
const database = urlOnly ? (parsedUrl?.source.database ?? "") : (s.database ?? "");
const name = s.name?.trim() || (sqlite ? fileSourceName(s.file ?? "") : `${database || s.user || "db"}@${host || "localhost"}`);
const name =
s.name?.trim() || (sqlite ? fileSourceName(s.file ?? "") : `${database || s.user || "db"}@${host || "localhost"}`);
const sshOn = !sqlite && !!ssh.enabled;
const valid = sqlite ? !!s.file?.trim() : (urlOnly ? urlValid : !!s.host?.trim()) && (!sshOn || !!ssh.host?.trim());
const sslOn = (s.ssl ?? "prefer") !== "disable";
@@ -472,7 +485,12 @@ export function SourceDialog({
<T>Host:</T>
</label>
<div className={styles.formRow}>
<Input id="db-host" className={styles.fill} value={s.host ?? ""} onChange={(e) => set({ host: e.target.value })} />
<Input
id="db-host"
className={styles.fill}
value={s.host ?? ""}
onChange={(e) => set({ host: e.target.value })}
/>
<label className={styles.formLabel} htmlFor="db-port">
<T>Port:</T>
</label>
@@ -514,7 +532,11 @@ export function SourceDialog({
placeholder={initial && password === undefined ? "<unchanged>" : ""}
onChange={(e) => setPassword(e.target.value)}
/>
<Checkbox checked={!!s.savePassword} onChange={(savePassword) => set({ savePassword })} label="Save password" />
<Checkbox
checked={!!s.savePassword}
onChange={(savePassword) => set({ savePassword })}
label="Save password"
/>
</div>
{!urlOnly && (
<>
@@ -587,7 +609,9 @@ export function SourceDialog({
<Input
id="db-time-zone"
value={options.timeZone ?? ""}
placeholder={postgres ? "Server default, e.g. UTC or Europe/Berlin" : "UTC (+00:00), e.g. Europe/Berlin or +08:00"}
placeholder={
postgres ? "Server default, e.g. UTC or Europe/Berlin" : "UTC (+00:00), e.g. Europe/Berlin or +08:00"
}
onChange={(e) => setOptions({ timeZone: e.target.value })}
/>
<SecondsOption
@@ -604,6 +628,16 @@ export function SourceDialog({
fallback={300}
onChange={(autoDisconnectSecs) => setOptions({ autoDisconnectSecs })}
/>
{postgres && (
<>
<span />
<Checkbox
checked={!!options.singleDatabase}
onChange={(singleDatabase) => setOptions({ singleDatabase })}
label="Single database mode (show only the configured database)"
/>
</>
)}
<label className={styles.formLabel} htmlFor="db-startup">
<T>Startup script:</T>
</label>
@@ -612,7 +646,13 @@ export function SourceDialog({
className={styles.textArea}
spellCheck={false}
value={options.startupScript ?? ""}
placeholder={postgres ? "SET statement_timeout = '30s';" : sqlite ? "PRAGMA foreign_keys = ON;" : "SET SESSION sql_mode = 'ANSI';"}
placeholder={
postgres
? "SET statement_timeout = '30s';"
: sqlite
? "PRAGMA foreign_keys = ON;"
: "SET SESSION sql_mode = 'ANSI';"
}
onChange={(e) => setOptions({ startupScript: e.target.value })}
/>
<span />
@@ -686,7 +726,9 @@ export function SourceDialog({
<>
<span />
<span className={styles.formHint}>
<T>{"Uses HostName, User, Port, IdentityFile and ProxyJump from ~/.ssh/config, and keys of the SSH agent."}</T>
<T>
{"Uses HostName, User, Port, IdentityFile and ProxyJump from ~/.ssh/config, and keys of the SSH agent."}
</T>
</span>
</>
) : (
@@ -775,11 +817,30 @@ export function SourceDialog({
onAll={(allSchemas) => setIntro({ allSchemas })}
onChosen={(schemas) => setIntro({ schemas })}
/>
<div className={styles.formRow}>
<label className={styles.formLabel} htmlFor="db-object-filter">
<T>Object filter:</T>
</label>
<Input
id="db-object-filter"
mono
value={intro.objectFilter ?? ""}
placeholder="e.g. order*, customers (tables, views, routines, sequences)"
onChange={(e) => setIntro({ objectFilter: e.target.value })}
/>
</div>
<Checkbox
checked={!!intro.showSystem}
onChange={(showSystem) => setIntro({ showSystem })}
label={postgres ? "Show system schemas and template databases" : "Show system schemas"}
label="Show internal system schemas"
/>
{postgres && (
<Checkbox
checked={!!intro.showTemplates}
onChange={(showTemplates) => setIntro({ showTemplates })}
label="Show template databases"
/>
)}
<div className={styles.formRow}>
<Button disabled={!initial?.id || busy} onClick={() => void loadNames()}>
<T>Load Names from Server</T>
@@ -815,7 +876,12 @@ export function SourceDialog({
placeholder="<user defined>"
onChange={(e) => setProperty(i, { name: e.target.value })}
/>
<Input aria-label="Property value" value={p.value ?? ""} placeholder="<value>" onChange={(e) => setProperty(i, { value: e.target.value })} />
<Input
aria-label="Property value"
value={p.value ?? ""}
placeholder="<value>"
onChange={(e) => setProperty(i, { value: e.target.value })}
/>
<IconButton
icon="close"
size="small"
@@ -871,7 +937,13 @@ export function SourceDialog({
<T>Name:</T>
</label>
<div className={styles.formRow}>
<Input id="db-name" className={styles.fill} value={s.name ?? ""} placeholder={name} onChange={(e) => set({ name: e.target.value })} />
<Input
id="db-name"
className={styles.fill}
value={s.name ?? ""}
placeholder={name}
onChange={(e) => set({ name: e.target.value })}
/>
<span className={styles.formLabel}>
<T>Color:</T>
</span>
@@ -883,7 +955,9 @@ export function SourceDialog({
onChange={(c) => set({ color: c === "none" ? null : c })}
renderValue={(o) => (
<span className={styles.colorValue}>
{o && o.value !== "none" && <span className={styles.colorSwatch} style={{ background: colorVar(o.value as DbColor) }} />}
{o && o.value !== "none" && (
<span className={styles.colorSwatch} style={{ background: colorVar(o.value as DbColor) }} />
)}
<T>{o?.label ?? "No color"}</T>
</span>
)}
@@ -932,4 +1006,3 @@ export function SourceDialog({
</Dialog>
);
}
+122 -36
Переглянути файл
@@ -3,7 +3,7 @@
* a WHERE filter, and pending cell edits / added / deleted rows submitted
* together in one transaction. Tables without a primary key are read-only.
*/
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
call,
commands,
@@ -13,11 +13,20 @@ import {
type DbTablePage,
type RequestId,
} from "@/platform/ipc";
import { Button, EmptyState, IconButton, Input, Select, Spinner, T } from "@/ui";
import { Button, EmptyState, Icon, IconButton, Input, Select, Spinner, T, showContextMenu } from "@/ui";
import { notifications, type EditorInput } from "@/workbench/contributions";
import { Grid } from "./Grid";
import { buildRowEdits, emptyEdits, hasEdits, type Cell, type PendingEdits } from "./model";
import { sourceById, withConnection } from "./store";
import { DataView, ExtractorSelect, PanelToggle, exportMenu, type DataPanel } from "./DataView";
import type { ExportFormat } from "./export";
import {
buildRowEdits,
emptyEdits,
formatOrderBy,
hasEdits,
parseOrderBy,
type Cell,
type PendingEdits,
} from "./model";
import { openDialog, sourceById, withConnection } from "./store";
import type { TableRef } from "./tableInput";
import styles from "./Database.module.css";
@@ -34,6 +43,10 @@ export function TableEditor({ input }: { input: EditorInput }) {
const [filter, setFilter] = useState("");
const [nonce, setNonce] = useState(0);
const [edits, setEdits] = useState<PendingEdits>(emptyEdits);
const [panel, setPanel] = useState<DataPanel | null>(null);
const [extractor, setExtractor] = useState<ExportFormat>("csv");
// The ORDER BY field while it's being edited; it shows the sort otherwise.
const [orderDraft, setOrderDraft] = useState<string | null>(null);
// The page for the current parameters (`key`); stale while loading.
const [loadedPage, setLoadedPage] = useState<{ key: string; page: DbTablePage | null; error: string | null } | null>(
null,
@@ -77,17 +90,34 @@ export function TableEditor({ input }: { input: EditorInput }) {
const editable = pk.length > 0 && !sourceById(ref.id)?.readOnly;
const loaded = page?.result.rows.length ?? 0;
const rowCount = loaded + edits.inserts.length;
const gridColumns = useMemo(
() =>
columns.map((c) => ({
name: c.name,
type: c.typeName,
primaryKey: pk.includes(c.name),
foreignKey: fkColumns.has(c.name),
})),
// `pk` / `fkColumns` derive from `page`.
// eslint-disable-next-line react-hooks/exhaustive-deps
[page],
);
const value = (row: number, col: number): Cell => {
const name = names[col]!;
if (row >= loaded) {
const ins = edits.inserts[row - loaded];
return ins?.has(name) ? ins.get(name)! : null;
}
const upd = edits.updates.get(row);
if (upd?.has(name)) return upd.get(name)!;
return page?.result.rows[row]?.[col] ?? null;
};
const value = useCallback(
(row: number, col: number): Cell => {
const name = names[col]!;
if (row >= loaded) {
const ins = edits.inserts[row - loaded];
return ins?.has(name) ? ins.get(name)! : null;
}
const upd = edits.updates.get(row);
if (upd?.has(name)) return upd.get(name)!;
return page?.result.rows[row]?.[col] ?? null;
},
// `names` / `loaded` derive from `page`.
// eslint-disable-next-line react-hooks/exhaustive-deps
[page, edits],
);
const onEdit = (row: number, col: number, v: Cell) => {
const name = names[col]!;
@@ -144,6 +174,7 @@ export function TableEditor({ input }: { input: EditorInput }) {
});
const dirty = hasEdits(edits);
const orderInvalid = orderDraft !== null && !parseOrderBy(orderDraft, names);
return (
<div className={styles.view}>
<div className={styles.toolbar}>
@@ -175,20 +206,6 @@ export function TableEditor({ input }: { input: EditorInput }) {
}}
/>
</div>
<Input
className={styles.filter}
mono
aria-label="WHERE filter"
placeholder="WHERE (e.g. id > 10)"
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
setOffset(0);
setFilter(filterText.trim());
}
}}
/>
{editable && (
<>
<IconButton
@@ -205,6 +222,77 @@ export function TableEditor({ input }: { input: EditorInput }) {
</>
)}
{loading && <Spinner size={12} />}
<span className={styles.spacer} />
<ExtractorSelect value={extractor} onChange={setExtractor} />
<IconButton
icon="export"
title="Export Data"
disabled={!page}
onClick={(e) =>
page &&
showContextMenu(
e,
exportMenu(
{
columns: gridColumns,
rows: page.result.rows,
table: ref.table,
driver: sourceById(ref.id)?.driver ?? "sqlite",
},
`${ref.schema}.${ref.table}`,
extractor,
),
)
}
/>
<IconButton
icon="import"
title="Import Data from File…"
disabled={!editable}
onClick={() => openDialog({ kind: "import", id: ref.id, schema: ref.schema, table: ref.table })}
/>
<PanelToggle panel={panel} onChange={setPanel} />
</div>
<div className={styles.filterBar}>
<label className={styles.filterField}>
<Icon name="filter" size={14} />
<Input
variant="bare"
mono
aria-label="WHERE filter"
placeholder="WHERE"
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
setOffset(0);
setFilter(filterText.trim());
}
}}
/>
</label>
<label className={styles.filterField}>
<Icon name="db-sort" size={14} />
<Input
variant="bare"
mono
aria-label="ORDER BY"
placeholder="ORDER BY"
invalid={orderInvalid}
value={orderDraft ?? formatOrderBy(sort)}
onChange={(e) => setOrderDraft(e.target.value)}
onBlur={() => !orderInvalid && setOrderDraft(null)}
onKeyDown={(e) => {
if (e.key === "Escape") setOrderDraft(null);
if (e.key !== "Enter" || orderDraft === null) return;
const next = parseOrderBy(orderDraft, names);
if (!next) return;
setOffset(0);
setSort(next);
setOrderDraft(null);
}}
/>
</label>
</div>
{page && !editable && (
<div className={styles.notice}>
@@ -217,16 +305,14 @@ export function TableEditor({ input }: { input: EditorInput }) {
<EmptyState title="Loading…" />
) : (
<div className={styles.fill}>
<Grid
<DataView
aria-label={`${ref.table} data`}
columns={columns.map((c) => ({
name: c.name,
type: c.typeName,
primaryKey: pk.includes(c.name),
foreignKey: fkColumns.has(c.name),
}))}
columns={gridColumns}
rowCount={rowCount}
fixedRows={loaded}
value={value}
panel={panel}
onPanelChange={setPanel}
firstRowNumber={offset + 1}
rowState={(r) => (r >= loaded ? "inserted" : edits.deletes.has(r) ? "deleted" : null)}
isModified={(r, c) => !!edits.updates.get(r)?.has(names[c]!)}
+61 -10
Переглянути файл
@@ -45,7 +45,9 @@ import {
cancelConsole,
consoleSource,
executeConsole,
explainConsole,
finishTransaction,
openHistory,
loadCompletion,
listsDatabases,
loadDatabases,
@@ -94,7 +96,12 @@ const runField = StateField.define<RunState>({
if (a <= m.to && b >= m.from) touched = true;
});
if (!touched)
marks.push({ ...m, from: tr.changes.mapPos(m.from), to: tr.changes.mapPos(m.to), timingAt: tr.changes.mapPos(m.timingAt) });
marks.push({
...m,
from: tr.changes.mapPos(m.from),
to: tr.changes.mapPos(m.to),
timingAt: tr.changes.mapPos(m.timingAt),
});
}
return { ...value, marks };
},
@@ -102,8 +109,12 @@ const runField = StateField.define<RunState>({
EditorView.decorations.from(f, (st) => {
const ranges = [];
for (const m of st.marks) {
if (m.error) ranges.push(Decoration.mark({ class: "cm-openidea-db-error", attributes: { title: m.error } }).range(m.from, m.to));
if (m.ms !== null) ranges.push(Decoration.widget({ widget: new TimingWidget(m.ms), side: 1 }).range(m.timingAt));
if (m.error)
ranges.push(
Decoration.mark({ class: "cm-openidea-db-error", attributes: { title: m.error } }).range(m.from, m.to),
);
if (m.ms !== null)
ranges.push(Decoration.widget({ widget: new TimingWidget(m.ms), side: 1 }).range(m.timingAt));
}
return Decoration.set(ranges, true);
}),
@@ -134,7 +145,11 @@ class RunMarker extends GutterMarker {
return other.error === this.error;
}
toDOM() {
const el = iconNode(this.error ? "error" : "check", 14, this.error ? "cm-openidea-db-run-error" : "cm-openidea-db-run-ok");
const el = iconNode(
this.error ? "error" : "check",
14,
this.error ? "cm-openidea-db-run-error" : "cm-openidea-db-run-ok",
);
el.title = this.error ?? "Executed";
return el;
}
@@ -196,6 +211,12 @@ export async function executeInView(view: EditorView, path: string) {
if (outcomes.length) markOutcomes(view, outcomes);
}
/** Explain Plan of the statement at the caret (or the first selected one) of `view`. */
export async function explainInView(view: EditorView, path: string) {
const sel = view.state.selection.main;
await explainConsole(path, view.state.doc.toString(), sel.from, sel.to);
}
const consolePath = Facet.define<string, string>({ combine: (v) => v[0] ?? "" });
function ConsoleBar({ path, view }: { path: string; view: EditorView }) {
@@ -209,7 +230,8 @@ function ConsoleBar({ path, view }: { path: string; view: EditorView }) {
const databases = useDatabase((s) => s.databases);
const boundId = bound ? splitTarget(bound).id : null;
useEffect(() => {
if (boundId && listsDatabases(sourceById(boundId)) && !useDatabase.getState().databases[boundId]) void loadDatabases(boundId);
if (boundId && listsDatabases(sourceById(boundId)) && !useDatabase.getState().databases[boundId])
void loadDatabases(boundId);
}, [boundId]);
const targets = useMemo(() => {
const out: Array<{ value: string; label: string; detail?: string; icon: string }> = [];
@@ -219,11 +241,17 @@ function ConsoleBar({ path, view }: { path: string; view: EditorView }) {
const host = sourceLabel(s).slice((s.name ?? "").length + 1);
if (listsDatabases(s) && dbs?.status === "ok") {
for (const name of dbs.value.names)
out.push({ value: targetOf(id, name, dbs.value.current), label: `${s.name ?? ""}: ${name}`, detail: host, icon: "database" });
out.push({
value: targetOf(id, name, dbs.value.current),
label: `${s.name ?? ""}: ${name}`,
detail: host,
icon: "database",
});
} else out.push({ value: id, label: s.name ?? "", detail: host, icon: "db-schema" });
}
// A console bound to a database whose list is not loaded yet still shows it.
if (bound && !out.some((o) => o.value === bound)) out.push({ value: bound, label: targetLabel(bound), icon: "database" });
if (bound && !out.some((o) => o.value === bound))
out.push({ value: bound, label: targetLabel(bound), icon: "database" });
return out;
}, [sources, databases, bound]);
useEffect(() => {
@@ -241,7 +269,20 @@ function ConsoleBar({ path, view }: { path: string; view: EditorView }) {
disabled={!bound || running}
onClick={() => void executeInView(view, path)}
/>
<IconButton icon="stop" title="Cancel Running Statement" disabled={!running} onClick={() => cancelConsole(path)} />
<IconButton
icon="db-plan"
title="Explain Plan"
disabled={!bound || running}
onClick={() => void explainInView(view, path)}
/>
<IconButton
icon="stop"
title="Cancel Running Statement"
disabled={!running}
onClick={() => cancelConsole(path)}
/>
<span className={styles.toolbarSeparator} />
<IconButton icon="history" title="Browse Query History" disabled={!bound} onClick={() => openHistory(path)} />
<span className={styles.toolbarSeparator} />
<Select
aria-label="Row limit"
@@ -262,8 +303,18 @@ function ConsoleBar({ path, view }: { path: string; view: EditorView }) {
/>
{manual && (
<>
<IconButton icon="commit" title="Commit" disabled={!pending || running} onClick={() => bound && void finishTransaction(bound, true)} />
<IconButton icon="revert" title="Roll Back" disabled={!pending || running} onClick={() => bound && void finishTransaction(bound, false)} />
<IconButton
icon="commit"
title="Commit"
disabled={!pending || running}
onClick={() => bound && void finishTransaction(bound, true)}
/>
<IconButton
icon="revert"
title="Roll Back"
disabled={!pending || running}
onClick={() => bound && void finishTransaction(bound, false)}
/>
</>
)}
<span className={styles.spacer} />
+45 -8
Переглянути файл
@@ -26,7 +26,32 @@ export interface ExportData {
driver: DbDriver;
}
export function exportData(format: ExportFormat, d: ExportData): string {
/** The Export Data dialog's options (CSV, TSV and Markdown; JSON and SQL keep their shape). */
export interface ExportOptions {
columnHeader: boolean;
rowHeader: boolean;
transpose: boolean;
}
export const DEFAULT_EXPORT_OPTIONS: ExportOptions = { columnHeader: true, rowHeader: false, transpose: false };
/** Header and rows of a delimited export after the row header / transpose options. */
function laidOut(d: ExportData, o: ExportOptions): { head: string[] | null; rows: Cell[][] } {
let head = d.columns.map((c) => c.name);
let rows = d.rows.map((r) => [...r]);
if (o.rowHeader) {
head = ["#", ...head];
rows = rows.map((r, i) => [String(i + 1), ...r]);
}
if (!o.transpose) return { head: o.columnHeader ? head : null, rows };
return { head: null, rows: head.map((h, c) => [...(o.columnHeader ? [h] : []), ...rows.map((r) => r[c] ?? null)]) };
}
export function exportData(
format: ExportFormat,
d: ExportData,
options: ExportOptions = DEFAULT_EXPORT_OPTIONS,
): string {
const names = d.columns.map((c) => c.name);
const numeric = d.columns.map((c, i) =>
isNumericColumn(
@@ -35,12 +60,17 @@ export function exportData(format: ExportFormat, d: ExportData): string {
),
);
switch (format) {
case "csv":
return toCsv(names, d.rows);
case "tsv":
return toTsv(names, d.rows);
case "csv": {
const t = laidOut(d, options);
return toCsv(t.head, t.rows);
}
case "tsv": {
const t = laidOut(d, options);
return toTsv(t.head, t.rows);
}
case "json": {
const value = (v: Cell, i: number) => (v === null ? null : numeric[i] && Number.isFinite(Number(v)) ? Number(v) : v);
const value = (v: Cell, i: number) =>
v === null ? null : numeric[i] && Number.isFinite(Number(v)) ? Number(v) : v;
return JSON.stringify(
d.rows.map((r) => Object.fromEntries(names.map((n, i) => [n, value(r[i] ?? null, i)]))),
null,
@@ -55,8 +85,15 @@ export function exportData(format: ExportFormat, d: ExportData): string {
}
case "markdown": {
const cell = (v: Cell) => (v ?? "").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
const align = numeric.map((n) => (n ? "--:" : "---"));
return [names.map(cell), align, ...d.rows.map((r) => r.map(cell))].map((r) => `| ${r.join(" | ")} |`).join("\n");
const t = laidOut(d, options);
const width = t.head?.length ?? t.rows[0]?.length ?? 0;
// A Markdown table always has a header row: blank when left out.
const head = t.head ?? Array.from({ length: width }, () => "");
const align =
options.transpose || t.head === null
? head.map(() => "---")
: [...(options.rowHeader ? ["--:"] : []), ...numeric.map((n) => (n ? "--:" : "---"))];
return [head.map(cell), align, ...t.rows.map((r) => r.map(cell))].map((r) => `| ${r.join(" | ")} |`).join("\n");
}
}
}
+40 -2
Переглянути файл
@@ -16,7 +16,7 @@ import {
setFileDecorations,
toolWindowService,
} from "@/workbench/contributions";
import { SQL_LANGUAGE, consoleExtension, executeInView } from "./console";
import { SQL_LANGUAGE, consoleExtension, executeInView, explainInView } from "./console";
import { DatabaseDialogs } from "./Dialogs";
import { TABLE_KIND, tableTitle } from "./tableInput";
import { nodeTarget, type DbNode } from "./nodes";
@@ -28,6 +28,7 @@ import {
consoleTints,
loadSources,
openConsole,
openHistory,
openSourceDialog,
useDatabase,
watchIdleConnections,
@@ -52,7 +53,12 @@ export function activate(): () => void {
onUse: true,
component: lazyComponent(() => import("./ResultsView").then((m) => m.ResultsView)),
}),
registerEditorKind({ kind: TABLE_KIND, component: lazyComponent(() => import("./TableEditor").then((m) => m.TableEditor)), title: tableTitle, icon: () => "database" }),
registerEditorKind({
kind: TABLE_KIND,
component: lazyComponent(() => import("./TableEditor").then((m) => m.TableEditor)),
title: tableTitle,
icon: () => "database",
}),
registerOverlay({ id: "database.dialogs", component: DatabaseDialogs }),
registerEditorContribution({ id: "database.console", order: 57, extensions: consoleExtension }),
// A reopened project starts with a fresh backend: forget the old one's state.
@@ -66,6 +72,7 @@ export function activate(): () => void {
schemas: {},
tables: {},
details: {},
objects: {},
completion: {},
results: [],
output: [],
@@ -145,6 +152,37 @@ export function activate(): () => void {
await executeInView(e.view, e.ctx.path);
},
},
{
id: "db.explainPlan",
title: "Explain Plan",
category: "Tools",
icon: "db-plan",
keywords: ["query plan", "explain", "sql"],
when: `editorTextFocus && editorLangId == ${SQL_LANGUAGE}`,
run: async () => {
const e = activeEditor();
if (!e || e.ctx.language !== SQL_LANGUAGE) return;
if (!useDatabase.getState().loaded) await loadSources();
await explainInView(e.view, e.ctx.path);
},
},
{
id: "db.queryHistory",
title: "Browse Query History",
category: "Tools",
icon: "history",
keywords: ["database", "sql", "history"],
when: "projectOpen",
run: async () => {
if (!useDatabase.getState().loaded) await loadSources();
const e = activeEditor();
const path = e?.ctx.language === SQL_LANGUAGE ? e.ctx.path : null;
const s = useDatabase.getState();
const id =
(path && consoleSource(path)) ?? s.selectedSource ?? (s.sources.length === 1 ? s.sources[0]!.id : null);
openHistory(path, id ?? null);
},
},
]),
];
return () => disposers.reverse().forEach((d) => d());
+125 -8
Переглянути файл
@@ -1,5 +1,5 @@
/** Pure helpers of the database tools: clipboard formats, edits, labels. */
import type { DbCellValue, DbColor, DbProperty, DbRowEdit, DbSslMode, DataSource } from "@/platform/ipc";
import type { DbCellValue, DbColor, DbProperty, DbRowEdit, DbSortKey, DbSslMode, DataSource } from "@/platform/ipc";
import { vendorOf, vendorOfScheme } from "./vendors";
export type Cell = string | null;
@@ -23,6 +23,74 @@ export function toCsv(header: readonly string[] | null, rows: ReadonlyArray<read
return lines.join("\r\n");
}
/** Rows of RFC 4180 text with separator `sep` (quoted fields, `""`, CR LF); a trailing newline adds no row. */
export function parseDelimited(text: string, sep: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let quoted = false;
let i = text.charCodeAt(0) === 0xfeff ? 1 : 0;
for (; i < text.length; i++) {
const ch = text[i]!;
if (quoted) {
if (ch !== '"') field += ch;
else if (text[i + 1] === '"') {
field += '"';
i++;
} else quoted = false;
} else if (ch === '"' && field === "") quoted = true;
else if (ch === sep) {
row.push(field);
field = "";
} else if (ch === "\n" || ch === "\r") {
if (ch === "\r" && text[i + 1] === "\n") i++;
row.push(field);
rows.push(row);
row = [];
field = "";
} else field += ch;
}
if (field !== "" || row.length) {
row.push(field);
rows.push(row);
}
return rows;
}
/** The separator of delimited text: tab when its first line has more tabs than commas or semicolons. */
export function guessSeparator(text: string): string {
const first = text.slice(0, text.search(/\r?\n|$/));
const count = (c: string) => first.split(c).length - 1;
return [",", "\t", ";"].reduce((best, c) => (count(c) > count(best) ? c : best), ",");
}
/**
* Insert edits importing `rows` into a table with `columns`: by header name
* (case-insensitive; unknown names skipped) or by position. `emptyAsNull`
* turns empty fields into NULL.
*/
export function importEdits(
rows: readonly string[][],
columns: readonly string[],
header: boolean,
emptyAsNull: boolean,
): { edits: DbRowEdit[]; mapped: string[] } {
const target = (h: string) => columns.find((c) => c.toLowerCase() === h.trim().toLowerCase()) ?? null;
const map: Array<string | null> = header
? (rows[0] ?? []).map(target)
: columns.slice(0, Math.max(0, ...rows.map((r) => r.length)));
const body = header ? rows.slice(1) : rows;
const edits: DbRowEdit[] = body
.filter((r) => r.some((v) => v !== ""))
.map((r) => ({
kind: "insert",
values: map.flatMap((column, i) =>
column === null ? [] : [{ column, value: emptyAsNull && (r[i] ?? "") === "" ? null : (r[i] ?? null) }],
),
}));
return { edits, mapped: map.filter((c): c is string => c !== null) };
}
/** Pending changes of the table editor. Row indices refer to the loaded page. */
export interface PendingEdits {
/** row → column → new value. */
@@ -53,7 +121,8 @@ export function buildRowEdits(
out.push({ kind: "update", key: keyOf(row), values: [...changes].map(([column, value]) => ({ column, value })) });
}
for (const row of [...edits.deletes].sort((a, b) => a - b)) out.push({ kind: "delete", key: keyOf(row) });
for (const ins of edits.inserts) out.push({ kind: "insert", values: [...ins].map(([column, value]) => ({ column, value })) });
for (const ins of edits.inserts)
out.push({ kind: "insert", values: [...ins].map(([column, value]) => ({ column, value })) });
return out;
}
@@ -80,15 +149,49 @@ export function colorVar(color: DbColor | null | undefined): string | undefined
/** `*` / `?` wildcard match of the Schemas tab (case-sensitive, like the server's names). */
export function matchesPattern(name: string, pattern: string): boolean {
if (!/[*?]/.test(pattern)) return name === pattern;
const re = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
const re = pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".");
return new RegExp(`^${re}$`).test(name);
}
/** The table editor's ORDER BY field for a sort: `name DESC, id`. */
export function formatOrderBy(sort: readonly DbSortKey[]): string {
return sort.map((k) => (k.descending ? `${k.column} DESC` : k.column)).join(", ");
}
/** Parses the ORDER BY field (`col [ASC|DESC], …`, names matched case-insensitively); null when a part isn't a column. */
export function parseOrderBy(text: string, columns: readonly string[]): DbSortKey[] | null {
const out: DbSortKey[] = [];
for (const part of text
.split(",")
.map((p) => p.trim())
.filter(Boolean)) {
const m = /^("?)([^"]+?)\1(?:\s+(asc|desc))?$/i.exec(part);
const column = m && columns.find((c) => c.toLowerCase() === m[2]!.trim().toLowerCase());
if (!column) return null;
out.push({ column, descending: m[3]?.toLowerCase() === "desc" });
}
return out;
}
/** Schemas tab › Object filter: `name` matches one of its patterns (comma / space separated; empty: all). */
export function passesObjectFilter(name: string, filter: string | null | undefined): boolean {
const patterns = (filter ?? "").split(/[\s,]+/).filter(Boolean);
return !patterns.length || patterns.some((p) => matchesPattern(name.toLowerCase(), p.toLowerCase()));
}
/**
* The databases / schemas the tree shows (Schemas tab): all of them, or the
* ones matching a chosen name or pattern; `keep` (the connected database) always stays.
*/
export function shownNames(names: readonly string[], all: boolean | undefined, chosen: readonly string[] | undefined, keep?: string | null): string[] {
export function shownNames(
names: readonly string[],
all: boolean | undefined,
chosen: readonly string[] | undefined,
keep?: string | null,
): string[] {
if (all !== false) return [...names];
const picks = (chosen ?? []).map((p) => p.trim()).filter(Boolean);
return names.filter((n) => n === keep || picks.some((p) => matchesPattern(n, p)));
@@ -115,7 +218,8 @@ export function idleTargets(
}
export function newSourceId(): string {
const rnd = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
const rnd =
typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
return rnd.replace(/[^a-zA-Z0-9-]/g, "").slice(0, 36);
}
@@ -127,7 +231,11 @@ export type ConnectRemedy = "password" | "sshSecret" | "trustHostKey" | null;
* or rejected SSH password / passphrase, or a database password. A changed host
* key is never offered for trust.
*/
export function connectRemedy(kind: string | undefined, message: string, source?: Pick<DataSource, "ssh">): ConnectRemedy {
export function connectRemedy(
kind: string | undefined,
message: string,
source?: Pick<DataSource, "ssh">,
): ConnectRemedy {
if (/^Unknown SSH host key/.test(message)) return "trustHostKey";
if (kind !== "permissionDenied") return null;
if (/^SSH host key .* has changed/.test(message)) return null;
@@ -189,7 +297,10 @@ export function parseSourceUrl(raw: string): { source: Partial<DataSource>; pass
const file = sqlite[1]!.replace(/^\/([A-Za-z]:)/, "$1");
return { source: { driver: "sqlite", file, name: fileSourceName(file) }, password: null };
}
const m = /^([a-z][a-z0-9]*):\/\/(?:([^:@/]*)(?::([^@/]*))?@)?([^:/?,]+|\[[^\]]+\])(?::(\d+))?(?:,[^/?]*)?(?:\/([^?]*))?(?:\?(.*))?$/i.exec(url);
const m =
/^([a-z][a-z0-9]*):\/\/(?:([^:@/]*)(?::([^@/]*))?@)?([^:/?,]+|\[[^\]]+\])(?::(\d+))?(?:,[^/?]*)?(?:\/([^?]*))?(?:\?(.*))?$/i.exec(
url,
);
const vendor = m ? vendorOfScheme(m[1]!) : undefined;
if (!m || !vendor || vendor.driver === "sqlite") return null;
const dec = (s: string | undefined) => {
@@ -204,7 +315,13 @@ export function parseSourceUrl(raw: string): { source: Partial<DataSource>; pass
let user = dec(m[2]);
let password = m[3] !== undefined ? dec(m[3]) : null;
const host = m[4]!.replace(/^\[(.*)\]$/, "$1");
const source: Partial<DataSource> = { driver: vendor.driver, host, port, user, database: dec(m[6]).replace(/\/$/, "") };
const source: Partial<DataSource> = {
driver: vendor.driver,
host,
port,
user,
database: dec(m[6]).replace(/\/$/, ""),
};
if (vendor.id !== vendor.driver) source.vendor = vendor.id;
const properties: DbProperty[] = [];
for (const [k, v] of new URLSearchParams(m[7] ?? "")) {
+49 -7
Переглянути файл
@@ -1,8 +1,27 @@
/** Nodes of the Database tool window tree and the objects they stand for. */
import type { DataSource, DbColumnInfo, DbForeignKey, DbIndexInfo, DbTableInfo } from "@/platform/ipc";
import type {
DataSource,
DbColumnInfo,
DbForeignKey,
DbIndexInfo,
DbObjectKind as DbSchemaObjectKind,
DbRoutineInfo,
DbSequenceInfo,
DbTableInfo,
DbTableKind,
DbTriggerInfo,
} from "@/platform/ipc";
import type { DbObjectRef } from "./objects";
export type Group = "columns" | "keys" | "foreignKeys" | "indexes";
export type Group = "columns" | "keys" | "foreignKeys" | "indexes" | "triggers";
/** Folders under a schema: IDEA's `tables`, `views`, `materialized views`, `routines`, `sequences`. */
export type Folder = "tables" | "views" | "materializedViews" | "routines" | "sequences";
/** The folder a table, view or materialized view is listed in. */
export function tableFolder(kind: DbTableKind): Folder {
return kind === "view" ? "views" : kind === "materializedView" ? "materializedViews" : "tables";
}
/** Where a table-level node lives: connection target, schema, table. */
interface InTable {
@@ -16,13 +35,16 @@ export type DbNode =
/** A database of a PostgreSQL server; `id` is its connection target. */
| { kind: "database"; key: string; id: string; name: string; current: boolean }
| { kind: "schema"; key: string; id: string; schema: string }
| { kind: "folder"; key: string; id: string; schema: string; folder: "tables" | "views" }
| { kind: "folder"; key: string; id: string; schema: string; folder: Folder }
| { kind: "table"; key: string; id: string; schema: string; table: DbTableInfo }
| { kind: "routine"; key: string; id: string; schema: string; routine: DbRoutineInfo }
| { kind: "sequence"; key: string; id: string; schema: string; sequence: DbSequenceInfo }
| ({ kind: "group"; key: string; group: Group } & InTable)
| ({ kind: "column"; key: string; column: DbColumnInfo; foreignKey: boolean } & InTable)
| ({ kind: "key"; key: string; columns: string[] } & InTable)
| ({ kind: "foreignKey"; key: string; fk: DbForeignKey } & InTable)
| ({ kind: "index"; key: string; index: DbIndexInfo } & InTable)
| ({ kind: "trigger"; key: string; trigger: DbTriggerInfo } & InTable)
| { kind: "message"; key: string; text: string; error?: boolean };
/** The connection target a node belongs to. */
@@ -33,9 +55,23 @@ export function nodeTarget(n: DbNode): string | null {
}
/** The table (or view) a node is, or is inside of. */
export function tableOf(n: DbNode): { id: string; schema: string; table: string; view: boolean } | null {
if (n.kind === "table") return { id: n.id, schema: n.schema, table: n.table.name, view: n.table.kind === "view" };
if ("table" in n && typeof n.table === "string") return { id: n.id, schema: n.schema, table: n.table, view: false };
export function tableOf(
n: DbNode,
): { id: string; schema: string; table: string; view: boolean; kind: DbTableKind } | null {
if (n.kind === "table")
return { id: n.id, schema: n.schema, table: n.table.name, view: n.table.kind !== "table", kind: n.table.kind };
if ("table" in n && typeof n.table === "string")
return { id: n.id, schema: n.schema, table: n.table, view: false, kind: "table" };
return null;
}
/** A routine, trigger or sequence node: what Go to DDL / Quick Documentation read. */
export function schemaObjectOf(
n: DbNode,
): { id: string; schema: string; kind: DbSchemaObjectKind; name: string } | null {
if (n.kind === "routine") return { id: n.id, schema: n.schema, kind: n.routine.kind, name: n.routine.name };
if (n.kind === "sequence") return { id: n.id, schema: n.schema, kind: "sequence", name: n.sequence.name };
if (n.kind === "trigger") return { id: n.id, schema: n.schema, kind: "trigger", name: n.trigger.name };
return null;
}
@@ -45,11 +81,17 @@ export function objectOf(n: DbNode): DbObjectRef | null {
case "schema":
return { kind: "schema", schema: n.schema, name: n.schema };
case "table":
return { kind: n.table.kind === "view" ? "view" : "table", schema: n.schema, name: n.table.name };
return { kind: n.table.kind, schema: n.schema, name: n.table.name };
case "column":
return { kind: "column", schema: n.schema, name: n.table, column: n.column.name };
case "index":
return { kind: "index", schema: n.schema, name: n.index.name, table: n.table };
case "routine":
return { kind: n.routine.kind, schema: n.schema, name: n.routine.name, arguments: n.routine.arguments };
case "sequence":
return { kind: "sequence", schema: n.schema, name: n.sequence.name };
case "trigger":
return { kind: "trigger", schema: n.schema, name: n.trigger.name, table: n.table };
default:
return null;
}
+65 -12
Переглянути файл
@@ -30,18 +30,30 @@ export function qualifiedName(driver: DbDriver, schema: string, name: string): s
return `${ident(driver, schema)}.${ident(driver, name)}`;
}
export type DbObjectKind = "table" | "view" | "column" | "index" | "schema";
export type DbObjectKind =
| "table"
| "view"
| "materializedView"
| "column"
| "index"
| "schema"
| "function"
| "procedure"
| "trigger"
| "sequence";
/** A schema object of a data source, as the tree actions see it. */
export interface DbObjectRef {
kind: DbObjectKind;
schema: string;
/** Table / view / index / schema name; the column's table for a column. */
/** Table / view / index / schema / routine / trigger / sequence name; the column's table for a column. */
name: string;
/** Column name (kind `column`). */
column?: string;
/** Table of an index (MySQL drops an index `ON` its table). */
/** Table of an index or trigger (MySQL drops an index `ON` its table, PostgreSQL a trigger). */
table?: string;
/** Argument types of a PostgreSQL routine (tells overloads apart). */
arguments?: string;
}
/** IDEA's Copy Reference: the object's qualified name. */
@@ -52,7 +64,10 @@ export function reference(driver: DbDriver, o: DbObjectRef): string {
}
export const canRename = (driver: DbDriver, o: DbObjectRef) =>
o.kind === "table" || o.kind === "column" || (o.kind === "view" && driver !== "sqlite") || (o.kind === "index" && driver === "postgres");
o.kind === "table" ||
o.kind === "column" ||
(o.kind === "view" && driver !== "sqlite") ||
(o.kind === "index" && driver === "postgres");
/** The statement renaming `o` to `to`. */
export function renameSql(driver: DbDriver, o: DbObjectRef, to: string): string {
@@ -87,17 +102,34 @@ export function dropSql(driver: DbDriver, o: DbObjectRef, opts: DropOptions): st
case "column":
return `alter table ${qualifiedName(driver, o.schema, o.name)} drop column ${ifExists && driver === "postgres" ? ifExists : ""}${ident(driver, o.column ?? "")}${cascade};`;
case "index":
if (driver === "mysql") return `drop index ${ident(driver, o.name)} on ${qualifiedName(driver, o.schema, o.table ?? "")};`;
if (driver === "mysql")
return `drop index ${ident(driver, o.name)} on ${qualifiedName(driver, o.schema, o.table ?? "")};`;
return `drop index ${ifExists}${qualifiedName(driver, o.schema, o.name)}${cascade};`;
case "schema":
return `drop ${driver === "mysql" ? "database" : "schema"} ${ifExists}${ident(driver, o.name)}${cascade};`;
case "materializedView":
return `drop materialized view ${ifExists}${qualifiedName(driver, o.schema, o.name)}${cascade};`;
case "function":
case "procedure": {
const args = driver === "postgres" ? `(${o.arguments ?? ""})` : "";
return `drop ${o.kind} ${ifExists}${qualifiedName(driver, o.schema, o.name)}${args}${cascade};`;
}
case "trigger":
if (driver === "postgres")
return `drop trigger ${ifExists}${ident(driver, o.name)} on ${qualifiedName(driver, o.schema, o.table ?? "")}${cascade};`;
return `drop trigger ${ifExists}${qualifiedName(driver, o.schema, o.name)};`;
default:
return `drop ${o.kind} ${ifExists}${qualifiedName(driver, o.schema, o.name)}${cascade};`;
}
}
/** Object Actions › Truncate (SQLite has no TRUNCATE: `delete from`). */
export function truncateSql(driver: DbDriver, schema: string, table: string, opts: { restartIdentity: boolean; cascade: boolean }): string {
export function truncateSql(
driver: DbDriver,
schema: string,
table: string,
opts: { restartIdentity: boolean; cascade: boolean },
): string {
const q = qualifiedName(driver, schema, table);
if (driver === "sqlite") return `delete from ${q};`;
if (driver === "mysql") return `truncate table ${q};`;
@@ -115,7 +147,13 @@ export const SCRIPT_LABELS: Record<ScriptKind, string> = {
};
/** SQL Scripts › … templates (IDEA's live templates `sel`, `selc`, `ins`, `upd`, `del`). */
export function scriptSql(driver: DbDriver, kind: ScriptKind, schema: string, table: string, columns: readonly DbColumnInfo[]): string {
export function scriptSql(
driver: DbDriver,
kind: ScriptKind,
schema: string,
table: string,
columns: readonly DbColumnInfo[],
): string {
const q = qualifiedName(driver, schema, table);
const names = columns.map((c) => ident(driver, c.name));
const keys = columns.filter((c) => c.primaryKey).map((c) => ident(driver, c.name));
@@ -172,7 +210,12 @@ function columnDefinition(driver: DbDriver, c: ColumnDraft): string {
}
/** `create table` for New › Table. */
export function createTableSql(driver: DbDriver, schema: string, name: string, columns: readonly ColumnDraft[]): string {
export function createTableSql(
driver: DbDriver,
schema: string,
name: string,
columns: readonly ColumnDraft[],
): string {
const live = columns.filter((c) => !c.dropped && c.name.trim());
const keys = live.filter((c) => c.primaryKey).map((c) => ident(driver, c.name));
const items = live.map((c) => ` ${columnDefinition(driver, c)}`);
@@ -190,7 +233,13 @@ export function canAlterColumn(driver: DbDriver, c: ColumnDraft): boolean {
* (Modify Table): renames, added / dropped columns, and — PostgreSQL / MySQL —
* type, nullability and default changes.
*/
export function alterTableSql(driver: DbDriver, schema: string, table: string, name: string, columns: readonly ColumnDraft[]): string[] {
export function alterTableSql(
driver: DbDriver,
schema: string,
table: string,
name: string,
columns: readonly ColumnDraft[],
): string[] {
const out: string[] = [];
const q = () => qualifiedName(driver, schema, table);
for (const c of columns) {
@@ -215,11 +264,15 @@ export function alterTableSql(driver: DbDriver, schema: string, table: string, n
const col = ident(driver, renamed ? c.name : o.name);
if (c.dataType.trim() && c.dataType.trim() !== o.dataType)
out.push(`alter table ${q()} alter column ${col} type ${c.dataType.trim()};`);
if (c.notNull !== !o.nullable) out.push(`alter table ${q()} alter column ${col} ${c.notNull ? "set" : "drop"} not null;`);
if (c.notNull !== !o.nullable)
out.push(`alter table ${q()} alter column ${col} ${c.notNull ? "set" : "drop"} not null;`);
if (c.default.trim() !== (o.default ?? "").trim())
out.push(`alter table ${q()} alter column ${col} ${c.default.trim() ? `set default ${c.default.trim()}` : "drop default"};`);
out.push(
`alter table ${q()} alter column ${col} ${c.default.trim() ? `set default ${c.default.trim()}` : "drop default"};`,
);
}
// Renamed last: the column statements above name the table as it was.
if (name.trim() && name.trim() !== table) out.push(renameSql(driver, { kind: "table", schema, name: table }, name.trim()));
if (name.trim() && name.trim() !== table)
out.push(renameSql(driver, { kind: "table", schema, name: table }, name.trim()));
return out;
}
+221
Переглянути файл
@@ -0,0 +1,221 @@
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;",
);
});
});
+194
Переглянути файл
@@ -0,0 +1,194 @@
/**
* Pure helpers of the result views: IDEA's local filter and client-side sort
* of loaded rows, the Aggregates of a selection, and the series of the chart.
*/
import type { Cell } from "./model";
/** Column index → the values still shown (IDEA's local filter: checked values). */
export type LocalFilter = ReadonlyMap<number, ReadonlySet<Cell>>;
export interface LocalSort {
column: number;
descending: boolean;
}
const NUMBER = /^-?\d+(\.\d+)?(e[+-]?\d+)?$/i;
/** Numbers by value, then text; NULL first (as most databases sort ascending). */
export function compareCells(a: Cell, b: Cell): number {
if (a === b) return 0;
if (a === null) return -1;
if (b === null) return 1;
if (NUMBER.test(a) && NUMBER.test(b)) return Number(a) - Number(b);
return a < b ? -1 : 1;
}
/**
* The loaded rows to show, in order: those passing every column filter,
* sorted by `sort` (stable, original order otherwise). Rows past `fixed`
* (the table editor's added rows) always stay, last.
*/
export function visibleRows(
rowCount: number,
value: (row: number, col: number) => Cell,
filter: LocalFilter,
sort: LocalSort | null,
fixed = rowCount,
): number[] {
const rows: number[] = [];
for (let r = 0; r < Math.min(rowCount, fixed); r++) {
let shown = true;
for (const [col, allowed] of filter) {
if (!allowed.has(value(r, col))) {
shown = false;
break;
}
}
if (shown) rows.push(r);
}
if (sort) {
const dir = sort.descending ? -1 : 1;
rows.sort((a, b) => dir * compareCells(value(a, sort.column), value(b, sort.column)) || a - b);
}
for (let r = fixed; r < rowCount; r++) rows.push(r);
return rows;
}
/** Distinct values of a column with their counts, sorted (the filter popup's list). */
export function distinctValues(
rowCount: number,
value: (row: number, col: number) => Cell,
col: number,
limit = 1000,
): Array<{ value: Cell; count: number }> {
const counts = new Map<Cell, number>();
for (let r = 0; r < rowCount; r++) {
const v = value(r, col);
counts.set(v, (counts.get(v) ?? 0) + 1);
}
return [...counts]
.map(([v, count]) => ({ value: v, count }))
.sort((a, b) => compareCells(a.value, b.value))
.slice(0, limit);
}
/** The new filter after toggling `v` of column `col` (all checked again: no filter). */
export function toggleFilterValue(
filter: LocalFilter,
col: number,
all: readonly Cell[],
v: Cell,
checked: boolean,
): LocalFilter {
const next = new Map(filter);
const current = new Set(filter.get(col) ?? all);
if (checked) current.add(v);
else current.delete(v);
if (all.every((x) => current.has(x))) next.delete(col);
else next.set(col, current);
return next;
}
export interface Aggregates {
count: number;
distinct: number;
nulls: number;
/** When every non-NULL value is a number; null otherwise. */
sum: number | null;
avg: number | null;
min: Cell;
max: Cell;
median: number | null;
}
/** IDEA's Aggregates view of the selected cells. */
export function aggregates(cells: readonly Cell[]): Aggregates {
const present = cells.filter((c): c is string => c !== null);
const numbers = present.filter((c) => NUMBER.test(c)).map(Number);
const numeric = numbers.length > 0 && numbers.length === present.length;
const sorted = [...present].sort(compareCells);
let median: number | null = null;
if (numeric) {
const n = [...numbers].sort((a, b) => a - b);
const mid = Math.floor(n.length / 2);
median = n.length % 2 ? n[mid]! : (n[mid - 1]! + n[mid]!) / 2;
}
const sum = numeric ? numbers.reduce((a, b) => a + b, 0) : null;
return {
count: cells.length,
distinct: new Set(cells).size,
nulls: cells.length - present.length,
sum,
avg: sum === null ? null : sum / numbers.length,
min: sorted[0] ?? null,
max: sorted.at(-1) ?? null,
median,
};
}
/** A number for display: integers as is, others to at most 6 significant decimals. */
export function formatNumber(n: number): string {
if (Number.isInteger(n)) return String(n);
return String(Number(n.toPrecision(12)));
}
export type ChartAggregate = "sum" | "avg" | "count" | "min" | "max" | "none";
export interface ChartPoint {
label: string;
value: number;
}
/**
* The chart's series: `y` per distinct `x` (in first-seen order), combined with
* `agg`; `none` plots every row as is. Non-numeric `y` values are skipped
* (except for `count`).
*/
export function chartSeries(
rows: ReadonlyArray<readonly Cell[]>,
x: number,
y: number,
agg: ChartAggregate,
): ChartPoint[] {
if (agg === "none")
return rows
.filter((r) => r[y] !== null && NUMBER.test(r[y]!))
.map((r) => ({ label: r[x] ?? "<null>", value: Number(r[y]) }));
const groups = new Map<string, number[]>();
for (const r of rows) {
const label = r[x] ?? "<null>";
const v = r[y] ?? null;
if (!groups.has(label)) groups.set(label, []);
if (agg === "count") groups.get(label)!.push(1);
else if (v !== null && NUMBER.test(v)) groups.get(label)!.push(Number(v));
}
const combine = (v: number[]): number => {
if (!v.length) return 0;
switch (agg) {
case "sum":
case "count":
return v.reduce((a, b) => a + b, 0);
case "avg":
return v.reduce((a, b) => a + b, 0) / v.length;
case "min":
return Math.min(...v);
case "max":
return Math.max(...v);
}
};
return [...groups].map(([label, v]) => ({ label, value: combine(v) }));
}
/** Round axis ticks from 0 (or the minimum, when negative) to `max`. */
export function niceTicks(min: number, max: number, count = 5): number[] {
const lo = Math.min(0, min);
const hi = Math.max(0, max);
if (hi === lo) return [lo];
const raw = (hi - lo) / count;
const mag = 10 ** Math.floor(Math.log10(raw));
const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? raw;
const out: number[] = [];
for (let t = Math.floor(lo / step) * step; t <= hi + step * 1e-9; t += step) out.push(Number(t.toPrecision(12)));
if (out.at(-1)! < hi) out.push(Number((out.at(-1)! + step).toPrecision(12)));
return out;
}
+24 -7
Переглянути файл
@@ -130,7 +130,7 @@ export function sqlCandidates({ model, analysis: a, driver, sourceName }: Comple
eq(t.schema, defaultSchema) || driver === "sqlite" ? q(t.name) : `${q(t.schema)}.${q(t.name)}`;
const tableCandidate = (t: DbCompletionTable, boost: number, qualified = false): SqlCandidate => ({
label: t.name,
kind: t.kind === "view" ? "view" : "table",
kind: t.kind !== "table" ? "view" : "table",
tail: t.schema ? `(${t.schema})` : undefined,
type: sourceName,
insert: qualified ? `${q(t.schema)}.${q(t.name)}` : tableText(t),
@@ -192,7 +192,7 @@ export function sqlCandidates({ model, analysis: a, driver, sourceName }: Comple
for (const t of tables) {
schemas.add(t.schema);
const local = eq(t.schema, defaultSchema) || driver === "sqlite";
out.push(tableCandidate(t, local ? (t.kind === "view" ? 28 : 30) : 0, !local));
out.push(tableCandidate(t, local ? (t.kind !== "table" ? 28 : 30) : 0, !local));
}
for (const s of schemas)
if (!eq(s, defaultSchema) || schemas.size > 1)
@@ -204,7 +204,9 @@ export function sqlCandidates({ model, analysis: a, driver, sourceName }: Comple
case "afterTable": {
if (!ctx.ref.alias && ctx.ref.kind === "table" && a.verb !== "INSERT" && a.verb !== "REPLACE") {
// IDEA suggests an alias first.
const taken = new Set(a.refs.filter((r) => r !== ctx.ref).flatMap((r) => [refName(r).toLowerCase(), r.name.toLowerCase()]));
const taken = new Set(
a.refs.filter((r) => r !== ctx.ref).flatMap((r) => [refName(r).toLowerCase(), r.name.toLowerCase()]),
);
out.push({ label: aliasFor(ctx.ref.name, taken), kind: "alias", boost: 45 });
}
if (a.verb === "UPDATE") keywords(["SET", "AS"], 20);
@@ -237,7 +239,15 @@ export function sqlCandidates({ model, analysis: a, driver, sourceName }: Comple
// The columns an INSERT needs (no default), then all of them.
const required = all.filter((c) => !c.hasDefault).map((c) => c.name);
if (required.length > 1) out.push(columnsCandidate(ctx.target, required, 60, false));
if (all.length > 1 && all.length !== required.length) out.push(columnsCandidate(ctx.target, all.map((c) => c.name), 55, false));
if (all.length > 1 && all.length !== required.length)
out.push(
columnsCandidate(
ctx.target,
all.map((c) => c.name),
55,
false,
),
);
out.push(...all.map((c) => columnCandidate(c, ownerOf(ctx.target), t, 50)));
keywords(["SELECT", "VALUES"], 5);
return out;
@@ -271,14 +281,21 @@ export function sqlCandidates({ model, analysis: a, driver, sourceName }: Comple
seen.add(key);
const ambiguous = r.depth > 0 || (count.get(c.name.toLowerCase()) ?? 0) > 1;
out.push(
columnCandidate(c, owner, t, r.depth === 0 ? 40 : 20, ambiguous ? `${q(refName(r))}.${q(c.name)}` : undefined),
columnCandidate(
c,
owner,
t,
r.depth === 0 ? 40 : 20,
ambiguous ? `${q(refName(r))}.${q(c.name)}` : undefined,
),
);
}
if (assigning) continue;
out.push({ label: refName(r), kind: "alias", boost: 35 });
// The whole column list (IDEA's "expand *" item in the select list).
const names = columnsOf(r).map((c) => c.name);
if (clause === "select" && r.depth === 0 && names.length > 1) out.push(columnsCandidate(r, names, 30, inner.length > 1));
if (clause === "select" && r.depth === 0 && names.length > 1)
out.push(columnsCandidate(r, names, 30, inner.length > 1));
}
if (assigning) return;
if (!inner.length && a.fromInsert !== null && clause === "select") {
@@ -478,5 +495,5 @@ function tableDoc(t: DbCompletionTable): string {
const cols = t.columns.map(
(c) => ` ${c.name.padEnd(width)} ${c.dataType}${c.nullable ? "" : " NOT NULL"}${c.primaryKey ? " PK" : ""}`,
);
return `${t.kind === "view" ? "VIEW" : "TABLE"} ${t.schema}.${t.name}\n${cols.join("\n")}`;
return `${t.kind === "materializedView" ? "MATERIALIZED VIEW" : t.kind === "view" ? "VIEW" : "TABLE"} ${t.schema}.${t.name}\n${cols.join("\n")}`;
}
+19 -6
Переглянути файл
@@ -87,7 +87,7 @@ function definition(path: string): ContributedDefinition {
const sym = r?.symbol;
const id = consoleSource(path);
const reveal = (t: DbCompletionTable, column?: string) => {
if (id) void revealInTree(id, t.schema, t.name, t.kind === "view", column);
if (id) void revealInTree(id, t.schema, t.name, t.kind, column);
};
if (!sym) {
showHint(view, "Cannot find declaration to go to");
@@ -126,13 +126,22 @@ function tableDoc(t: DbCompletionTable, model: DbCompletion | null): string {
};
const rows = t.columns.map(
(c) =>
`| ${code(c.name)} | ${c.dataType} | ${[c.primaryKey ? "PK" : "", c.nullable ? "" : "not null", c.hasDefault ? "default" : "", refOf(c.name)]
`| ${code(c.name)} | ${c.dataType} | ${[
c.primaryKey ? "PK" : "",
c.nullable ? "" : "not null",
c.hasDefault ? "default" : "",
refOf(c.name),
]
.filter(Boolean)
.join(", ")} |`,
);
return [`${t.kind === "view" ? "view" : "table"} **${t.name}** in ${code(t.schema)}`, "", "| column | type | |", "|---|---|---|", ...rows].join(
"\n",
);
return [
`${t.kind === "materializedView" ? "materialized view" : t.kind} **${t.name}** in ${code(t.schema)}`,
"",
"| column | type | |",
"|---|---|---|",
...rows,
].join("\n");
}
function symbolDoc(sym: SqlSymbol, model: DbCompletion | null): string | null {
@@ -149,7 +158,11 @@ function symbolDoc(sym: SqlSymbol, model: DbCompletion | null): string | null {
const c = sym.column;
const owner = sym.table ? `${sym.table.schema}.${sym.table.name}` : sym.owner;
if (!c) return `column ${code(sym.name)} of ${code(owner)}`;
const traits = [c.primaryKey ? "primary key" : "", c.nullable ? "" : "not null", c.hasDefault ? "has default" : ""].filter(Boolean);
const traits = [
c.primaryKey ? "primary key" : "",
c.nullable ? "" : "not null",
c.hasDefault ? "has default" : "",
].filter(Boolean);
return `column **${c.name}** ${code(c.dataType)}${traits.length ? ` ${traits.join(", ")}` : ""}\n\nof ${code(owner)}`;
}
}
+163 -19
Переглянути файл
@@ -13,17 +13,28 @@ import {
type DbCompletion,
type DbDatabases,
type DbDriver,
type DbObjectKind,
type DbPlanNode,
type DbSchemaObjects,
type DbStatementOutcome,
type DbTableDetails,
type DbTableInfo,
type DbTableKind,
type RequestId,
type SqlStatement,
} from "@/platform/ipc";
import { editorsFor } from "@/editor/api";
import { editorService, notifications, showServiceView, toolWindowService, type FileDecoration } from "@/workbench/contributions";
import {
editorService,
notifications,
showServiceView,
toolWindowService,
type FileDecoration,
} from "@/workbench/contributions";
import { ask, confirm } from "@/workbench/messages";
import { colorVar, connectRemedy, databaseKey, idleTargets, newSourceId, splitTarget } from "./model";
import type { DbNode } from "./nodes";
import { tableFolder, type DbNode } from "./nodes";
import type { ExportData, ExportFormat } from "./export";
import type { DbObjectRef } from "./objects";
import { formatted } from "./sql/format";
import { bindParams, findParams } from "./sql/params";
@@ -41,6 +52,8 @@ export interface ResultTab {
path: string;
sourceId: string;
outcome: DbStatementOutcome;
/** The Query Plan tab: the plan of `outcome.statement` (which was explained, not run). */
plan?: DbPlanNode[];
/** Being re-run. */
busy?: boolean;
}
@@ -80,7 +93,13 @@ export type DialogState =
/** SSH password or key passphrase (`prompt` is the backend's reason). */
| { kind: "sshSecret"; source: DataSource; prompt: string; resolve: (ok: boolean) => void }
/** Values of query parameters (`null`: cancelled). */
| { kind: "params"; names: string[]; resolve: (values: Record<string, string> | null) => void };
| { kind: "params"; names: string[]; resolve: (values: Record<string, string> | null) => void }
/** Export Data of a result or table page; `source` names it (`main.customers`). */
| { kind: "export"; data: ExportData; source: string; format?: ExportFormat }
/** Browse Query History of target `id`; Paste goes to console `path`. */
| { kind: "history"; id: string; path: string | null }
/** Import Data from File into a table. */
| { kind: "import"; id: string; schema: string; table: string };
interface DatabaseState {
sources: DataSource[];
@@ -95,6 +114,8 @@ interface DatabaseState {
tables: Record<string, Loaded<DbTableInfo[]>>;
/** `${id}\u0000${schema}\u0000${table}` → columns / indexes. */
details: Record<string, Loaded<DbTableDetails>>;
/** `${id}\u0000${schema}` → routines and sequences (loaded with the tables). */
objects: Record<string, DbSchemaObjects>;
/** Console / SQL file path → data source id. */
consoles: Record<string, string>;
completion: Record<string, DbCompletion>;
@@ -141,6 +162,7 @@ export const useDatabase = create<DatabaseState>(() => ({
schemas: {},
tables: {},
details: {},
objects: {},
consoles: readBindings(),
completion: {},
results: [],
@@ -210,6 +232,7 @@ function forget(id: string) {
completion: dropKeys(s.completion),
tables: dropKeys(s.tables),
details: dropKeys(s.details),
objects: dropKeys(s.objects),
};
});
}
@@ -399,7 +422,7 @@ export function consoleTints(s: Pick<DatabaseState, "consoles" | "sources">): Ma
/** PostgreSQL data sources show their server's databases (IDEA's data source › database › schema). */
export function listsDatabases(source: DataSource | undefined): boolean {
return source?.driver === "postgres";
return source?.driver === "postgres" && !source.options?.singleDatabase;
}
/**
@@ -413,7 +436,8 @@ export async function refresh(id: string): Promise<void> {
return t === id || (whole && splitTarget(t).id === id);
};
const st = useDatabase.getState();
const loaded = (m: Record<string, Loaded<unknown>>) => Object.keys(m).filter((k) => ofTarget(k) && m[k]!.status === "ok");
const loaded = (m: Record<string, Loaded<unknown>>) =>
Object.keys(m).filter((k) => ofTarget(k) && m[k]!.status === "ok");
const schemas = loaded(st.schemas);
const tables = loaded(st.tables).map((k) => k.split("\u0000") as [string, string]);
const details = loaded(st.details).map((k) => k.split("\u0000") as [string, string, string]);
@@ -454,7 +478,12 @@ export async function loadTables(id: string, schema: string): Promise<void> {
useDatabase.setState((s) => ({ tables: { ...s.tables, [key]: { status: "loading" } } }));
try {
const value = await withConnection(id, () => call(commands.dbTables(id, schema)));
useDatabase.setState((s) => ({ tables: { ...s.tables, [key]: { status: "ok", value } } }));
// Routines / sequences are extras: a server refusing their catalog still lists tables.
const objects = await call(commands.dbSchemaObjects(id, schema)).catch(() => null);
useDatabase.setState((s) => ({
tables: { ...s.tables, [key]: { status: "ok", value } },
objects: objects ? { ...s.objects, [key]: objects } : s.objects,
}));
} catch (e) {
useDatabase.setState((s) => ({ tables: { ...s.tables, [key]: { status: "error", message: message(e) } } }));
}
@@ -475,7 +504,13 @@ export async function loadDetails(id: string, schema: string, table: string): Pr
* Selects a table (or one of its columns) in the Database tool window,
* loading and expanding the nodes that lead to it.
*/
export async function revealInTree(id: string, schema: string, table: string, view: boolean, column?: string): Promise<void> {
export async function revealInTree(
id: string,
schema: string,
table: string,
kind: DbTableKind,
column?: string,
): Promise<void> {
const loaded = (l: Loaded<unknown> | undefined) => l?.status === "ok";
const { id: sourceId, database } = splitTarget(id);
let databaseNode: string[] = [];
@@ -493,10 +528,17 @@ export async function revealInTree(id: string, schema: string, table: string, vi
await loadSchemas(id);
}
if (!loaded(useDatabase.getState().tables[tablesKey(id, schema)])) await loadTables(id, schema);
if (column && !loaded(useDatabase.getState().details[detailsKey(id, schema, table)])) await loadDetails(id, schema, table);
const folder = `${id}/${schema}/${view ? "views" : "tables"}`;
if (column && !loaded(useDatabase.getState().details[detailsKey(id, schema, table)]))
await loadDetails(id, schema, table);
const folder = `${id}/${schema}/${tableFolder(kind)}`;
const tableKey = `${folder}/${table}`;
const open = [sourceId, ...databaseNode, `${id}/${schema}`, folder, ...(column ? [tableKey, `${tableKey}/columns`] : [])];
const open = [
sourceId,
...databaseNode,
`${id}/${schema}`,
folder,
...(column ? [tableKey, `${tableKey}/columns`] : []),
];
useDatabase.setState((s) => ({
treeExpanded: new Set([...s.treeExpanded, ...open]),
treeReveal: { key: column ? `${tableKey}/columns/${column}` : tableKey, seq: (s.treeReveal?.seq ?? 0) + 1 },
@@ -582,7 +624,10 @@ export function outcomeSummary(o: DbStatementOutcome): string {
const n = r.columns.length ? r.rows.length : (r.affected ?? 0);
const rows = `${n} row${n === 1 ? "" : "s"}`;
if (!r.columns.length) return `${rows} affected in ${elapsed(r.elapsedMs ?? 0)}`;
return `${rows} retrieved starting from 1 in ${elapsed(r.elapsedMs ?? 0)}${r.hasMore ? " (limit reached)" : ""}`;
const total = r.elapsedMs ?? 0;
const execution = Math.min(total, r.executionMs ?? total);
const split = ` (execution: ${elapsed(execution)}, fetching: ${elapsed(total - execution)})`;
return `${rows} retrieved starting from 1 in ${elapsed(total)}${split}${r.hasMore ? " (limit reached)" : ""}`;
}
/** The schema shown as the Output prompt (`main>`). */
@@ -593,7 +638,9 @@ function promptOf(id: string): string {
/** Runs `statements` on `id`, logging to Output; errors stop the batch (backend). */
async function run(id: string, statements: SqlStatement[], request: RequestId): Promise<DbStatementOutcome[]> {
const wasConnected = useDatabase.getState().connected.has(id);
const outcomes = await withConnection(id, () => call(commands.dbExecute(request, id, statements, useDatabase.getState().maxRows)));
const outcomes = await withConnection(id, () =>
call(commands.dbExecute(request, id, statements, useDatabase.getState().maxRows)),
);
if (!wasConnected) log("info", "Connected");
for (const o of outcomes) {
log("statement", o.statement.text, `${promptOf(id)}>`);
@@ -621,12 +668,17 @@ export async function runSql(id: string, texts: string[]): Promise<boolean> {
const outcomes = await run(id, statements, nextRequestId());
const failed = outcomes.find((o) => o.error);
if (failed) {
notifications().notify({ level: "error", title: `[${targetLabel(id)}] ${failed.statement.text}`, body: failed.error ?? "" });
notifications().notify({
level: "error",
title: `[${targetLabel(id)}] ${failed.statement.text}`,
body: failed.error ?? "",
});
return false;
}
return true;
} catch (e) {
if (!isCancelled(e)) notifications().notify({ level: "error", title: `Cannot execute on ${targetLabel(id)}`, body: message(e) });
if (!isCancelled(e))
notifications().notify({ level: "error", title: `Cannot execute on ${targetLabel(id)}`, body: message(e) });
return false;
}
}
@@ -706,7 +758,12 @@ async function withParams(statements: SqlStatement[], driver: DbDriver): Promise
return statements.map((st, i) => ({ ...st, text: bindParams(st.text, found[i]!, values) }));
}
export async function executeConsole(path: string, text: string, from: number, to: number): Promise<DbStatementOutcome[]> {
export async function executeConsole(
path: string,
text: string,
from: number,
to: number,
): Promise<DbStatementOutcome[]> {
const id = consoleSource(path);
if (!id) {
notifications().notify({
@@ -719,9 +776,7 @@ export async function executeConsole(path: string, text: string, from: number, t
});
return [];
}
let statements: SqlStatement[] = await call(commands.dbStatements(id, text, from, to));
if (!statements.length) return [];
statements = await withParams(statements, sourceById(id)?.driver ?? "sqlite");
const statements = await consoleStatements(id, text, from, to);
if (!statements.length) return [];
const prev = useDatabase.getState().running[path];
if (prev) void commands.cancelRequest(prev);
@@ -765,13 +820,102 @@ export async function executeConsole(path: string, text: string, from: number, t
}
}
/** Re-runs the statement of result tab `tabId`, replacing its rows. */
/** The statements of console `path` to run or explain: the selection's, else the one at `from`, parameters bound. */
async function consoleStatements(id: string, text: string, from: number, to: number): Promise<SqlStatement[]> {
const statements: SqlStatement[] = await call(commands.dbStatements(id, text, from, to));
if (!statements.length) return [];
return withParams(statements, sourceById(id)?.driver ?? "sqlite");
}
const PLAN_TITLE = "Query Plan";
/** Shows `plan` of `statement` in the window's Query Plan tab (one per window, like IDEA's). */
function showPlan(path: string, id: string, statement: SqlStatement, plan: DbPlanNode[]) {
useDatabase.setState((s) => {
const old = s.results.find((r) => r.plan);
const tab: ResultTab = {
id: old?.id ?? nextTab++,
title: PLAN_TITLE,
path,
sourceId: id,
outcome: { statement, result: null, error: null },
plan,
};
const results = old ? s.results.map((r) => (r === old ? tab : r)) : [...s.results, tab].slice(-MAX_TABS);
return { results, activeResult: tab.id };
});
showServiceView(RESULTS_VIEW, false);
}
async function explain(id: string, statement: SqlStatement): Promise<DbPlanNode[]> {
const plan = await withConnection(id, () => call(commands.dbExplain(nextRequestId(), id, statement.text)));
log("statement", `explain ${statement.text}`, `${promptOf(id)}>`);
log("result", `${plan.length} plan node${plan.length === 1 ? "" : "s"}`);
return plan;
}
/** Explain Plan: the plan of the statement at `from` (the first selected one) of console `path`. */
export async function explainConsole(path: string, text: string, from: number, to: number): Promise<void> {
const id = consoleSource(path);
if (!id) return;
try {
const [statement] = await consoleStatements(id, text, from, to);
if (!statement) return;
showPlan(path, id, statement, await explain(id, statement));
} catch (e) {
if (isCancelled(e)) return;
log("error", `[${targetLabel(id)}] ${message(e)}`);
notifications().notify({
level: "error",
title: "Cannot explain the statement",
body: message(e),
key: "db-explain",
});
}
}
/** Browse Query History of the data source of console `path` (or of target `id`). */
export function openHistory(path: string | null, id: string | null = path ? consoleSource(path) : null) {
if (!id) {
notifications().notify({ level: "info", title: "Choose a data source for this console", key: "db-history" });
return;
}
openDialog({ kind: "history", id, path });
}
/** Go to DDL of a routine, trigger or sequence: its definition in a SQL file next to the console. */
export async function openObjectDdl(id: string, schema: string, kind: DbObjectKind, name: string): Promise<void> {
try {
const [text, console] = await Promise.all([
withConnection(id, () => call(commands.dbObjectDdl(id, schema, kind, name))),
call(commands.dbConsolePath(id)),
]);
const safe = (s: string) => s.replace(/[^\w.-]+/g, "_");
const path = console.replace(/[^\\/]*$/, `${safe(schema)}.${safe(name)}.sql`);
await call(commands.dbExportFile(path, text));
bindConsole(path, id);
await editorService().openFile(path, { focus: true });
} catch (e) {
notifications().notify({ level: "error", title: `Cannot get the definition of ${name}`, body: message(e) });
}
}
/** Re-runs the statement of result tab `tabId`, replacing its rows (a plan tab explains it again). */
export async function rerunResult(tabId: number): Promise<void> {
const tab = useDatabase.getState().results.find((r) => r.id === tabId);
if (!tab || tab.busy) return;
const patch = (p: Partial<ResultTab>) =>
useDatabase.setState((s) => ({ results: s.results.map((r) => (r.id === tabId ? { ...r, ...p } : r)) }));
patch({ busy: true });
if (tab.plan) {
try {
patch({ plan: await explain(tab.sourceId, tab.outcome.statement), busy: false });
} catch (e) {
patch({ busy: false });
if (!isCancelled(e)) log("error", `[${targetLabel(tab.sourceId)}] ${message(e)}`);
}
return;
}
try {
const [outcome] = await run(tab.sourceId, [tab.outcome.statement], nextRequestId());
patch(outcome?.result ? { outcome, busy: false } : { busy: false });
+71 -15
Переглянути файл
@@ -7,7 +7,7 @@ import { call, commands, type DataSource, type DbDriver, type DbTableInfo } from
import { registerActions, type Action } from "@/platform/actions";
import { editorService, notifications, showServiceView, toolWindowService } from "@/workbench/contributions";
import { splitTarget } from "./model";
import { nodeTarget, objectOf, tableOf, type DbNode } from "./nodes";
import { nodeTarget, objectOf, schemaObjectOf, tableFolder, tableOf, type DbNode } from "./nodes";
import { canDrop, canRename, reference, scriptSql, type ScriptKind } from "./objects";
import {
DB_TOOL_WINDOW,
@@ -23,6 +23,7 @@ import {
openConsole,
openConsoleWith,
openDdl,
openObjectDdl,
openDialog,
openSourceDialog,
readableDdl,
@@ -66,7 +67,9 @@ async function copy(text: string, what: string) {
/** Where Quick Documentation opens: below the tree's selected row. */
function rowAnchor(): { x: number; y: number } {
const row = document.querySelector('[role="tree"][aria-label="Data sources"] [role="treeitem"][aria-selected="true"]');
const row = document.querySelector(
'[role="tree"][aria-label="Data sources"] [role="treeitem"][aria-selected="true"]',
);
const r = row?.getBoundingClientRect();
return r ? { x: r.left + 24, y: r.bottom + 2 } : { x: window.innerWidth / 2, y: window.innerHeight / 3 };
}
@@ -89,6 +92,9 @@ async function ddlOf(n: DbNode): Promise<{ id: string; text: string } | null> {
const one = async (schema: string, table: string) =>
readableDdl(await withConnection(id, () => call(commands.dbDdl(id, schema, table))), driverOf(id));
try {
const o = schemaObjectOf(n);
if (o)
return { id, text: await withConnection(id, () => call(commands.dbObjectDdl(id, o.schema, o.kind, o.name))) };
const t = tableOf(n);
if (t) return { id, text: await one(t.schema, t.table) };
let schemas: string[] = [];
@@ -100,14 +106,20 @@ async function ddlOf(n: DbNode): Promise<{ id: string; text: string } | null> {
}
const parts: string[] = [];
for (const schema of schemas) {
const tables = (await tablesIn(id, schema)).filter((t) => n.kind !== "folder" || (t.kind === "view") === (n.folder === "views"));
const tables = (await tablesIn(id, schema)).filter(
(t) => n.kind !== "folder" || tableFolder(t.kind) === n.folder,
);
// Tables before views: views select from them.
tables.sort((a, b) => Number(a.kind === "view") - Number(b.kind === "view"));
tables.sort((a, b) => Number(a.kind !== "table") - Number(b.kind !== "table"));
for (const t of tables) parts.push(await one(schema, t.name));
}
return { id, text: parts.join("\n") };
} catch (e) {
notifications().notify({ level: "error", title: "Cannot generate the DDL", body: e instanceof Error ? e.message : String(e) });
notifications().notify({
level: "error",
title: "Cannot generate the DDL",
body: e instanceof Error ? e.message : String(e),
});
return null;
}
}
@@ -126,17 +138,34 @@ export async function insertScript(n: DbNode, kind: ScriptKind) {
/** SQL Scripts › Run SQL Script…: picks a `.sql` file and runs all its statements. */
async function runScriptFile(id: string) {
const { open } = await import("@tauri-apps/plugin-dialog");
const file = await open({ multiple: false, directory: false, title: "Run SQL Script", filters: [{ name: "SQL", extensions: ["sql", "ddl"] }] });
const file = await open({
multiple: false,
directory: false,
title: "Run SQL Script",
filters: [{ name: "SQL", extensions: ["sql", "ddl"] }],
});
if (typeof file !== "string") return;
try {
const text = await call(commands.fsReadText(file));
const statements = await call(commands.dbStatements(id, text, 0, text.length));
useDatabase.setState({ activeResult: OUTPUT_TAB });
showServiceView(RESULTS_VIEW, false);
if (await runSql(id, statements.map((s) => s.text)))
notifications().notify({ level: "info", title: `Script finished: ${statements.length} statement${statements.length === 1 ? "" : "s"}` });
if (
await runSql(
id,
statements.map((s) => s.text),
)
)
notifications().notify({
level: "info",
title: `Script finished: ${statements.length} statement${statements.length === 1 ? "" : "s"}`,
});
} catch (e) {
notifications().notify({ level: "error", title: "Cannot run the script", body: e instanceof Error ? e.message : String(e) });
notifications().notify({
level: "error",
title: "Cannot run the script",
body: e instanceof Error ? e.message : String(e),
});
}
}
@@ -156,7 +185,12 @@ function refreshNode(n: DbNode | null) {
}
export function registerDatabaseTreeActions(): () => void {
const tree = (a: Omit<Action, "category">): Action => ({ category: "Database", when: OPEN, keyWhen: DB_VIEW_FOCUS, ...a });
const tree = (a: Omit<Action, "category">): Action => ({
category: "Database",
when: OPEN,
keyWhen: DB_VIEW_FOCUS,
...a,
});
return registerActions([
tree({
id: "db.properties",
@@ -223,6 +257,8 @@ export function registerDatabaseTreeActions(): () => void {
keywords: ["edit source", "definition", "create table"],
run: ({ arg }) => {
const n = nodeFrom(arg);
const o = n && schemaObjectOf(n);
if (o) return void openObjectDdl(o.id, o.schema, o.kind, o.name);
const t = n && tableOf(n);
if (t) void openDdl(t.id, t.schema, t.table);
},
@@ -269,7 +305,8 @@ export function registerDatabaseTreeActions(): () => void {
keywords: ["delete all rows", "empty table"],
run: ({ arg }) => {
const n = nodeFrom(arg);
if (n?.kind === "table" && n.table.kind !== "view") openDialog({ kind: "truncate", id: n.id, schema: n.schema, table: n.table.name });
if (n?.kind === "table" && n.table.kind === "table")
openDialog({ kind: "truncate", id: n.id, schema: n.schema, table: n.table.name });
},
}),
tree({
@@ -281,7 +318,13 @@ export function registerDatabaseTreeActions(): () => void {
const n = nodeFrom(arg);
const t = n && tableOf(n);
if (!t || t.view) return;
openDialog({ kind: "modifyTable", id: t.id, schema: t.schema, table: t.table, focusColumn: n.kind === "column" ? n.column.name : undefined });
openDialog({
kind: "modifyTable",
id: t.id,
schema: t.schema,
table: t.table,
focusColumn: n.kind === "column" ? n.column.name : undefined,
});
},
}),
tree({
@@ -293,7 +336,8 @@ export function registerDatabaseTreeActions(): () => void {
const n = nodeFrom(arg);
const id = n && nodeTarget(n);
if (!n || !id) return;
const schema = "schema" in n && typeof n.schema === "string" ? n.schema : driverOf(id) === "sqlite" ? "main" : null;
const schema =
"schema" in n && typeof n.schema === "string" ? n.schema : driverOf(id) === "sqlite" ? "main" : null;
if (schema !== null) openDialog({ kind: "modifyTable", id, schema, table: null });
},
}),
@@ -305,7 +349,19 @@ export function registerDatabaseTreeActions(): () => void {
run: ({ arg }) => {
const n = nodeFrom(arg);
const t = n && tableOf(n);
if (t && !t.view) openDialog({ kind: "modifyTable", id: t.id, schema: t.schema, table: t.table, addColumn: true });
if (t && !t.view)
openDialog({ kind: "modifyTable", id: t.id, schema: t.schema, table: t.table, addColumn: true });
},
}),
tree({
id: "db.importData",
title: "Import Data from File…",
icon: "import",
keywords: ["csv", "tsv", "load data"],
run: ({ arg }) => {
const n = nodeFrom(arg);
if (n?.kind === "table" && n.table.kind === "table")
openDialog({ kind: "import", id: n.id, schema: n.schema, table: n.table.name });
},
}),
tree({
@@ -383,7 +439,7 @@ export function registerDatabaseTreeActions(): () => void {
keywords: ["foreign key"],
run: ({ arg }) => {
const n = nodeFrom(arg);
if (n?.kind === "foreignKey") void revealInTree(n.id, n.fk.refSchema, n.fk.refTable, false);
if (n?.kind === "foreignKey") void revealInTree(n.id, n.fk.refSchema, n.fk.refTable, "table");
},
}),
tree({
+73 -8
Переглянути файл
@@ -1,12 +1,17 @@
/** Menus of the Database tool window, laid out like IDEA's (New ▸, Copy/Paste ▸, SQL Scripts ▸ …). */
import type { MenuItem } from "@/ui";
import { fileSourceName } from "./model";
import { notifications } from "@/workbench/contributions";
import { fileSourceName, parseSourceUrl } from "./model";
import { VENDORS, vendorPreset } from "./vendors";
import { nodeTarget, objectOf, tableOf, type DbNode } from "./nodes";
import { SCRIPT_LABELS, canDrop, canRename, type ScriptKind } from "./objects";
import { connectedTargets, openDialog, openSourceDialog, sourceById, useDatabase } from "./store";
const act = (action: string, node: DbNode | null, extra: Partial<{ label: string; disabled: boolean; icon: string }> = {}): MenuItem => ({
const act = (
action: string,
node: DbNode | null,
extra: Partial<{ label: string; disabled: boolean; icon: string }> = {},
): MenuItem => ({
kind: "action",
action,
arg: node ?? undefined,
@@ -27,13 +32,37 @@ function dataSourceItems(): MenuItem[] {
{
label: "Data Source",
icon: "database",
submenu: VENDORS.map((v) => ({ label: v.label, icon: v.icon, run: () => openSourceDialog(null, vendorPreset(v)) })),
submenu: VENDORS.map((v) => ({
label: v.label,
icon: v.icon,
run: () => openSourceDialog(null, vendorPreset(v)),
})),
},
{ label: "Data Source from File…", icon: "folder", run: () => void sqliteFromFile() },
{ label: "Data Source from URL…", icon: "external-link", run: () => openDialog({ kind: "sourceUrl" }) },
{ label: "Import from Clipboard", icon: "paste", run: () => void sourceFromClipboard() },
];
}
/** New ▸ Import from Clipboard: a connection URL (JDBC or native) on the clipboard becomes a new data source. */
async function sourceFromClipboard() {
let text = "";
try {
text = (await navigator.clipboard?.readText()) ?? "";
} catch {
// Unreadable clipboard: reported below like an unknown one.
}
const parsed = text.trim() ? parseSourceUrl(text.trim()) : null;
if (parsed) openSourceDialog(null, parsed.source, parsed.password);
else
notifications().notify({
level: "info",
title: "No data source on the clipboard",
body: "Copy a connection URL such as postgresql://user@host:5432/db or jdbc:sqlite:/path/app.db.",
key: "db-clipboard",
});
}
/** The toolbar's "+" menu (IDEA: New). */
export function newMenu(): MenuItem[] {
return [{ kind: "action", action: "db.openConsole", label: "Query Console" }, sep, ...dataSourceItems()];
@@ -64,7 +93,10 @@ export function nodeMenu(n: DbNode | null): MenuItem[] {
const rename = act("db.rename", n, { disabled: !(n.kind === "source" || (o && canRename(driver, o))) });
const drop = act("db.drop", n, { disabled: !o || !canDrop(driver, o) });
const copyRef = act("db.copyReference", n);
const navigation = (extra: MenuItem[] = []): MenuItem => ({ label: "Navigation", submenu: [...extra, act("db.jumpToConsole", n)] });
const navigation = (extra: MenuItem[] = []): MenuItem => ({
label: "Navigation",
submenu: [...extra, act("db.jumpToConsole", n)],
});
switch (n.kind) {
case "source": {
@@ -115,7 +147,11 @@ export function nodeMenu(n: DbNode | null): MenuItem[] {
const view = !!t?.view;
const column = n.kind === "column";
return [
{ label: "New", icon: "plus", submenu: [newConsole, sep, act("db.newColumn", n, { disabled: view }), act("db.newTable", n)] },
{
label: "New",
icon: "plus",
submenu: [newConsole, sep, act("db.newColumn", n, { disabled: view }), act("db.newTable", n)],
},
act("db.modifyTable", n, { label: column ? "Modify Column…" : "Modify Table…", disabled: view }),
{ label: "Copy/Paste", submenu: [copyRef] },
act("db.editData", n),
@@ -127,19 +163,48 @@ export function nodeMenu(n: DbNode | null): MenuItem[] {
navigation([act("db.goToDdl", n)]),
sep,
sqlScripts(n, true),
...(n.kind === "table" && !view ? [{ label: "Object Actions", submenu: [act("db.truncate", n)] }] : []),
...(n.kind === "table" && !view
? [
{ label: "Import/Export", submenu: [act("db.importData", n)] },
{ label: "Object Actions", submenu: [act("db.truncate", n)] },
]
: []),
sep,
drop,
];
}
case "index":
return [{ label: "Copy/Paste", submenu: [copyRef] }, refresh, sep, rename, sep, navigation([act("db.goToDdl", n)]), sep, drop];
return [
{ label: "Copy/Paste", submenu: [copyRef] },
refresh,
sep,
rename,
sep,
navigation([act("db.goToDdl", n)]),
sep,
drop,
];
case "foreignKey":
return [refresh, sep, { label: "Navigation", submenu: [act("db.goToReferencedTable", n), act("db.goToDdl", n)] }];
case "routine":
case "sequence":
case "trigger":
return [
{ label: "Copy/Paste", submenu: [copyRef] },
refresh,
sep,
quickDoc,
navigation([act("db.goToDdl", n)]),
{ label: "SQL Scripts", submenu: [act("db.ddlToClipboard", n), act("db.ddlToConsole", n)] },
sep,
drop,
];
case "group":
case "key":
return [
...(n.kind === "group" && n.group === "columns" ? [{ label: "New", icon: "plus", submenu: [act("db.newColumn", n)] }] : []),
...(n.kind === "group" && n.group === "columns"
? [{ label: "New", icon: "plus", submenu: [act("db.newColumn", n)] }]
: []),
refresh,
sep,
{ label: "Navigation", submenu: [act("db.goToDdl", n)] },
+6
Переглянути файл
@@ -51,6 +51,9 @@ html {
--link: #71a1fe;
--link-hover: #92b7ff;
/* charts (IDEA's first series colour) */
--chart-series: #e07a7c;
/* status */
--danger: #f57e84;
--warning: #d59637;
@@ -406,6 +409,9 @@ html {
--link: #2f5eb9;
--link-hover: #2e4d89;
/* charts */
--chart-series: #d9686a;
/* status */
--danger: #c54e58;
--warning: #a56906;
+8 -9
Переглянути файл
@@ -248,6 +248,13 @@ const ICONS = {
"db-index": "openidea/database/index",
"db-schema": "openidea/database/schema",
"db-console": "openidea/database/console",
"db-matview": "openidea/database/matview",
"db-routine": "openidea/database/routine",
"db-trigger": "openidea/database/trigger",
"db-sequence": "openidea/database/sequence",
"db-plan": "openidea/database/plan",
"db-chart": "openidea/database/chart",
"db-sort": "openidea/database/sort",
"db-folder": "openidea/database/folder",
"db-add": "openidea/database/addDropdown",
"db-properties": "openidea/database/dataSourceProperties",
@@ -830,15 +837,7 @@ export function fileIconName(fileName: string): IconName {
}
export type FolderKind =
| "plain"
| "source"
| "test"
| "resources"
| "testResources"
| "excluded"
| "library"
| "github"
| "module";
"plain" | "source" | "test" | "resources" | "testResources" | "excluded" | "library" | "github" | "module";
/** Folder icon by role (expui has no separate "open" folder glyph). */
export function folderIconName(kind: FolderKind = "plain"): IconName {
+4
Переглянути файл
@@ -0,0 +1,4 @@
<!-- OpenIDEA: result chart (bars on an axis) -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.5 2.5V13.5H13.5" stroke="#6C707E" stroke-linecap="round"/><path d="M5.5 11.5V8.5M8.5 11.5V5.5M11.5 11.5V7.5" stroke="#6C707E" stroke-width="2"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 310 B

+4
Переглянути файл
@@ -0,0 +1,4 @@
<!-- OpenIDEA: materialized view (table outline with a filled lens) -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.5 12.5H3.5C2.94772 12.5 2.5 12.0523 2.5 11.5V4.5C2.5 3.94772 2.94772 3.5 3.5 3.5H12.5C13.0523 3.5 13.5 3.94772 13.5 4.5V8.5" stroke="#6C707E"/><path d="M2.5 6.5H13.5M6.5 6.5V12.5" stroke="#6C707E"/><circle cx="12" cy="12" r="2" fill="#6C707E" stroke="#6C707E"/><path d="M13.5 13.5L15 15" stroke="#6C707E" stroke-linecap="round"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 517 B

+4
Переглянути файл
@@ -0,0 +1,4 @@
<!-- OpenIDEA: query plan (operation tree) -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="2.5" width="5" height="3" rx="0.5" stroke="#6C707E"/><rect x="8.5" y="7.5" width="5" height="3" rx="0.5" stroke="#6C707E"/><rect x="8.5" y="11.5" width="5" height="3" rx="0.5" stroke="#6C707E"/><path d="M5 5.5V13H8.5M5 9H8.5" stroke="#6C707E"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 412 B

+4
Переглянути файл
@@ -0,0 +1,4 @@
<!-- OpenIDEA: stored routine (function sign in a box) -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.5" y="2.5" width="13" height="11" rx="1.5" stroke="#6C707E"/><path d="M9.5 5C8 4.5 7 5.2 7 6.5V11M5.5 8H9" stroke="#6C707E" stroke-linecap="round"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 322 B

+4
Переглянути файл
@@ -0,0 +1,4 @@
<!-- OpenIDEA: database sequence (counter) -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.5" y="4.5" width="13" height="7" rx="1" stroke="#6C707E"/><path d="M5.5 6.5V9.5M8 6.5H9.5V8H8V9.5H10" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 329 B

+4
Переглянути файл
@@ -0,0 +1,4 @@
<!-- OpenIDEA: unsorted column (stacked up / down carets) -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.5 6.5L8 4L10.5 6.5M5.5 9.5L8 12L10.5 9.5" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 285 B

+4
Переглянути файл
@@ -0,0 +1,4 @@
<!-- OpenIDEA: database trigger (bolt) -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.5 1.5L3.5 9H8L6.5 14.5L12.5 7H8L9.5 1.5Z" stroke="#6C707E" stroke-linejoin="round"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 243 B

+3
Переглянути файл
@@ -42,6 +42,9 @@
"--link": "#5cc8bd",
"--link-hover": "#8adcd3",
// Charts
"--chart-series": "#e07a7c",
// Status
"--danger": "#f57e84",
"--warning": "#d59637",