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}"
);
}
// ------------------------------------------------------------------