feat: Query history + saved queries (#5)

* fix: restore frontend test baseline (vitest jsdom env + tsc + mock fixes)

* feat: v6 migration — favorite column + queries table (Task 1)

* feat: extend TS types for favorites + saved queries (Task 2)

* feat: dedup consecutive + prune to 500 in insert_query_history (Task 3)

* feat: favorite column threading + set_history_favorite command (Task 4)

* feat: saved queries store CRUD (Task 5)

* feat: saved query commands + registration (Task 6)

* feat: queryStore — Zustand cache for history + saved queries (Task 7)

* feat: QueryHistoryDropdown — toolbar history dropdown (Task 8)

* feat: SaveQueryDialog — save query modal (Task 9)

* feat: QueryToolbar — add History + Save icons (Task 10)

* feat: QueriesPanel — History + Saved tabs (Task 11)

* feat: wire Queries view + toolbar to panel (Task 12)

* fix: global scope wrappers, empty/spinner states, cache invalidation (Task 13)

* feat: Queries view — history sidebar + tabbed query workspace (PR feedback)

* fix: toolbar action order + view-specific empty state (PR feedback)

* fix: view-specific empty state icon (PR feedback)

* fix: portal Tooltip to body so it is never clipped by overflow containers (PR feedback)

* feat: Queries sidebar — Explorer-style header + per-connection scoping (PR feedback)

* fix: move History/Saved dropdown to right side of Queries header (PR feedback)

* fix: History/Saved dropdown on its own row in Queries header (PR feedback)

* fix: Queries header order (search above dropdown) + sidebar width matches Explorer (PR feedback)

* fix: Queries header spacing — tight rows, pb-3 on container (PR feedback)

* fix: Queries header spacing — space-y-2 on container, no mb on title row (PR feedback)

* feat: history/saved rows click-to-load, remove sub-buttons (PR feedback)

* feat: merge Functions/Triggers/Sequences/Enums/Extensions into single Objects view (PR feedback)

* fix: Schema Visualizer nav icon — node graph glyph (PR feedback)

* fix: Objects view — type dropdown replaces static header label (PR feedback)

* fix: Objects empty state — remove bg circle, larger icon (PR feedback)

* fix: center icon in Objects empty state (PR feedback)

* feat: merge Backup/Restore/DB Sync into single Tools view (PR feedback)

* docs: update AGENTS.md + README for query history/saved queries, merged Objects + Tools views
This commit is contained in:
2026-08-01 17:43:40 +08:00
committed by GitHub
parent 80962d7d11
commit 7db66f1160
33 changed files with 3069 additions and 192 deletions
+211
View File
@@ -32,6 +32,46 @@ pub struct QueryHistoryEntry {
pub status: String,
pub error_message: Option<String>,
pub executed_at: String,
pub favorite: bool, // NEW — v6
}
// ---------------------------------------------------------------------------
// SavedQueryCommand
// ---------------------------------------------------------------------------
/// A saved query, returned to the frontend.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SavedQueryCommand {
pub id: String,
pub connection_id: Option<String>,
pub name: String,
pub query_text: String,
pub folder: String,
pub created_at: String,
pub updated_at: String,
}
/// Patch body for `update_saved_query` — all fields optional.
#[derive(Debug, Deserialize)]
pub struct UpdateSavedQueryPatch {
pub name: Option<String>,
#[serde(rename = "queryText")]
pub query_text: Option<String>,
pub folder: Option<String>,
}
impl From<crate::store::SavedQueryRow> for SavedQueryCommand {
fn from(r: crate::store::SavedQueryRow) -> Self {
Self {
id: r.id,
connection_id: r.connection_id,
name: r.name,
query_text: r.query_text,
folder: r.folder,
created_at: r.created_at,
updated_at: r.updated_at,
}
}
}
// ---------------------------------------------------------------------------
@@ -494,6 +534,57 @@ pub(crate) fn clear_query_history_inner(
store.clear_query_history(connection_id)
}
pub(crate) fn set_history_favorite_inner(
db_store: &std::sync::Mutex<crate::store::Store>,
id: &str,
connection_id: &str,
) -> Result<(), String> {
let store = db_store.lock().map_err(|e| e.to_string())?;
store.set_history_favorite(id, connection_id)
}
pub(crate) fn save_query_inner(
db_store: &std::sync::Mutex<crate::store::Store>,
connection_id: Option<String>,
name: String,
query_text: String,
folder: String,
) -> Result<SavedQueryCommand, String> {
let store = db_store.lock().map_err(|e| e.to_string())?;
store
.save_query(connection_id.as_deref(), &name, &query_text, &folder)
.map(SavedQueryCommand::from)
}
pub(crate) fn get_saved_queries_inner(
db_store: &std::sync::Mutex<crate::store::Store>,
connection_id: Option<String>,
) -> Result<Vec<SavedQueryCommand>, String> {
let store = db_store.lock().map_err(|e| e.to_string())?;
store
.list_saved_queries(connection_id.as_deref())
.map(|rows| rows.into_iter().map(SavedQueryCommand::from).collect())
}
pub(crate) fn update_saved_query_inner(
db_store: &std::sync::Mutex<crate::store::Store>,
id: String,
name: Option<String>,
query_text: Option<String>,
folder: Option<String>,
) -> Result<(), String> {
let store = db_store.lock().map_err(|e| e.to_string())?;
store.update_saved_query(&id, name.as_deref(), query_text.as_deref(), folder.as_deref())
}
pub(crate) fn delete_saved_query_inner(
db_store: &std::sync::Mutex<crate::store::Store>,
id: String,
) -> Result<(), String> {
let store = db_store.lock().map_err(|e| e.to_string())?;
store.delete_saved_query(&id)
}
// ---------------------------------------------------------------------------
// Tauri commands
// ---------------------------------------------------------------------------
@@ -534,6 +625,57 @@ pub async fn clear_query_history(
store.clear_query_history(connection_id.as_deref())
}
#[tauri::command]
pub async fn set_history_favorite(
id: String,
connection_id: String,
state: State<'_, crate::AppState>,
) -> Result<(), String> {
set_history_favorite_inner(&state.db_store, &id, &connection_id)
}
#[tauri::command]
pub async fn save_query(
connection_id: Option<String>,
name: String,
query_text: String,
folder: Option<String>,
state: State<'_, crate::AppState>,
) -> Result<SavedQueryCommand, String> {
save_query_inner(
&state.db_store,
connection_id,
name,
query_text,
folder.unwrap_or_default(),
)
}
#[tauri::command]
pub async fn get_saved_queries(
connection_id: Option<String>,
state: State<'_, crate::AppState>,
) -> Result<Vec<SavedQueryCommand>, String> {
get_saved_queries_inner(&state.db_store, connection_id)
}
#[tauri::command]
pub async fn update_saved_query(
id: String,
patch: UpdateSavedQueryPatch,
state: State<'_, crate::AppState>,
) -> Result<(), String> {
update_saved_query_inner(&state.db_store, id, patch.name, patch.query_text, patch.folder)
}
#[tauri::command]
pub async fn delete_saved_query(
id: String,
state: State<'_, crate::AppState>,
) -> Result<(), String> {
delete_saved_query_inner(&state.db_store, id)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -735,6 +877,75 @@ mod tests {
}
}
// ------------------------------------------------------------------
// Test 5: Saved query command roundtrip (save → list → update → delete)
// ------------------------------------------------------------------
#[test]
fn save_query_command_roundtrip() {
use crate::models::ConnectionInput;
let conn = SqliteConnection::open_in_memory().unwrap();
crate::store::migrations::run_migrations(&conn).unwrap();
let store = Mutex::new(Store::from_connection(conn));
let sc2 = store
.lock()
.unwrap()
.create_connection(ConnectionInput {
name: "sc2".into(),
db_type: "postgresql".into(),
host: "h".into(),
port: Some(5432),
username: None,
folder_id: None,
tag_ids: vec![],
password: None,
database: None,
environment: 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,
})
.unwrap();
// save
let result = save_query_inner(
&store,
Some(sc2.id.clone()),
"Q1".to_string(),
"SELECT 1".to_string(),
"r".to_string(),
)
.unwrap();
assert_eq!(result.name, "Q1");
assert_eq!(result.connection_id, Some(sc2.id.clone()));
// list
let list = get_saved_queries_inner(&store, Some(sc2.id.clone())).unwrap();
assert_eq!(list.len(), 1);
// update (bogus id "update" — no-op, exercises the code path)
update_saved_query_inner(
&store,
"update".to_string(),
Some("Renamed".to_string()),
None,
None,
)
.unwrap();
// delete
delete_saved_query_inner(&store, result.id).unwrap();
let after = get_saved_queries_inner(&store, Some(sc2.id.clone())).unwrap();
assert!(after.is_empty());
}
// ------------------------------------------------------------------
// Helper: unwrap a DbHandle::Sqlite to get the connection reference
// ------------------------------------------------------------------
+5
View File
@@ -98,6 +98,11 @@ pub fn run() {
query::execute_query,
query::get_query_history,
query::clear_query_history,
query::set_history_favorite,
query::save_query,
query::get_saved_queries,
query::update_saved_query,
query::delete_saved_query,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+122 -1
View File
@@ -194,6 +194,31 @@ pub fn run_migrations(conn: &Connection) -> Result<(), String> {
.map_err(|e| e.to_string())?;
}
// v6: query history favorites + saved queries
if current_ver < 6 {
conn.execute_batch(
"ALTER TABLE query_history ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS queries (
id TEXT PRIMARY KEY,
connection_id TEXT,
name TEXT NOT NULL,
query_text TEXT NOT NULL,
folder TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (connection_id) REFERENCES connections(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_queries_connection ON queries(connection_id);
CREATE INDEX IF NOT EXISTS idx_queries_folder ON queries(folder);"
).map_err(|e| e.to_string())?;
conn.execute(
"INSERT INTO schema_version (version) VALUES (6)",
[],
)
.map_err(|e| e.to_string())?;
}
Ok(())
}
@@ -244,7 +269,7 @@ mod tests {
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 4);
assert_eq!(count, 5);
}
#[test]
@@ -296,4 +321,100 @@ mod tests {
.unwrap();
assert_eq!(count, 0);
}
#[test]
fn v6_adds_favorite_column_to_query_history() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
// Verify the favorite column exists via PRAGMA
let columns: Vec<String> = {
let mut stmt = conn.prepare("PRAGMA table_info(query_history)").unwrap();
let rows = stmt
.query_map([], |row| row.get::<_, String>(1))
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
assert!(
columns.contains(&"favorite".to_string()),
"Expected query_history to have a 'favorite' column after v6 migration"
);
// Existing rows default to 0
let fav: i64 = conn
.query_row(
"SELECT COUNT(*) FROM query_history WHERE favorite != 0",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(fav, 0);
}
#[test]
fn v6_creates_queries_table() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
// Table exists
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM queries", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 0);
// Column check
let columns: Vec<String> = {
let mut stmt = conn.prepare("PRAGMA table_info(queries)").unwrap();
let rows = stmt
.query_map([], |row| row.get::<_, String>(1))
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
for c in &["id", "connection_id", "name", "query_text", "folder", "created_at", "updated_at"] {
assert!(columns.contains(&c.to_string()), "Expected queries table to have column: {}", c);
}
}
#[test]
fn v6_queries_cascade_on_connection_delete() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
// Insert a connection
let conn_id = "test-qc-conn";
conn.execute(
"INSERT INTO connections (id, name, db_type, host, port, created_at, updated_at) VALUES (?1, 't', 'postgresql', 'h', 5432, datetime('now'), datetime('now'))",
rusqlite::params![conn_id],
).unwrap();
// Insert a saved query for that connection
conn.execute(
"INSERT INTO queries (id, connection_id, name, query_text, folder) VALUES ('q1', ?1, 'my query', 'SELECT 1', '')",
rusqlite::params![conn_id],
).unwrap();
// Insert a global saved query (connection_id NULL)
conn.execute(
"INSERT INTO queries (id, connection_id, name, query_text, folder) VALUES ('q2', NULL, 'global query', 'SELECT 2', '')",
[],
).unwrap();
// Delete connection — should cascade the non-NULL row
conn.execute("DELETE FROM connections WHERE id = ?1", rusqlite::params![conn_id]).unwrap();
let count_scoped: i64 = conn
.query_row("SELECT COUNT(*) FROM queries WHERE connection_id = ?1", rusqlite::params![conn_id], |r| r.get(0))
.unwrap();
assert_eq!(count_scoped, 0, "Scoped saved query should be cascade-deleted");
// NULL-saved query survives
let count_global: i64 = conn
.query_row("SELECT COUNT(*) FROM queries WHERE id = 'q2'", [], |r| r.get(0))
.unwrap();
assert_eq!(count_global, 1, "Global saved query (connection_id NULL) should survive");
}
#[test]
fn v6_bumps_schema_version_to_6() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
let ver: i64 = conn
.query_row(
"SELECT MAX(version) FROM schema_version",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(ver, 6, "Schema version should be 6 after v6 migration");
}
}
+523 -2
View File
@@ -11,6 +11,22 @@ pub struct Store {
conn: Mutex<SqliteConnection>,
}
/// A saved query row (returned from the store).
#[derive(Debug, Clone)]
pub struct SavedQueryRow {
pub id: String,
pub connection_id: Option<String>,
pub name: String,
pub query_text: String,
pub folder: String,
pub created_at: String,
pub updated_at: String,
}
const MAX_NAME_LEN: usize = 200;
const MAX_FOLDER_LEN: usize = 100;
const MAX_QUERY_TEXT_LEN: usize = 1_048_576; // 1 MB
impl Store {
pub fn from_connection(conn: SqliteConnection) -> Self {
Self {
@@ -449,6 +465,8 @@ impl Store {
}
/// Insert a row into the `query_history` table.
/// Dedups consecutive identical queries per connection (UPDATE the last row
/// instead of INSERTing a new one) and prunes to at most 500 rows per connection.
pub fn insert_query_history(
&self,
id: &str,
@@ -461,11 +479,46 @@ impl Store {
) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let now = Self::now();
// DEDUP: check last row for this connection
let last: Option<(String, String)> = {
let mut stmt = conn
.prepare(
"SELECT id, query_text FROM query_history
WHERE connection_id = ?1
ORDER BY executed_at DESC LIMIT 1",
)
.map_err(|e| e.to_string())?;
stmt.query_row(params![connection_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.ok()
};
if let Some((existing_id, existing_text)) = last {
if existing_text == query_text {
// Consecutive identical — UPDATE the existing row
conn.execute(
"UPDATE query_history SET execution_time_ms = ?1, row_count = ?2, status = ?3, error_message = ?4, executed_at = ?5 WHERE id = ?6",
params![execution_time_ms, row_count, status, error_message, now, existing_id],
)
.map_err(|e| e.to_string())?;
// Still run pruning in case updates shifted retention needs
prune_query_history(&conn, connection_id, 500)?;
return Ok(());
}
}
// INSERT new row
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())?;
// PRUNING: keep at most `max_rows` per connection
prune_query_history(&conn, connection_id, 500)?;
Ok(())
}
@@ -481,12 +534,12 @@ impl Store {
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(),
"SELECT id, connection_id, query_text, execution_time_ms, row_count, status, error_message, executed_at, favorite 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(),
"SELECT id, connection_id, query_text, execution_time_ms, row_count, status, error_message, executed_at, favorite FROM query_history ORDER BY executed_at DESC LIMIT ?1 OFFSET ?2".to_string(),
vec![Box::new(limit), Box::new(offset)],
)
};
@@ -503,6 +556,7 @@ impl Store {
status: row.get(5)?,
error_message: row.get(6)?,
executed_at: row.get(7)?,
favorite: row.get::<_, i64>(8)? != 0, // convert INTEGER to bool
})
})
.map_err(|e| e.to_string())?;
@@ -524,6 +578,179 @@ impl Store {
}
Ok(())
}
/// Toggle the `favorite` flag for a query history entry.
/// Returns an error if no row with the given id + connection_id exists.
pub fn set_history_favorite(&self, id: &str, connection_id: &str) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let affected = conn
.execute(
"UPDATE query_history SET favorite = 1 - favorite WHERE id = ?1 AND connection_id = ?2",
params![id, connection_id],
)
.map_err(|e| e.to_string())?;
if affected == 0 {
return Err("History entry not found".to_string());
}
Ok(())
}
pub fn save_query(
&self,
connection_id: Option<&str>,
name: &str,
query_text: &str,
folder: &str,
) -> Result<SavedQueryRow, String> {
if name.is_empty() || name.len() > MAX_NAME_LEN {
return Err("Name must be 1200 characters".to_string());
}
if folder.len() > MAX_FOLDER_LEN {
return Err("Folder must be ≤100 characters".to_string());
}
if query_text.len() > MAX_QUERY_TEXT_LEN {
return Err("Query text must be ≤1 MB".to_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 queries (id, connection_id, name, query_text, folder, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![id, connection_id, name, query_text, folder, now, now],
)
.map_err(|e| e.to_string())?;
Ok(SavedQueryRow {
id,
connection_id: connection_id.map(|s| s.to_string()),
name: name.to_string(),
query_text: query_text.to_string(),
folder: folder.to_string(),
created_at: now.clone(),
updated_at: now,
})
}
pub fn list_saved_queries(
&self,
connection_id: Option<&str>,
) -> Result<Vec<SavedQueryRow>, String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let (sql, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
if let Some(cid) = connection_id {
(
"SELECT id, connection_id, name, query_text, folder, created_at, updated_at FROM queries WHERE connection_id = ?1 ORDER BY updated_at DESC".to_string(),
vec![Box::new(cid.to_string())],
)
} else {
(
"SELECT id, connection_id, name, query_text, folder, created_at, updated_at FROM queries ORDER BY updated_at DESC".to_string(),
vec![],
)
};
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
let refs: Vec<&dyn rusqlite::types::ToSql> = params_vec.iter().map(|p| p.as_ref()).collect();
let rows = stmt
.query_map(rusqlite::params_from_iter(&refs), |row| {
Ok(SavedQueryRow {
id: row.get(0)?,
connection_id: row.get(1)?,
name: row.get(2)?,
query_text: row.get(3)?,
folder: row.get(4)?,
created_at: row.get(5)?,
updated_at: row.get(6)?,
})
})
.map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}
pub fn update_saved_query(
&self,
id: &str,
name: Option<&str>,
query_text: Option<&str>,
folder: Option<&str>,
) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let now = Self::now();
// Build dynamic SET clauses
let mut sets: Vec<String> = vec!["updated_at = ?1".to_string()];
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(now)];
let mut idx = 2i32; // next param index (after ?1 for updated_at)
if let Some(n) = name {
if n.is_empty() || n.len() > MAX_NAME_LEN {
return Err("Name must be 1200 characters".to_string());
}
sets.push(format!("name = ?{idx}"));
params.push(Box::new(n.to_string()));
idx += 1;
}
if let Some(qt) = query_text {
if qt.len() > MAX_QUERY_TEXT_LEN {
return Err("Query text must be ≤1 MB".to_string());
}
sets.push(format!("query_text = ?{idx}"));
params.push(Box::new(qt.to_string()));
idx += 1;
}
if let Some(f) = folder {
if f.len() > MAX_FOLDER_LEN {
return Err("Folder must be ≤100 characters".to_string());
}
sets.push(format!("folder = ?{idx}"));
params.push(Box::new(f.to_string()));
idx += 1;
}
let sql = format!(
"UPDATE queries SET {} WHERE id = ?{idx}",
sets.join(", "),
);
let mut all_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let id_param: Box<dyn rusqlite::types::ToSql> = Box::new(id.to_string());
all_refs.push(id_param.as_ref());
conn.execute(&sql, rusqlite::params_from_iter(&all_refs))
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn delete_saved_query(&self, id: &str) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM queries WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
}
}
/// Delete oldest rows for a connection, keeping at most `max_rows`.
fn prune_query_history(
conn: &rusqlite::Connection,
connection_id: &str,
max_rows: i64,
) -> Result<(), String> {
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM query_history WHERE connection_id = ?1",
params![connection_id],
|r| r.get(0),
)
.map_err(|e| e.to_string())?;
if count > max_rows {
conn.execute(
"DELETE FROM query_history WHERE connection_id = ?1 AND id NOT IN (
SELECT id FROM query_history WHERE connection_id = ?1
ORDER BY executed_at DESC LIMIT ?2
)",
params![connection_id, max_rows],
)
.map_err(|e| e.to_string())?;
}
Ok(())
}
#[cfg(test)]
@@ -816,4 +1043,298 @@ mod tests {
Some("/etc/ssl/private/client-key.pem")
);
}
#[test]
fn insert_query_history_dedups_consecutive_identical() {
let store = fresh_store();
// Insert connection needed for FK
let conn = store
.create_connection(ConnectionInput {
name: "dedup-conn".into(),
db_type: "postgresql".into(),
host: "localhost".into(),
port: Some(5432),
username: None,
folder_id: None,
password: None,
database: Some("public".into()),
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();
// First insert
store
.insert_query_history("h1", &conn.id, "SELECT 1", Some(10), Some(5), "success", None)
.unwrap();
// Consecutive identical — should UPDATE, not INSERT
store
.insert_query_history("h2", &conn.id, "SELECT 1", Some(20), Some(8), "success", None)
.unwrap();
// There should still be 1 row (not 2), with updated stats
let rows = store.get_query_history(Some(&conn.id), 10, 0).unwrap();
assert_eq!(rows.len(), 1, "Consecutive identical queries should dedup to one row");
assert_eq!(rows[0].execution_time_ms, Some(20), "Stats should update after dedup");
assert_eq!(rows[0].id, "h1", "Original ID should persist after dedup");
// Different query — should INSERT a new row
store
.insert_query_history("h3", &conn.id, "SELECT 2", Some(5), Some(0), "success", None)
.unwrap();
let rows2 = store.get_query_history(Some(&conn.id), 10, 0).unwrap();
assert_eq!(rows2.len(), 2, "Different query should create a new row");
assert_eq!(rows2[0].id, "h3", "Most recent row should be the new one");
}
#[test]
fn insert_query_history_prunes_oldest_beyond_500() {
let store = fresh_store();
let conn = store
.create_connection(ConnectionInput {
name: "prune-conn".into(),
db_type: "postgresql".into(),
host: "localhost".into(),
port: Some(5432),
username: None,
folder_id: None,
password: None,
database: Some("public".into()),
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();
// Insert 510 rows — should trigger pruning beyond 500
for i in 0..510 {
store
.insert_query_history(
&format!("ph{}", i),
&conn.id,
&format!("SELECT {}", i),
Some(1),
Some(1),
"success",
None,
)
.unwrap();
}
let rows = store.get_query_history(Some(&conn.id), 1000, 0).unwrap();
assert_eq!(rows.len(), 500, "Should be pruned to 500 rows");
// Oldest rows (ph0..ph9) should be pruned; most recent (ph509) kept
let all_ids: Vec<String> = rows.iter().map(|r| r.id.clone()).collect();
assert!(!all_ids.contains(&"ph0".to_string()), "Oldest rows should be pruned");
assert!(all_ids.contains(&"ph509".to_string()), "Most recent rows should be kept");
}
#[test]
fn get_query_history_includes_favorite_column() {
let store = fresh_store();
let conn = store
.create_connection(ConnectionInput {
name: "fav-conn".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![],
})
.unwrap();
store
.insert_query_history("fh1", &conn.id, "SELECT 1", Some(5), Some(1), "success", None)
.unwrap();
let rows = store.get_query_history(Some(&conn.id), 10, 0).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].favorite, false, "Default favorite should be false");
}
#[test]
fn set_history_favorite_toggles() {
let store = fresh_store();
let conn = store
.create_connection(ConnectionInput {
name: "ft-conn".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![],
})
.unwrap();
store
.insert_query_history("ft1", &conn.id, "SELECT 1", Some(5), Some(1), "success", None)
.unwrap();
// Toggle on
store.set_history_favorite("ft1", &conn.id).unwrap();
let rows = store.get_query_history(Some(&conn.id), 10, 0).unwrap();
assert_eq!(rows[0].favorite, true);
// Toggle off
store.set_history_favorite("ft1", &conn.id).unwrap();
let rows2 = store.get_query_history(Some(&conn.id), 10, 0).unwrap();
assert_eq!(rows2[0].favorite, false);
}
#[test]
fn set_history_favorite_unknown_id_returns_error() {
let store = fresh_store();
let result = store.set_history_favorite("nonexistent", "any-conn");
assert!(result.is_err(), "Unknown id should be an error");
}
fn create_test_connection(store: &Store, name: &str) -> crate::models::Connection {
store
.create_connection(ConnectionInput {
name: name.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![],
})
.unwrap()
}
#[test]
fn save_and_list_saved_queries() {
let store = fresh_store();
let conn = create_test_connection(&store, "sq-conn");
let saved = store
.save_query(Some(&conn.id), "My Query", "SELECT 1", "reports")
.unwrap();
assert_eq!(saved.name, "My Query");
assert_eq!(saved.query_text, "SELECT 1");
assert_eq!(saved.folder, "reports");
assert_eq!(saved.connection_id, Some(conn.id.clone()));
let list = store.list_saved_queries(Some(&conn.id)).unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].id, saved.id);
}
#[test]
fn save_global_saved_query() {
let store = fresh_store();
let saved = store
.save_query(None, "Global Query", "SELECT version()", "")
.unwrap();
assert_eq!(saved.connection_id, None);
let list = store.list_saved_queries(None).unwrap();
assert!(list.iter().any(|q| q.id == saved.id));
}
#[test]
fn update_and_delete_saved_query() {
let store = fresh_store();
let conn = create_test_connection(&store, "ud-conn");
let saved = store
.save_query(Some(&conn.id), "Original", "SELECT 1", "")
.unwrap();
// Update name
store
.update_saved_query(&saved.id, Some("Renamed"), None, None)
.unwrap();
let after = store.list_saved_queries(Some(&conn.id)).unwrap();
assert_eq!(after[0].name, "Renamed");
// Update query text
store
.update_saved_query(&saved.id, None, Some("SELECT 2"), None)
.unwrap();
let after2 = store.list_saved_queries(Some(&conn.id)).unwrap();
assert_eq!(after2[0].query_text, "SELECT 2");
// Delete
store.delete_saved_query(&saved.id).unwrap();
let empty = store.list_saved_queries(Some(&conn.id)).unwrap();
assert!(empty.is_empty());
}
#[test]
fn saved_query_name_validated() {
let store = fresh_store();
// Name > 200 chars should fail
let long_name = "a".repeat(201);
let result = store.save_query(None, &long_name, "SELECT 1", "");
assert!(result.is_err(), "Over-long name should be rejected");
}
#[test]
fn saved_query_folder_validated() {
let store = fresh_store();
let long_folder = "b".repeat(101);
let result = store.save_query(None, "ok", "SELECT 1", &long_folder);
assert!(result.is_err(), "Over-long folder should be rejected");
}
#[test]
fn saved_query_text_size_validated() {
let store = fresh_store();
let huge_text = "x".repeat(1_048_577); // 1MB + 1 byte
let result = store.save_query(None, "ok", &huge_text, "");
assert!(result.is_err(), "Over-size query text should be rejected");
}
}