* feat: add query_history table migration (v5) (Task 1) * feat: add isDestructiveQuery utility (Task 2) * feat: add tabType discriminator and openQueryTab to dbViewerStore (Task 3) * feat: add execute_query command with pagination and query history (Task 4) * feat: add typed wrappers for executeQuery, getQueryHistory, clearQueryHistory (Task 5) * fix: global search bypasses folder scope when filters active (Task 6) * feat: add TagFilterDropdown with checkboxes and empty state (Task 7) * feat: add DbTypeFilterDropdown with checkboxes and clear all (Task 8) * feat: wire TagFilterDropdown/DbTypeFilterDropdown into ActionRow, add inline tag creation (Task 9) * feat: add Name input to GeneralTab for connection editing (Task 10) * feat: add QueryEditor Monaco wrapper with SQL mode and Cmd+Enter (Task 11) * feat: add DestructiveQueryDialog with SQL preview and confirmation (Task 12) * feat: integrate query tabs, Monaco editor, destructive guard into DbViewerScreen (Task 13) * fix: harden moveConnection against race conditions on rapid drags (Task 14) * docs: update AGENTS.md implementation status for Home Screen UX + Query Editor (Task 15) * feat: switch tag filter to OR semantics, add environment filter (F-T16) * feat: add activeEnvironment filter state to uiStore and useFilteredConnections (F-T17) * feat: add environment filter select to Filters dropdown (F-T18) * feat: filter folder cards by tag match or contained connections (F-T19) * fix: keep grid header width to content, border last column * fix: hide select-all checkbox and empty-state when no table open * fix: filter folder cards by any active filter, show global search results (F-T20) * feat: show 'Showing Search Results' breadcrumb with clear button (F-T21) * docs: update README + AGENTS.md for Query Editor, filters, and planned AI integration (BYOK) * feat: refresh indicator with spinning icon and pulse, defer auto-refresh on tab switch * feat: smart default schema selection, refresh schemas on database switch * fix: auto-refresh waits for in-flight refresh to complete before next tick * style: shrink db viewer sidebar nav icons from 20px to 16px * style: shrink db viewer sidebar nav buttons to 32px (8px padding) * style: make Tables panel title xs, regular weight, muted * style: bump Tables panel title back to sm, keep regular weight and muted * feat: export schema diagram as PNG/JPEG/SVG (entire schema or viewport) * chore: lockfile for html-to-image * fix: raise schema visualizer toolbar above legend so export menu isn't hidden * feat: schema export via save dialog, transparent background option, save notification * fix: render nothing in tab bar when no tabs are open * style: reduce tab bar height from 40px to 36px * style: reduce tab bar height to 32px * style: revert tab bar height to 36px * feat: split tab bar with fixed +Query and Changes actions on the right * style: blue play-icon Query button in tab bar * refactor: remove sidebar New Query button (now in tab bar) * style: conditional bottom padding in sidebar toolbar when nothing is below * feat: distinguish table and query tabs with icons * style: tab icons follow active/inactive state, muted colors * feat: query tab toolbar (run/format/dialect badge) + bare transparent editor * style: blue rounded Run Query button in query toolbar * feat: smart platform-aware shortcut tooltip on Run Query (⌘+⏎ / Ctrl+Enter) * style: show only the shortcut in the Run Query tooltip * fix: Cmd+Enter keybinding stale closure; add run pulse to query toolbar; bundle monaco locally (offline) * feat: show placeholder text in empty query editor * feat: SQL autocomplete — keywords + table names from active schema * feat: per-table column autocomplete on 'table.' + docs update * feat: query-variant result toolbar — export/refresh/columns left, smart-unit execution time right * fix: populate execution_time_ms on query results so the toolbar can show time taken * fix: re-measure monaco fonts after async font load to stop cursor drift * feat: resizable + collapsible query results panel * refactor: move results caret onto the resize handle (centered), bottom caret when collapsed * style: thin drag strip with caret on its own centered pill * refactor: remove Queue button from table toolbar (Changes lives in tab bar) * style: changes button becomes bordered rounded icon with count badge * docs: mark tab-bar Changes queue button in AGENTS.md and README
819 lines
31 KiB
Rust
819 lines
31 KiB
Rust
pub mod migrations;
|
|
|
|
use rusqlite::params;
|
|
use rusqlite::Connection as SqliteConnection;
|
|
use std::collections::HashMap;
|
|
use std::sync::Mutex;
|
|
|
|
use crate::models::{Connection, ConnectionInput, Folder, FolderInput, Settings, Tag, TagInput};
|
|
|
|
pub struct Store {
|
|
conn: Mutex<SqliteConnection>,
|
|
}
|
|
|
|
impl Store {
|
|
pub fn from_connection(conn: SqliteConnection) -> Self {
|
|
Self {
|
|
conn: Mutex::new(conn),
|
|
}
|
|
}
|
|
|
|
pub fn open(path: &str) -> Result<Self, String> {
|
|
let conn = SqliteConnection::open(path).map_err(|e| e.to_string())?;
|
|
migrations::run_migrations(&conn).map_err(|e| e.to_string())?;
|
|
Ok(Self::from_connection(conn))
|
|
}
|
|
|
|
fn now() -> String {
|
|
chrono::Utc::now().to_rfc3339()
|
|
}
|
|
|
|
pub fn get_folders(&self) -> Result<Vec<Folder>, String> {
|
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
|
let mut stmt = conn
|
|
.prepare("SELECT id, name, parent_id, created_at, updated_at FROM folders ORDER BY name")
|
|
.map_err(|e| e.to_string())?;
|
|
let rows = stmt
|
|
.query_map([], |row| {
|
|
Ok(Folder {
|
|
id: row.get(0)?,
|
|
name: row.get(1)?,
|
|
parent_id: row.get(2)?,
|
|
tag_ids: vec![],
|
|
created_at: row.get(3)?,
|
|
updated_at: row.get(4)?,
|
|
})
|
|
})
|
|
.map_err(|e| e.to_string())?;
|
|
let mut folders: Vec<Folder> = rows.filter_map(|r| r.ok()).collect();
|
|
// Load tags for each folder
|
|
for f in folders.iter_mut() {
|
|
let mut tag_stmt = conn
|
|
.prepare("SELECT tag_id FROM folder_tags WHERE folder_id = ?1")
|
|
.map_err(|e| e.to_string())?;
|
|
let tag_rows = tag_stmt
|
|
.query_map(params![f.id], |row| row.get::<_, String>(0))
|
|
.map_err(|e| e.to_string())?;
|
|
f.tag_ids = tag_rows.filter_map(|r| r.ok()).collect();
|
|
}
|
|
Ok(folders)
|
|
}
|
|
|
|
pub fn create_folder(&self, input: FolderInput) -> Result<Folder, String> {
|
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
|
let id = uuid::Uuid::new_v4().to_string();
|
|
let now = Self::now();
|
|
conn.execute(
|
|
"INSERT INTO folders (id, name, parent_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
params![id, input.name, input.parent_id, now, now],
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
let tag_ids = input.tag_ids.unwrap_or_default();
|
|
for tag_id in &tag_ids {
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO folder_tags (folder_id, tag_id) VALUES (?1, ?2)",
|
|
params![id, tag_id],
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
}
|
|
Ok(Folder {
|
|
id,
|
|
name: input.name,
|
|
parent_id: input.parent_id,
|
|
tag_ids,
|
|
created_at: now.clone(),
|
|
updated_at: now,
|
|
})
|
|
}
|
|
|
|
pub fn update_folder(&self, id: &str, input: FolderInput) -> Result<Folder, String> {
|
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
|
let now = Self::now();
|
|
conn.execute(
|
|
"UPDATE folders SET name = ?1, parent_id = ?2, updated_at = ?3 WHERE id = ?4",
|
|
params![input.name, input.parent_id, now, id],
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
// Replace all tags: clear existing, insert new
|
|
conn.execute("DELETE FROM folder_tags WHERE folder_id = ?1", params![id])
|
|
.map_err(|e| e.to_string())?;
|
|
let tag_ids = input.tag_ids.unwrap_or_default();
|
|
for tag_id in &tag_ids {
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO folder_tags (folder_id, tag_id) VALUES (?1, ?2)",
|
|
params![id, tag_id],
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
}
|
|
Ok(Folder {
|
|
id: id.to_string(),
|
|
name: input.name,
|
|
parent_id: input.parent_id,
|
|
tag_ids,
|
|
created_at: now.clone(),
|
|
updated_at: now,
|
|
})
|
|
}
|
|
|
|
pub fn delete_folder(&self, id: &str) -> Result<(), String> {
|
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
|
// Get the folder's parent_id to reparent children
|
|
let parent_id: Option<String> = conn
|
|
.query_row(
|
|
"SELECT parent_id FROM folders WHERE id = ?1",
|
|
params![id],
|
|
|row| row.get(0),
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
// Move child folders to the parent
|
|
conn.execute(
|
|
"UPDATE folders SET parent_id = ?1 WHERE parent_id = ?2",
|
|
params![parent_id, id],
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
// Move child connections to the parent
|
|
conn.execute(
|
|
"UPDATE connections SET folder_id = ?1 WHERE folder_id = ?2",
|
|
params![parent_id, id],
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
// Delete the folder
|
|
conn.execute("DELETE FROM folders WHERE id = ?1", params![id])
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn add_folder_tags(&self, folder_id: &str, tag_ids: &[String]) -> Result<(), String> {
|
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
|
for tag_id in tag_ids {
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO folder_tags (folder_id, tag_id) VALUES (?1, ?2)",
|
|
params![folder_id, tag_id],
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn add_connection_tags(&self, conn_id: &str, tag_ids: &[String]) -> Result<(), String> {
|
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
|
for tag_id in tag_ids {
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO connection_tags (connection_id, tag_id) VALUES (?1, ?2)",
|
|
params![conn_id, tag_id],
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn get_tags(&self) -> Result<Vec<Tag>, String> {
|
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
|
let mut stmt = conn
|
|
.prepare("SELECT id, name, color, created_at FROM tags ORDER BY name")
|
|
.map_err(|e| e.to_string())?;
|
|
let rows = stmt
|
|
.query_map([], |row| {
|
|
Ok(Tag {
|
|
id: row.get(0)?,
|
|
name: row.get(1)?,
|
|
color: row.get(2)?,
|
|
created_at: row.get(3)?,
|
|
})
|
|
})
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(rows.filter_map(|r| r.ok()).collect())
|
|
}
|
|
|
|
pub fn create_tag(&self, input: TagInput) -> Result<Tag, String> {
|
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
|
let id = uuid::Uuid::new_v4().to_string();
|
|
let now = Self::now();
|
|
conn.execute(
|
|
"INSERT INTO tags (id, name, color, created_at) VALUES (?1, ?2, ?3, ?4)",
|
|
params![id, input.name, input.color, now],
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(Tag {
|
|
id,
|
|
name: input.name,
|
|
color: input.color,
|
|
created_at: now,
|
|
})
|
|
}
|
|
|
|
pub fn delete_tag(&self, id: &str) -> Result<(), String> {
|
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
|
conn.execute("DELETE FROM tags WHERE id = ?1", params![id])
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn update_tag(&self, id: &str, input: TagInput) -> Result<Tag, String> {
|
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
|
let now = Self::now();
|
|
conn.execute(
|
|
"UPDATE tags SET name = ?1, color = ?2 WHERE id = ?3",
|
|
params![input.name, input.color, id],
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(Tag {
|
|
id: id.to_string(),
|
|
name: input.name,
|
|
color: input.color,
|
|
created_at: now,
|
|
})
|
|
}
|
|
|
|
pub fn get_connections(&self) -> Result<Vec<Connection>, String> {
|
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
|
let mut stmt = conn
|
|
.prepare(
|
|
"SELECT id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, created_at, updated_at FROM connections ORDER BY name",
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
let rows = stmt
|
|
.query_map([], |row| {
|
|
Ok(Connection {
|
|
id: row.get(0)?,
|
|
name: row.get(1)?,
|
|
db_type: row.get(2)?,
|
|
host: row.get(3)?,
|
|
port: row.get(4)?,
|
|
username: row.get(5)?,
|
|
database: row.get(6)?,
|
|
folder_id: row.get(7)?,
|
|
keychain_ref: row.get(8)?,
|
|
ssh_host: row.get(9)?,
|
|
ssh_port: row.get(10)?,
|
|
ssh_user: row.get(11)?,
|
|
ssh_auth_method: row.get(12)?,
|
|
ssh_private_key_path: row.get(13)?,
|
|
ssl_mode: row.get(14)?,
|
|
ssl_ca_path: row.get(15)?,
|
|
ssl_cert_path: row.get(16)?,
|
|
ssl_key_path: row.get(17)?,
|
|
environment: row.get(18)?,
|
|
tag_ids: vec![],
|
|
created_at: row.get(19)?,
|
|
updated_at: row.get(20)?,
|
|
})
|
|
})
|
|
.map_err(|e| e.to_string())?;
|
|
let mut conns: Vec<Connection> = rows.filter_map(|r| r.ok()).collect();
|
|
// Load tags for each connection
|
|
for c in conns.iter_mut() {
|
|
let mut tag_stmt = conn
|
|
.prepare("SELECT tag_id FROM connection_tags WHERE connection_id = ?1")
|
|
.map_err(|e| e.to_string())?;
|
|
let tag_rows = tag_stmt
|
|
.query_map(params![c.id], |row| row.get::<_, String>(0))
|
|
.map_err(|e| e.to_string())?;
|
|
c.tag_ids = tag_rows.filter_map(|r| r.ok()).collect();
|
|
}
|
|
Ok(conns)
|
|
}
|
|
|
|
pub fn create_connection(&self, input: ConnectionInput) -> Result<Connection, String> {
|
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
|
let id = uuid::Uuid::new_v4().to_string();
|
|
let now = Self::now();
|
|
conn.execute(
|
|
"INSERT INTO connections (id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
|
|
params![id, input.name, input.db_type, input.host, input.port, input.username, input.database, input.folder_id, input.ssh_host, input.ssh_port, input.ssh_user, input.ssh_auth_method, input.ssh_private_key_path, input.ssl_mode, input.ssl_ca_path, input.ssl_cert_path, input.ssl_key_path, input.environment, now, now],
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
for tag_id in &input.tag_ids {
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO connection_tags (connection_id, tag_id) VALUES (?1, ?2)",
|
|
params![id, tag_id],
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
}
|
|
Ok(Connection {
|
|
id,
|
|
name: input.name,
|
|
db_type: input.db_type,
|
|
host: input.host,
|
|
port: input.port,
|
|
username: input.username,
|
|
folder_id: input.folder_id,
|
|
database: input.database,
|
|
keychain_ref: None,
|
|
environment: input.environment,
|
|
ssh_host: input.ssh_host,
|
|
ssh_port: input.ssh_port,
|
|
ssh_user: input.ssh_user,
|
|
ssh_auth_method: input.ssh_auth_method,
|
|
ssh_private_key_path: input.ssh_private_key_path,
|
|
ssl_mode: input.ssl_mode,
|
|
ssl_ca_path: input.ssl_ca_path,
|
|
ssl_cert_path: input.ssl_cert_path,
|
|
ssl_key_path: input.ssl_key_path,
|
|
tag_ids: input.tag_ids,
|
|
created_at: now.clone(),
|
|
updated_at: now,
|
|
})
|
|
}
|
|
|
|
pub fn delete_connection(&self, id: &str) -> Result<(), String> {
|
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
|
conn.execute("DELETE FROM connections WHERE id = ?1", params![id])
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn update_connection(&self, id: &str, input: ConnectionInput) -> Result<Connection, String> {
|
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
|
let now = Self::now();
|
|
conn.execute(
|
|
"UPDATE connections SET name=?1, db_type=?2, host=?3, port=?4, username=?5, database=?6, folder_id=?7, ssh_host=?8, ssh_port=?9, ssh_user=?10, ssh_auth_method=?11, ssh_private_key_path=?12, ssl_mode=?13, ssl_ca_path=?14, ssl_cert_path=?15, ssl_key_path=?16, environment=?17, updated_at=?18 WHERE id=?19",
|
|
params![
|
|
input.name, input.db_type, input.host, input.port, input.username,
|
|
input.database, input.folder_id, input.ssh_host, input.ssh_port,
|
|
input.ssh_user, input.ssh_auth_method, input.ssh_private_key_path,
|
|
input.ssl_mode, input.ssl_ca_path, input.ssl_cert_path, input.ssl_key_path,
|
|
input.environment, now, id
|
|
],
|
|
).map_err(|e| e.to_string())?;
|
|
// Update tags
|
|
conn.execute("DELETE FROM connection_tags WHERE connection_id = ?1", params![id])
|
|
.map_err(|e| e.to_string())?;
|
|
for tag_id in &input.tag_ids {
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO connection_tags (connection_id, tag_id) VALUES (?1, ?2)",
|
|
params![id, tag_id],
|
|
).map_err(|e| e.to_string())?;
|
|
}
|
|
Ok(Connection {
|
|
id: id.to_string(),
|
|
name: input.name,
|
|
db_type: input.db_type,
|
|
host: input.host,
|
|
port: input.port,
|
|
username: input.username,
|
|
database: input.database,
|
|
folder_id: input.folder_id,
|
|
keychain_ref: None,
|
|
environment: input.environment,
|
|
ssh_host: input.ssh_host,
|
|
ssh_port: input.ssh_port,
|
|
ssh_user: input.ssh_user,
|
|
ssh_auth_method: input.ssh_auth_method,
|
|
ssh_private_key_path: input.ssh_private_key_path,
|
|
ssl_mode: input.ssl_mode,
|
|
ssl_ca_path: input.ssl_ca_path,
|
|
ssl_cert_path: input.ssl_cert_path,
|
|
ssl_key_path: input.ssl_key_path,
|
|
tag_ids: input.tag_ids.clone(),
|
|
created_at: String::new(), // not updated
|
|
updated_at: now,
|
|
})
|
|
}
|
|
|
|
pub fn get_settings(&self) -> Result<Settings, String> {
|
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
|
let mut map: HashMap<String, String> = HashMap::new();
|
|
let mut stmt = conn
|
|
.prepare("SELECT key, value FROM settings")
|
|
.map_err(|e| e.to_string())?;
|
|
let rows = stmt
|
|
.query_map([], |row| {
|
|
Ok((
|
|
row.get::<_, String>(0)?,
|
|
row.get::<_, String>(1)?,
|
|
))
|
|
})
|
|
.map_err(|e| e.to_string())?;
|
|
for r in rows.filter_map(|r| r.ok()) {
|
|
map.insert(r.0, r.1);
|
|
}
|
|
let theme = map
|
|
.get("theme")
|
|
.cloned()
|
|
.unwrap_or_else(|| "system".to_string());
|
|
let font_size = map
|
|
.get("font_size")
|
|
.cloned()
|
|
.unwrap_or_else(|| "medium".to_string());
|
|
let confirm = map
|
|
.get("confirm_before_delete")
|
|
.map(|v| v == "true")
|
|
.unwrap_or(true);
|
|
let default_folder_id = map
|
|
.get("default_folder_id")
|
|
.filter(|v| v.as_str() != "null")
|
|
.cloned();
|
|
let mut default_ports = HashMap::new();
|
|
default_ports.insert("postgresql".to_string(), Some(5432i64));
|
|
default_ports.insert("mysql".to_string(), Some(3306i64));
|
|
default_ports.insert("redis".to_string(), Some(6379i64));
|
|
default_ports.insert("sqlite".to_string(), None);
|
|
if let Some(ports_json) = map.get("default_ports") {
|
|
if let Ok(parsed) =
|
|
serde_json::from_str::<HashMap<String, Option<i64>>>(ports_json)
|
|
{
|
|
default_ports = parsed;
|
|
}
|
|
}
|
|
Ok(Settings {
|
|
confirm_before_delete: confirm,
|
|
default_folder_id,
|
|
theme,
|
|
font_size,
|
|
default_ports,
|
|
tag_order: map.get("tag_order").cloned(),
|
|
table_refresh_rate: map
|
|
.get("table_refresh_rate")
|
|
.and_then(|v| v.parse().ok())
|
|
.unwrap_or(0),
|
|
table_page_size: map
|
|
.get("table_page_size")
|
|
.and_then(|v| v.parse().ok())
|
|
.unwrap_or(50),
|
|
shortcuts: map
|
|
.get("shortcuts")
|
|
.and_then(|v| serde_json::from_str(v).ok())
|
|
.unwrap_or_default(),
|
|
})
|
|
}
|
|
|
|
pub fn update_setting(&self, key: &str, value: &str) -> Result<(), String> {
|
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
|
conn.execute(
|
|
"INSERT INTO settings (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
params![key, value],
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Insert a row into the `query_history` table.
|
|
pub fn insert_query_history(
|
|
&self,
|
|
id: &str,
|
|
connection_id: &str,
|
|
query_text: &str,
|
|
execution_time_ms: Option<i64>,
|
|
row_count: Option<i64>,
|
|
status: &str,
|
|
error_message: Option<&str>,
|
|
) -> Result<(), String> {
|
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
|
let now = Self::now();
|
|
conn.execute(
|
|
"INSERT INTO query_history (id, connection_id, query_text, execution_time_ms, row_count, status, error_message, executed_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
|
params![id, connection_id, query_text, execution_time_ms, row_count, status, error_message, now],
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Fetch query history rows, optionally filtered by `connection_id`.
|
|
/// Returns results ordered by `executed_at DESC`.
|
|
pub fn get_query_history(
|
|
&self,
|
|
connection_id: Option<&str>,
|
|
limit: i64,
|
|
offset: i64,
|
|
) -> Result<Vec<crate::commands::query::QueryHistoryEntry>, String> {
|
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
|
let (sql, params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
|
|
if let Some(cid) = connection_id {
|
|
(
|
|
"SELECT id, connection_id, query_text, execution_time_ms, row_count, status, error_message, executed_at FROM query_history WHERE connection_id = ?1 ORDER BY executed_at DESC LIMIT ?2 OFFSET ?3".to_string(),
|
|
vec![Box::new(cid.to_string()), Box::new(limit), Box::new(offset)],
|
|
)
|
|
} else {
|
|
(
|
|
"SELECT id, connection_id, query_text, execution_time_ms, row_count, status, error_message, executed_at FROM query_history ORDER BY executed_at DESC LIMIT ?1 OFFSET ?2".to_string(),
|
|
vec![Box::new(limit), Box::new(offset)],
|
|
)
|
|
};
|
|
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
|
|
let refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
|
let rows = stmt
|
|
.query_map(rusqlite::params_from_iter(&refs), |row| {
|
|
Ok(crate::commands::query::QueryHistoryEntry {
|
|
id: row.get(0)?,
|
|
connection_id: row.get(1)?,
|
|
query_text: row.get(2)?,
|
|
execution_time_ms: row.get(3)?,
|
|
row_count: row.get(4)?,
|
|
status: row.get(5)?,
|
|
error_message: row.get(6)?,
|
|
executed_at: row.get(7)?,
|
|
})
|
|
})
|
|
.map_err(|e| e.to_string())?;
|
|
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Delete all query history rows, optionally filtered by `connection_id`.
|
|
pub fn clear_query_history(&self, connection_id: Option<&str>) -> Result<(), String> {
|
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
|
if let Some(cid) = connection_id {
|
|
conn.execute(
|
|
"DELETE FROM query_history WHERE connection_id = ?1",
|
|
params![cid],
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
} else {
|
|
conn.execute("DELETE FROM query_history", [])
|
|
.map_err(|e| e.to_string())?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::models::{ConnectionInput, FolderInput, TagInput};
|
|
|
|
fn fresh_store() -> Store {
|
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
|
crate::store::migrations::run_migrations(&conn).unwrap();
|
|
Store::from_connection(conn)
|
|
}
|
|
|
|
#[test]
|
|
fn create_and_get_folder() {
|
|
let store = fresh_store();
|
|
let folder = store
|
|
.create_folder(FolderInput { tag_ids: None,
|
|
name: "Work".into(),
|
|
parent_id: None,
|
|
})
|
|
.unwrap();
|
|
let got = store.get_folders().unwrap();
|
|
assert_eq!(got.len(), 1);
|
|
assert_eq!(got[0].name, "Work");
|
|
assert_eq!(got[0].id, folder.id);
|
|
assert!(got[0].parent_id.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn create_nested_folders() {
|
|
let store = fresh_store();
|
|
let parent = store
|
|
.create_folder(FolderInput { tag_ids: None,
|
|
name: "root".into(),
|
|
parent_id: None,
|
|
})
|
|
.unwrap();
|
|
let child = store
|
|
.create_folder(FolderInput { tag_ids: None,
|
|
name: "child".into(),
|
|
parent_id: Some(parent.id.clone()),
|
|
})
|
|
.unwrap();
|
|
assert_eq!(child.parent_id, Some(parent.id));
|
|
}
|
|
|
|
#[test]
|
|
fn create_and_get_tag() {
|
|
let store = fresh_store();
|
|
let tag = store
|
|
.create_tag(TagInput {
|
|
name: "production".into(),
|
|
color: "#ef4444".into(),
|
|
})
|
|
.unwrap();
|
|
let got = store.get_tags().unwrap();
|
|
assert_eq!(got.len(), 1);
|
|
assert_eq!(got[0].name, "production");
|
|
assert_eq!(got[0].color, "#ef4444");
|
|
assert_eq!(got[0].id, tag.id);
|
|
}
|
|
|
|
#[test]
|
|
fn create_and_get_connection() {
|
|
let store = fresh_store();
|
|
let conn = store
|
|
.create_connection(ConnectionInput {
|
|
name: "Prod".into(),
|
|
db_type: "postgresql".into(),
|
|
host: "prod.example.com".into(),
|
|
port: Some(5432),
|
|
username: Some("admin".into()),
|
|
folder_id: None,
|
|
password: None,
|
|
database: None,
|
|
ssh_host: None,
|
|
ssh_port: None,
|
|
ssh_user: None,
|
|
ssh_auth_method: None,
|
|
ssh_private_key_path: None,
|
|
ssh_passphrase: None,
|
|
ssl_mode: None,
|
|
ssl_ca_path: None,
|
|
ssl_cert_path: None,
|
|
ssl_key_path: None,
|
|
environment: None,
|
|
tag_ids: vec![],
|
|
})
|
|
.unwrap();
|
|
let got = store.get_connections().unwrap();
|
|
assert_eq!(got.len(), 1);
|
|
assert_eq!(got[0].name, "Prod");
|
|
assert_eq!(got[0].port, Some(5432));
|
|
assert!(got[0].tag_ids.is_empty());
|
|
assert_eq!(got[0].id, conn.id);
|
|
}
|
|
|
|
#[test]
|
|
fn connection_with_tags_persists_join() {
|
|
let store = fresh_store();
|
|
let t1 = store
|
|
.create_tag(TagInput {
|
|
name: "prod".into(),
|
|
color: "#ef4444".into(),
|
|
})
|
|
.unwrap();
|
|
let t2 = store
|
|
.create_tag(TagInput {
|
|
name: "primary".into(),
|
|
color: "#3b82f6".into(),
|
|
})
|
|
.unwrap();
|
|
store
|
|
.create_connection(ConnectionInput {
|
|
name: "Prod".into(),
|
|
db_type: "postgresql".into(),
|
|
host: "h".into(),
|
|
port: Some(5432),
|
|
username: None,
|
|
folder_id: None,
|
|
password: None,
|
|
database: None,
|
|
ssh_host: None,
|
|
ssh_port: None,
|
|
ssh_user: None,
|
|
ssh_auth_method: None,
|
|
ssh_private_key_path: None,
|
|
ssh_passphrase: None,
|
|
ssl_mode: None,
|
|
ssl_ca_path: None,
|
|
ssl_cert_path: None,
|
|
ssl_key_path: None,
|
|
environment: None,
|
|
tag_ids: vec![t1.id.clone(), t2.id.clone()],
|
|
})
|
|
.unwrap();
|
|
let got = store.get_connections().unwrap();
|
|
assert_eq!(got[0].tag_ids.len(), 2);
|
|
assert!(got[0].tag_ids.contains(&t1.id));
|
|
assert!(got[0].tag_ids.contains(&t2.id));
|
|
}
|
|
|
|
#[test]
|
|
fn delete_folder_sets_connection_folder_null() {
|
|
let store = fresh_store();
|
|
let folder = store
|
|
.create_folder(FolderInput { tag_ids: None,
|
|
name: "f".into(),
|
|
parent_id: None,
|
|
})
|
|
.unwrap();
|
|
store
|
|
.create_connection(ConnectionInput {
|
|
name: "C".into(),
|
|
db_type: "postgresql".into(),
|
|
host: "h".into(),
|
|
port: Some(5432),
|
|
username: None,
|
|
folder_id: Some(folder.id.clone()),
|
|
password: None,
|
|
database: None,
|
|
ssh_host: None,
|
|
ssh_port: None,
|
|
ssh_user: None,
|
|
ssh_auth_method: None,
|
|
ssh_private_key_path: None,
|
|
ssh_passphrase: None,
|
|
ssl_mode: None,
|
|
ssl_ca_path: None,
|
|
ssl_cert_path: None,
|
|
ssl_key_path: None,
|
|
environment: None,
|
|
tag_ids: vec![],
|
|
})
|
|
.unwrap();
|
|
store.delete_folder(&folder.id).unwrap();
|
|
let conns = store.get_connections().unwrap();
|
|
assert!(conns[0].folder_id.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn delete_tag_removes_from_connection() {
|
|
let store = fresh_store();
|
|
let tag = store
|
|
.create_tag(TagInput {
|
|
name: "prod".into(),
|
|
color: "#ef4444".into(),
|
|
})
|
|
.unwrap();
|
|
store
|
|
.create_connection(ConnectionInput {
|
|
name: "C".into(),
|
|
db_type: "postgresql".into(),
|
|
host: "h".into(),
|
|
port: Some(5432),
|
|
username: None,
|
|
folder_id: None,
|
|
password: None,
|
|
database: None,
|
|
ssh_host: None,
|
|
ssh_port: None,
|
|
ssh_user: None,
|
|
ssh_auth_method: None,
|
|
ssh_private_key_path: None,
|
|
ssh_passphrase: None,
|
|
ssl_mode: None,
|
|
ssl_ca_path: None,
|
|
ssl_cert_path: None,
|
|
ssl_key_path: None,
|
|
environment: None,
|
|
tag_ids: vec![tag.id.clone()],
|
|
})
|
|
.unwrap();
|
|
store.delete_tag(&tag.id).unwrap();
|
|
let conns = store.get_connections().unwrap();
|
|
assert!(conns[0].tag_ids.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn settings_get_returns_defaults_when_empty() {
|
|
let store = fresh_store();
|
|
let settings = store.get_settings().unwrap();
|
|
assert_eq!(settings.theme, "system");
|
|
assert_eq!(settings.font_size, "medium");
|
|
assert!(settings.confirm_before_delete);
|
|
assert_eq!(
|
|
settings.default_ports.get("postgresql"),
|
|
Some(&Some(5432))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn settings_update_persists() {
|
|
let store = fresh_store();
|
|
store.update_setting("theme", "light").unwrap();
|
|
let settings = store.get_settings().unwrap();
|
|
assert_eq!(settings.theme, "light");
|
|
}
|
|
|
|
#[test]
|
|
fn ssh_ssl_fields_persist_and_retrieve() {
|
|
let store = fresh_store();
|
|
let _conn = store
|
|
.create_connection(ConnectionInput {
|
|
name: "SSH-Tunnel-DB".into(),
|
|
db_type: "postgresql".into(),
|
|
host: "localhost".into(),
|
|
port: Some(5432),
|
|
username: Some("dbuser".into()),
|
|
folder_id: None,
|
|
password: None,
|
|
database: Some("analytics".into()),
|
|
ssh_host: Some("jumphost.example.com".into()),
|
|
ssh_port: Some(2222),
|
|
ssh_user: Some("tunneluser".into()),
|
|
ssh_auth_method: Some("Key".into()),
|
|
ssh_private_key_path: Some("/home/user/.ssh/id_rsa".into()),
|
|
ssh_passphrase: None,
|
|
ssl_mode: Some("verify-full".into()),
|
|
ssl_ca_path: Some("/etc/ssl/certs/ca.pem".into()),
|
|
ssl_cert_path: Some("/etc/ssl/certs/client-cert.pem".into()),
|
|
ssl_key_path: Some("/etc/ssl/private/client-key.pem".into()),
|
|
environment: None,
|
|
tag_ids: vec![],
|
|
})
|
|
.unwrap();
|
|
let got = store.get_connections().unwrap();
|
|
assert_eq!(got.len(), 1);
|
|
assert_eq!(got[0].database.as_deref(), Some("analytics"));
|
|
assert_eq!(got[0].ssh_host.as_deref(), Some("jumphost.example.com"));
|
|
assert_eq!(got[0].ssh_port, Some(2222));
|
|
assert_eq!(got[0].ssh_user.as_deref(), Some("tunneluser"));
|
|
assert_eq!(got[0].ssh_auth_method.as_deref(), Some("Key"));
|
|
assert_eq!(
|
|
got[0].ssh_private_key_path.as_deref(),
|
|
Some("/home/user/.ssh/id_rsa")
|
|
);
|
|
assert_eq!(got[0].ssl_mode.as_deref(), Some("verify-full"));
|
|
assert_eq!(
|
|
got[0].ssl_ca_path.as_deref(),
|
|
Some("/etc/ssl/certs/ca.pem")
|
|
);
|
|
assert_eq!(
|
|
got[0].ssl_cert_path.as_deref(),
|
|
Some("/etc/ssl/certs/client-cert.pem")
|
|
);
|
|
assert_eq!(
|
|
got[0].ssl_key_path.as_deref(),
|
|
Some("/etc/ssl/private/client-key.pem")
|
|
);
|
|
}
|
|
} |