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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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,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};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RecentConnection {
|
||||
pub connection_id: String,
|
||||
pub opened_at: String,
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user