v0.5.0 — Grid Interactivity, Home Polish, Deeper PostgreSQL (#8)

* chore: bump version to 0.5.0 (Task 1)

* feat(store): v7 migration — favorites + recent_connections (Task 2)

* feat(models): add favorite to Connection + Store CRUD (Task 3)

* feat(types): ColumnInfo editability + IndexInfo/ConstraintInfo/RecentConnection (Task 4)

* chore: bump version to 0.5.0 (Task 1) — lockfile

* feat(commands): typed wrappers for favorites/recents/indexes/constraints (Task 5)

* feat(db): PG indexes/constraints queries + matview UNION in tables (Task 6)

* feat(db): get_table_data editability flags + ctid/rowid locator (Task 7)

* feat(db): execute_change no-PK locator guard + affected-count check (Task 8)

* feat(db): preserve bigint precision as string on PG read path (Task 9)

* feat(db): get_indexes / get_constraints commands (Task 10)

* feat(store): favorites + recents Store methods (Task 11)

* feat(commands): favorites/recents IPC + register indexes/constraints (Task 12)

* feat(store): connectionStore favorites/recents/move-selection (Task 13)

* feat(lib): recent-connections pure helpers (Task 14)

* feat(store): dbViewerStore indexes/constraints + stageCellEdit (Task 15)

* feat(grid): pure editability + filter-operator + cell transform (Task 16)

* feat(grid): pure keyboard-nav helper (Task 17)

* feat(grid): CellEditor inline editor (Task 18)

* feat(grid): CellContextMenu + RowDetailDrawer (Task 19)

* feat(grid): focus model + keyboard nav + inline edit + copy + context menu (Task 20)

* feat(db-viewer): FilterBuilder drag-and-drop + type-aware operators (Task 21)

* feat(db-viewer): ObjectExplorer indexes/constraints/procedures + matview icon (Task 22)

* feat(home): ConnectionCard favorite star + on-demand StatusDot (Task 23)

* feat(home): move-to-folder + recents strip + status wiring (Task 24)

* feat(db-viewer): grid wiring + matview read-only + post-commit refetch (Task 25)

* docs: v0.5.0 status + roadmap updates (Task 26)

* polish: empty/error/loading states for v0.5.0 surfaces (Task 28)

* feat(home): duplicateConnection + useConnectionStatus hook, drop StatusDot (FEAT-A)

* feat(home): connection card kebab menu — favorite/test/manage (FEAT-B)

* fix(home): populate server_version/latency_ms in test_connection + clean online display

* style(home): swap grab handle and kebab positions on connection card

* style(home): nudge kebab menu to right-1

* style(home): nudge kebab menu to right-0.5

* feat(home): Escape clears + exits focused search

* feat(grid): context-menu View/Select Row, outside-click close, Esc cancels edit, FK reference (GRID-A)

* feat(grid): smart CellEditor — enum select, FK searchable dropdown, textarea height (GRID-B)

* feat(grid): enums + FK options fed into CellEditor (GRID-C)

* feat(grid): FK dropdown display-column labels + placeholder + empty state

* fix(grid): FK dropdown renders as fixed overlay to avoid clipping

* fix(grid): portal FK dropdown to body + FK reference icon instead of click-to-open

* style(grid): move FK reference icon to the start of the cell

* feat(grid): optimistic staged cell values + pending dot, cleared on refetch

* fix(grid): queue is source of truth for staged values — value diff, Clear All clears dots, same-cell edits replace

* fix(db-viewer): type getLocator for staged-value matching

* test(db-viewer): unit-test deriveStagedValues; fix activeTab null guard

* fix(db-viewer): pass table prop to VirtualDataGrid — staged edits now carry the table name

* test(db-viewer): use index access instead of .at() for TS lib target

* feat(grid): FK dropdown options as one-row column cells (FK-reference style, cap 5)

* style(grid): FK dropdown — values only, fixed 360px width, FK-viewer surface styling

* style(grid): harden FK dropdown minWidth to 360px

* style(grid): cap FK dropdown cells at 3

* style(grid): cap FK dropdown cells at 4

* fix(grid): pending dot clears on commit — values stay until refetch

* fix(db): deserialize pg_attribute char columns as i8 — no more panic on get_table_data

* docs: reflect grid interactivity, smart editors, optimistic queue, FK reference, kebab status
This commit is contained in:
2026-08-03 02:42:24 +08:00
committed by GitHub
parent e0c0db8352
commit 16888460b7
77 changed files with 5671 additions and 265 deletions
+126
View File
@@ -122,6 +122,66 @@ pub fn add_connection_tags(
add_connection_tags_inner(&state.db_store, connection_id, tag_ids)
}
pub fn set_connection_favorite_inner(
state: &Mutex<Store>,
connection_id: String,
favorite: bool,
) -> Result<(), String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.set_connection_favorite(&connection_id, favorite)
}
pub fn record_recent_connection_inner(
state: &Mutex<Store>,
connection_id: String,
) -> Result<(), String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.record_recent_connection(&connection_id)
}
pub fn get_recent_connections_inner(
state: &Mutex<Store>,
limit: i64,
) -> Result<Vec<crate::models::RecentConnection>, String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.get_recent_connections(limit)
}
pub fn clear_recent_connections_inner(state: &Mutex<Store>) -> Result<(), String> {
let store = state.lock().map_err(|e| e.to_string())?;
store.clear_recent_connections()
}
#[tauri::command]
pub fn set_connection_favorite(
state: tauri::State<crate::AppState>,
connection_id: String,
favorite: bool,
) -> Result<(), String> {
set_connection_favorite_inner(&state.db_store, connection_id, favorite)
}
#[tauri::command]
pub fn record_recent_connection(
state: tauri::State<crate::AppState>,
connection_id: String,
) -> Result<(), String> {
record_recent_connection_inner(&state.db_store, connection_id)
}
#[tauri::command]
pub fn get_recent_connections(
state: tauri::State<crate::AppState>,
limit: i64,
) -> Result<Vec<crate::models::RecentConnection>, String> {
get_recent_connections_inner(&state.db_store, limit)
}
#[tauri::command]
pub fn clear_recent_connections(state: tauri::State<crate::AppState>) -> Result<(), String> {
clear_recent_connections_inner(&state.db_store)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -232,4 +292,70 @@ mod tests {
delete_connection_inner(&st, &conn.id).unwrap();
assert_eq!(get_connections_inner(&st).unwrap().len(), 0);
}
#[test]
fn set_connection_favorite_command_persists() {
let st = state();
let input = ConnectionInput {
name: "P".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_password: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
};
let conn = create_connection_inner(&st, input).unwrap();
set_connection_favorite_inner(&st, conn.id.clone(), true).unwrap();
assert_eq!(get_connections_inner(&st).unwrap()[0].favorite, true);
}
#[test]
fn record_recent_command_upserts() {
let st = state();
let input = ConnectionInput {
name: "P".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_password: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
};
let conn = create_connection_inner(&st, input).unwrap();
record_recent_connection_inner(&st, conn.id.clone()).unwrap();
record_recent_connection_inner(&st, conn.id.clone()).unwrap(); // idempotent upsert
let recent = get_recent_connections_inner(&st, 10).unwrap();
assert_eq!(recent.len(), 1);
clear_recent_connections_inner(&st).unwrap();
assert_eq!(get_recent_connections_inner(&st, 10).unwrap().len(), 0);
}
}
+407 -51
View File
@@ -5,8 +5,8 @@
use crate::db::pool::{DbConfig, DbHandle};
use crate::models::db_viewer::{
Change, ColumnInfo, EnumInfo, ExtensionInfo, FunctionInfo, QueryResult,
SequenceInfo, TableInfo, TriggerInfo,
Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo,
IndexInfo, QueryResult, SequenceInfo, TableInfo, TriggerInfo,
};
use std::collections::HashMap;
use tauri::State;
@@ -90,6 +90,40 @@ pub fn offset(page: i64, page_size: i64) -> i64 {
(page - 1) * page_size
}
/// Split a `pg_get_indexdef(...,0,true)` / `pg_attribute` column CSV into a
/// Vec, trimming whitespace. Splits on commas that are NOT inside parens
/// (to keep expression-index columns intact).
pub(crate) fn split_columns_csv(csv: &str) -> Vec<String> {
let csv = csv.trim();
if csv.is_empty() {
return Vec::new();
}
let mut out = Vec::new();
let mut depth = 0i32;
let mut buf = String::new();
for ch in csv.chars() {
match ch {
'(' => {
depth += 1;
buf.push(ch);
}
')' => {
depth -= 1;
buf.push(ch);
}
',' if depth == 0 => {
out.push(buf.trim().to_string());
buf.clear();
}
_ => buf.push(ch),
}
}
if !buf.trim().is_empty() {
out.push(buf.trim().to_string());
}
out
}
// ---------------------------------------------------------------------------
// Table DDL helpers
// ---------------------------------------------------------------------------
@@ -275,6 +309,66 @@ fn build_order_clause(sorts: &[crate::models::db_viewer::SortRule]) -> String {
}
}
// ---------------------------------------------------------------------------
// Row-locator / editability helpers (Task 7)
// ---------------------------------------------------------------------------
/// Decide editability from pg_attribute flags: `attgenerated` ('' or 's'/'v')
/// and `attidentity` ('' or 'a'='ALWAYS' / 'd'='BY DEFAULT').
/// Generated (stored) columns and IDENTITY ALWAYS columns are non-editable.
pub(crate) fn editable_from_att(attgenerated: &str, attidentity: &str) -> bool {
attgenerated.is_empty() && attidentity != "a"
}
/// Convert pg_attribute's internal "char" (i8, OID 18) to the 1-char string
/// used by `editable_from_att`: '' = not set, 's' = STORED, 'v' = VIRTUAL,
/// 'a' = ALWAYS, 'd' = BY DEFAULT. `None`/`\0` → "" (safe, no panic).
pub(crate) fn pg_char_to_att(value: Option<i8>) -> String {
match value.and_then(|c| char::from_u32(c as u32)) {
Some(c) if c != '\0' => c.to_string(),
_ => String::new(),
}
}
/// Assemble a PG SELECT statement from pre-formatted select items (already
/// quoted and optionally `::text`-cast), appending `ctid` when the table has
/// no primary key so later UPDATE/DELETE queue changes can target the exact
/// row. `ctid` is appended last so it does not shift visible column order.
fn build_pg_select_from_items(schema: &str, table: &str, items: Vec<String>, has_pk: bool) -> String {
let mut all_cols = items;
if !has_pk {
all_cols.push("ctid".to_string());
}
format!("SELECT {} FROM \"{}\".\"{}\"", all_cols.join(", "), schema, table)
}
/// Build the PG data SELECT, appending `ctid` only when the table has no PK.
pub(crate) fn build_pg_data_select(
schema: &str,
table: &str,
visible_cols: &[String],
has_pk: bool,
) -> String {
let base_cols: Vec<String> = visible_cols.iter().map(|c| format!("\"{}\"", c)).collect();
build_pg_select_from_items(schema, table, base_cols, has_pk)
}
/// Build the SQLite data SELECT, appending `rowid` only when the table has no
/// PK. The table is unqualified; SQLite browsing in this app is always scoped
/// to the `main` schema, where an unqualified name resolves identically.
pub(crate) fn build_sqlite_data_select(
table: &str,
visible_cols: &[String],
has_pk: bool,
) -> String {
let base_cols: Vec<String> = visible_cols.iter().map(|c| format!("\"{}\"", c)).collect();
let mut all_cols = base_cols;
if !has_pk {
all_cols.push("rowid".to_string());
}
format!("SELECT {} FROM \"{}\"", all_cols.join(", "), table)
}
/// Build a parameterized UPDATE SQL statement.
///
/// The returned SQL uses `?` placeholders for both the SET values and the
@@ -289,7 +383,10 @@ pub fn build_update_sql(
table: &str,
primary_key: &[(String, serde_json::Value)],
new_data: &[(String, serde_json::Value)],
) -> String {
) -> Result<(String, Vec<serde_json::Value>), String> {
if primary_key.is_empty() {
return Err("cannot update a row without a primary key or row locator".to_string());
}
let set_clause: Vec<String> = new_data
.iter()
.map(|(col, _)| format!("\"{}\" = ?", col))
@@ -298,13 +395,22 @@ pub fn build_update_sql(
.iter()
.map(|(col, _)| format!("\"{}\" = ?", col))
.collect();
format!(
"UPDATE \"{}\".\"{}\" SET {} WHERE {}",
schema,
table,
set_clause.join(", "),
where_clause.join(" AND ")
)
// Params must follow placeholder order: SET values first, then WHERE.
let params: Vec<serde_json::Value> = new_data
.iter()
.chain(primary_key.iter())
.map(|(_, v)| v.clone())
.collect();
Ok((
format!(
"UPDATE \"{}\".\"{}\" SET {} WHERE {}",
schema,
table,
set_clause.join(", "),
where_clause.join(" AND ")
),
params,
))
}
/// Build a parameterized DELETE SQL statement.
@@ -317,17 +423,23 @@ pub fn build_delete_sql(
schema: &str,
table: &str,
primary_key: &[(String, serde_json::Value)],
) -> String {
) -> Result<(String, Vec<serde_json::Value>), String> {
if primary_key.is_empty() {
return Err("cannot delete a row without a primary key or row locator".to_string());
}
let where_clause: Vec<String> = primary_key
.iter()
.map(|(col, _)| format!("\"{}\" = ?", col))
.collect();
format!(
"DELETE FROM \"{}\".\"{}\" WHERE {}",
schema,
table,
where_clause.join(" AND ")
)
Ok((
format!(
"DELETE FROM \"{}\".\"{}\" WHERE {}",
schema,
table,
where_clause.join(" AND ")
),
primary_key.iter().map(|(_, v)| v.clone()).collect(),
))
}
/// Build a parameterized INSERT SQL statement.
@@ -367,7 +479,10 @@ pub fn build_pg_update_sql(
table: &str,
primary_key: &[(String, serde_json::Value)],
new_data: &[(String, serde_json::Value)],
) -> (String, Vec<serde_json::Value>) {
) -> Result<(String, Vec<serde_json::Value>), String> {
if primary_key.is_empty() {
return Err("cannot update a row without a primary key or row locator".to_string());
}
let mut params: Vec<serde_json::Value> = Vec::new();
let set_clause: Vec<String> = new_data
.iter()
@@ -383,7 +498,7 @@ pub fn build_pg_update_sql(
format!("\"{}\" = ${}", col, params.len())
})
.collect();
(
Ok((
format!(
"UPDATE \"{}\".\"{}\" SET {} WHERE {}",
schema,
@@ -392,7 +507,7 @@ pub fn build_pg_update_sql(
where_clause.join(" AND ")
),
params,
)
))
}
/// Build a PostgreSQL DELETE statement.
@@ -400,7 +515,10 @@ pub fn build_pg_delete_sql(
schema: &str,
table: &str,
primary_key: &[(String, serde_json::Value)],
) -> (String, Vec<serde_json::Value>) {
) -> Result<(String, Vec<serde_json::Value>), String> {
if primary_key.is_empty() {
return Err("cannot delete a row without a primary key or row locator".to_string());
}
let mut params: Vec<serde_json::Value> = Vec::new();
let where_clause: Vec<String> = primary_key
.iter()
@@ -409,7 +527,7 @@ pub fn build_pg_delete_sql(
format!("\"{}\" = ${}", col, params.len())
})
.collect();
(
Ok((
format!(
"DELETE FROM \"{}\".\"{}\" WHERE {}",
schema,
@@ -417,7 +535,7 @@ pub fn build_pg_delete_sql(
where_clause.join(" AND ")
),
params,
)
))
}
/// Build a PostgreSQL INSERT statement.
@@ -661,13 +779,20 @@ fn sqlite_value_to_json(row: &rusqlite::Row, i: usize) -> serde_json::Value {
}
}
/// Serialize an i64 as a JSON string to preserve precision across the IPC
/// boundary (JS `Number` loses integer fidelity beyond 2^53). The frontend
/// treats numeric columns as strings for edit round-trips.
pub(crate) fn i64_to_json(v: i64) -> serde_json::Value {
serde_json::Value::String(v.to_string())
}
pub(crate) fn pg_value_to_json(row: &tokio_postgres::Row, i: usize) -> serde_json::Value {
// Integer types
if let Ok(Some(v)) = row.try_get::<_, Option<i32>>(i) {
return serde_json::json!(v);
}
if let Ok(Some(v)) = row.try_get::<_, Option<i64>>(i) {
return serde_json::json!(v);
return i64_to_json(v);
}
if let Ok(Some(v)) = row.try_get::<_, Option<i16>>(i) {
return serde_json::json!(v);
@@ -1024,7 +1149,9 @@ pub async fn get_table_data(
COALESCE(fk.is_fk, false) AS is_fk,
fk.foreign_table_name,
fk.foreign_column_name,
c.column_default
c.column_default,
a.attgenerated,
a.attidentity
FROM information_schema.columns c
LEFT JOIN (
SELECT ku.column_name, true AS is_pk
@@ -1056,6 +1183,11 @@ LEFT JOIN (
AND tc.table_schema = $1
AND tc.table_name = $2
) fk ON c.column_name = fk.column_name
LEFT JOIN pg_attribute a
ON a.attrelid = (quote_ident(c.table_schema) || '.' || quote_ident(c.table_name))::regclass
AND a.attname = c.column_name
AND a.attnum > 0
AND NOT a.attisdropped
WHERE c.table_schema = $1 AND c.table_name = $2
ORDER BY c.ordinal_position"#;
let col_rows = client
@@ -1068,11 +1200,21 @@ ORDER BY c.ordinal_position"#;
let is_fk: bool = r.get(4);
let fk_table: Option<String> = r.get(5);
let fk_column: Option<String> = r.get(6);
// pg_attribute.attgenerated/attidentity are PG's internal
// "char" type (OID 18) → tokio-postgres delivers i8, not
// String; deserializing as String panics. Convert safely.
let attgenerated = pg_char_to_att(
r.try_get::<_, Option<i8>>(8).unwrap_or(None),
);
let attidentity = pg_char_to_att(
r.try_get::<_, Option<i8>>(9).unwrap_or(None),
);
let is_pk: bool = r.get(3);
ColumnInfo {
name: r.get(0),
data_type: r.get(1),
is_nullable: r.get::<_, String>(2) == "YES",
is_pk: r.get(3),
is_pk,
is_fk,
fk_ref: if is_fk {
Some((fk_table.unwrap_or_default(), fk_column.unwrap_or_default()))
@@ -1080,6 +1222,8 @@ ORDER BY c.ordinal_position"#;
None
},
default_value: r.get::<_, Option<String>>(7),
editable: editable_from_att(&attgenerated, &attidentity) && !is_pk,
is_generated: !attgenerated.is_empty(),
}
})
.collect();
@@ -1098,7 +1242,8 @@ ORDER BY c.ordinal_position"#;
"timestamp without time zone", "timestamp with time zone",
"time without time zone", "time with time zone",
];
let select_cols: Vec<String> = columns
let has_pk = columns.iter().any(|c| c.is_pk);
let select_items: Vec<String> = columns
.iter()
.map(|c| {
let lower = c.data_type.to_lowercase();
@@ -1110,10 +1255,12 @@ ORDER BY c.ordinal_position"#;
}
})
.collect();
// `ctid` is appended last for no-PK tables so later UPDATE/DELETE
// queue changes can target the exact row. It stays out of `columns`.
let data_query = format!(
"SELECT {} FROM \"{}\".\"{}\" WHERE 1=1{} {} LIMIT {} OFFSET {}",
select_cols.join(", "),
schema, table, filter_clause, order_clause, ps, off
"{} WHERE 1=1{} {} LIMIT {} OFFSET {}",
build_pg_select_from_items(&schema, &table, select_items, has_pk),
filter_clause, order_clause, ps, off
);
let data_rows = if filter_params.is_empty() {
client
@@ -1207,14 +1354,21 @@ ORDER BY c.ordinal_position"#;
is_fk: fk.is_some(),
fk_ref: fk.map(|(t, c)| (t.clone(), c.clone())),
default_value: default_val.clone(),
editable: !*is_pk,
is_generated: false,
}
})
.collect();
// Get data (with filters and sorts applied)
// Get data (with filters and sorts applied).
// `rowid` is appended last for no-PK tables so later UPDATE/DELETE
// queue changes can target the exact row. It stays out of `columns`.
let visible_names: Vec<String> = columns.iter().map(|c| c.name.clone()).collect();
let has_pk = columns.iter().any(|c| c.is_pk);
let data_query = format!(
"SELECT * FROM \"{}\".\"{}\" WHERE 1=1{} {} LIMIT {} OFFSET {}",
schema, table, filter_clause, order_clause, ps, off
"{} WHERE 1=1{} {} LIMIT {} OFFSET {}",
build_sqlite_data_select(&table, &visible_names, has_pk),
filter_clause, order_clause, ps, off
);
let mut stmt = conn.prepare(&data_query).map_err(|e| e.to_string())?;
let col_count = stmt.column_count();
@@ -1334,6 +1488,8 @@ ORDER BY c.ordinal_position"#;
None
},
default_value: r.get::<_, Option<String>>(7),
editable: true,
is_generated: false,
}
})
.collect();
@@ -1411,6 +1567,8 @@ ORDER BY c.ordinal_position"#;
is_fk: fk.is_some(),
fk_ref: fk.map(|(t, c)| (t.clone(), c.clone())),
default_value: default_val.clone(),
editable: true,
is_generated: false,
}
})
.collect();
@@ -1447,6 +1605,16 @@ ORDER BY c.ordinal_position"#;
}
}
/// Map a tokio_postgres/rusqlite affected-row count to a friendly error.
/// Exactly 1 -> Ok (None). 0 -> stale; >1 -> ambiguous.
pub(crate) fn affected_count_error(n: u64) -> Option<String> {
match n {
0 => Some("row was modified or removed by another session".to_string()),
1 => None,
_ => Some("ambiguous row match".to_string()),
}
}
#[tauri::command]
pub async fn execute_change(
connection_id: String,
@@ -1467,7 +1635,7 @@ pub async fn execute_change(
} => {
let pk = parse_json_pairs(primary_key)?;
let data = parse_json_pairs(new_data)?;
build_pg_update_sql(schema, table, &pk, &data)
build_pg_update_sql(schema, table, &pk, &data)?
}
Change::Insert {
schema,
@@ -1485,7 +1653,7 @@ pub async fn execute_change(
..
} => {
let pk = parse_json_pairs(primary_key)?;
build_pg_delete_sql(schema, table, &pk)
build_pg_delete_sql(schema, table, &pk)?
}
Change::AlterTable { sql, .. } => {
// Execute the raw DDL directly; no bound parameters.
@@ -1536,7 +1704,13 @@ pub async fn execute_change(
r
})
.collect();
client.execute(&sql, &refs).await.map_err(|e| e.to_string())?;
let n = client
.execute(&sql, &refs)
.await
.map_err(|e| e.to_string())?;
if let Some(msg) = affected_count_error(n) {
return Err(msg);
}
Ok(())
}
Some(crate::db::pool::DbHandle::Sqlite(conn)) => {
@@ -1550,13 +1724,7 @@ pub async fn execute_change(
} => {
let pk = parse_json_pairs(primary_key)?;
let data = parse_json_pairs(new_data)?;
(
build_update_sql(schema, table, &pk, &data),
pk.iter()
.chain(data.iter())
.map(|(_, v)| v.clone())
.collect(),
)
build_update_sql(schema, table, &pk, &data)?
}
Change::Insert {
schema,
@@ -1579,10 +1747,7 @@ pub async fn execute_change(
..
} => {
let pk = parse_json_pairs(primary_key)?;
(
build_delete_sql(schema, table, &pk),
pk.iter().map(|(_, v)| v.clone()).collect(),
)
build_delete_sql(schema, table, &pk)?
}
Change::AlterTable { sql, .. } => {
conn.execute(sql, []).map_err(|e| e.to_string())?;
@@ -1612,8 +1777,12 @@ pub async fn execute_change(
let sqlite_params: Vec<rusqlite::types::Value> =
params.iter().map(json_to_sqlite_value).collect();
conn.execute(&sql, rusqlite::params_from_iter(sqlite_params))
let n = conn
.execute(&sql, rusqlite::params_from_iter(sqlite_params))
.map_err(|e| e.to_string())?;
if let Some(msg) = affected_count_error(n as u64) {
return Err(msg);
}
Ok(())
}
None => Err("Connection not found".to_string()),
@@ -1678,6 +1847,85 @@ pub async fn get_functions(
}
}
#[tauri::command]
pub async fn get_indexes(
connection_id: String,
schema: Option<String>,
state: State<'_, crate::AppState>,
) -> Result<Vec<IndexInfo>, String> {
let mut pm = state.pool_manager.lock().await;
match pm.get(&connection_id) {
Some(DbHandle::Postgresql(client, _)) => {
let schema = schema.unwrap_or_else(|| "public".to_string());
let query = crate::db::introspection::pg_indexes_query(&schema);
let rows = client
.query(&query, &[&schema])
.await
.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|r| IndexInfo {
name: r.get(0),
schema: r.get(1),
table: r.get(2),
definition: r.get(3),
is_unique: r.get(4),
method: r.get::<_, Option<String>>(5).unwrap_or_default(),
columns: split_columns_csv(&r.get::<_, Option<String>>(6).unwrap_or_default()),
size_bytes: r.get::<_, Option<i64>>(7),
tablespace: r.get::<_, Option<String>>(8),
})
.collect())
}
Some(DbHandle::Sqlite(_)) => Ok(vec![]),
None => Err("Connection not found".into()),
}
}
#[tauri::command]
pub async fn get_constraints(
connection_id: String,
schema: Option<String>,
state: State<'_, crate::AppState>,
) -> Result<Vec<ConstraintInfo>, String> {
let mut pm = state.pool_manager.lock().await;
match pm.get(&connection_id) {
Some(DbHandle::Postgresql(client, _)) => {
let schema = schema.unwrap_or_else(|| "public".to_string());
let query = crate::db::introspection::pg_constraints_query(&schema);
let rows = client
.query(&query, &[&schema])
.await
.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|r| {
// contype::text decodes as a String ("c" | "u" | "x").
let contype = match r.get::<_, Option<String>>(3).unwrap_or_default().as_str() {
"c" => "CHECK",
"u" => "UNIQUE",
"x" => "EXCLUSION",
other => other,
}
.to_string();
ConstraintInfo {
name: r.get(0),
schema: r.get(1),
table: r.get(2),
contype,
definition: r.get(4),
deferrable: r.get(5),
validated: r.get(6),
columns: split_columns_csv(&r.get::<_, Option<String>>(7).unwrap_or_default()),
}
})
.collect())
}
Some(DbHandle::Sqlite(_)) => Ok(vec![]),
None => Err("Connection not found".into()),
}
}
#[tauri::command]
pub async fn get_triggers(
connection_id: String,
@@ -1877,6 +2125,28 @@ mod tests {
use super::*;
use crate::models::db_viewer::Change;
#[test]
fn split_columns_csv_handles_commas_and_trims() {
assert_eq!(split_columns_csv("id, name, created_at"), vec!["id", "name", "created_at"]);
assert_eq!(split_columns_csv("id"), vec!["id"]);
assert_eq!(split_columns_csv(""), Vec::<String>::new());
// expression index column list may include parens — keep raw, just split on top-level commas
assert_eq!(split_columns_csv("lower(name), id"), vec!["lower(name)", "id"]);
}
/// bigint precision: values beyond 2^53 must round-trip as strings.
#[test]
fn i64_preserves_precision_as_string() {
// A bigint beyond 2^53 must round-trip as a string, not a JS number.
let big: i64 = 9_007_199_254_740_993; // 2^53 + 1
let v = i64_to_json(big);
assert_eq!(v, serde_json::Value::String("9007199254740993".to_string()),
"bigint must be a string to avoid float precision loss");
let small: i64 = 42;
let v2 = i64_to_json(small);
assert_eq!(v2, serde_json::Value::String("42".to_string()));
}
/// Verify the `offset` helper produces correct pagination offsets.
#[test]
fn pagination_offset_is_correct() {
@@ -1930,7 +2200,7 @@ mod tests {
("email".to_string(), serde_json::json!("bob@example.com")),
];
let sql = build_update_sql("public", "users", &pk, &data);
let (sql, _params) = build_update_sql("public", "users", &pk, &data).unwrap();
assert!(
sql.to_uppercase().contains("UPDATE"),
@@ -1955,7 +2225,7 @@ mod tests {
fn build_change_delete_sql_is_valid() {
let pk = vec![("id".to_string(), serde_json::json!(1))];
let sql = build_delete_sql("public", "users", &pk);
let (sql, _params) = build_delete_sql("public", "users", &pk).unwrap();
assert!(
sql.to_uppercase().contains("DELETE FROM"),
@@ -2116,4 +2386,90 @@ mod tests {
r#"DELETE FROM "public"."users""#
);
}
}
// -----------------------------------------------------------------------
// Row-locator / editability helpers (Task 7)
// -----------------------------------------------------------------------
#[test]
fn editable_pg_column_flags_mark_generated_and_identity_always() {
// generated STORED ('s') -> not editable; identity ALWAYS ('a') -> not editable
assert!(!editable_from_att("s", ""));
assert!(!editable_from_att("", "a"));
// plain column -> editable
assert!(editable_from_att("", ""));
// identity BY DEFAULT ('d') -> editable
assert!(editable_from_att("", "d"));
}
#[test]
fn pg_char_to_att_maps_internal_char_codes_safely() {
// pg_attribute "char" arrives as i8; None/\0 -> "", codes -> 1-char string
assert_eq!(pg_char_to_att(None), "");
assert_eq!(pg_char_to_att(Some(0)), "");
assert_eq!(pg_char_to_att(Some(b's' as i8)), "s");
assert_eq!(pg_char_to_att(Some(b'v' as i8)), "v");
assert_eq!(pg_char_to_att(Some(b'a' as i8)), "a");
assert_eq!(pg_char_to_att(Some(b'd' as i8)), "d");
// wiring: a STORED generated column must be non-editable through the helper
assert!(!editable_from_att(&pg_char_to_att(Some(b's' as i8)), ""));
}
#[test]
fn pg_locator_select_adds_ctid() {
let sql = build_pg_data_select("public", "no_pk", &["id".into(), "name".into()], false);
assert!(sql.contains("ctid"), "no-PK table must select ctid; got: {}", sql);
assert!(sql.contains("\"public\""), "schema must be quoted; got: {}", sql);
}
#[test]
fn pg_locator_select_omits_ctid_when_pk_present() {
let sql = build_pg_data_select("public", "with_pk", &["id".into(), "name".into()], true);
assert!(!sql.contains("ctid"), "PK table must NOT select ctid; got: {}", sql);
}
#[test]
fn sqlite_locator_select_adds_rowid_for_no_pk() {
let sql = build_sqlite_data_select("no_pk", &["id".into(), "name".into()], false);
assert!(sql.contains("rowid"), "no-PK sqlite table must select rowid; got: {}", sql);
}
// -----------------------------------------------------------------------
// No-PK row locator updates + affected-row-count guard (Task 8)
// -----------------------------------------------------------------------
#[test]
fn pg_update_with_locator_uses_it_in_where() {
// The frontend supplies ctid as the "primary_key" pair for no-PK rows.
let locator = vec![("ctid".to_string(), serde_json::json!("(0,1)"))];
let data = vec![("name".to_string(), serde_json::json!("Bob"))];
let (sql, params) = build_pg_update_sql("public", "no_pk", &locator, &data).unwrap();
assert!(sql.contains("\"ctid\" = $"), "locator update must WHERE on ctid; got: {}", sql);
assert_eq!(params.len(), 2); // 1 SET value + 1 WHERE value
}
#[test]
fn pg_update_with_pk_uses_pk_where() {
let pk = vec![("id".to_string(), serde_json::json!(1))];
let data = vec![("name".to_string(), serde_json::json!("Bob"))];
let (sql, _params) = build_pg_update_sql("public", "users", &pk, &data).unwrap();
assert!(sql.contains("\"id\" = $"), "PK update must WHERE on id; got: {}", sql);
assert!(!sql.contains("ctid"), "PK update must NOT use ctid; got: {}", sql);
}
#[test]
fn pg_update_with_empty_primary_key_is_rejected() {
// Defense-in-depth: an empty locator must NOT yield `UPDATE ... WHERE `.
let pk: Vec<(String, serde_json::Value)> = vec![];
let data = vec![("name".to_string(), serde_json::json!("Bob"))];
let result = build_pg_update_sql("public", "no_pk", &pk, &data);
assert!(result.is_err(), "empty primary_key must be rejected, not produce broken SQL");
}
#[test]
fn affected_row_count_message_for_zero_rows() {
assert_eq!(affected_count_error(0u64), Some("row was modified or removed by another session".to_string()));
assert_eq!(affected_count_error(1u64), None);
assert_eq!(affected_count_error(2u64), Some("ambiguous row match".to_string()));
}
}
+6
View File
@@ -243,6 +243,8 @@ async fn execute_pg_query(
is_fk: false,
fk_ref: None,
default_value: None,
editable: true,
is_generated: false,
})
.collect(),
None => {
@@ -309,6 +311,8 @@ async fn execute_pg_raw(
is_fk: false,
fk_ref: None,
default_value: None,
editable: true,
is_generated: false,
})
.collect();
saw_columns = true;
@@ -432,6 +436,8 @@ fn execute_sqlite_with_query(
is_fk: false,
fk_ref: None,
default_value: None,
editable: true,
is_generated: false,
}
})
.collect();
+139 -16
View File
@@ -13,6 +13,10 @@ pub struct TestConnectionResult {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub server_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub latency_ms: Option<u64>,
}
/// Strip credentials and sensitive information from error messages while
@@ -212,6 +216,8 @@ pub async fn test_database_connection(
return TestConnectionResult {
ok: false,
error: Some(err),
server_version: None,
latency_ms: None,
};
}
@@ -223,12 +229,16 @@ pub async fn test_database_connection(
other => TestConnectionResult {
ok: false,
error: Some(format!("unsupported database type: {other}")),
server_version: None,
latency_ms: None,
},
};
TestConnectionResult {
ok: result.ok,
error: result.error.map(|e| sanitize_error(&e)),
server_version: result.server_version,
latency_ms: result.latency_ms,
}
}
@@ -245,7 +255,14 @@ async fn test_pg_connection(config: &DbConfig, ssh: &SshManager) -> TestConnecti
let target = match resolve_connect_target(config, ssh, 5432).await {
Ok(t) => t,
Err(e) => return TestConnectionResult { ok: false, error: Some(e) },
Err(e) => {
return TestConnectionResult {
ok: false,
error: Some(e),
server_version: None,
latency_ms: None,
}
}
};
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
@@ -263,7 +280,12 @@ async fn test_pg_connection(config: &DbConfig, ssh: &SshManager) -> TestConnecti
Ok(t) => t,
Err(e) => {
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
return TestConnectionResult { ok: false, error: Some(e) };
return TestConnectionResult {
ok: false,
error: Some(e),
server_version: None,
latency_ms: None,
};
}
};
@@ -278,6 +300,7 @@ async fn test_pg_connection(config: &DbConfig, ssh: &SshManager) -> TestConnecti
.dbname(dbname)
.connect_timeout(std::time::Duration::from_secs(10));
let start = std::time::Instant::now();
let result = match tls {
None => crate::commands::db_viewer::connect_pg_with(&pgconfig, tokio_postgres::NoTls).await,
Some(cc) => {
@@ -286,19 +309,33 @@ async fn test_pg_connection(config: &DbConfig, ssh: &SshManager) -> TestConnecti
crate::commands::db_viewer::connect_pg_with(&pgconfig, connector).await
}
};
let latency_ms = Some(start.elapsed().as_millis() as u64);
match result {
Ok((_client, _handle)) => {
Ok((client, _handle)) => {
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
// Spawn the connection handler so it keeps running while we test.
// (Already spawned inside `connect_pg_with`.)
TestConnectionResult { ok: true, error: None }
// Best-effort server version from the live client; None if the
// query fails. The driver task is already spawned inside
// `connect_pg_with`, so the client is fully usable here.
let server_version = client
.query_one("SELECT current_setting('server_version')", &[])
.await
.ok()
.and_then(|row| row.try_get::<_, String>(0).ok());
TestConnectionResult {
ok: true,
error: None,
server_version,
latency_ms,
}
}
Err(e) => {
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
TestConnectionResult {
ok: false,
error: Some(e.to_string()),
server_version: None,
latency_ms: None,
}
}
}
@@ -315,7 +352,14 @@ async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
let target = match resolve_connect_target(config, ssh, 3306).await {
Ok(t) => t,
Err(e) => return TestConnectionResult { ok: false, error: Some(e) },
Err(e) => {
return TestConnectionResult {
ok: false,
error: Some(e),
server_version: None,
latency_ms: None,
}
}
};
let mut opts = MySqlConnectOptions::new()
@@ -351,6 +395,7 @@ async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
}
}
let start = std::time::Instant::now();
match MySqlPoolOptions::new()
.max_connections(1)
.acquire_timeout(std::time::Duration::from_secs(10))
@@ -359,14 +404,28 @@ async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
{
Ok(pool) => {
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
// Best-effort server version; None if the query fails.
let server_version =
sqlx::query_scalar::<_, String>("SELECT VERSION()")
.fetch_one(&pool)
.await
.ok();
let latency_ms = Some(start.elapsed().as_millis() as u64);
pool.close().await;
TestConnectionResult { ok: true, error: None }
TestConnectionResult {
ok: true,
error: None,
server_version,
latency_ms,
}
}
Err(e) => {
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
TestConnectionResult {
ok: false,
error: Some(e.to_string()),
server_version: None,
latency_ms: None,
}
}
}
@@ -377,11 +436,27 @@ async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
/// Opens the database file at `config.host`. Returns success if the file
/// can be opened as a valid SQLite database.
fn test_sqlite_connection(config: &DbConfig) -> TestConnectionResult {
let start = std::time::Instant::now();
match rusqlite::Connection::open(&config.host) {
Ok(_conn) => TestConnectionResult { ok: true, error: None },
Ok(conn) => {
// Best-effort server version; None if the query fails.
let server_version = conn
.query_row("SELECT sqlite_version()", [], |r| {
r.get::<_, String>(0)
})
.ok();
TestConnectionResult {
ok: true,
error: None,
server_version,
latency_ms: Some(start.elapsed().as_millis() as u64),
}
}
Err(e) => TestConnectionResult {
ok: false,
error: Some(e.to_string()),
server_version: None,
latency_ms: None,
},
}
}
@@ -396,7 +471,14 @@ async fn test_redis_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
let target = match resolve_connect_target(config, ssh, 6379).await {
Ok(t) => t,
Err(e) => return TestConnectionResult { ok: false, error: Some(e) },
Err(e) => {
return TestConnectionResult {
ok: false,
error: Some(e),
server_version: None,
latency_ms: None,
}
}
};
let password = config.password.as_deref();
@@ -406,6 +488,7 @@ async fn test_redis_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
format!("redis://{}:{}/", target.host, target.port)
};
let start = std::time::Instant::now();
match redis::Client::open(conn_str.as_str()) {
Ok(client) => {
match timeout(
@@ -416,13 +499,20 @@ async fn test_redis_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
{
Ok(Ok(_conn)) => {
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
TestConnectionResult { ok: true, error: None }
TestConnectionResult {
ok: true,
error: None,
server_version: None,
latency_ms: Some(start.elapsed().as_millis() as u64),
}
}
Ok(Err(e)) => {
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
TestConnectionResult {
ok: false,
error: Some(e.to_string()),
server_version: None,
latency_ms: None,
}
}
Err(_) => {
@@ -430,6 +520,8 @@ async fn test_redis_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
TestConnectionResult {
ok: false,
error: Some("connection timed out after 10 seconds".to_string()),
server_version: None,
latency_ms: None,
}
}
}
@@ -439,6 +531,8 @@ async fn test_redis_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
TestConnectionResult {
ok: false,
error: Some(e.to_string()),
server_version: None,
latency_ms: None,
}
}
}
@@ -466,18 +560,47 @@ mod tests {
#[test]
fn test_connection_result_serialization() {
// ok=true result serializes correctly
let result = TestConnectionResult { ok: true, error: None };
// ok=true result with server_version/latency serializes all fields
let result = TestConnectionResult {
ok: true,
error: None,
server_version: Some("15.2".to_string()),
latency_ms: Some(12),
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"ok\":true"), "ok=true should appear in JSON");
assert!(
json.contains("\"ok\":true"),
"ok=true should appear in JSON: {json}"
);
assert!(
json.contains("\"server_version\":\"15.2\""),
"server_version should appear in JSON: {json}"
);
assert!(
json.contains("\"latency_ms\":12"),
"latency_ms should appear in JSON: {json}"
);
// error result includes the error message
// error result includes the error message; None fields are skipped
let result = TestConnectionResult {
ok: false,
error: Some("connection refused".to_string()),
server_version: None,
latency_ms: None,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"connection refused\""), "error message should appear in JSON");
assert!(
json.contains("\"connection refused\""),
"error message should appear in JSON: {json}"
);
assert!(
!json.contains("server_version"),
"None server_version should be skipped: {json}"
);
assert!(
!json.contains("latency_ms"),
"None latency_ms should be skipped: {json}"
);
}
// ------------------------------------------------------------------
+107 -2
View File
@@ -26,12 +26,19 @@ pub fn pg_tables_query(schema: Option<&str>) -> String {
match schema {
Some(s) => format!(
"SELECT table_name, table_type FROM information_schema.tables \
WHERE table_schema = '{}' ORDER BY table_name",
s
WHERE table_schema = '{}' \
UNION ALL \
SELECT matviewname AS table_name, 'MATERIALIZED VIEW' AS table_type \
FROM pg_matviews WHERE schemaname = '{}' \
ORDER BY table_name",
s, s
),
None => {
"SELECT table_name, table_type, table_schema FROM information_schema.tables \
WHERE table_schema NOT IN ('pg_catalog', 'information_schema') \
UNION ALL \
SELECT matviewname AS table_name, 'MATERIALIZED VIEW' AS table_type, schemaname AS table_schema \
FROM pg_matviews WHERE schemaname NOT IN ('pg_catalog', 'information_schema') \
ORDER BY table_schema, table_name"
.to_string()
}
@@ -285,6 +292,61 @@ pub fn pg_extensions_query() -> String {
.to_string()
}
/// Query indexes in a schema.
///
/// Returns index name, schema, table, definition (`pg_get_indexdef`),
/// uniqueness, access method, columns CSV, size in bytes, and tablespace.
pub fn pg_indexes_query(_schema: &str) -> String {
format!(
"SELECT \
i.relname AS index_name, \
ns.nspname AS schema, \
t.relname AS table_name, \
pg_get_indexdef(ix.indexrelid) AS definition, \
ix.indisunique AS is_unique, \
am.amname AS method, \
(SELECT string_agg(a.attname, ', ' ORDER BY ord.ord) \
FROM unnest(ix.indkey) WITH ORDINALITY AS ord(attnum, ord) \
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ord.attnum) AS columns, \
pg_relation_size(i.oid) AS size_bytes, \
ts.spcname AS tablespace \
FROM pg_index ix \
JOIN pg_class i ON i.oid = ix.indexrelid \
JOIN pg_class t ON t.oid = ix.indrelid \
JOIN pg_namespace ns ON t.relnamespace = ns.oid \
JOIN pg_am am ON i.relam = am.oid \
LEFT JOIN pg_tablespace ts ON i.reltablespace = ts.oid \
WHERE ns.nspname = $1 \
ORDER BY i.relname"
)
}
/// Query CHECK / UNIQUE / EXCLUSION constraints in a schema.
///
/// Primary and foreign keys are intentionally excluded — they surface in the
/// table grid. Returns name, schema, table, contype, definition
/// (`pg_get_constraintdef`), deferrability, validation, and columns CSV.
pub fn pg_constraints_query(_schema: &str) -> String {
format!(
"SELECT \
c.conname AS name, \
ns.nspname AS schema, \
cl.relname AS table_name, \
c.contype::text, \
pg_get_constraintdef(c.oid) AS definition, \
c.condeferrable, \
c.convalidated, \
(SELECT string_agg(a.attname, ', ' ORDER BY ord.ord) \
FROM unnest(c.conkey) WITH ORDINALITY AS ord(attnum, ord) \
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ord.attnum) AS columns \
FROM pg_constraint c \
JOIN pg_class cl ON c.conrelid = cl.oid \
JOIN pg_namespace ns ON cl.relnamespace = ns.oid \
WHERE ns.nspname = $1 AND c.contype IN ('c', 'u', 'x') \
ORDER BY c.conname"
)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -365,6 +427,49 @@ mod tests {
assert!(sql.contains("datistemplate"));
}
#[test]
fn pg_indexes_query_is_parameterized_and_joins() {
let sql = pg_indexes_query("public");
assert!(sql.contains("$1"), "schema must be parameterized; got: {}", sql);
assert!(
sql.contains("pg_indexes") || sql.contains("pg_index"),
"should query pg_index; got: {}",
sql
);
assert!(sql.contains("pg_get_indexdef"), "should include index definition");
assert!(sql.contains("indisunique"), "should include uniqueness");
}
#[test]
fn pg_constraints_query_filters_check_unique_exclusion() {
let sql = pg_constraints_query("public");
assert!(sql.contains("$1"), "schema must be parameterized; got: {}", sql);
assert!(
sql.contains("pg_constraint"),
"should query pg_constraint; got: {}",
sql
);
assert!(sql.contains("contype"), "should select contype");
assert!(sql.contains("'c'"), "should filter CHECK ('c')");
assert!(sql.contains("'u'"), "should filter UNIQUE ('u')");
assert!(sql.contains("'x'"), "should filter EXCLUSION ('x')");
assert!(sql.contains("pg_get_constraintdef"), "should include definition");
}
#[test]
fn pg_tables_query_includes_materialized_views() {
let sql = pg_tables_query(Some("public"));
assert!(
sql.contains("pg_matviews"),
"matview UNION must source pg_matviews; got: {}",
sql,
);
assert!(
sql.contains("MATERIALIZED VIEW"),
"should label materialized views"
);
}
// ---------------------------------------------------------------
// MySQL
// ---------------------------------------------------------------
+6
View File
@@ -78,6 +78,10 @@ pub fn run() {
connections::update_connection,
connections::delete_connection,
connections::add_connection_tags,
connections::set_connection_favorite,
connections::record_recent_connection,
connections::get_recent_connections,
connections::clear_recent_connections,
folders::get_folders,
folders::create_folder,
folders::delete_folder,
@@ -107,6 +111,8 @@ pub fn run() {
db_viewer::get_sequences,
db_viewer::get_enums,
db_viewer::get_extensions,
db_viewer::get_indexes,
db_viewer::get_constraints,
keychain::save_connection_password,
keychain::get_connection_password,
keychain::delete_connection_password,
+18
View File
@@ -12,6 +12,7 @@ pub struct Connection {
pub folder_id: Option<String>,
pub keychain_ref: Option<String>,
pub environment: Option<String>,
pub favorite: bool,
pub ssh_host: Option<String>,
pub ssh_port: Option<i64>,
pub ssh_user: Option<String>,
@@ -118,6 +119,7 @@ mod tests {
folder_id: Some("folder".to_string()),
keychain_ref: Some("keychain-ref".to_string()),
environment: None,
favorite: false,
tag_ids: vec![],
created_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
@@ -136,4 +138,20 @@ mod tests {
let json = serde_json::to_string(&conn).unwrap();
assert!(!json.contains("password"), "Connection JSON should not contain password field");
}
#[test]
fn connection_serializes_favorite_field() {
let conn = Connection {
id: "x".into(), name: "n".into(), db_type: "postgresql".into(),
host: "h".into(), port: Some(5432), username: None, database: None,
folder_id: None, keychain_ref: None, environment: None,
ssh_host: None, ssh_port: None, ssh_user: None, ssh_auth_method: None,
ssh_private_key_path: None, ssl_mode: None, ssl_ca_path: None,
ssl_cert_path: None, ssl_key_path: None, tag_ids: vec![],
favorite: true,
created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(),
};
let json = serde_json::to_string(&conn).unwrap();
assert!(json.contains("\"favorite\":true"));
}
}
+41
View File
@@ -33,6 +33,33 @@ pub struct ColumnInfo {
pub is_fk: bool,
pub fk_ref: Option<(String, String)>,
pub default_value: Option<String>,
pub editable: bool,
pub is_generated: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexInfo {
pub name: String,
pub schema: String,
pub table: String,
pub definition: String,
pub is_unique: bool,
pub method: String,
pub columns: Vec<String>,
pub size_bytes: Option<i64>,
pub tablespace: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConstraintInfo {
pub name: String,
pub schema: String,
pub table: String,
pub contype: String, // "CHECK" | "UNIQUE" | "EXCLUSION"
pub definition: String,
pub deferrable: bool,
pub validated: bool,
pub columns: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -338,12 +365,26 @@ mod tests {
is_fk: true,
fk_ref: Some(("users".to_string(), "id".to_string())),
default_value: None,
editable: true,
is_generated: false,
};
let json = serde_json::to_string(&col).unwrap();
assert!(json.contains("user_id"));
assert!(json.contains("users"));
}
#[test]
fn column_info_has_editability_fields() {
let c = ColumnInfo {
name: "id".into(), data_type: "integer".into(), is_nullable: false,
is_pk: true, is_fk: false, fk_ref: None, default_value: None,
editable: false, is_generated: false,
};
let json = serde_json::to_string(&c).unwrap();
assert!(json.contains("\"editable\":false"));
assert!(json.contains("\"is_generated\":false"));
}
#[test]
fn pagination_serialization() {
let pagination = Pagination {
+2
View File
@@ -2,11 +2,13 @@ pub mod backup;
pub mod connection;
pub mod db_viewer;
pub mod folder;
pub mod recent;
pub mod ssh;
pub mod tag;
pub mod settings;
pub use connection::{Connection, ConnectionInput};
pub use recent::RecentConnection;
#[allow(unused_imports)]
pub use db_viewer::{Change, ColumnInfo, FilterRule, Pagination, QueryResult, SortRule, TableInfo};
pub use folder::{Folder, FolderInput};
+7
View File
@@ -0,0 +1,7 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecentConnection {
pub connection_id: String,
pub opened_at: String,
}
+109 -4
View File
@@ -19,6 +19,11 @@ const CONNECTION_COLUMNS_V3: &[(&str, &str)] = &[
("environment", "TEXT"),
];
/// New columns added in version 7.
const CONNECTION_COLUMNS_V7: &[(&str, &str)] = &[
("favorite", "INTEGER NOT NULL DEFAULT 0"),
];
pub fn run_migrations(conn: &Connection) -> Result<(), String> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY);
@@ -219,6 +224,42 @@ pub fn run_migrations(conn: &Connection) -> Result<(), String> {
.map_err(|e| e.to_string())?;
}
// v7: connection favorites + recent_connections
if current_ver < 7 {
let existing: Vec<String> = {
let mut stmt = conn
.prepare("PRAGMA table_info(connections)")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| row.get::<_, String>(1))
.map_err(|e| e.to_string())?;
rows.filter_map(|r| r.ok()).collect()
};
for (col_name, col_type) in CONNECTION_COLUMNS_V7 {
if !existing.contains(&col_name.to_string()) {
let sql = format!(
"ALTER TABLE connections ADD COLUMN {} {}",
col_name, col_type
);
conn.execute(&sql, []).map_err(|e| e.to_string())?;
}
}
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS recent_connections (
connection_id TEXT PRIMARY KEY REFERENCES connections(id) ON DELETE CASCADE,
opened_at TEXT NOT NULL
);"
).map_err(|e| e.to_string())?;
conn.execute(
"INSERT INTO schema_version (version) VALUES (7)",
[],
)
.map_err(|e| e.to_string())?;
}
Ok(())
}
@@ -269,7 +310,7 @@ mod tests {
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 5);
assert_eq!(count, 6);
}
#[test]
@@ -408,13 +449,77 @@ mod tests {
fn v6_bumps_schema_version_to_6() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
let ver: i64 = conn
let count: i64 = conn
.query_row(
"SELECT MAX(version) FROM schema_version",
"SELECT COUNT(*) FROM schema_version WHERE version = 6",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(ver, 6, "Schema version should be 6 after v6 migration");
assert_eq!(count, 1, "Schema version 6 should be recorded after v6 migration");
}
#[test]
fn v7_adds_favorite_column_to_connections() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
let columns: Vec<String> = {
let mut stmt = conn.prepare("PRAGMA table_info(connections)").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 connections to have a 'favorite' column after v7"
);
let conn_id = "fav-test";
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();
let fav: i64 = conn.query_row(
"SELECT favorite FROM connections WHERE id = ?1", rusqlite::params![conn_id], |r| r.get(0),
).unwrap();
assert_eq!(fav, 0, "favorite defaults to 0");
}
#[test]
fn v7_creates_recent_connections_table() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM recent_connections", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 0);
}
#[test]
fn v7_recent_connections_cascade_on_connection_delete() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
let conn_id = "rc-cascade";
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();
conn.execute(
"INSERT INTO recent_connections (connection_id, opened_at) VALUES (?1, datetime('now'))",
rusqlite::params![conn_id],
).unwrap();
conn.execute("DELETE FROM connections WHERE id = ?1", rusqlite::params![conn_id]).unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM recent_connections WHERE connection_id = ?1", rusqlite::params![conn_id], |r| r.get(0))
.unwrap();
assert_eq!(count, 0);
}
#[test]
fn v7_bumps_schema_version_to_7() {
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, 7);
}
}
+110 -3
View File
@@ -248,7 +248,7 @@ impl Store {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, created_at, updated_at FROM connections ORDER BY name",
"SELECT id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, favorite, created_at, updated_at FROM connections ORDER BY name",
)
.map_err(|e| e.to_string())?;
let rows = stmt
@@ -273,9 +273,10 @@ impl Store {
ssl_cert_path: row.get(16)?,
ssl_key_path: row.get(17)?,
environment: row.get(18)?,
favorite: row.get(19)?,
tag_ids: vec![],
created_at: row.get(19)?,
updated_at: row.get(20)?,
created_at: row.get(20)?,
updated_at: row.get(21)?,
})
})
.map_err(|e| e.to_string())?;
@@ -320,6 +321,7 @@ impl Store {
database: input.database,
keychain_ref: None,
environment: input.environment,
favorite: false,
ssh_host: input.ssh_host,
ssh_port: input.ssh_port,
ssh_user: input.ssh_user,
@@ -335,6 +337,64 @@ impl Store {
})
}
pub fn set_connection_favorite(&self, id: &str, favorite: bool) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let fav: i64 = if favorite { 1 } else { 0 };
conn.execute(
"UPDATE connections SET favorite = ?1, updated_at = ?2 WHERE id = ?3",
params![fav, Self::now(), id],
)
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn get_recent_connections(
&self,
limit: i64,
) -> Result<Vec<crate::models::RecentConnection>, String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT connection_id, opened_at FROM recent_connections ORDER BY opened_at DESC LIMIT ?1",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![limit], |row| {
Ok(crate::models::RecentConnection {
connection_id: row.get(0)?,
opened_at: row.get(1)?,
})
})
.map_err(|e| e.to_string())?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
pub fn record_recent_connection(&self, connection_id: &str) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let now = Self::now();
conn.execute(
"INSERT INTO recent_connections (connection_id, opened_at) VALUES (?1, ?2)
ON CONFLICT(connection_id) DO UPDATE SET opened_at = excluded.opened_at",
params![connection_id, now],
)
.map_err(|e| e.to_string())?;
conn.execute(
"DELETE FROM recent_connections WHERE connection_id NOT IN (
SELECT connection_id FROM recent_connections ORDER BY opened_at DESC LIMIT 20
)",
[],
)
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn clear_recent_connections(&self) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM recent_connections", [])
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn delete_connection(&self, id: &str) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM connections WHERE id = ?1", params![id])
@@ -375,6 +435,7 @@ impl Store {
folder_id: input.folder_id,
keychain_ref: None,
environment: input.environment,
favorite: false,
ssh_host: input.ssh_host,
ssh_port: input.ssh_port,
ssh_user: input.ssh_user,
@@ -1282,6 +1343,52 @@ mod tests {
assert!(result.is_err(), "Unknown id should be an error");
}
#[test]
fn set_connection_favorite_toggles_and_persists() {
let store = fresh_store();
let conn = create_test_connection(&store, "fav-conn");
store.set_connection_favorite(&conn.id, true).unwrap();
let conns = store.get_connections().unwrap();
assert_eq!(conns[0].favorite, true);
store.set_connection_favorite(&conn.id, false).unwrap();
assert_eq!(store.get_connections().unwrap()[0].favorite, false);
}
#[test]
fn record_recent_connection_upserts_and_caps_at_20() {
let store = fresh_store();
for i in 0..25 {
let conn = create_test_connection(&store, &format!("c{}", i));
store.record_recent_connection(&conn.id).unwrap();
}
let recent = store.get_recent_connections(100).unwrap();
assert_eq!(recent.len(), 20, "recent list capped at 20");
}
#[test]
fn record_recent_connection_dedupes_and_bumps_to_top() {
let store = fresh_store();
let a = create_test_connection(&store, "a");
let b = create_test_connection(&store, "b");
store.record_recent_connection(&a.id).unwrap();
store.record_recent_connection(&b.id).unwrap();
store.record_recent_connection(&a.id).unwrap(); // a re-opened -> should be most recent
let recent = store.get_recent_connections(10).unwrap();
assert_eq!(recent.len(), 2, "dedupe keeps one row per connection");
let pos_a = recent.iter().position(|r| r.connection_id == a.id).unwrap();
let pos_b = recent.iter().position(|r| r.connection_id == b.id).unwrap();
assert!(pos_a < pos_b, "re-opened a must be most recent");
}
#[test]
fn clear_recent_connections_empties_table() {
let store = fresh_store();
let conn = create_test_connection(&store, "x");
store.record_recent_connection(&conn.id).unwrap();
store.clear_recent_connections().unwrap();
assert_eq!(store.get_recent_connections(10).unwrap().len(), 0);
}
fn create_test_connection(store: &Store, name: &str) -> crate::models::Connection {
store
.create_connection(ConnectionInput {