v0.7.7: PG roles/grants, table maintenance, Create/Edit Table + atomic column-reorder rebuild, FK management, table options (#13)
Release / publish (, ubuntu-22.04) (push) Failing after 14m39s
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 (, windows-latest) (push) Canceled after 0s
Release / publish (, ubuntu-22.04) (push) Failing after 14m39s
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 (, windows-latest) (push) Canceled after 0s
* feat(models): Change::RebuildTable + role/privilege/readiness/maintenance structs (Task 1.1) * feat(introspection): role/privilege/tablespace/rebuild-readiness query builders (Task 1.2) * feat(lib): rebuild_table payload, table/role object kinds, v0.7.7 capabilities + types (Task 1.3) * feat(object_crud): table_ddl builder (create/edit-diff/options/drop) + build_ddl table arm (Task 2.1) * feat(object_crud): role_ddl builder (create/edit/drop/grant/revoke) + build_ddl role arm (Task 2.2) * feat(object_crud): rebuild_script assembler + fk/grant/sequence introspection queries (Task 2.3) * feat(commands): transactional RebuildTable arm + build_rebuild_script + get_table_columns commands (Task 2.4) * feat(commands): get_roles/privileges/readiness/tablespaces + run_maintenance (Task 2.5) * feat(commands): role/privilege/rebuild/maintenance typed wrappers (Task 3.1) * feat(store): roles slice + openFormTab table/role dedup (Task 3.2) * feat(ui): TableForm (create/edit-diff/rebuild) + cross-schema FK with ON DELETE/UPDATE (Task 4.1) * feat(ui): RoleForm + RoleDetail + RoleGrantsEditor (Task 4.2) * feat(ui): wire roles into object registry + explorer (Task 4.3) * feat(ui): Edit Table + maintenance menu + Create Table toolbar + rebuild badge (Task 4.4) * chore: bump version to 0.7.7 (Task 5.1) * docs: sync README/ROADMAP/AGENTS to v0.7.7 (Task 5.2) * test: v0.7.7 manual smoke checklist (Task 5.3) * chore: sync Cargo.lock to v0.7.7 * style(ui): match TableForm visual layout to shared object-form rows (remove stray padding) * feat(ui): table creator grid redesign + per-column FK slide-in panel with relationship controls * style(ui): table-creator columns grid — bordered cells like the data table view * feat(ui): table creator — type dropdown, PK/Nullable rules, drag-to-reorder columns (vertical), tidy add-column row * feat(ui): table creator — single-PK exclusivity, name on top, fixed column widths + contained h-scroll, no-wrap constraints * style(ui): table creator — constraints column sized to content (no clip), overscroll containment * style(ui): table creator — overscrollBehavior none on both scroll containers, add-column row py-2 * feat(ui): table creator — centered add-column button, schema dropdown auto-selected to current schema * style(ui): table creator — columns grid fills container width, scrolls only when too narrow * feat(ui): table creator — shorthand PG types in dropdown, Auto-Increment only for int-family * feat(ui): FK panel — animate in/out from the right, flush create-table-style rows * fix(ui): FK panel — smooth slide (willChange + eased), local column options from the form grid * feat(ui): FK composer (multi-column, type preview, gated sections) + composite PK + constraints cog in actions col + auto-named FKs * fix(ui): FK panel blocks unnamed table with hint; Foreign keys section lists staged + DB FKs * style(ui): FK panel — transparent full-width selects, title↔select borders, 2-col column picker * style(ui): FK panel — vertical divider between local/ref columns * style(ui): FK panel — section title padding py-2 * style(ui): FK panel — remove row margins + select/text padding in column picker * style(ui): FK panel — flush column picker (no section padding), border-r + py-1 cells * style(ui): FK panel — cell padding + row borders on column picker (user polish) * feat: FK inline into CREATE TABLE (single staged change in create mode); touch up class * style(ui): hide FK icon on PK columns; fix grid header/row column alignment * feat(ui): SQL preview in table creator uses read-only Monaco editor * feat(ui): Foreign keys section — relation rows with icon + Edit/Remove actions * style(ui): FK relation row — label, icon, table on one line * style(ui): FormRow selects transparent like FK panel; test matches FK relation label * style(ui): form-row focus outline amber-400/20 (subtle) * style(ui): SQL view — remove px-4 py-3 wrapper padding * feat(ui): Create Table button label; auto tree refresh after schema-modifying commits; table name on staged ddl card * fix(roles): cast rolconnlimit::int8 to fix panic deserializing int4 into i64 * fix(privileges): cast privilege_type::text to avoid domain-array panic; aclexplode for sequences/schemas (role_sequence_grants removed in PG15, schema_privileges never existed) * feat(ui): role privileges sections collapsible (collapsed by default) with 5-entry preview + fade + Show more * feat(ui): role privileges — collapsed state shows first 5 with fade; expand reveals all * chore(test): drop unused fireEvent import in RoleDetail test * style(ui): hide chevron when section has 5 or fewer entries * docs: README/ROADMAP/AGENTS — v0.7.7 shipped details (roles/grants, table editor, FK composer, privilege explorer); ROADMAP adds v0.7.8 Next up (quick wins) * docs(readme): collapse Recent changes and Key features into details blocks * docs(readme): move hero caption under image; align download + comparison table columns * docs(readme): collapse only body content — keep Recent Changes / Key Features headings visible * docs(readme): per-subsection collapsibles with meaningful summary labels
This commit is contained in:
@@ -1864,6 +1864,60 @@ pub async fn get_table_data(
|
||||
.await
|
||||
}
|
||||
|
||||
/// Headless column introspection for a table, shared by the `get_table_columns`
|
||||
/// command and integration tests (thin-command principle — no Tauri `State`).
|
||||
/// Mirrors the `pg_columns_query` row mapping `get_table_data` uses.
|
||||
pub(crate) async fn get_table_columns_inner(
|
||||
pm: &tokio::sync::Mutex<ConnectionPoolManager>,
|
||||
connection_id: &str,
|
||||
schema: &str,
|
||||
table: &str,
|
||||
) -> Result<Vec<crate::models::ColumnInfo>, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
let client = match pm.get(connection_id) {
|
||||
Some(crate::db::pool::DbHandle::Postgresql(c, _)) => c,
|
||||
Some(_) => return Err("Table columns are PostgreSQL-only".into()),
|
||||
None => return Err("Connection not found".into()),
|
||||
};
|
||||
let rows = client
|
||||
.query(&crate::db::introspection::pg_columns_query(schema, table), &[])
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let is_fk: bool = r.get::<_, Option<String>>(10).is_some();
|
||||
let fk_schema: Option<String> = r.get(9);
|
||||
let fk_table: Option<String> = r.get(10);
|
||||
crate::models::ColumnInfo {
|
||||
name: r.get(0),
|
||||
data_type: r.get(1),
|
||||
is_nullable: r.get::<_, String>(2) == "YES",
|
||||
is_pk: r.get::<_, Option<String>>(8).as_deref() == Some("PRIMARY KEY"),
|
||||
is_fk,
|
||||
fk_ref: if is_fk {
|
||||
Some((fk_schema.unwrap_or_default(), fk_table.unwrap_or_default()))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
default_value: r.get::<_, Option<String>>(6),
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_table_columns(
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
state: State<'_, crate::AppState>,
|
||||
) -> Result<Vec<crate::models::ColumnInfo>, String> {
|
||||
get_table_columns_inner(&state.pool_manager, &connection_id, &schema, &table).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_fk_preview(
|
||||
connection_id: String,
|
||||
@@ -2110,14 +2164,15 @@ pub(crate) fn affected_count_error(n: u64) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn execute_change(
|
||||
connection_id: String,
|
||||
/// Headless change-application shared by the `execute_change` command and
|
||||
/// integration tests (thin-command principle — no Tauri `State`).
|
||||
pub(crate) async fn execute_change_inner(
|
||||
pm: &tokio::sync::Mutex<ConnectionPoolManager>,
|
||||
connection_id: &str,
|
||||
change: Change,
|
||||
state: State<'_, crate::AppState>,
|
||||
) -> Result<(), String> {
|
||||
let mut pm = state.pool_manager.lock().await;
|
||||
match pm.get(&connection_id) {
|
||||
let mut pm = pm.lock().await;
|
||||
match pm.get(connection_id) {
|
||||
Some(crate::db::pool::DbHandle::Postgresql(client, _)) => {
|
||||
// Build the parameterized SQL + bound values from the change.
|
||||
let (sql, params): (String, Vec<serde_json::Value>) = match &change {
|
||||
@@ -2159,6 +2214,23 @@ pub async fn execute_change(
|
||||
client.execute(sql, &[]).await.map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
Change::RebuildTable { sql, .. } => {
|
||||
// Single transaction: all-or-nothing rebuild (multi-statement
|
||||
// DDL only; VACUUM never reaches here). Rolls back on any
|
||||
// statement failure.
|
||||
let tx = client
|
||||
.build_transaction()
|
||||
.start()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
tx.batch_execute(sql)
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
return Ok(());
|
||||
}
|
||||
Change::BulkInsert {
|
||||
schema,
|
||||
table,
|
||||
@@ -2254,6 +2326,9 @@ pub async fn execute_change(
|
||||
Change::Ddl { .. } => {
|
||||
return Err("Object management is PostgreSQL-only".to_string());
|
||||
}
|
||||
Change::RebuildTable { .. } => {
|
||||
return Err("Object management is PostgreSQL-only".to_string());
|
||||
}
|
||||
Change::BulkInsert {
|
||||
table,
|
||||
columns,
|
||||
@@ -2329,6 +2404,9 @@ pub async fn execute_change(
|
||||
Change::Ddl { .. } => {
|
||||
return Err("Object management is PostgreSQL-only".to_string());
|
||||
}
|
||||
Change::RebuildTable { .. } => {
|
||||
return Err("Object management is PostgreSQL-only".to_string());
|
||||
}
|
||||
Change::BulkInsert {
|
||||
schema,
|
||||
table,
|
||||
@@ -2384,6 +2462,15 @@ pub async fn execute_change(
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn execute_change(
|
||||
connection_id: String,
|
||||
change: Change,
|
||||
state: State<'_, crate::AppState>,
|
||||
) -> Result<(), String> {
|
||||
execute_change_inner(&state.pool_manager, &connection_id, change).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn refresh_connection(
|
||||
connection_id: String,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
use tauri::State;
|
||||
use crate::db::pool::{ConnectionPoolManager, DbHandle};
|
||||
use crate::models::MaintenanceResult;
|
||||
|
||||
/// Run a table-scoped maintenance command via the SIMPLE query protocol
|
||||
/// (never inside a transaction — VACUUM cannot run in a transaction block).
|
||||
pub(crate) async fn run_maintenance_inner(
|
||||
pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, table: &str, action: &str,
|
||||
) -> Result<MaintenanceResult, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
let client = match pm.get(connection_id) {
|
||||
Some(DbHandle::Postgresql(c, _)) => c,
|
||||
Some(_) => return Err("Maintenance is PostgreSQL-only".into()),
|
||||
None => return Err("Connection not found".into()),
|
||||
};
|
||||
crate::db::object_ddl::validate_object_name(&schema)?;
|
||||
crate::db::object_ddl::validate_object_name(&table)?;
|
||||
let verb = match action {
|
||||
"vacuum" => "VACUUM",
|
||||
"analyze" => "ANALYZE",
|
||||
"reindex" => "REINDEX TABLE",
|
||||
_ => return Err(format!("Unknown maintenance action: {action}")),
|
||||
};
|
||||
let sql = format!("{} {}.{}", verb,
|
||||
crate::db::object_ddl::quote_ident(&schema), crate::db::object_ddl::quote_ident(&table));
|
||||
let start = std::time::Instant::now();
|
||||
client.simple_query(&sql).await
|
||||
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
|
||||
Ok(MaintenanceResult { duration_ms: start.elapsed().as_millis() as i64, message: format!("{} completed on {}.{}", verb, schema, table) })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn run_maintenance(connection_id: String, schema: String, table: String, action: String, state: State<'_, crate::AppState>) -> Result<MaintenanceResult, String> {
|
||||
run_maintenance_inner(&state.pool_manager, &connection_id, &schema, &table, &action).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "maintenance.test.rs"]
|
||||
mod maintenance_test;
|
||||
@@ -0,0 +1,28 @@
|
||||
use crate::db::pool::{ConnectionPoolManager, DbHandle};
|
||||
|
||||
/// Connect directly via tokio-postgres (no tunnel) so tests are Tauri-free.
|
||||
async fn pool() -> (tokio::sync::Mutex<ConnectionPoolManager>, String) {
|
||||
let h = std::env::var("GRIDLINE_TEST_PG_HOST").expect("set GRIDLINE_TEST_PG_HOST");
|
||||
let p: u16 = std::env::var("GRIDLINE_TEST_PG_PORT").unwrap_or_else(|_| "5432".into()).parse().unwrap();
|
||||
let u = std::env::var("GRIDLINE_TEST_PG_USER").expect("set GRIDLINE_TEST_PG_USER");
|
||||
let d = std::env::var("GRIDLINE_TEST_PG_DB").expect("set GRIDLINE_TEST_PG_DB");
|
||||
let pw = std::env::var("GRIDLINE_TEST_PG_PASSWORD").unwrap_or_default();
|
||||
let (client, conn) = tokio_postgres::connect(
|
||||
&format!("host={h} port={p} user={u} dbname={d} password={pw}"),
|
||||
tokio_postgres::NoTls,
|
||||
).await.expect("connect to test PG");
|
||||
let handle = tokio::spawn(async move { let _ = conn.await; });
|
||||
let mut pm = ConnectionPoolManager::new();
|
||||
let id = "test-conn".to_string();
|
||||
pm.register(&id, DbHandle::Postgresql(client, handle));
|
||||
(tokio::sync::Mutex::new(pm), id)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn run_maintenance_analyze_succeeds() {
|
||||
let (pm, id) = pool().await;
|
||||
let res = crate::commands::maintenance::run_maintenance_inner(&pm, &id, "public", "pg_type", "analyze").await.unwrap();
|
||||
assert!(res.duration_ms >= 0);
|
||||
assert!(res.message.to_lowercase().contains("analyze"));
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub mod demo;
|
||||
pub mod folders;
|
||||
pub mod import_export;
|
||||
pub mod keychain;
|
||||
pub mod maintenance;
|
||||
pub mod objects;
|
||||
pub mod query;
|
||||
pub mod schema_graph;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use tauri::State;
|
||||
use crate::db::pool::{ConnectionPoolManager, DbHandle};
|
||||
use crate::db::object_crud::build_ddl;
|
||||
use crate::db::object_crud::{build_ddl, rebuild_script, RebuildConstraint, RebuildFk, RebuildFkIn, RebuildGrant, RebuildIndex, RebuildInput, RebuildOwnedSequence, TableColumn};
|
||||
use crate::db::object_ddl::*;
|
||||
use crate::models::db_viewer::{ObjectSearchHit, DependencyInfo, ExtensionInfo};
|
||||
|
||||
@@ -188,6 +188,274 @@ pub async fn get_available_extensions(connection_id: String, state: State<'_, cr
|
||||
get_available_extensions_inner(&state.pool_manager, &connection_id).await
|
||||
}
|
||||
|
||||
/// Build a reorder-only table rebuild script (executed transactionally via
|
||||
/// `execute_change`'s `RebuildTable` arm). Headless inner: locks the pool once,
|
||||
/// validates the new column list against the live snapshot (names + types must
|
||||
/// be preserved), assembles a `RebuildInput` from live introspection, and
|
||||
/// delegates to `rebuild_script`.
|
||||
pub(crate) async fn build_rebuild_script_inner(
|
||||
pm: &tokio::sync::Mutex<ConnectionPoolManager>,
|
||||
connection_id: &str,
|
||||
schema: &str,
|
||||
table: &str,
|
||||
new_columns: serde_json::Value,
|
||||
) -> Result<String, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
let client = match pm.get(connection_id) {
|
||||
Some(DbHandle::Postgresql(c, _)) => c,
|
||||
Some(_) => return Err("Rebuild is PostgreSQL-only".into()),
|
||||
None => return Err("Connection not found".into()),
|
||||
};
|
||||
let new_cols: Vec<TableColumn> = serde_json::from_value(new_columns).map_err(|e| e.to_string())?;
|
||||
|
||||
// 1. live columns — validate reorder-only: the (name,type) multiset must be
|
||||
// unchanged (attribute edits belong in the diff path, not the rebuild).
|
||||
let live = client
|
||||
.query(&crate::db::introspection::pg_columns_query(schema, table), &[])
|
||||
.await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
let mut live_pairs: Vec<(String, String)> = live
|
||||
.iter()
|
||||
.map(|r| (r.get::<_, String>(0), r.get::<_, String>(1).trim().to_string()))
|
||||
.collect();
|
||||
let mut new_pairs: Vec<(String, String)> = new_cols
|
||||
.iter()
|
||||
.map(|c| (c.name.clone(), c.type_.trim().to_string()))
|
||||
.collect();
|
||||
live_pairs.sort();
|
||||
new_pairs.sort();
|
||||
if live_pairs != new_pairs {
|
||||
return Err(
|
||||
"Reorder must preserve column names and types; undo attribute changes or stage a diff"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
// 2. assemble RebuildInput from live introspection (one client, all sub-queries).
|
||||
let fk_out_rows = client
|
||||
.query(&crate::db::introspection::pg_table_fk_out_query(), &[&schema, &table])
|
||||
.await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
let fk_in_rows = client
|
||||
.query(&crate::db::introspection::pg_table_fk_in_query(), &[&schema, &table])
|
||||
.await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
let grant_rows = client
|
||||
.query(&crate::db::introspection::pg_table_grants_query(), &[&schema, &table])
|
||||
.await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
let seq_rows = client
|
||||
.query(&crate::db::introspection::pg_table_owned_sequences_query(), &[&schema, &table])
|
||||
.await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
let index_rows = client
|
||||
.query(&crate::db::introspection::pg_indexes_query(schema), &[&schema])
|
||||
.await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
// PK/UNIQUE/CHECK (contype p/u/c) scoped to this table; FKs are carried
|
||||
// separately as fks_out/fks_in so they are not double-applied.
|
||||
let constraint_rows = client
|
||||
.query(
|
||||
"SELECT c.conname AS name, ns.nspname AS schema, cl.relname AS table_name, \
|
||||
c.contype::text, pg_get_constraintdef(c.oid) AS definition \
|
||||
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 cl.relname = $2 AND c.contype IN ('p','u','c') \
|
||||
ORDER BY c.conname",
|
||||
&[&schema, &table],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
|
||||
let input = RebuildInput {
|
||||
schema: schema.to_string(),
|
||||
name: table.to_string(),
|
||||
constraints: constraint_rows
|
||||
.iter()
|
||||
.map(|r| RebuildConstraint {
|
||||
name: r.get(0),
|
||||
definition: r.get(4),
|
||||
})
|
||||
.collect(),
|
||||
indexes: index_rows
|
||||
.iter()
|
||||
.filter(|r| r.get::<_, String>(2) == table)
|
||||
.map(|r| RebuildIndex {
|
||||
name: r.get(0),
|
||||
definition: r.get(3),
|
||||
})
|
||||
.collect(),
|
||||
fks_out: fk_out_rows
|
||||
.iter()
|
||||
.map(|r| RebuildFk {
|
||||
name: r.get(0),
|
||||
definition: r.get(1),
|
||||
})
|
||||
.collect(),
|
||||
fks_in: fk_in_rows
|
||||
.iter()
|
||||
.map(|r| RebuildFkIn {
|
||||
name: r.get(0),
|
||||
own_schema: r.get(1),
|
||||
own_table: r.get(2),
|
||||
definition: r.get(3),
|
||||
})
|
||||
.collect(),
|
||||
grants: grant_rows
|
||||
.iter()
|
||||
.map(|r| RebuildGrant {
|
||||
grantee: r.get(0),
|
||||
privileges: r.get(1),
|
||||
grantable: r.get(2),
|
||||
})
|
||||
.collect(),
|
||||
owned_sequences: seq_rows
|
||||
.iter()
|
||||
.map(|r| RebuildOwnedSequence {
|
||||
seq_schema: r.get(0),
|
||||
seq_name: r.get(1),
|
||||
column: r.get(2),
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
rebuild_script(&input, &new_cols)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn build_rebuild_script(
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
new_columns: serde_json::Value,
|
||||
state: State<'_, crate::AppState>,
|
||||
) -> Result<String, String> {
|
||||
build_rebuild_script_inner(&state.pool_manager, &connection_id, &schema, &table, new_columns)
|
||||
.await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Roles, privileges, rebuild readiness, tablespaces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// List non-system roles with all attributes, memberships grouped by member role.
|
||||
pub(crate) async fn get_roles_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str) -> Result<Vec<crate::models::RoleInfo>, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
let client = match pm.get(connection_id) {
|
||||
Some(DbHandle::Postgresql(c, _)) => c,
|
||||
Some(_) => return Err("Roles are PostgreSQL-only".into()),
|
||||
None => return Err("Connection not found".into()),
|
||||
};
|
||||
let roles = client.query(&crate::db::introspection::pg_roles_query(), &[]).await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
let mems = client.query(&crate::db::introspection::pg_role_memberships_query(), &[]).await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
let mut by_name: std::collections::HashMap<String, crate::models::RoleInfo> = std::collections::HashMap::new();
|
||||
for r in roles {
|
||||
let name: String = r.get("rolname");
|
||||
by_name.insert(name.clone(), crate::models::RoleInfo {
|
||||
name, superuser: r.get("rolsuper"), inherit: r.get("rolinherit"),
|
||||
create_db: r.get("rolcreatedb"), create_role: r.get("rolcreaterole"),
|
||||
can_login: r.get("rolcanlogin"), replication: r.get("rolreplication"),
|
||||
bypass_rls: r.get("rolbypassrls"), connection_limit: r.get("rolconnlimit"),
|
||||
valid_until: { let v: String = r.get("rolvaliduntil"); if v.is_empty() { None } else { Some(v) } },
|
||||
memberships: vec![],
|
||||
});
|
||||
}
|
||||
for m in mems {
|
||||
let member: String = m.get("member");
|
||||
if let Some(ri) = by_name.get_mut(&member) {
|
||||
ri.memberships.push(crate::models::RoleMembership {
|
||||
role: m.get("role"), member, grantor: m.get("grantor"), admin_option: m.get("admin_option"),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(by_name.into_values().collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_roles(connection_id: String, state: State<'_, crate::AppState>) -> Result<Vec<crate::models::RoleInfo>, String> {
|
||||
get_roles_inner(&state.pool_manager, &connection_id).await
|
||||
}
|
||||
|
||||
/// All privilege grants for a role across tables, sequences, routines, schemas, and databases.
|
||||
pub(crate) async fn get_role_privileges_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, role: &str) -> Result<Vec<crate::models::PrivilegeEntry>, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
let client = match pm.get(connection_id) {
|
||||
Some(DbHandle::Postgresql(c, _)) => c,
|
||||
Some(_) => return Err("Privileges are PostgreSQL-only".into()),
|
||||
None => return Err("Connection not found".into()),
|
||||
};
|
||||
validate_object_name(role)?; // role is interpolated into the privilege queries
|
||||
let mut out: Vec<crate::models::PrivilegeEntry> = Vec::new();
|
||||
let push = |out: &mut Vec<crate::models::PrivilegeEntry>, class: &str, schema: Option<String>, name: String, privileges: Vec<String>, grantable: bool| {
|
||||
out.push(crate::models::PrivilegeEntry { object_class: class.into(), schema, name, privileges, grantable });
|
||||
};
|
||||
for row in client.query(&crate::db::introspection::pg_table_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
|
||||
push(&mut out, "table", Some(row.get("schema")), row.get("name"), row.get("privileges"), row.get("grantable"));
|
||||
}
|
||||
for row in client.query(&crate::db::introspection::pg_sequence_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
|
||||
push(&mut out, "sequence", Some(row.get("schema")), row.get("name"), row.get("privileges"), row.get("grantable"));
|
||||
}
|
||||
for row in client.query(&crate::db::introspection::pg_routine_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
|
||||
push(&mut out, "routine", Some(row.get("schema")), row.get("name"), row.get("privileges"), row.get("grantable"));
|
||||
}
|
||||
for row in client.query(&crate::db::introspection::pg_schema_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
|
||||
push(&mut out, "schema", None, row.get("name"), row.get("privileges"), row.get("grantable"));
|
||||
}
|
||||
for row in client.query(&crate::db::introspection::pg_database_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
|
||||
push(&mut out, "database", None, row.get("name"), row.get("privileges"), row.get("grantable"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_role_privileges(connection_id: String, role: String, state: State<'_, crate::AppState>) -> Result<Vec<crate::models::PrivilegeEntry>, String> {
|
||||
get_role_privileges_inner(&state.pool_manager, &connection_id, &role).await
|
||||
}
|
||||
|
||||
/// Check whether a table can be rebuilt (no triggers, policies, inheritance, partitioning, generated columns).
|
||||
pub(crate) async fn get_table_rebuild_readiness_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, table: &str) -> Result<crate::models::RebuildReadiness, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
let client = match pm.get(connection_id) {
|
||||
Some(DbHandle::Postgresql(c, _)) => c,
|
||||
Some(_) => return Err("Rebuild is PostgreSQL-only".into()),
|
||||
None => return Err("Connection not found".into()),
|
||||
};
|
||||
let row = client.query_one(&crate::db::introspection::pg_rebuild_readiness_query(), &[&schema, &table]).await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
let mut reasons = Vec::new();
|
||||
if row.get::<_, bool>("has_triggers") { reasons.push("table has triggers".into()); }
|
||||
if row.get::<_, bool>("has_policies") { reasons.push("table has RLS policies".into()); }
|
||||
if row.get::<_, bool>("is_inherits") { reasons.push("table participates in inheritance".into()); }
|
||||
if row.get::<_, bool>("is_partitioned") { reasons.push("table is partitioned".into()); }
|
||||
if row.get::<_, bool>("has_generated") { reasons.push("table has generated/identity columns".into()); }
|
||||
Ok(crate::models::RebuildReadiness { ok: reasons.is_empty(), reasons })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_table_rebuild_readiness(connection_id: String, schema: String, table: String, state: State<'_, crate::AppState>) -> Result<crate::models::RebuildReadiness, String> {
|
||||
get_table_rebuild_readiness_inner(&state.pool_manager, &connection_id, &schema, &table).await
|
||||
}
|
||||
|
||||
/// List non-system tablespaces for the table-options picker.
|
||||
pub(crate) async fn get_tablespaces_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str) -> Result<Vec<crate::models::TablespaceInfo>, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
let client = match pm.get(connection_id) {
|
||||
Some(DbHandle::Postgresql(c, _)) => c,
|
||||
Some(_) => return Err("Tablespaces are PostgreSQL-only".into()),
|
||||
None => return Err("Connection not found".into()),
|
||||
};
|
||||
let rows = client.query(&crate::db::introspection::pg_tablespaces_query(), &[]).await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
Ok(rows.into_iter().map(|r| crate::models::TablespaceInfo { name: r.get("spcname") }).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_tablespaces(connection_id: String, state: State<'_, crate::AppState>) -> Result<Vec<crate::models::TablespaceInfo>, String> {
|
||||
get_tablespaces_inner(&state.pool_manager, &connection_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "objects.test.rs"]
|
||||
mod tests;
|
||||
@@ -114,4 +114,56 @@ async fn object_dependencies_for_table_includes_view() {
|
||||
// schema contents path
|
||||
let contents = get_object_dependencies_inner(&pm, &id, "public", "schema", "public").await.unwrap();
|
||||
assert!(!contents.is_empty(), "public schema should list contents");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn get_roles_returns_current_role_and_memberships() {
|
||||
let (pm, id) = pool().await;
|
||||
let roles = crate::commands::objects::get_roles_inner(&pm, &id).await.unwrap();
|
||||
assert!(roles.iter().any(|r| r.can_login), "at least one login role (the test user)");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn rebuild_table_rolls_back_on_failure() {
|
||||
let (pm, id) = pool().await;
|
||||
// setup: a table with a PK + one row
|
||||
{
|
||||
let mut g = pm.lock().await;
|
||||
if let crate::db::pool::DbHandle::Postgresql(c, _) = g.get(&id).unwrap() {
|
||||
c.batch_execute("DROP TABLE IF EXISTS rebuild_t; CREATE TABLE rebuild_t (id int PRIMARY KEY, v text); INSERT INTO rebuild_t VALUES (1,'a');").await.unwrap();
|
||||
}
|
||||
}
|
||||
// build a rebuild script whose final statement intentionally fails (syntax error)
|
||||
// so the whole transaction rolls back and rebuild_t keeps its row.
|
||||
let bad_script = "CREATE TABLE _gridline_rb_rebuild_t (id int PRIMARY KEY, v text); \
|
||||
INSERT INTO _gridline_rb_rebuild_t (id, v) SELECT id, v FROM rebuild_t; \
|
||||
DROP TABLE rebuild_t; \
|
||||
ALTER TABLE _gridline_rb_rebuild_t RENAME TO rebuild_t; \
|
||||
THIS IS NOT SQL;";
|
||||
let change = crate::models::db_viewer::Change::RebuildTable { id: "rb".into(), sql: bad_script.to_string() };
|
||||
let res = crate::commands::db_viewer::execute_change_inner(&pm, &id, change).await;
|
||||
assert!(res.is_err(), "expected rollback (transaction should fail on bad SQL)");
|
||||
// table still intact
|
||||
let mut g = pm.lock().await;
|
||||
if let crate::db::pool::DbHandle::Postgresql(c, _) = g.get(&id).unwrap() {
|
||||
let row = c.query_one("SELECT count(*) FROM rebuild_t", &[]).await.unwrap();
|
||||
assert_eq!(row.get::<_, i64>(0), 1, "rollback must preserve the original table");
|
||||
c.batch_execute("DROP TABLE rebuild_t").await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires live PG"]
|
||||
async fn get_role_privileges_returns_grants_across_object_classes() {
|
||||
let (pm, id) = pool().await;
|
||||
let out = crate::commands::objects::get_role_privileges_inner(&pm, &id, "read_only").await
|
||||
.unwrap_or_else(|e| panic!("privileges command failed: {e}"));
|
||||
let classes: std::collections::HashSet<String> =
|
||||
out.iter().map(|p| p.object_class.clone()).collect();
|
||||
assert!(classes.contains("table"), "expected a table grant: {out:?}");
|
||||
assert!(classes.contains("routine"), "expected a routine grant: {out:?}");
|
||||
assert!(classes.contains("schema"), "expected a schema grant: {out:?}");
|
||||
assert!(classes.contains("database"), "expected a database grant: {out:?}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user