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:
@@ -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
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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 1–200 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 1–200 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user