DB viewer + query editor enhancements (home-screen-ux-query-editor) (#4)

* 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
This commit is contained in:
2026-08-01 05:56:47 +08:00
committed by GitHub
parent 4f18993e70
commit 80962d7d11
66 changed files with 5681 additions and 509 deletions
+4
View File
@@ -880,6 +880,7 @@ ORDER BY c.ordinal_position"#;
total_rows,
page: p,
page_size: ps,
execution_time_ms: None,
})
}
Some(crate::db::pool::DbHandle::Sqlite(conn)) => {
@@ -990,6 +991,7 @@ ORDER BY c.ordinal_position"#;
total_rows,
page: p,
page_size: ps,
execution_time_ms: None,
})
}
None => Err("Connection not found".to_string()),
@@ -1097,6 +1099,7 @@ ORDER BY c.ordinal_position"#;
total_rows: 1,
page: 1,
page_size: 1,
execution_time_ms: None,
})
}
Some(crate::db::pool::DbHandle::Sqlite(conn)) => {
@@ -1178,6 +1181,7 @@ ORDER BY c.ordinal_position"#;
total_rows: 1,
page: 1,
page_size: 1,
execution_time_ms: None,
})
}
None => Err("Connection not found".to_string()),
+2 -1
View File
@@ -9,4 +9,5 @@ pub mod ssh;
pub mod keychain;
pub mod demo;
pub mod backup;
pub mod schema_graph;
pub mod schema_graph;
pub mod query;
+748
View File
@@ -0,0 +1,748 @@
//! Arbitrary SQL query execution with subquery-based pagination and
//! queryhistory recording.
//!
//! Architecture:
//! 1. Subquery wrapping is attempted first:
//! `SELECT * FROM (user_query) AS _gridline_data LIMIT x OFFSET y`
//! `SELECT COUNT(*) FROM (user_query) AS _gridline_cnt`
//! 2. If wrapping fails (CTEs, multistatement), fall back to raw
//! execution with clientside slicing.
//! 3. Every query is recorded in the local `query_history` table.
use crate::db::pool::DbHandle;
use crate::models::db_viewer::{ColumnInfo, QueryResult};
use serde::{Deserialize, Serialize};
use std::time::Instant;
use tauri::State;
use uuid::Uuid;
// ---------------------------------------------------------------------------
// QueryHistoryEntry
// ---------------------------------------------------------------------------
/// A single record in the local `query_history` table.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryHistoryEntry {
pub id: String,
pub connection_id: String,
pub query_text: String,
pub execution_time_ms: Option<i64>,
pub row_count: Option<i64>,
/// `"success"` or `"error"`.
pub status: String,
pub error_message: Option<String>,
pub executed_at: String,
}
// ---------------------------------------------------------------------------
// Core executor (not a Tauri command itself — called by the command wrapper)
// ---------------------------------------------------------------------------
/// Execute arbitrary SQL on PostgreSQL or SQLite with subquerybased
/// pagination and automatic fallback to clientside slicing.
///
/// Returns `(QueryResult, Option<QueryHistoryEntry>)` so the caller can
/// write the history record through the store.
pub(crate) async fn execute_query_inner(
pool_manager: &mut crate::db::pool::ConnectionPoolManager,
db_store: &std::sync::Mutex<crate::store::Store>,
connection_id: &str,
query: &str,
page: i64,
page_size: i64,
) -> Result<QueryResult, String> {
let start = Instant::now();
let history_id = Uuid::new_v4().to_string();
// Try subquery-wrapped execution first; fall back to raw on failure.
let mut result = match pool_manager.get(connection_id) {
Some(DbHandle::Postgresql(client, _)) => {
execute_pg_query(client, query, page, page_size).await
}
Some(DbHandle::Sqlite(conn)) => {
execute_sqlite_query(conn, query, page, page_size)
}
None => {
let elapsed = start.elapsed().as_millis() as i64;
let err = "Connection not found".to_string();
insert_history(
db_store,
&history_id,
connection_id,
query,
Some(elapsed),
None,
"error",
Some(&err),
);
return Err(err);
}
};
let elapsed = start.elapsed().as_millis() as i64;
// Attach server-side execution time to the returned result so the UI can
// show "time taken" for query runs.
if let Ok(qr) = &mut result {
qr.execution_time_ms = Some(elapsed);
}
match &result {
Ok(qr) => {
insert_history(
db_store,
&history_id,
connection_id,
query,
Some(elapsed),
Some(qr.rows.len() as i64),
"success",
None,
);
}
Err(e) => {
insert_history(
db_store,
&history_id,
connection_id,
query,
Some(elapsed),
None,
"error",
Some(e),
);
}
}
result
}
/// Helper: insert a query_history row through the store, swallowing errors.
fn insert_history(
db_store: &std::sync::Mutex<crate::store::Store>,
id: &str,
connection_id: &str,
query_text: &str,
execution_time_ms: Option<i64>,
row_count: Option<i64>,
status: &str,
error_message: Option<&str>,
) {
if let Ok(store) = db_store.lock() {
let _ = store.insert_query_history(
id,
connection_id,
query_text,
execution_time_ms,
row_count,
status,
error_message,
);
}
}
// ---------------------------------------------------------------------------
// PostgreSQL execution
// ---------------------------------------------------------------------------
/// Try subquerywrapped pagination for PostgreSQL. Falls back to raw
/// execution via `simple_query` if wrapping produces a parse error.
async fn execute_pg_query(
client: &tokio_postgres::Client,
query: &str,
page: i64,
page_size: i64,
) -> Result<QueryResult, String> {
let trimmed = query.trim();
if trimmed.is_empty() {
return Err("Query cannot be empty".to_string());
}
let off = (page.saturating_sub(1).max(0)) * page_size;
// Attempt subquery wrapping.
let wrapped_data = format!(
"SELECT * FROM ({}) AS _gridline_data LIMIT $1 OFFSET $2",
trimmed
);
let wrapped_count = format!(
"SELECT COUNT(*) FROM ({}) AS _gridline_cnt",
trimmed
);
// Try the wrapped count query first — if this fails we fall back to raw.
let total_rows: i64 = match client
.query_one(&wrapped_count, &[])
.await
{
Ok(row) => row.get::<_, i64>(0),
Err(_) => {
// Wrapping failed — fall back to raw execution.
return execute_pg_raw(client, trimmed, page, page_size, off).await;
}
};
// Now execute the wrapped data query.
let data_rows = match client.query(&wrapped_data, &[&page_size, &off]).await {
Ok(rows) => rows,
Err(_) => {
return execute_pg_raw(client, trimmed, page, page_size, off).await;
}
};
// Build column info from the data rows.
let columns: Vec<ColumnInfo> = match data_rows.first() {
Some(first) => first
.columns()
.iter()
.map(|c| ColumnInfo {
name: c.name().to_string(),
data_type: format!("{:?}", c.type_()),
is_nullable: true,
is_pk: false,
is_fk: false,
fk_ref: None,
default_value: None,
})
.collect(),
None => {
// No rows — fall back to raw execution which handles
// column metadata via simple_query.
return execute_pg_raw(client, trimmed, page, page_size, off).await;
}
};
// Convert rows to JSON.
let rows: Vec<Vec<serde_json::Value>> = data_rows
.iter()
.map(|row| {
(0..row.len())
.map(|i| crate::commands::db_viewer::pg_value_to_json(row, i))
.collect()
})
.collect();
Ok(QueryResult {
columns,
rows,
total_rows,
page,
page_size,
execution_time_ms: None,
})
}
// ---------------------------------------------------------------------------
// PostgreSQL raw fallback (simple_query)
// ---------------------------------------------------------------------------
/// Execute a raw SQL string via `simple_query`, collecting all result rows
/// and slicing them on the client side for pagination.
async fn execute_pg_raw(
client: &tokio_postgres::Client,
query: &str,
page: i64,
page_size: i64,
offset: i64,
) -> Result<QueryResult, String> {
let messages = client
.simple_query(query)
.await
.map_err(|e| crate::commands::db_viewer::pg_error_message(&e))?;
let mut columns: Vec<ColumnInfo> = Vec::new();
let mut all_rows: Vec<Vec<serde_json::Value>> = Vec::new();
let mut saw_columns = false;
for msg in messages {
match msg {
tokio_postgres::SimpleQueryMessage::Row(row) => {
if !saw_columns {
columns = row
.columns()
.iter()
.map(|c| ColumnInfo {
name: c.name().to_string(),
data_type: "text".to_string(),
is_nullable: true,
is_pk: false,
is_fk: false,
fk_ref: None,
default_value: None,
})
.collect();
saw_columns = true;
}
let values: Vec<serde_json::Value> = (0..row.len())
.map(|i| {
// simple_query protocol returns everything as strings;
// try_get with just the index returns Option<&str>.
match row.try_get::<usize>(i) {
Ok(Some(s)) => serde_json::Value::String(s.to_string()),
Ok(None) => serde_json::Value::Null,
Err(_) => serde_json::Value::Null,
}
})
.collect();
all_rows.push(values);
}
tokio_postgres::SimpleQueryMessage::CommandComplete(..) => {
// DML statements like INSERT, UPDATE, DELETE, etc.
// Return empty result with row count from the tag.
}
_ => {}
}
}
let total_rows = all_rows.len() as i64;
let uoffset = offset as usize;
let ulimit = page_size as usize;
let rows: Vec<Vec<serde_json::Value>> = if uoffset < all_rows.len() {
all_rows
.into_iter()
.skip(uoffset)
.take(ulimit)
.collect()
} else {
Vec::new()
};
Ok(QueryResult {
columns,
rows,
total_rows,
page,
page_size,
execution_time_ms: None,
})
}
// ---------------------------------------------------------------------------
// SQLite execution
// ---------------------------------------------------------------------------
/// Try subquerywrapped pagination for SQLite. Falls back to raw execution
/// if wrapping produces a parse error.
fn execute_sqlite_query(
conn: &rusqlite::Connection,
query: &str,
page: i64,
page_size: i64,
) -> Result<QueryResult, String> {
let trimmed = query.trim();
if trimmed.is_empty() {
return Err("Query cannot be empty".to_string());
}
let off = (page.saturating_sub(1).max(0)) * page_size;
// Attempt subquery wrapping.
let wrapped_data = format!(
"SELECT * FROM ({}) AS _gridline_data LIMIT {} OFFSET {}",
trimmed, page_size, off
);
let wrapped_count = format!(
"SELECT COUNT(*) FROM ({}) AS _gridline_cnt",
trimmed
);
// Try the wrapped count query first.
let total_rows: i64 = match conn.query_row(&wrapped_count, [], |row| row.get::<_, i64>(0)) {
Ok(n) => n,
Err(_) => {
// Wrapping failed — fall back to raw execution.
return execute_sqlite_raw(conn, trimmed, page, page_size, off);
}
};
// Execute the wrapped data query.
let (columns, all_rows) = match execute_sqlite_with_query(conn, &wrapped_data) {
Ok(result) => result,
Err(_) => {
return execute_sqlite_raw(conn, trimmed, page, page_size, off);
}
};
Ok(QueryResult {
columns,
rows: all_rows,
total_rows,
page,
page_size,
execution_time_ms: None,
})
}
/// Execute a SQL string on SQLite and return `(columns, rows)`.
fn execute_sqlite_with_query(
conn: &rusqlite::Connection,
sql: &str,
) -> Result<(Vec<ColumnInfo>, Vec<Vec<serde_json::Value>>), String> {
let mut stmt = conn.prepare(sql).map_err(|e| e.to_string())?;
let columns: Vec<ColumnInfo> = (0..stmt.column_count())
.map(|i| {
let name = stmt.column_name(i).unwrap_or("?").to_string();
ColumnInfo {
name,
data_type: "TEXT".to_string(),
is_nullable: true,
is_pk: false,
is_fk: false,
fk_ref: None,
default_value: None,
}
})
.collect();
let col_count = stmt.column_count();
let rows: Vec<Vec<serde_json::Value>> = stmt
.query_map([], |row| {
let mut vals = Vec::with_capacity(col_count);
for i in 0..col_count {
vals.push(sqlite_value_to_json(row, i));
}
Ok(vals)
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
Ok((columns, rows))
}
// ---------------------------------------------------------------------------
// SQLite raw fallback
// ---------------------------------------------------------------------------
/// Execute raw SQL on SQLite without subquery wrapping, paginating
/// clientside.
fn execute_sqlite_raw(
conn: &rusqlite::Connection,
query: &str,
page: i64,
page_size: i64,
offset: i64,
) -> Result<QueryResult, String> {
let (columns, all_rows) = execute_sqlite_with_query(conn, query)?;
let total_rows = all_rows.len() as i64;
let uoffset = offset as usize;
let ulimit = page_size as usize;
let rows: Vec<Vec<serde_json::Value>> = if uoffset < all_rows.len() {
all_rows
.into_iter()
.skip(uoffset)
.take(ulimit)
.collect()
} else {
Vec::new()
};
Ok(QueryResult {
columns,
rows,
total_rows,
page,
page_size,
execution_time_ms: None,
})
}
// ---------------------------------------------------------------------------
// Value conversion helpers
// ---------------------------------------------------------------------------
/// Convert a `rusqlite::Row` cell to `serde_json::Value`.
fn sqlite_value_to_json(row: &rusqlite::Row, i: usize) -> serde_json::Value {
use rusqlite::types::ValueRef;
match row.get_ref(i) {
Ok(ValueRef::Null) => serde_json::Value::Null,
Ok(ValueRef::Integer(v)) => serde_json::json!(v),
Ok(ValueRef::Real(v)) => serde_json::json!(v),
Ok(ValueRef::Text(v)) => {
serde_json::Value::String(String::from_utf8_lossy(v).to_string())
}
Ok(ValueRef::Blob(v)) => {
serde_json::Value::String(format!("[{}B blob]", v.len()))
}
Err(_) => serde_json::Value::Null,
}
}
// ---------------------------------------------------------------------------
// Query history commands
// ---------------------------------------------------------------------------
pub(crate) fn get_query_history_inner(
db_store: &std::sync::Mutex<crate::store::Store>,
connection_id: Option<&str>,
limit: i64,
offset: i64,
) -> Result<Vec<QueryHistoryEntry>, String> {
let store = db_store.lock().map_err(|e| e.to_string())?;
store.get_query_history(connection_id, limit, offset)
}
pub(crate) fn clear_query_history_inner(
db_store: &std::sync::Mutex<crate::store::Store>,
connection_id: Option<&str>,
) -> Result<(), String> {
let store = db_store.lock().map_err(|e| e.to_string())?;
store.clear_query_history(connection_id)
}
// ---------------------------------------------------------------------------
// Tauri commands
// ---------------------------------------------------------------------------
#[tauri::command]
pub async fn execute_query(
connection_id: String,
query: String,
page: Option<i64>,
page_size: Option<i64>,
state: State<'_, crate::AppState>,
) -> Result<QueryResult, String> {
let p = page.unwrap_or(1);
let ps = page_size.unwrap_or(50);
let mut pm = state.pool_manager.lock().await;
execute_query_inner(&mut pm, &state.db_store, &connection_id, &query, p, ps).await
}
#[tauri::command]
pub async fn get_query_history(
connection_id: Option<String>,
limit: Option<i64>,
offset: Option<i64>,
state: State<'_, crate::AppState>,
) -> Result<Vec<QueryHistoryEntry>, String> {
let l = limit.unwrap_or(50);
let o = offset.unwrap_or(0);
let store = state.db_store.lock().map_err(|e| e.to_string())?;
store.get_query_history(connection_id.as_deref(), l, o)
}
#[tauri::command]
pub async fn clear_query_history(
connection_id: Option<String>,
state: State<'_, crate::AppState>,
) -> Result<(), String> {
let store = state.db_store.lock().map_err(|e| e.to_string())?;
store.clear_query_history(connection_id.as_deref())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::store::Store;
use rusqlite::Connection as SqliteConnection;
use std::sync::Mutex;
/// Create an inmemory Store with all migrations applied.
fn test_store() -> Mutex<Store> {
let conn = SqliteConnection::open_in_memory().unwrap();
crate::store::migrations::run_migrations(&conn).unwrap();
// Insert a placeholder connection so FK constraints are satisfied.
conn.execute(
"INSERT INTO connections (id, name, db_type, host, port, created_at, updated_at)
VALUES ('test-conn', 'test', 'sqlite', ':memory:', NULL, datetime('now'), datetime('now'))",
[],
)
.unwrap();
Mutex::new(Store::from_connection(conn))
}
/// Open an inmemory SQLite database and return a DbHandle.
fn test_sqlite_handle() -> DbHandle {
let conn = SqliteConnection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT);
INSERT INTO users VALUES (1, 'Alice', 'alice@example.com');
INSERT INTO users VALUES (2, 'Bob', 'bob@example.com');
INSERT INTO users VALUES (3, 'Charlie', 'charlie@example.com');",
)
.unwrap();
DbHandle::Sqlite(conn)
}
/// Open an inmemory SQLite database for CTE testing.
fn test_sqlite_cte_handle() -> DbHandle {
let conn = SqliteConnection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE items (id INTEGER PRIMARY KEY, val INTEGER);
INSERT INTO items VALUES (1, 10);
INSERT INTO items VALUES (2, 20);
INSERT INTO items VALUES (3, 30);
INSERT INTO items VALUES (4, 40);
INSERT INTO items VALUES (5, 50);",
)
.unwrap();
DbHandle::Sqlite(conn)
}
// ------------------------------------------------------------------
// Test 1: Basic SELECT execution with pagination
// ------------------------------------------------------------------
#[test]
fn basic_select_with_pagination() {
let _store = test_store();
let conn = test_sqlite_handle();
let (columns, rows) =
execute_sqlite_with_query(&unwrap_sqlite(&conn), "SELECT * FROM users ORDER BY id")
.unwrap();
assert_eq!(columns.len(), 3);
assert_eq!(columns[0].name, "id");
assert_eq!(rows.len(), 3);
// Verify row values
assert_eq!(rows[0][0], serde_json::json!(1));
assert_eq!(rows[0][1], serde_json::json!("Alice"));
assert_eq!(rows[1][0], serde_json::json!(2));
}
// ------------------------------------------------------------------
// Test 2: Subquery wrapping produces correct LIMIT/OFFSET
// ------------------------------------------------------------------
#[test]
fn subquery_wrapping_paginates_correctly() {
let _store = test_store();
let conn = test_sqlite_handle();
// Page 1: 2 rows
let result =
execute_sqlite_query(&unwrap_sqlite(&conn), "SELECT * FROM users ORDER BY id", 1, 2)
.unwrap();
assert_eq!(result.total_rows, 3);
assert_eq!(result.rows.len(), 2);
assert_eq!(result.rows[0][1], serde_json::json!("Alice"));
assert_eq!(result.rows[1][1], serde_json::json!("Bob"));
assert_eq!(result.page, 1);
assert_eq!(result.page_size, 2);
// Page 2: 1 row
let result2 =
execute_sqlite_query(&unwrap_sqlite(&conn), "SELECT * FROM users ORDER BY id", 2, 2)
.unwrap();
assert_eq!(result2.total_rows, 3);
assert_eq!(result2.rows.len(), 1);
assert_eq!(result2.rows[0][1], serde_json::json!("Charlie"));
}
// ------------------------------------------------------------------
// Test 3: EXPLAIN falls back to raw execution with clientside pagination
// ------------------------------------------------------------------
#[test]
fn cte_falls_back_to_raw_execution() {
let _store = test_store();
let conn = test_sqlite_cte_handle();
// EXPLAIN cannot be wrapped in a subquery:
// SELECT * FROM (EXPLAIN SELECT ...) is a syntax error.
// This forces the fallback to raw execution.
let query = "EXPLAIN SELECT * FROM items WHERE val > 20";
let result = execute_sqlite_query(&unwrap_sqlite(&conn), query, 1, 10).unwrap();
// Should fall back to raw — all rows fetched, clientslice.
// EXPLAIN returns rows (addr, opcode, p1, p2, p3, p4, p5, comment).
assert!(result.total_rows > 0, "EXPLAIN should return rows");
assert!(result.rows.len() <= 10, "page_size should limit rows");
assert_eq!(result.columns.len(), 8, "EXPLAIN has 8 columns");
}
// ------------------------------------------------------------------
// Test 4: Query history is recorded
// ------------------------------------------------------------------
#[test]
fn query_history_is_recorded() {
let store = test_store();
// Directly insert a history entry and read it back.
let entry_id = "hist-001";
{
let s = store.lock().unwrap();
s.insert_query_history(
entry_id,
"test-conn",
"SELECT 1",
Some(42),
Some(1),
"success",
None,
)
.unwrap();
}
// Read it back.
{
let s = store.lock().unwrap();
let history = s.get_query_history(Some("test-conn"), 10, 0).unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].id, "hist-001");
assert_eq!(history[0].connection_id, "test-conn");
assert_eq!(history[0].query_text, "SELECT 1");
assert_eq!(history[0].execution_time_ms, Some(42));
assert_eq!(history[0].row_count, Some(1));
assert_eq!(history[0].status, "success");
assert_eq!(history[0].error_message, None);
}
// Insert an error entry.
{
let s = store.lock().unwrap();
s.insert_query_history(
"hist-002",
"test-conn",
"SELECT invalid",
Some(5),
None,
"error",
Some("syntax error"),
)
.unwrap();
}
// Read back with limit.
{
let s = store.lock().unwrap();
let history = s.get_query_history(Some("test-conn"), 1, 0).unwrap();
assert_eq!(history.len(), 1);
// Most recent first (DESC order)
assert_eq!(history[0].id, "hist-002");
}
// Clear history for connection.
{
let s = store.lock().unwrap();
s.clear_query_history(Some("test-conn")).unwrap();
let history = s.get_query_history(Some("test-conn"), 10, 0).unwrap();
assert_eq!(history.len(), 0);
}
}
// ------------------------------------------------------------------
// Helper: unwrap a DbHandle::Sqlite to get the connection reference
// ------------------------------------------------------------------
fn unwrap_sqlite(handle: &DbHandle) -> &SqliteConnection {
match handle {
DbHandle::Sqlite(conn) => conn,
_ => panic!("Expected Sqlite handle"),
}
}
}
+4 -1
View File
@@ -19,7 +19,7 @@ pub struct AppState {
pub ssh_manager: StdMutex<SshTunnelManager>,
}
use commands::{connections, db_viewer, folders, tags, settings, import_export, keychain, demo, backup, schema_graph};
use commands::{connections, db_viewer, folders, tags, settings, import_export, keychain, demo, backup, schema_graph, query};
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
@@ -95,6 +95,9 @@ pub fn run() {
backup::pg_restore,
backup::db_sync,
schema_graph::get_schema_graph,
query::execute_query,
query::get_query_history,
query::clear_query_history,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+2
View File
@@ -42,6 +42,7 @@ pub struct QueryResult {
pub total_rows: i64,
pub page: i64,
pub page_size: i64,
pub execution_time_ms: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -216,6 +217,7 @@ mod tests {
total_rows: 0,
page: 1,
page_size: 100,
execution_time_ms: None,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains(r#""rows":[]"#));
+76 -1
View File
@@ -169,6 +169,31 @@ pub fn run_migrations(conn: &Connection) -> Result<(), String> {
.map_err(|e| e.to_string())?;
}
// v5: query_history
if current_ver < 5 {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS query_history (
id TEXT PRIMARY KEY,
connection_id TEXT NOT NULL,
query_text TEXT NOT NULL,
execution_time_ms INTEGER,
row_count INTEGER,
status TEXT NOT NULL CHECK(status IN ('success', 'error')),
error_message TEXT,
executed_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (connection_id) REFERENCES connections(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_query_history_connection
ON query_history(connection_id, executed_at DESC);"
).map_err(|e| e.to_string())?;
conn.execute(
"INSERT INTO schema_version (version) VALUES (5)",
[],
)
.map_err(|e| e.to_string())?;
}
Ok(())
}
@@ -219,6 +244,56 @@ mod tests {
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 3);
assert_eq!(count, 4);
}
#[test]
fn v5_creates_query_history_table() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
// Verify the table exists
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM query_history", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 0);
// Verify columns 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(&"id".to_string()));
assert!(columns.contains(&"connection_id".to_string()));
assert!(columns.contains(&"query_text".to_string()));
assert!(columns.contains(&"execution_time_ms".to_string()));
assert!(columns.contains(&"row_count".to_string()));
assert!(columns.contains(&"status".to_string()));
assert!(columns.contains(&"error_message".to_string()));
assert!(columns.contains(&"executed_at".to_string()));
}
#[test]
fn query_history_cascades_on_connection_delete() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
// Insert a connection
let conn_id = "test-conn-id";
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 query history entry
conn.execute(
"INSERT INTO query_history (id, connection_id, query_text, status, executed_at) VALUES ('qh1', ?1, 'SELECT 1', 'success', datetime('now'))",
rusqlite::params![conn_id],
).unwrap();
// Delete connection — should cascade
conn.execute("DELETE FROM connections WHERE id = ?1", rusqlite::params![conn_id]).unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM query_history WHERE connection_id = ?1", rusqlite::params![conn_id], |r| r.get(0))
.unwrap();
assert_eq!(count, 0);
}
}
+78 -1
View File
@@ -447,6 +447,83 @@ impl Store {
.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)]
@@ -690,7 +767,7 @@ mod tests {
#[test]
fn ssh_ssl_fields_persist_and_retrieve() {
let store = fresh_store();
let conn = store
let _conn = store
.create_connection(ConnectionInput {
name: "SSH-Tunnel-DB".into(),
db_type: "postgresql".into(),