v0.7.6: PostgreSQL object management, keychain toggle, tabbed Objects workspace (#12)
Release / publish (, ubuntu-22.04) (push) Failing after 14m42s
Release / publish (, windows-latest) (push) Canceled after 0s
Release / publish (--target aarch64-apple-darwin, macos-latest) (push) Canceled after 0s
Release / publish (--target x86_64-apple-darwin, macos-15-intel) (push) Canceled after 0s
Release / publish (, ubuntu-22.04) (push) Failing after 14m42s
Release / publish (, windows-latest) (push) Canceled after 0s
Release / publish (--target aarch64-apple-darwin, macos-latest) (push) Canceled after 0s
Release / publish (--target x86_64-apple-darwin, macos-15-intel) (push) Canceled after 0s
* [P1-T1] Change::Ddl Rust variant + execute_change arm * [P1-T2] Frontend ddl change type + objectCrud capability * [P1-T3] use_keychain data model + migration v8 * [P2-T1] object_crud skeleton + validators + build_ddl dispatch * [P2-T2] Sequence builders * [P2-T3] Enum builders (no value removal) * [P2-T4] View / matview / extension builders * [P2-T5] Index + constraint builders * [P2-T6] Function / procedure + trigger builders * [P2-T7] build_object_ddl + get_available_extensions commands + wrappers * [P3-T1] conditional keychain + session passwords * [P3-T2] keychain-off password prompt on connect * [P3-T3] default-ON keychain opt-out + tooltip + modal conditional * [P3-T4] ddl queue card + after-commit refetch * [P4-T1] ObjectCrudDialog shell * [P4-T2] SequenceForm * [P4-T3] EnumForm with no-removal note * [P4-T4] ExtensionForm with available-extensions picker * [P4-T5] ViewForm (view + materialized view) * [P5-T1] IndexForm with column picker * [P5-T2] ConstraintForm (check/unique/pk/fk) + ColumnPicker * [P5-T3] FunctionForm (function + procedure) * [P5-T4] TriggerForm with trigger-function picker * [P5-T5] ObjectContextMenu + Explorer/TableOverflowMenu CRUD wiring * [P6-T1] object tab type + openObjectTab dedup * [P6-T2] extract ObjectDetail for object tabs * [P6-T3] Objects view two-pane sidebar + workspace * [P6-T4] object-tab content + per-type tab icons * [P7-T1] Version bump 0.7.5 -> 0.7.6 * [P7-T2] docs sync README/ROADMAP/AGENTS for v0.7.6 * [P7-T3] chore: Cargo.lock version sync 0.7.5 -> 0.7.6 * fix(ui): object tab icon stacks above name (preflight svg block) * fix(ui): optically center object tab icon with name * fix(ui): object tab icon matches query/table icon handling * [UI-POLISH-1] objectForm tab type + openFormTab store action * [UI-POLISH-2] ObjectFormTab + KindForm with Visual/SQL toggle * [UI-POLISH-3] route create/edit through form tabs; remove modal * docs: create/edit now open as form tabs (AGENTS sync) * [FB-1] follow app styling patterns + schema dropdown in forms * [FB-2] Monaco editor for function body + view definition * [FB-3] form tabs styled like viewers + in-cell editing * [FB-5] focus outline scoped to input area (label excluded) * [FB-6] no amber focus outline on Monaco body/definition rows * [FB-7] header dedupe + schema default + full edit prefill * [FB-8] SQL view in read-only Monaco editor * docs: roadmap — table create/edit + relationships (next) * docs: roadmap — Admin follow-up is 0.7.7 (next after 0.7.6)
This commit is contained in:
@@ -223,6 +223,7 @@ mod tests {
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
};
|
||||
let result = create_connection_inner(&st, input.clone()).unwrap();
|
||||
assert_eq!(result.name, "Prod");
|
||||
@@ -254,6 +255,7 @@ mod tests {
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
};
|
||||
assert!(create_connection_inner(&st, input).is_err());
|
||||
}
|
||||
@@ -283,6 +285,7 @@ mod tests {
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
};
|
||||
let conn = create_connection_inner(&st, input).unwrap();
|
||||
delete_connection_inner(&st, &conn.id).unwrap();
|
||||
@@ -300,6 +303,7 @@ mod tests {
|
||||
username: None,
|
||||
folder_id: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
password: None,
|
||||
database: None,
|
||||
environment: None,
|
||||
@@ -331,6 +335,7 @@ mod tests {
|
||||
username: None,
|
||||
folder_id: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
password: None,
|
||||
database: None,
|
||||
environment: None,
|
||||
|
||||
@@ -2155,6 +2155,10 @@ pub async fn execute_change(
|
||||
client.execute(sql, &[]).await.map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
Change::Ddl { sql, .. } => {
|
||||
client.execute(sql, &[]).await.map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
Change::BulkInsert {
|
||||
schema,
|
||||
table,
|
||||
@@ -2247,6 +2251,9 @@ pub async fn execute_change(
|
||||
conn.execute(sql, []).map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
Change::Ddl { .. } => {
|
||||
return Err("Object management is PostgreSQL-only".to_string());
|
||||
}
|
||||
Change::BulkInsert {
|
||||
table,
|
||||
columns,
|
||||
@@ -2319,6 +2326,9 @@ pub async fn execute_change(
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
return Ok(());
|
||||
}
|
||||
Change::Ddl { .. } => {
|
||||
return Err("Object management is PostgreSQL-only".to_string());
|
||||
}
|
||||
Change::BulkInsert {
|
||||
schema,
|
||||
table,
|
||||
@@ -2820,6 +2830,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that a `Change::Ddl` serializes with the snake_case `ddl` tag.
|
||||
#[test]
|
||||
fn change_ddl_serialization_uses_snake_case_tag() {
|
||||
let change = Change::Ddl {
|
||||
id: "chg-ddl-1".to_string(),
|
||||
sql: "CREATE TYPE public.role AS ENUM ('admin')".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&change).unwrap();
|
||||
assert!(
|
||||
json.contains(r#""type":"ddl""#),
|
||||
"serialized Change::Ddl should use snake_case tag 'ddl'; got: {}",
|
||||
json
|
||||
);
|
||||
assert!(json.contains(r#""id":"chg-ddl-1""#));
|
||||
assert!(json.contains(r#""sql":"CREATE TYPE public.role AS ENUM ('admin')""#));
|
||||
}
|
||||
|
||||
/// Verify that `Change::id()` returns the identifier of a `Change::Ddl`.
|
||||
#[test]
|
||||
fn change_ddl_id_is_accessible() {
|
||||
let change = Change::Ddl {
|
||||
id: "chg-ddl-42".to_string(),
|
||||
sql: "DROP INDEX public.i".to_string(),
|
||||
};
|
||||
assert_eq!(change.id(), "chg-ddl-42");
|
||||
}
|
||||
|
||||
/// Verify that `build_update_sql` produces valid SQL with all required
|
||||
/// clauses.
|
||||
#[test]
|
||||
|
||||
@@ -53,6 +53,7 @@ fn demo_connection_input(db_path: &Path) -> ConnectionInput {
|
||||
database: None,
|
||||
folder_id: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
environment: Some("development".to_string()),
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
|
||||
@@ -102,6 +102,7 @@ pub fn import_connections_inner(
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: rec.tag_ids.clone().unwrap_or_default(),
|
||||
use_keychain: true,
|
||||
};
|
||||
match store.create_connection(input) {
|
||||
Ok(_) => imported += 1,
|
||||
@@ -214,6 +215,7 @@ mod tests {
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
});
|
||||
let json = export_connections_inner(&st).unwrap();
|
||||
assert!(json.contains("\"name\""));
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use tauri::State;
|
||||
use crate::db::pool::{ConnectionPoolManager, DbHandle};
|
||||
use crate::db::object_crud::build_ddl;
|
||||
use crate::db::object_ddl::*;
|
||||
use crate::models::db_viewer::{ObjectSearchHit, DependencyInfo};
|
||||
use crate::models::db_viewer::{ObjectSearchHit, DependencyInfo, ExtensionInfo};
|
||||
|
||||
fn sanitize(e: &str) -> String { crate::commands::db_viewer::sanitize_error(e) }
|
||||
|
||||
@@ -146,6 +147,47 @@ pub async fn get_object_dependencies(connection_id: String, schema: String, obje
|
||||
get_object_dependencies_inner(&state.pool_manager, &connection_id, &schema, &object_type, &name).await
|
||||
}
|
||||
|
||||
/// Build SQL for an object CRUD operation. The pool is resolved only to enforce
|
||||
/// PostgreSQL-only / present-connection; the SQL itself is built by the pure
|
||||
/// `crate::db::object_crud::build_ddl` dispatcher (one statement per String).
|
||||
pub(crate) async fn build_object_ddl_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, kind: &str, params: serde_json::Value) -> Result<Vec<String>, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
match pm.get(connection_id) {
|
||||
Some(DbHandle::Postgresql(_, _)) => build_ddl(kind, params),
|
||||
Some(_) => Err("Object management is PostgreSQL-only".into()),
|
||||
None => Err("Connection not found".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn build_object_ddl(connection_id: String, kind: String, params: serde_json::Value, state: State<'_, crate::AppState>) -> Result<Vec<String>, String> {
|
||||
build_object_ddl_inner(&state.pool_manager, &connection_id, &kind, params).await
|
||||
}
|
||||
|
||||
/// List extensions installable on this server (`pg_available_extensions`):
|
||||
/// name + default version + comment. The picker uses name/version only;
|
||||
/// `schema` is left empty (available extensions are schema-wide).
|
||||
pub(crate) async fn get_available_extensions_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str) -> Result<Vec<ExtensionInfo>, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
let client = match pm.get(connection_id) {
|
||||
Some(DbHandle::Postgresql(c, _)) => c,
|
||||
Some(_) => return Err("Extensions are PostgreSQL-only".into()),
|
||||
None => return Err("Connection not found".into()),
|
||||
};
|
||||
let rows = client.query(&crate::db::introspection::pg_available_extensions_query(), &[]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||
Ok(rows.iter().map(|r| ExtensionInfo {
|
||||
name: r.get(0),
|
||||
schema: String::new(),
|
||||
version: r.get(1),
|
||||
comment: r.get(2),
|
||||
}).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_available_extensions(connection_id: String, state: State<'_, crate::AppState>) -> Result<Vec<ExtensionInfo>, String> {
|
||||
get_available_extensions_inner(&state.pool_manager, &connection_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "objects.test.rs"]
|
||||
mod tests;
|
||||
@@ -57,6 +57,53 @@ async fn object_ddl_for_sequence_enum_function() {
|
||||
assert!(f.clone().unwrap().contains("CREATE FUNCTION") || f.unwrap().contains("CREATE OR REPLACE FUNCTION"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_object_ddl_inner_guards_postgresql_only() {
|
||||
let pm = tokio::sync::Mutex::new(ConnectionPoolManager::new());
|
||||
// Missing connection -> Connection not found
|
||||
let err = build_object_ddl_inner(&pm, "missing", "sequence", serde_json::json!({
|
||||
"schema": "public", "name": "s", "action": { "op": "drop" }
|
||||
})).await.unwrap_err();
|
||||
assert!(err.contains("Connection not found"), "{err}");
|
||||
// Non-PostgreSQL handle -> PostgreSQL-only error
|
||||
pm.lock().await.register("sqlite", DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()));
|
||||
let err = build_object_ddl_inner(&pm, "sqlite", "sequence", serde_json::json!({
|
||||
"schema": "public", "name": "s", "action": { "op": "drop" }
|
||||
})).await.unwrap_err();
|
||||
assert!(err.contains("PostgreSQL-only"), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn build_object_ddl_inner_on_postgresql_pool() {
|
||||
let (pm, id) = pool().await;
|
||||
let sql = build_object_ddl_inner(&pm, &id, "sequence", serde_json::json!({
|
||||
"schema": "public", "name": "s", "action": { "op": "drop" }
|
||||
})).await.unwrap();
|
||||
assert_eq!(sql, vec!["DROP SEQUENCE \"public\".\"s\""]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_available_extensions_inner_guards_postgresql_only() {
|
||||
let pm = tokio::sync::Mutex::new(ConnectionPoolManager::new());
|
||||
// Missing connection -> Connection not found
|
||||
let err = get_available_extensions_inner(&pm, "missing").await.unwrap_err();
|
||||
assert!(err.contains("Connection not found"), "{err}");
|
||||
// Non-PostgreSQL handle -> PostgreSQL-only error
|
||||
pm.lock().await.register("sqlite", DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()));
|
||||
let err = get_available_extensions_inner(&pm, "sqlite").await.unwrap_err();
|
||||
assert!(err.contains("PostgreSQL-only"), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn get_available_extensions_on_postgresql_pool() {
|
||||
let (pm, id) = pool().await;
|
||||
let exts = get_available_extensions_inner(&pm, &id).await.unwrap();
|
||||
assert!(!exts.is_empty(), "pg_available_extensions should list built-ins");
|
||||
assert!(exts.iter().all(|e| !e.name.is_empty() && !e.version.is_empty()), "every extension needs name + default version: {exts:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn object_dependencies_for_table_includes_view() {
|
||||
|
||||
@@ -1060,6 +1060,7 @@ mod tests {
|
||||
username: None,
|
||||
folder_id: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
password: None,
|
||||
database: None,
|
||||
environment: None,
|
||||
|
||||
Reference in New Issue
Block a user