v0.7.7: PG roles/grants, table maintenance, Create/Edit Table + atomic column-reorder rebuild, FK management, table options (#13)

* 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:
2026-08-07 05:51:23 +08:00
committed by GitHub
parent 0c74d77e75
commit 7401e91a12
64 changed files with 6255 additions and 200 deletions
+93 -6
View File
@@ -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,
+39
View File
@@ -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"));
}
+1
View File
@@ -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;
+269 -1
View File
@@ -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;
+53 -1
View File
@@ -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:?}");
}
+245
View File
@@ -355,6 +355,165 @@ pub fn pg_constraints_query(_schema: &str) -> String {
)
}
// ---------------------------------------------------------------------------
// Roles, privileges, tablespaces, rebuild readiness
// ---------------------------------------------------------------------------
/// List non-system roles with all attributes used by the role form.
pub fn pg_roles_query() -> String {
"SELECT rolname, rolsuper, rolinherit, rolcreatedb, rolcreaterole, rolcanlogin, \
rolreplication, rolbypassrls, rolconnlimit::int8, COALESCE(rolvaliduntil::text, '') AS rolvaliduntil \
FROM pg_roles WHERE rolname !~ '^pg_' ORDER BY rolname"
.to_string()
}
/// All role-to-role memberships (member/admin/grantor).
pub fn pg_role_memberships_query() -> String {
"SELECT roleid::regrole::text AS role, member::regrole::text AS member, \
grantor::regrole::text AS grantor, admin_option \
FROM pg_auth_members ORDER BY role"
.to_string()
}
/// Table/view/matview privileges for a grantee, grouped one row per object.
pub fn pg_table_privileges_query(role: &str) -> String {
format!(
"SELECT table_schema AS schema, table_name AS name, \
array_agg(privilege_type::text) AS privileges, \
bool_or(is_grantable = 'YES') AS grantable \
FROM information_schema.table_privileges \
WHERE grantee = '{}' AND table_schema NOT IN ('pg_catalog','information_schema') \
GROUP BY table_schema, table_name \
ORDER BY table_schema, table_name",
role
)
}
/// Sequence privileges for a grantee, grouped one row per sequence.
/// aclexplode-based (role_sequence_grants was removed in PG 15).
pub fn pg_sequence_privileges_query(role: &str) -> String {
format!(
"SELECT n.nspname AS schema, c.relname AS name, \
array_agg(p.privilege_type::text) AS privileges, \
bool_or(p.is_grantable) AS grantable \
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace, \
LATERAL aclexplode(c.relacl) p \
WHERE c.relkind = 'S' AND n.nspname NOT IN ('pg_catalog','information_schema') \
AND p.grantee = (SELECT oid FROM pg_roles WHERE rolname = '{}') \
GROUP BY n.nspname, c.relname \
ORDER BY n.nspname, c.relname",
role
)
}
/// Routine (function/procedure) privileges for a grantee.
pub fn pg_routine_privileges_query(role: &str) -> String {
format!(
"SELECT routine_schema AS schema, routine_name AS name, \
array_agg(privilege_type::text) AS privileges, \
bool_or(is_grantable = 'YES') AS grantable \
FROM information_schema.routine_privileges \
WHERE grantee = '{}' \
GROUP BY routine_schema, routine_name \
ORDER BY routine_schema, routine_name",
role
)
}
/// Schema privileges for a grantee (USAGE/CREATE). aclexplode-based —
/// information_schema has no schema_privileges view.
pub fn pg_schema_privileges_query(role: &str) -> String {
format!(
"SELECT n.nspname AS name, \
array_agg(p.privilege_type::text) AS privileges, \
bool_or(p.is_grantable) AS grantable \
FROM pg_namespace n, LATERAL aclexplode(n.nspacl) p \
WHERE n.nspname NOT IN ('pg_catalog','information_schema') \
AND p.grantee = (SELECT oid FROM pg_roles WHERE rolname = '{}') \
GROUP BY n.nspname ORDER BY n.nspname",
role
)
}
/// Database privileges for a grantee via aclexplode (broadly compatible; avoids PG15-only view).
pub fn pg_database_privileges_query(role: &str) -> String {
format!(
"SELECT d.datname AS name, array_agg(a.privilege_type) AS privileges, \
bool_or(a.is_grantable) AS grantable \
FROM pg_database d, LATERAL aclexplode(d.datacl) a \
WHERE a.grantee = (SELECT oid FROM pg_roles WHERE rolname = '{}') \
AND d.datistemplate = false \
GROUP BY d.datname \
ORDER BY d.datname",
role
)
}
/// Non-system tablespaces for the table-options picker.
pub fn pg_tablespaces_query() -> String {
"SELECT spcname FROM pg_tablespace WHERE spcname !~ '^pg_' ORDER BY spcname".to_string()
}
/// Single query returning boolean blockers for a table rebuild (triggers, policies,
/// inheritance, partitioning, generated/identity columns). Parameterized via $1/$2.
pub fn pg_rebuild_readiness_query() -> String {
"SELECT \
EXISTS(SELECT 1 FROM pg_trigger t JOIN pg_class c ON t.tgrelid = c.oid \
JOIN pg_namespace n ON c.relnamespace = n.oid \
WHERE n.nspname = $1 AND c.relname = $2 AND NOT t.tgisinternal) AS has_triggers, \
EXISTS(SELECT 1 FROM pg_policy p JOIN pg_class c ON p.polrelid = c.oid \
JOIN pg_namespace n ON c.relnamespace = n.oid \
WHERE n.nspname = $1 AND c.relname = $2) AS has_policies, \
EXISTS(SELECT 1 FROM pg_inherits i JOIN pg_class c ON i.inhrelid = c.oid \
JOIN pg_namespace n ON c.relnamespace = n.oid \
WHERE n.nspname = $1 AND c.relname = $2) AS is_inherits, \
EXISTS(SELECT 1 FROM pg_partitioned_table pt JOIN pg_class c ON pt.partrelid = c.oid \
JOIN pg_namespace n ON c.relnamespace = n.oid \
WHERE n.nspname = $1 AND c.relname = $2) AS is_partitioned, \
EXISTS(SELECT 1 FROM information_schema.columns \
WHERE table_schema = $1 AND table_name = $2 AND is_generated <> '') AS has_generated"
.to_string()
}
/// FKs owned by this table (contype='f'). Parameterized $1 schema, $2 table.
pub fn pg_table_fk_out_query() -> String {
"SELECT c.conname, pg_get_constraintdef(c.oid) AS definition \
FROM pg_constraint c JOIN pg_class cl ON c.conrelid = cl.oid \
JOIN pg_namespace n ON cl.relnamespace = n.oid \
WHERE n.nspname = $1 AND cl.relname = $2 AND c.contype = 'f'".to_string()
}
/// FKs from other tables referencing this table. Parameterized $1 schema, $2 table.
pub fn pg_table_fk_in_query() -> String {
"SELECT c.conname, cn.nspname AS own_schema, cl.relname AS own_table, \
pg_get_constraintdef(c.oid) AS definition \
FROM pg_constraint c JOIN pg_class cl ON c.conrelid = cl.oid \
JOIN pg_namespace cn ON cl.relnamespace = cn.oid \
JOIN pg_class r ON c.confrelid = r.oid \
JOIN pg_namespace rn ON r.relnamespace = rn.oid \
WHERE rn.nspname = $1 AND r.relname = $2 AND c.contype = 'f'".to_string()
}
/// Grants on this table (all grantees), grouped. Parameterized $1 schema, $2 table.
pub fn pg_table_grants_query() -> String {
"SELECT grantee, array_agg(privilege_type::text) AS privileges, \
bool_or(is_grantable = 'YES') AS grantable \
FROM information_schema.table_privileges \
WHERE table_schema = $1 AND table_name = $2 AND grantee <> 'PUBLIC' \
GROUP BY grantee".to_string()
}
/// Sequences owned by this table's columns (via pg_depend). Parameterized $1 schema, $2 table.
pub fn pg_table_owned_sequences_query() -> String {
"SELECT sn.nspname AS seq_schema, s.relname AS seq_name, a.attname AS column \
FROM pg_depend d JOIN pg_class s ON d.objid = s.oid \
JOIN pg_namespace sn ON s.relnamespace = sn.oid \
JOIN pg_class t ON d.refobjid = t.oid \
JOIN pg_namespace tn ON t.relnamespace = tn.oid \
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid \
WHERE tn.nspname = $1 AND t.relname = $2 AND d.classid = 'pg_class'::regclass AND s.relkind = 'S'".to_string()
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -641,4 +800,90 @@ mod tests {
assert!(sql.contains("comment"), "should select comment; got: {sql}");
assert!(sql.contains("ORDER BY name"), "should order by name; got: {sql}");
}
// ---------------------------------------------------------------
// Roles, privileges, tablespaces, rebuild readiness
// ---------------------------------------------------------------
#[test]
fn pg_roles_query_filters_pg_prefix() {
let sql = pg_roles_query();
assert!(sql.contains("pg_roles"));
assert!(sql.contains("rolname !~ '^pg_'"));
assert!(sql.contains("rolcanlogin"));
assert!(sql.contains("rolconnlimit::int8"));
}
#[test]
fn pg_role_memberships_query_uses_pg_auth_members() {
let sql = pg_role_memberships_query();
assert!(sql.contains("pg_auth_members"));
assert!(sql.contains("admin_option"));
}
#[test]
fn pg_table_privileges_query_filters_grantee_and_aggregates() {
let sql = pg_table_privileges_query("appuser");
assert!(sql.contains("information_schema.table_privileges"));
assert!(sql.contains("grantee = 'appuser'"));
assert!(sql.contains("array_agg"));
assert!(sql.contains("GROUP BY"));
}
#[test]
fn pg_database_privileges_query_uses_aclexplode() {
let sql = pg_database_privileges_query("appuser");
assert!(sql.contains("aclexplode"));
assert!(sql.contains("pg_database"));
assert!(sql.contains("appuser"));
}
#[test]
fn pg_rebuild_readiness_query_checks_blockers() {
let sql = pg_rebuild_readiness_query();
assert!(sql.contains("pg_trigger"));
assert!(sql.contains("pg_policy"));
assert!(sql.contains("pg_inherits"));
assert!(sql.contains("pg_partitioned_table"));
assert!(sql.contains("is_generated"));
}
#[test]
fn pg_tablespaces_query_filters_pg_prefix() {
let sql = pg_tablespaces_query();
assert!(sql.contains("pg_tablespace"));
assert!(sql.contains("spcname !~ '^pg_'"));
}
#[test]
fn pg_table_fk_out_query_uses_pg_constraint_f() {
let sql = pg_table_fk_out_query();
assert!(sql.contains("pg_constraint"));
assert!(sql.contains("contype = 'f'"));
assert!(sql.contains("$1") && sql.contains("$2"));
}
#[test]
fn pg_table_fk_in_query_finds_referencing_tables() {
let sql = pg_table_fk_in_query();
assert!(sql.contains("pg_constraint"));
assert!(sql.contains("confrel"));
assert!(sql.contains("$1") && sql.contains("$2"));
}
#[test]
fn pg_table_grants_query_uses_table_privileges() {
let sql = pg_table_grants_query();
assert!(sql.contains("information_schema.table_privileges"));
assert!(sql.contains("$1") && sql.contains("$2"));
assert!(sql.contains("array_agg"));
}
#[test]
fn pg_table_owned_sequences_query_uses_pg_depend() {
let sql = pg_table_owned_sequences_query();
assert!(sql.contains("pg_depend"));
assert!(sql.contains("pg_class"));
assert!(sql.contains("$1") && sql.contains("$2"));
}
}
+716 -10
View File
@@ -249,35 +249,55 @@ pub enum ConstraintAction {
Check { expression: String },
Unique { columns: Vec<String> },
PrimaryKey { columns: Vec<String> },
ForeignKey { columns: Vec<String>, ref_schema: String, ref_table: String, ref_columns: Vec<String> },
ForeignKey { columns: Vec<String>, ref_schema: String, ref_table: String, ref_columns: Vec<String>, on_delete: Option<String>, on_update: Option<String>, deferrable: Option<bool>, initially_deferred: Option<bool> },
Drop,
}
pub fn constraint_ddl(p: &ConstraintParams) -> Result<Vec<String>, String> {
validate_object_name(&p.schema)?;
validate_object_name(&p.table)?;
validate_object_name(&p.name)?;
let table = format!("{}.{}", quote_ident(&p.schema), quote_ident(&p.table));
let name = quote_ident(&p.name);
Ok(vec![match &p.action {
ConstraintAction::Check { expression } =>
format!("ALTER TABLE {} ADD CONSTRAINT {} CHECK ({})", table, name, validate_expression(expression)?),
ConstraintAction::Check { expression } => {
validate_object_name(&p.name)?;
format!("ALTER TABLE {} ADD CONSTRAINT {} CHECK ({})", table, quote_ident(&p.name), validate_expression(expression)?)
}
ConstraintAction::Unique { columns } => {
if columns.is_empty() { return Err("UNIQUE constraint requires a column".into()); }
format!("ALTER TABLE {} ADD CONSTRAINT {} UNIQUE ({})", table, name, quoted_cols(columns))
validate_object_name(&p.name)?;
format!("ALTER TABLE {} ADD CONSTRAINT {} UNIQUE ({})", table, quote_ident(&p.name), quoted_cols(columns))
}
ConstraintAction::PrimaryKey { columns } => {
if columns.is_empty() { return Err("PRIMARY KEY requires a column".into()); }
format!("ALTER TABLE {} ADD CONSTRAINT {} PRIMARY KEY ({})", table, name, quoted_cols(columns))
validate_object_name(&p.name)?;
format!("ALTER TABLE {} ADD CONSTRAINT {} PRIMARY KEY ({})", table, quote_ident(&p.name), quoted_cols(columns))
}
ConstraintAction::ForeignKey { columns, ref_schema, ref_table, ref_columns } => {
ConstraintAction::ForeignKey { columns, ref_schema, ref_table, ref_columns, on_delete, on_update, deferrable, initially_deferred } => {
if columns.is_empty() || ref_columns.is_empty() { return Err("FOREIGN KEY requires source and referenced columns".into()); }
validate_object_name(ref_schema)?;
validate_object_name(ref_table)?;
let refq = format!("{}.{}", quote_ident(ref_schema), quote_ident(ref_table));
format!("ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({})", table, name, quoted_cols(columns), refq, quoted_cols(ref_columns))
// An empty name is allowed for FKs — PG auto-generates it (ADD FOREIGN KEY …).
let name_clause = if p.name.trim().is_empty() {
String::new()
} else {
validate_object_name(&p.name)?;
format!("CONSTRAINT {} ", quote_ident(&p.name))
};
let mut sql = format!("ALTER TABLE {} ADD {}FOREIGN KEY ({}) REFERENCES {} ({})", table, name_clause, quoted_cols(columns), refq, quoted_cols(ref_columns));
if let Some(d) = on_delete { sql.push_str(&format!(" ON DELETE {}", d)); }
if let Some(u) = on_update { sql.push_str(&format!(" ON UPDATE {}", u)); }
match (deferrable, initially_deferred) {
(Some(true), Some(true)) => sql.push_str(" DEFERRABLE INITIALLY DEFERRED"),
(Some(true), _) => sql.push_str(" DEFERRABLE INITIALLY IMMEDIATE"),
_ => {}
}
sql
}
ConstraintAction::Drop => {
validate_object_name(&p.name)?;
format!("ALTER TABLE {} DROP CONSTRAINT {}", table, quote_ident(&p.name))
}
ConstraintAction::Drop => format!("ALTER TABLE {} DROP CONSTRAINT {}", table, name),
}])
}
@@ -369,10 +389,403 @@ pub fn trigger_ddl(p: &TriggerParams) -> Result<Vec<String>, String> {
}])
}
use std::collections::HashSet;
fn validate_type(t: &str) -> Result<String, String> {
let s = t.trim();
if s.is_empty() { return Err("Column type must not be empty".into()); }
if s.ends_with(';') { return Err("Column type must not end with ';'".into()); }
if s.contains("--") || s.contains("/*") { return Err("Column type must not contain comments".into()); }
Ok(s.to_string())
}
#[derive(Deserialize, Clone)]
pub struct TableColumn {
pub name: String,
#[serde(rename = "type")] pub type_: String,
pub nullable: bool,
pub default: Option<Option<String>>, // null | Some(null) | Some(expr)
pub is_pk: bool,
pub unique: Option<bool>,
}
impl TableColumn {
fn default_sql(&self) -> Option<&str> {
match &self.default { Some(Some(d)) => Some(d.as_str()), _ => None }
}
}
#[derive(Deserialize)]
pub struct TableParams { pub schema: String, pub name: String, pub action: TableAction }
#[derive(Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum TableAction {
Create {
columns: Vec<TableColumn>,
tablespace: Option<String>,
#[serde(default)]
foreign_keys: Vec<TableForeignKey>,
},
Edit { columns: Vec<TableColumn>, old_columns: Vec<TableColumn> },
Options { tablespace: Option<String>, rls: Option<String> },
Drop,
}
#[derive(Deserialize, Clone)]
pub struct TableForeignKey {
pub columns: Vec<String>,
pub ref_schema: String,
pub ref_table: String,
pub ref_columns: Vec<String>,
pub on_delete: Option<String>,
pub on_update: Option<String>,
pub deferrable: Option<bool>,
pub initially_deferred: Option<bool>,
}
fn col_def(c: &TableColumn) -> Result<String, String> {
validate_object_name(&c.name)?;
let ty = validate_type(&c.type_)?;
let mut s = format!("{} {}", quote_ident(&c.name), ty);
if c.unique == Some(true) { s.push_str(" UNIQUE"); }
if !c.nullable { s.push_str(" NOT NULL"); }
if let Some(d) = c.default_sql() { s.push_str(&format!(" DEFAULT {}", validate_expression(d)?)); }
Ok(s)
}
pub fn table_ddl(p: &TableParams) -> Result<Vec<String>, String> {
let q = qual(&p.schema, &p.name)?;
Ok(match &p.action {
TableAction::Create { columns, tablespace, foreign_keys } => {
if columns.is_empty() { return Err("CREATE TABLE requires at least one column".into()); }
let mut seen = HashSet::new();
for c in columns {
validate_object_name(&c.name)?;
if !seen.insert(c.name.clone()) { return Err(format!("Duplicate column name: {}", c.name)); }
}
let mut defs: Vec<String> = columns.iter().map(col_def).collect::<Result<_, _>>()?;
let pk: Vec<String> = columns.iter().filter(|c| c.is_pk).map(|c| quote_ident(&c.name)).collect();
if !pk.is_empty() { defs.push(format!("PRIMARY KEY ({})", pk.join(", "))); }
for fk in foreign_keys {
if fk.columns.is_empty() || fk.ref_columns.is_empty() {
return Err("FOREIGN KEY requires source and referenced columns".into());
}
for c in &fk.columns { validate_object_name(c)?; }
for c in &fk.ref_columns { validate_object_name(c)?; }
validate_object_name(&fk.ref_schema)?;
validate_object_name(&fk.ref_table)?;
let refq = format!("{}.{}", quote_ident(&fk.ref_schema), quote_ident(&fk.ref_table));
let mut s = format!("FOREIGN KEY ({}) REFERENCES {} ({})", quoted_cols(&fk.columns), refq, quoted_cols(&fk.ref_columns));
if let Some(d) = &fk.on_delete { s.push_str(&format!(" ON DELETE {}", d)); }
if let Some(u) = &fk.on_update { s.push_str(&format!(" ON UPDATE {}", u)); }
match (fk.deferrable, fk.initially_deferred) {
(Some(true), Some(true)) => s.push_str(" DEFERRABLE INITIALLY DEFERRED"),
(Some(true), _) => s.push_str(" DEFERRABLE"),
_ => {}
}
defs.push(s);
}
let mut sql = format!("CREATE TABLE {} (\n {}\n)", q, defs.join(",\n "));
if let Some(ts) = tablespace { validate_object_name(ts)?; sql.push_str(&format!(" TABLESPACE {}", quote_ident(ts))); }
vec![sql]
}
TableAction::Edit { columns, old_columns } => table_diff(&q, old_columns, columns)?,
TableAction::Options { tablespace, rls } => {
let mut out = Vec::new();
if let Some(ts) = tablespace { validate_object_name(ts)?; out.push(format!("ALTER TABLE {} SET TABLESPACE {}", q, quote_ident(ts))); }
match rls.as_deref() {
Some("enable") | Some("force") => out.push(format!("ALTER TABLE {} ENABLE ROW LEVEL SECURITY", q)),
_ => {}
}
if rls.as_deref() == Some("force") { out.push(format!("ALTER TABLE {} FORCE ROW LEVEL SECURITY", q)); }
if rls.as_deref() == Some("disable") { out.push(format!("ALTER TABLE {} DISABLE ROW LEVEL SECURITY", q)); }
out
}
TableAction::Drop => vec![format!("DROP TABLE {}", q)],
})
}
fn table_diff(q: &str, old: &[TableColumn], new: &[TableColumn]) -> Result<Vec<String>, String> {
for c in new { validate_object_name(&c.name)?; validate_type(&c.type_)?; }
let mut old_by_name: std::collections::HashMap<&str, &TableColumn> = old.iter().map(|c| (c.name.as_str(), c)).collect();
let mut out: Vec<String> = Vec::new();
let mut used_old: HashSet<usize> = HashSet::new();
// 1. RENAMEs: unmatched new col at ordinal i matching an unmatched old col at ordinal i with identical type/nullable/default
let mut renames: Vec<(usize, String, String)> = Vec::new(); // (old_idx, old_name, new_name)
for (i, nc) in new.iter().enumerate() {
if old_by_name.contains_key(nc.name.as_str()) { continue; }
if let Some(oc) = old.get(i) {
if !used_old.contains(&i) && oc.type_.trim() == nc.type_.trim()
&& oc.nullable == nc.nullable && oc.default_sql() == nc.default_sql() {
renames.push((i, oc.name.clone(), nc.name.clone()));
used_old.insert(i);
old_by_name.remove(oc.name.as_str());
}
}
}
for (_, oldn, newn) in &renames {
out.push(format!("ALTER TABLE {} RENAME COLUMN \"{}\" TO \"{}\"", q, oldn, newn));
}
// 2. ADDs: remaining unmatched new cols
for nc in new {
if old_by_name.contains_key(nc.name.as_str()) || renames.iter().any(|r| r.2 == nc.name) { continue; }
let s = format!("ALTER TABLE {} ADD COLUMN {}", q, col_def(nc)?);
out.push(s);
}
// 3. ALTERs for kept (name-matched) cols
for nc in new {
let Some(oc) = old.iter().find(|c| c.name == nc.name) else { continue; };
if oc.type_.trim() != nc.type_.trim() {
out.push(format!("ALTER TABLE {} ALTER COLUMN {} TYPE {}", q, quote_ident(&nc.name), validate_type(&nc.type_)?));
}
match (oc.default_sql(), nc.default_sql()) {
(None, Some(d)) => out.push(format!("ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {}", q, quote_ident(&nc.name), validate_expression(d)?)),
(Some(_), None) => out.push(format!("ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT", q, quote_ident(&nc.name))),
(Some(a), Some(b)) if a != b => out.push(format!("ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {}", q, quote_ident(&nc.name), validate_expression(b)?)),
_ => {}
}
match (oc.nullable, nc.nullable) {
(true, false) => out.push(format!("ALTER TABLE {} ALTER COLUMN {} SET NOT NULL", q, quote_ident(&nc.name))),
(false, true) => out.push(format!("ALTER TABLE {} ALTER COLUMN {} DROP NOT NULL", q, quote_ident(&nc.name))),
_ => {}
}
}
// 4. DROPs last: old cols not present in new and not renamed-away
let new_names: HashSet<&str> = new.iter().map(|c| c.name.as_str()).collect();
for oc in old {
if !new_names.contains(oc.name.as_str()) && !renames.iter().any(|r| r.1 == oc.name) {
out.push(format!("ALTER TABLE {} DROP COLUMN {}", q, quote_ident(&oc.name)));
}
}
Ok(out)
}
#[derive(Debug, Clone)]
pub struct RebuildConstraint { pub name: String, pub definition: String }
#[derive(Debug, Clone)]
pub struct RebuildIndex { pub name: String, pub definition: String }
#[derive(Debug, Clone)]
pub struct RebuildFk { pub name: String, pub definition: String }
#[derive(Debug, Clone)]
pub struct RebuildFkIn {
pub name: String,
pub own_schema: String,
pub own_table: String,
pub definition: String,
}
#[derive(Debug, Clone)]
pub struct RebuildGrant {
pub grantee: String,
pub privileges: Vec<String>,
pub grantable: bool,
}
#[derive(Debug, Clone)]
pub struct RebuildOwnedSequence {
pub seq_schema: String,
pub seq_name: String,
pub column: String,
}
/// Everything needed to reconstruct a table's metadata after a reorder-only rebuild.
/// Rebuild is reorder-only: column names + types in `new_columns` must equal the live
/// snapshot (validated by the Task 2.4 command). These structs are assembled in Rust
/// from introspection rows, so they derive Clone/Debug only (no IPC Deserialize).
#[derive(Debug, Clone)]
pub struct RebuildInput {
pub schema: String,
pub name: String,
pub constraints: Vec<RebuildConstraint>,
pub indexes: Vec<RebuildIndex>,
pub fks_out: Vec<RebuildFk>,
pub fks_in: Vec<RebuildFkIn>,
pub grants: Vec<RebuildGrant>,
pub owned_sequences: Vec<RebuildOwnedSequence>,
}
/// Assemble the full rebuild script for a reorder-only table rebuild.
///
/// Order (transactional at the caller — the script itself is BEGIN-free):
/// detach owned sequences → drop inbound FKs → create temp table in the new column
/// order → INSERT..SELECT (cast-free: names/types unchanged) → DROP original → RENAME
/// temp → recreate PK/unique/check constraints → recreate indexes → re-add outbound
/// FKs → re-add inbound FKs → re-grant privileges → re-attach owned sequences.
///
/// The caller executes this via transactional `batch_execute` (Task 2.4), so the
/// script must NOT contain BEGIN/COMMIT.
pub fn rebuild_script(input: &RebuildInput, new_columns: &[TableColumn]) -> Result<String, String> {
validate_object_name(&input.schema)?;
validate_object_name(&input.name)?;
for c in new_columns {
validate_object_name(&c.name)?;
validate_type(&c.type_)?;
}
let q = qual(&input.schema, &input.name)?;
let tmp = qual(&input.schema, &format!("_gridline_rb_{}", input.name))?;
let mut s = String::new();
// 1. detach owned sequences (OWNED BY NONE keeps the sequence alive but decoupled)
for seq in &input.owned_sequences {
validate_object_name(&seq.seq_schema)?;
validate_object_name(&seq.seq_name)?;
s.push_str(&format!("ALTER SEQUENCE \"{}\".\"{}\" OWNED BY NONE;\n", seq.seq_schema, seq.seq_name));
}
// 2. drop FKs IN (from other tables) before DROP TABLE
for fk in &input.fks_in {
validate_object_name(&fk.own_schema)?;
validate_object_name(&fk.own_table)?;
validate_object_name(&fk.name)?;
s.push_str(&format!("ALTER TABLE \"{}\".\"{}\" DROP CONSTRAINT \"{}\";\n", fk.own_schema, fk.own_table, fk.name));
}
// 3. create temp table (new order, NOT NULL + DEFAULT only)
let defs: Vec<String> = new_columns.iter().map(col_def).collect::<Result<_, _>>()?;
s.push_str(&format!("CREATE TABLE {} (\n {}\n);\n", tmp, defs.join(",\n ")));
// 4. copy data (names unchanged = cast-free SELECT in new order)
let cols: Vec<String> = new_columns.iter().map(|c| quote_ident(&c.name)).collect();
s.push_str(&format!("INSERT INTO {} ({}) SELECT {} FROM {};\n", tmp, cols.join(", "), cols.join(", "), q));
// 5. drop old + 6. rename temp
s.push_str(&format!("DROP TABLE {};\n", q));
s.push_str(&format!("ALTER TABLE {} RENAME TO \"{}\";\n", tmp, input.name));
// 7. recreate constraints (PK/unique/check)
for c in &input.constraints {
validate_object_name(&c.name)?;
s.push_str(&format!("ALTER TABLE {} ADD CONSTRAINT \"{}\" {};\n", q, c.name, c.definition));
}
// 8. recreate indexes (pg_get_indexdef references schema.name = the renamed table)
for idx in &input.indexes {
s.push_str(&format!("{};\n", idx.definition));
}
// 9. recreate FKs OUT (this table's FKs)
for fk in &input.fks_out {
validate_object_name(&fk.name)?;
s.push_str(&format!("ALTER TABLE {} ADD CONSTRAINT \"{}\" {};\n", q, fk.name, fk.definition));
}
// 10. recreate FKs IN (other tables)
for fk in &input.fks_in {
s.push_str(&format!("ALTER TABLE \"{}\".\"{}\" ADD CONSTRAINT \"{}\" {};\n", fk.own_schema, fk.own_table, fk.name, fk.definition));
}
// 11. re-apply grants
for g in &input.grants {
validate_object_name(&g.grantee)?;
let opt = if g.grantable { " WITH GRANT OPTION" } else { "" };
s.push_str(&format!("GRANT {} ON {} TO {}{};\n", g.privileges.join(", "), q, quote_ident(&g.grantee), opt));
}
// 12. re-attach owned sequences
for seq in &input.owned_sequences {
validate_object_name(&seq.column)?;
s.push_str(&format!("ALTER SEQUENCE \"{}\".\"{}\" OWNED BY {}.\"{}\";\n", seq.seq_schema, seq.seq_name, q, seq.column));
}
Ok(s.trim_end().to_string())
}
#[derive(Deserialize)]
pub struct RoleParams {
pub schema: String,
pub name: String,
pub action: RoleAction,
}
#[derive(Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum RoleAction {
Create { login: bool, superuser: bool, createdb: bool, createrole: bool, inherit: bool,
replication: bool, bypassrls: bool, connection_limit: i64, valid_until: String,
password: String, members: Vec<String> },
Edit { login: bool, superuser: bool, createdb: bool, createrole: bool, inherit: bool,
replication: bool, bypassrls: bool, connection_limit: i64, valid_until: String,
password: String, members: Vec<String> },
Drop,
Grant { object_class: String, object_schema: Option<String>, object_name: String,
privileges: Vec<String>, grantee: String, grant_option: bool },
Revoke { object_class: String, object_schema: Option<String>, object_name: String,
privileges: Vec<String>, grantee: String, grant_option: bool },
}
/// Compose the option-clause portion of CREATE/ALTER ROLE.
fn role_options(a: &RoleAction) -> String {
let (login, superuser, createdb, createrole, inherit, replication, bypassrls, conn, until, pw) = match a {
RoleAction::Create { login, superuser, createdb, createrole, inherit, replication, bypassrls, connection_limit, valid_until, password, .. }
| RoleAction::Edit { login, superuser, createdb, createrole, inherit, replication, bypassrls, connection_limit, valid_until, password, .. } =>
(*login, *superuser, *createdb, *createrole, *inherit, *replication, *bypassrls, *connection_limit, valid_until.clone(), password.clone()),
_ => return String::new(),
};
let mut o: Vec<String> = Vec::new();
if login { o.push("LOGIN".into()); }
if superuser { o.push("SUPERUSER".into()); }
if createdb { o.push("CREATEDB".into()); }
if createrole { o.push("CREATEROLE".into()); }
o.push(if inherit { "INHERIT".into() } else { "NOINHERIT".into() });
if replication { o.push("REPLICATION".into()); }
if bypassrls { o.push("BYPASSRLS".into()); }
o.push(format!("CONNECTION LIMIT {}", conn));
if !pw.is_empty() { o.push(format!("PASSWORD '{}'", pw.replace('\'', "''"))); }
if !until.is_empty() { o.push(format!("VALID UNTIL '{}'", until.replace('\'', "''"))); }
o.join(" ")
}
/// Qualified object reference for GRANT/REVOKE. Schemaless classes (schema, database)
/// quote the bare name; everything else is emitted as schema.name.
fn grant_object_ref(class: &str, schema: Option<&str>, name: &str) -> Result<String, String> {
validate_object_name(name)?;
match class {
"schema" | "database" => Ok(quote_ident(name)),
_ => {
let s = match schema {
Some(s) => { validate_object_name(s)?; quote_ident(s) }
None => String::new(),
};
Ok(format!("{}.{}", s, quote_ident(name)))
}
}
}
pub fn role_ddl(p: &RoleParams) -> Result<Vec<String>, String> {
if !p.name.is_empty() { validate_object_name(&p.name)?; }
let name = quote_ident(&p.name);
Ok(match &p.action {
RoleAction::Create { members, .. } => {
let mut out = vec![format!("CREATE ROLE {} {}", name, role_options(&p.action))];
for m in members {
validate_object_name(m)?;
out.push(format!("GRANT {} TO {}", quote_ident(m), name));
}
out
}
RoleAction::Edit { members, .. } => {
// membership edits are advisory in v1 (no diff); skip
let _ = members;
vec![format!("ALTER ROLE {} {}", name, role_options(&p.action))]
}
RoleAction::Drop => vec![format!("DROP ROLE {}", name)],
RoleAction::Grant { object_class, object_schema, object_name, privileges, grantee, grant_option } => {
validate_object_name(grantee)?;
let obj = grant_object_ref(object_class, object_schema.as_deref(), object_name)?;
let privs = privileges.join(", ");
let opt = if *grant_option { " WITH GRANT OPTION" } else { "" };
vec![format!("GRANT {} ON {} TO {}{}", privs, obj, quote_ident(grantee), opt)]
}
RoleAction::Revoke { object_class, object_schema, object_name, privileges, grantee, grant_option } => {
validate_object_name(grantee)?;
let obj = grant_object_ref(object_class, object_schema.as_deref(), object_name)?;
let privs = privileges.join(", ");
let opt = if *grant_option { " GRANT OPTION FOR" } else { "" };
vec![format!("REVOKE{} {} ON {} FROM {}", opt, privs, obj, quote_ident(grantee))]
}
})
}
/// Dispatch a DDL build by kind. `params` is the JSON payload from the frontend.
/// Returns one or more single SQL statements.
pub fn build_ddl(kind: &str, params: serde_json::Value) -> Result<Vec<String>, String> {
match kind {
"role" => {
let p: RoleParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
role_ddl(&p)
}
"sequence" => {
let p: SequenceParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
sequence_ddl(&p)
@@ -406,6 +819,10 @@ pub fn build_ddl(kind: &str, params: serde_json::Value) -> Result<Vec<String>, S
let p: TriggerParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
trigger_ddl(&p)
}
"table" => {
let p: TableParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
table_ddl(&p)
}
other => Err(format!("Unsupported object kind: {other}")),
}
}
@@ -642,6 +1059,43 @@ mod tests {
vec!["ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"fk_user\" FOREIGN KEY (\"user_id\") REFERENCES \"public\".\"users\" (\"id\")"]);
}
#[test]
fn constraint_foreign_key_with_actions() {
let p = serde_json::json!({
"schema": "public", "table": "orders", "name": "fk_user",
"action": {
"op": "foreign_key",
"columns": ["user_id"],
"ref_schema": "public",
"ref_table": "users",
"ref_columns": ["id"],
"on_delete": "CASCADE",
"on_update": "SET NULL",
"deferrable": true,
"initially_deferred": true
}
});
assert_eq!(build_ddl("constraint", p).unwrap(),
vec!["ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"fk_user\" FOREIGN KEY (\"user_id\") REFERENCES \"public\".\"users\" (\"id\") ON DELETE CASCADE ON UPDATE SET NULL DEFERRABLE INITIALLY DEFERRED"]);
}
#[test]
fn constraint_foreign_key_empty_name_auto_names() {
let p = serde_json::json!({
"schema": "public", "table": "orders", "name": "",
"action": {
"op": "foreign_key",
"columns": ["user_id"],
"ref_schema": "public",
"ref_table": "users",
"ref_columns": ["id"],
"on_delete": "CASCADE"
}
});
assert_eq!(build_ddl("constraint", p).unwrap(),
vec!["ALTER TABLE \"public\".\"orders\" ADD FOREIGN KEY (\"user_id\") REFERENCES \"public\".\"users\" (\"id\") ON DELETE CASCADE"]);
}
#[test]
fn constraint_drop() {
let p = serde_json::json!({ "schema": "public", "table": "orders", "name": "ck_pos", "action": { "op": "drop" } });
@@ -724,4 +1178,256 @@ mod tests {
assert_eq!(build_ddl("trigger", base("disable")).unwrap(), vec!["ALTER TABLE \"public\".\"orders\" DISABLE TRIGGER \"tr_audit\""]);
assert_eq!(build_ddl("trigger", base("drop")).unwrap(), vec!["DROP TRIGGER \"tr_audit\" ON \"public\".\"orders\""]);
}
#[test]
fn table_create_multi_col_pk_and_default() {
let p = serde_json::json!({
"schema": "public", "name": "users",
"action": { "op": "create", "columns": [
{ "name": "id", "type": "integer", "nullable": false, "default": null, "is_pk": true },
{ "name": "email", "type": "text", "nullable": false, "default": null, "is_pk": false }
], "tablespace": null }
});
let sql = build_ddl("table", p).unwrap();
assert_eq!(sql, vec![
"CREATE TABLE \"public\".\"users\" (\n \"id\" integer NOT NULL,\n \"email\" text NOT NULL,\n PRIMARY KEY (\"id\")\n)"
]);
}
#[test]
fn table_create_with_tablespace() {
let p = serde_json::json!({
"schema": "public", "name": "t",
"action": { "op": "create", "columns": [
{ "name": "id", "type": "integer", "nullable": false, "default": null, "is_pk": true }
], "tablespace": "fastdisk" }
});
let sql = build_ddl("table", p).unwrap();
assert!(sql[0].ends_with(" TABLESPACE \"fastdisk\""));
}
#[test]
fn table_create_unique_column_emits_unique_keyword() {
let p = serde_json::json!({
"schema": "public", "name": "users",
"action": { "op": "create", "columns": [
{ "name": "id", "type": "integer", "nullable": false, "default": null, "is_pk": true },
{ "name": "email", "type": "text", "nullable": false, "default": null, "is_pk": false, "unique": true }
], "tablespace": null }
});
let sql = build_ddl("table", p).unwrap();
assert!(sql[0].contains("\"email\" text UNIQUE NOT NULL"), "expected UNIQUE in column def; got: {}", sql[0]);
}
#[test]
fn table_column_deserialization_ignores_unknown_fields() {
let p = serde_json::json!({
"schema": "public", "name": "t",
"action": { "op": "create", "columns": [
{ "name": "id", "type": "integer", "nullable": false, "default": null, "is_pk": true, "params": "(50)", "auto_increment": true }
], "tablespace": null }
});
let sql = build_ddl("table", p).unwrap();
assert!(sql[0].contains("\"id\" integer NOT NULL"), "expected column def; got: {}", sql[0]);
}
#[test]
fn table_create_rejects_empty_and_duplicate_columns() {
let empty = serde_json::json!({ "schema": "public", "name": "t", "action": { "op": "create", "columns": [], "tablespace": null } });
assert!(build_ddl("table", empty).is_err());
let dup = serde_json::json!({ "schema": "public", "name": "t", "action": { "op": "create", "columns": [
{ "name": "id", "type": "int", "nullable": false, "default": null, "is_pk": true },
{ "name": "id", "type": "int", "nullable": false, "default": null, "is_pk": false }
], "tablespace": null } });
assert!(build_ddl("table", dup).is_err());
}
#[test]
fn table_create_with_inline_foreign_key() {
let p = serde_json::json!({
"schema": "public", "name": "products",
"action": { "op": "create",
"columns": [
{ "name": "id", "type": "integer", "nullable": false, "default": null, "is_pk": true },
{ "name": "category_id", "type": "integer", "nullable": true, "default": null, "is_pk": false }
],
"tablespace": null,
"foreign_keys": [
{ "columns": ["category_id"], "ref_schema": "public", "ref_table": "categories",
"ref_columns": ["id"], "on_delete": "CASCADE", "on_update": "SET NULL",
"deferrable": true, "initially_deferred": false }
]
}
});
assert_eq!(build_ddl("table", p).unwrap(), vec![
"CREATE TABLE \"public\".\"products\" (\n \"id\" integer NOT NULL,\n \"category_id\" integer,\n PRIMARY KEY (\"id\"),\n FOREIGN KEY (\"category_id\") REFERENCES \"public\".\"categories\" (\"id\") ON DELETE CASCADE ON UPDATE SET NULL DEFERRABLE\n)"
]);
}
#[test]
fn table_edit_emits_rename_add_alter_drop_in_order() {
let p = serde_json::json!({
"schema": "public", "name": "users",
"action": { "op": "edit",
"old_columns": [
{ "name": "id", "type": "integer", "nullable": false, "default": null, "is_pk": true },
{ "name": "name", "type": "text", "nullable": true, "default": null, "is_pk": false },
{ "name": "age", "type": "int", "nullable": true, "default": null, "is_pk": false }
],
"columns": [
{ "name": "id", "type": "integer", "nullable": false, "default": null, "is_pk": true },
{ "name": "label", "type": "text", "nullable": true, "default": null, "is_pk": false },
{ "name": "email", "type": "text", "nullable": false, "default": "'x'", "is_pk": false }
]
}
});
let sql = build_ddl("table", p).unwrap();
let joined = sql.join("\n");
let rename_idx = joined.find("ALTER TABLE \"public\".\"users\" RENAME COLUMN \"name\" TO \"label\"").unwrap();
let add_idx = joined.find("ALTER TABLE \"public\".\"users\" ADD COLUMN \"email\" text NOT NULL DEFAULT 'x'").unwrap();
let drop_idx = joined.find("ALTER TABLE \"public\".\"users\" DROP COLUMN \"age\"").unwrap();
assert!(rename_idx < add_idx, "RENAME before ADD; got: {}", joined);
assert!(add_idx < drop_idx, "ADD before DROP; got: {}", joined);
assert!(sql.iter().any(|s| s == "ALTER TABLE \"public\".\"users\" RENAME COLUMN \"name\" TO \"label\""));
assert!(sql.iter().any(|s| s == "ALTER TABLE \"public\".\"users\" ADD COLUMN \"email\" text NOT NULL DEFAULT 'x'"));
assert!(sql.iter().any(|s| s == "ALTER TABLE \"public\".\"users\" DROP COLUMN \"age\""));
}
#[test]
fn table_edit_emits_alter_type_default_notnull() {
let p = serde_json::json!({
"schema": "public", "name": "t",
"action": { "op": "edit",
"old_columns": [ { "name": "c", "type": "int", "nullable": true, "default": null, "is_pk": false } ],
"columns": [ { "name": "c", "type": "bigint", "nullable": false, "default": "0", "is_pk": false } ]
}
});
let sql = build_ddl("table", p).unwrap();
assert!(sql.iter().any(|s| s == "ALTER TABLE \"public\".\"t\" ALTER COLUMN \"c\" TYPE bigint"));
assert!(sql.iter().any(|s| s == "ALTER TABLE \"public\".\"t\" ALTER COLUMN \"c\" SET DEFAULT 0"));
assert!(sql.iter().any(|s| s == "ALTER TABLE \"public\".\"t\" ALTER COLUMN \"c\" SET NOT NULL"));
}
#[test]
fn table_options_rls_and_tablespace() {
let p = serde_json::json!({ "schema": "public", "name": "t", "action": { "op": "options", "tablespace": "fastdisk", "rls": "force" } });
let sql = build_ddl("table", p).unwrap();
assert!(sql.iter().any(|s| s == "ALTER TABLE \"public\".\"t\" SET TABLESPACE \"fastdisk\""));
assert!(sql.iter().any(|s| s == "ALTER TABLE \"public\".\"t\" ENABLE ROW LEVEL SECURITY"));
assert!(sql.iter().any(|s| s == "ALTER TABLE \"public\".\"t\" FORCE ROW LEVEL SECURITY"));
}
#[test]
fn table_drop() {
let p = serde_json::json!({ "schema": "public", "name": "t", "action": { "op": "drop" } });
assert_eq!(build_ddl("table", p).unwrap(), vec!["DROP TABLE \"public\".\"t\""]);
}
#[test]
fn role_create_with_options_and_membership() {
let p = serde_json::json!({
"schema": "", "name": "app",
"action": { "op": "create", "login": true, "superuser": false, "createdb": true,
"createrole": false, "inherit": true, "replication": false, "bypassrls": false,
"connection_limit": 10, "valid_until": "", "password": "s3cr3t", "members": ["reader"] }
});
let sql = build_ddl("role", p).unwrap();
assert_eq!(sql[0], "CREATE ROLE \"app\" LOGIN CREATEDB INHERIT CONNECTION LIMIT 10 PASSWORD 's3cr3t'");
assert!(sql.iter().any(|s| s == "GRANT \"reader\" TO \"app\""));
}
#[test]
fn role_create_escapes_password_literal() {
let p = serde_json::json!({ "schema": "", "name": "r", "action": { "op": "create",
"login": false, "superuser": false, "createdb": false, "createrole": false, "inherit": true,
"replication": false, "bypassrls": false, "connection_limit": -1, "valid_until": "", "password": "a'b", "members": [] } });
let sql = build_ddl("role", p).unwrap();
assert!(sql[0].contains("PASSWORD 'a''b'"));
}
#[test]
fn role_edit_blank_password_omits_password_clause() {
let p = serde_json::json!({ "schema": "", "name": "app",
"action": { "op": "edit", "login": true, "superuser": false, "createdb": false,
"createrole": false, "inherit": true, "replication": false, "bypassrls": false,
"connection_limit": -1, "valid_until": "2027-01-01", "password": "", "members": [] } });
let sql = build_ddl("role", p).unwrap();
assert!(sql[0].contains("ALTER ROLE \"app\""));
assert!(!sql[0].contains("PASSWORD")); // blank = keep
assert!(sql[0].contains("VALID UNTIL '2027-01-01'"));
}
#[test]
fn role_drop() {
let p = serde_json::json!({ "schema": "", "name": "app", "action": { "op": "drop" } });
assert_eq!(build_ddl("role", p).unwrap(), vec!["DROP ROLE \"app\""]);
}
#[test]
fn role_grant_and_revoke() {
let g = serde_json::json!({ "schema": "", "name": "",
"action": { "op": "grant", "object_class": "table", "object_schema": "public", "object_name": "users",
"privileges": ["SELECT", "INSERT"], "grantee": "app", "grant_option": false } });
assert_eq!(build_ddl("role", g).unwrap(), vec!["GRANT SELECT, INSERT ON \"public\".\"users\" TO \"app\""]);
let r = serde_json::json!({ "schema": "", "name": "",
"action": { "op": "revoke", "object_class": "table", "object_schema": "public", "object_name": "users",
"privileges": ["SELECT"], "grantee": "app", "grant_option": false } });
assert_eq!(build_ddl("role", r).unwrap(), vec!["REVOKE SELECT ON \"public\".\"users\" FROM \"app\""]);
}
#[test]
fn role_grant_rejects_bad_identifier() {
let p = serde_json::json!({ "schema": "", "name": "",
"action": { "op": "grant", "object_class": "table", "object_schema": "public", "object_name": "a; DROP",
"privileges": ["SELECT"], "grantee": "app", "grant_option": false } });
assert!(build_ddl("role", p).is_err());
}
#[test]
fn rebuild_script_preserves_order_and_recreates_fk_index_grant() {
let input = RebuildInput {
schema: "public".into(), name: "users".into(),
constraints: vec![ RebuildConstraint { name: "users_pkey".into(), definition: "PRIMARY KEY (id)".into() } ],
indexes: vec![ RebuildIndex { name: "users_email_key".into(), definition: "CREATE UNIQUE INDEX users_email_key ON public.users (email)".into() } ],
fks_out: vec![],
fks_in: vec![ RebuildFkIn { name: "orders_user_fk".into(), own_schema: "public".into(), own_table: "orders".into(), definition: "FOREIGN KEY (user_id) REFERENCES public.users(id)".into() } ],
grants: vec![ RebuildGrant { grantee: "reader".into(), privileges: vec!["SELECT".into()], grantable: false } ],
owned_sequences: vec![],
};
let new = vec![ TableColumn { name: "id".into(), type_: "integer".into(), nullable: false, default: None, is_pk: false, unique: None },
TableColumn { name: "email".into(), type_: "text".into(), nullable: true, default: None, is_pk: false, unique: None } ];
let script = rebuild_script(&input, &new).unwrap();
assert!(script.contains("ALTER TABLE \"public\".\"orders\" DROP CONSTRAINT \"orders_user_fk\""), "drop fks_in first; got: {script}");
assert!(script.contains("CREATE TABLE \"public\".\"_gridline_rb_users\" ("));
assert!(script.contains("INSERT INTO \"public\".\"_gridline_rb_users\" (\"id\", \"email\") SELECT \"id\", \"email\" FROM \"public\".\"users\""));
assert!(script.contains("DROP TABLE \"public\".\"users\""));
assert!(script.contains("ALTER TABLE \"public\".\"_gridline_rb_users\" RENAME TO \"users\""));
assert!(script.contains("ALTER TABLE \"public\".\"users\" ADD CONSTRAINT \"users_pkey\" PRIMARY KEY (id)"));
assert!(script.contains("CREATE UNIQUE INDEX users_email_key ON public.users (email)"));
assert!(script.contains("ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"orders_user_fk\" FOREIGN KEY (user_id) REFERENCES public.users(id)"));
assert!(script.contains("GRANT SELECT ON \"public\".\"users\" TO \"reader\""));
// ordering: drop fks_in before DROP TABLE; DROP before RENAME; RENAME before recreate
let d_fk = script.find("DROP CONSTRAINT \"orders_user_fk\"").unwrap();
let drop = script.find("DROP TABLE \"public\".\"users\"").unwrap();
let rename = script.find("RENAME TO \"users\"").unwrap();
let addcon = script.find("ADD CONSTRAINT \"users_pkey\"").unwrap();
assert!(d_fk < drop && drop < rename && rename < addcon, "ordering wrong; got: {script}");
}
#[test]
fn rebuild_script_detaches_and_reattaches_owned_sequence() {
let input = RebuildInput {
schema: "public".into(), name: "t".into(), constraints: vec![], indexes: vec![],
fks_out: vec![], fks_in: vec![], grants: vec![],
owned_sequences: vec![ RebuildOwnedSequence { seq_schema: "public".into(), seq_name: "t_id_seq".into(), column: "id".into() } ],
};
let new = vec![ TableColumn { name: "id".into(), type_: "integer".into(), nullable: false, default: Some(Some("nextval('t_id_seq'::regclass)".into())), is_pk: false, unique: None } ];
let script = rebuild_script(&input, &new).unwrap();
assert!(script.contains("ALTER SEQUENCE \"public\".\"t_id_seq\" OWNED BY NONE"));
assert!(script.contains("ALTER SEQUENCE \"public\".\"t_id_seq\" OWNED BY \"public\".\"t\".\"id\""));
let detach = script.find("OWNED BY NONE").unwrap();
let drop = script.find("DROP TABLE").unwrap();
let attach = script.find("OWNED BY \"public\".\"t\".\"id\"").unwrap();
assert!(detach < drop && drop < attach, "detach before drop before reattach; got: {script}");
}
}
+5 -1
View File
@@ -20,7 +20,7 @@ pub struct AppState {
}
use commands::{
backup, connections, db_viewer, demo, folders, import_export, keychain, objects, query,
backup, connections, db_viewer, demo, folders, import_export, keychain, maintenance, objects, query,
schema_graph, settings, tags,
};
@@ -105,6 +105,7 @@ pub fn run() {
db_viewer::get_schemas,
db_viewer::get_tables,
db_viewer::get_table_data,
db_viewer::get_table_columns,
db_viewer::get_fk_preview,
db_viewer::execute_change,
db_viewer::get_table_ddl,
@@ -123,7 +124,10 @@ pub fn run() {
objects::get_object_ddl,
objects::get_object_dependencies,
objects::build_object_ddl,
objects::build_rebuild_script,
objects::get_available_extensions,
objects::get_roles, objects::get_role_privileges, objects::get_table_rebuild_readiness, objects::get_tablespaces,
maintenance::run_maintenance,
keychain::save_connection_password,
keychain::get_connection_password,
keychain::delete_connection_password,
+112 -1
View File
@@ -146,6 +146,55 @@ pub struct DependencyInfo {
pub name: String, // resolved dependent object name
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoleMembership {
pub role: String,
pub member: String,
pub grantor: String,
pub admin_option: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoleInfo {
pub name: String,
pub superuser: bool,
pub inherit: bool,
pub create_db: bool,
pub create_role: bool,
pub can_login: bool,
pub replication: bool,
pub bypass_rls: bool,
pub connection_limit: i64,
pub valid_until: Option<String>,
pub memberships: Vec<RoleMembership>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrivilegeEntry {
pub object_class: String, // "table" | "sequence" | "routine" | "schema" | "database"
pub schema: Option<String>,
pub name: String,
pub privileges: Vec<String>,
pub grantable: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RebuildReadiness {
pub ok: bool,
pub reasons: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MaintenanceResult {
pub duration_ms: i64,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TablespaceInfo {
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Change {
@@ -197,6 +246,10 @@ pub enum Change {
id: String,
sql: String,
},
RebuildTable {
id: String,
sql: String,
},
}
impl Change {
@@ -209,7 +262,8 @@ impl Change {
| Change::BulkInsert { id, .. }
| Change::DropTable { id, .. }
| Change::EmptyTable { id, .. }
| Change::Ddl { id, .. } => id,
| Change::Ddl { id, .. }
| Change::RebuildTable { id, .. } => id,
}
}
}
@@ -619,4 +673,61 @@ mod tests {
assert!(json.contains("\"deptype\":\"n\""));
assert!(json.contains("v_users"));
}
#[test]
fn change_rebuild_table_serializes_with_snake_case_tag() {
let change = Change::RebuildTable {
id: "chg-rb1".to_string(),
sql: "CREATE TABLE _t ...".to_string(),
};
let json = serde_json::to_string(&change).unwrap();
assert!(
json.contains(r#""type":"rebuild_table""#),
"RebuildTable should serialize with tag 'rebuild_table'; got: {json}"
);
assert_eq!(change.id(), "chg-rb1");
}
#[test]
fn change_rebuild_table_roundtrip() {
let json = serde_json::json!({
"type": "rebuild_table", "id": "rb", "sql": "SELECT 1"
});
let c: Change = serde_json::from_value(json).unwrap();
match c {
Change::RebuildTable { sql, .. } => assert_eq!(sql, "SELECT 1"),
_ => panic!("expected RebuildTable"),
}
}
#[test]
fn role_info_roundtrip() {
let r = RoleInfo {
name: "app".into(), superuser: false, inherit: true, create_db: false,
create_role: false, can_login: true, replication: false, bypass_rls: false,
connection_limit: -1, valid_until: None, memberships: vec![RoleMembership {
role: "parent".into(), member: "app".into(), grantor: "admin".into(), admin_option: false,
}],
};
let json = serde_json::to_string(&r).unwrap();
assert!(json.contains(r#""name":"app""#));
assert!(json.contains(r#""can_login":true"#));
let back: RoleInfo = serde_json::from_str(&json).unwrap();
assert_eq!(back.memberships.len(), 1);
}
#[test]
fn privilege_entry_and_readiness_roundtrip() {
let pe = PrivilegeEntry {
object_class: "table".into(), schema: Some("public".into()), name: "users".into(),
privileges: vec!["SELECT".into(), "INSERT".into()], grantable: false,
};
assert!(serde_json::to_string(&pe).unwrap().contains(r#""object_class":"table""#));
let rr = RebuildReadiness { ok: false, reasons: vec!["has triggers".into()] };
assert!(serde_json::to_string(&rr).unwrap().contains(r#""ok":false"#));
let mr = MaintenanceResult { duration_ms: 42, message: "ok".into() };
assert!(serde_json::to_string(&mr).unwrap().contains(r#""duration_ms":42"#));
let ts = TablespaceInfo { name: "pg_default".into() };
assert!(serde_json::to_string(&ts).unwrap().contains("pg_default"));
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ pub mod tag;
pub use connection::{Connection, ConnectionInput};
#[allow(unused_imports)]
pub use db_viewer::{Change, ColumnInfo, FilterRule, Pagination, QueryResult, SortRule, TableInfo};
pub use db_viewer::{Change, ColumnInfo, FilterRule, MaintenanceResult, Pagination, PrivilegeEntry, QueryResult, RebuildReadiness, RoleInfo, RoleMembership, SortRule, TableInfo, TablespaceInfo};
pub use folder::{Folder, FolderInput};
pub use recent::RecentConnection;
pub use settings::Settings;