v0.7.5: bundled PG tools, schema CRUD, object search, copy-as-DDL, object dependencies (#11)
* chore: bump version to 0.7.5 (Task 1.1) * feat(db): pure object DDL/search/depend builders (Task 1.2) * feat(models): ObjectSearchHit, DependencyInfo, PgToolPaths, tool source fields (Task 1.3) * feat(backup): bundle-aware pg tool resolution, system-first fallback (Task 2.1) * feat(objects): schema CRUD commands + integration test (Task 2.2) * feat(objects): search_objects command (current-schema, all types) (Task 2.3) * feat(objects): get_object_ddl for all browsable types (Task 2.4) * feat(objects): pg_depend object dependencies + schema contents (Task 2.5) * feat(ipc): register object management commands (Task 3.1) * feat(ipc): frontend wrappers + types for object management (Task 3.2) * feat(build): declare bundled pg_tools resources (Task 3.3) * test(capabilities): lock objects capability PG-only for search/ddl/dependencies (Task 3.4) * feat(ui): Cmd+K object search palette in DB viewer (Task 4.1) * feat(ui): schema CRUD menu + dependency dialog (Task 4.2) * feat(ui): copy-as-DDL + dependency view context menu in Objects (Task 4.3) * feat(ui): dependency check before table drop + bundled-tool status (Task 4.4) * docs: v0.7.5 release notes + status table + bundled-tools (Task 5.1) * ci: build/verify bundled pg client tools per platform before tauri build (Task 5.2) * fix(objects): report pg_rewrite dependencies as the dependent view (pg_class) pg_depend records view dependencies via the view's internal rewrite rule (classid = pg_rewrite). The user-facing dependent object is the VIEW itself, so map that classid to pg_class (name still resolved through ev_class). Fixes the live-PG integration test object_dependencies_for_table_includes_view and makes the dependency dialog readable for view dependents. * test: add idempotent PG integration-test seed script Seeds the first PG test db (GRIDLINE_TEST_SRC) with the objects the #[ignore] integration tests assert against: users (+users_id_seq), products (3 rows), orders (3 rows), order_summary view, audit_log + get_user functions, user_role enum. Idempotent: DROP + recreate. * fix(build): regenerate multi-resolution icons + roadmap Keychain item - icon.ico previously contained a single 16x16 frame (Windows scaled it up -> blurry taskbar/start-menu icon). Regenerated from the 512px source via 'tauri icon': ICO now has 16/24/32/48/64/256 frames, icns has full @2x coverage up to 1024px, PNGs re-rendered from the same source. - Added icons/icon.png (512px) to bundle.icon so Linux hicolor installs a high-DPI entry. - ROADMAP: log the 'Enable Keychain' toggle (currently a form-only placeholder) under Next up -> Connection & credentials. * docs(readme): dynamic version-free download links + 3-col table - release.yml: set releaseAssetNamePattern to '[name]_[platform]_[arch][setup][ext]' (version-free), so asset filenames are stable across releases: Gridline_darwin_aarch64.dmg, Gridline_windows_x64-setup.exe, Gridline_linux_amd64.deb, Gridline_linux_x86_64.rpm, etc. - README: both download tables now 3-column (OS | Architecture | Download) and link via GitHub's releases/latest/download/<file> redirect — they always point at the newest published release, no per-release edits. - AGENTS.md: releases checklist updated — download links stay version-free. * docs(readme): platform-per-column download table (macOS | Windows | Linux) Table now mirrors the release layout: one column per OS with a logo row and a download-links row underneath. Links stay version-free via releases/latest/download/<file> (releaseAssetNamePattern in release.yml). * docs(readme): revert download table to clean image-less 3-col layout Logo row was hard to read on GitHub dark mode; the plain OS | Architecture | Download table is cleaner and still uses dynamic releases/latest links.
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
use tauri::State;
|
||||
use crate::db::pool::{ConnectionPoolManager, DbHandle};
|
||||
use crate::db::object_ddl::*;
|
||||
use crate::models::db_viewer::{ObjectSearchHit, DependencyInfo};
|
||||
|
||||
fn sanitize(e: &str) -> String { crate::commands::db_viewer::sanitize_error(e) }
|
||||
|
||||
async fn exec_sql(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, sql: String) -> Result<(), String> {
|
||||
let mut pm = pm.lock().await;
|
||||
match pm.get(connection_id) {
|
||||
Some(DbHandle::Postgresql(client, _)) => client.execute(&sql, &[]).await.map(|_| ()).map_err(|e| sanitize(&e.to_string())),
|
||||
Some(_) => Err("Schema CRUD is PostgreSQL-only".into()),
|
||||
None => Err("Connection not found".into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn create_schema_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, name: &str) -> Result<(), String> {
|
||||
exec_sql(pm, connection_id, create_schema_sql(name)?).await
|
||||
}
|
||||
pub(crate) async fn rename_schema_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, old: &str, new: &str) -> Result<(), String> {
|
||||
exec_sql(pm, connection_id, rename_schema_sql(old, new)?).await
|
||||
}
|
||||
pub(crate) async fn drop_schema_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, name: &str, cascade: bool) -> Result<(), String> {
|
||||
exec_sql(pm, connection_id, drop_schema_sql(name, cascade)?).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_schema(connection_id: String, name: String, state: State<'_, crate::AppState>) -> Result<(), String> {
|
||||
create_schema_inner(&state.pool_manager, &connection_id, &name).await
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn rename_schema(connection_id: String, old_name: String, new_name: String, state: State<'_, crate::AppState>) -> Result<(), String> {
|
||||
rename_schema_inner(&state.pool_manager, &connection_id, &old_name, &new_name).await
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn drop_schema(connection_id: String, name: String, cascade: bool, state: State<'_, crate::AppState>) -> Result<(), String> {
|
||||
drop_schema_inner(&state.pool_manager, &connection_id, &name, cascade).await
|
||||
}
|
||||
|
||||
pub(crate) async fn search_objects_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, needle: &str) -> Result<Vec<ObjectSearchHit>, String> {
|
||||
let sql = pg_object_search_query();
|
||||
let mut pm = pm.lock().await;
|
||||
match pm.get(connection_id) {
|
||||
Some(DbHandle::Postgresql(client, _)) => {
|
||||
let rows = client.query(&sql, &[&needle, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||
Ok(rows.iter().map(|r| ObjectSearchHit {
|
||||
name: r.get(0), schema: r.get(1), object_type: r.get(2),
|
||||
}).collect())
|
||||
}
|
||||
Some(DbHandle::Sqlite(_)) | Some(DbHandle::MySql(_)) => Ok(vec![]),
|
||||
None => Err("Connection not found".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn search_objects(connection_id: String, schema: String, query: String, state: State<'_, crate::AppState>) -> Result<Vec<ObjectSearchHit>, String> {
|
||||
search_objects_inner(&state.pool_manager, &connection_id, &schema, &query).await
|
||||
}
|
||||
|
||||
pub(crate) async fn get_object_ddl_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, object_type: &str, name: &str) -> Result<String, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
let client = match pm.get(connection_id) {
|
||||
Some(DbHandle::Postgresql(c, _)) => c,
|
||||
Some(_) => return Err("Copy-as-DDL is PostgreSQL-only".into()),
|
||||
None => return Err("Connection not found".into()),
|
||||
};
|
||||
match object_type {
|
||||
"function" | "procedure" => {
|
||||
let row = client.query_one("SELECT pg_get_functiondef(p.oid) FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE p.proname=$1 AND n.nspname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||
Ok(row.get::<_, String>(0))
|
||||
}
|
||||
"trigger" => {
|
||||
let row = client.query_one("SELECT pg_get_triggerdef(t.oid) FROM pg_trigger t JOIN pg_class c ON t.tgrelid=c.oid JOIN pg_namespace n ON c.relnamespace=n.oid WHERE t.tgname=$1 AND n.nspname=$2 AND NOT t.tgisinternal LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||
Ok(row.get::<_, String>(0))
|
||||
}
|
||||
"index" => {
|
||||
let row = client.query_one("SELECT pg_get_indexdef(ix.indexrelid) FROM pg_index ix JOIN pg_class i ON i.oid=ix.indexrelid JOIN pg_class t ON t.oid=ix.indrelid JOIN pg_namespace n ON t.relnamespace=n.oid WHERE i.relname=$1 AND n.nspname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||
Ok(row.get::<_, String>(0))
|
||||
}
|
||||
"constraint" => {
|
||||
let row = client.query_one("SELECT c.conname, ns.nspname, cl.relname, pg_get_constraintdef(c.oid) FROM pg_constraint c JOIN pg_class cl ON c.conrelid=cl.oid JOIN pg_namespace ns ON cl.relnamespace=ns.oid WHERE c.conname=$1 AND ns.nspname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||
Ok(constraint_ddl(&crate::models::db_viewer::ConstraintInfo {
|
||||
name: row.get(0), schema: row.get(1), table: row.get(2), contype: "CHECK".into(),
|
||||
definition: row.get(3), deferrable: false, validated: true, columns: vec![] }))
|
||||
}
|
||||
"view" => {
|
||||
let row = client.query_one("SELECT pg_get_viewdef(c.oid, true) FROM pg_class c JOIN pg_namespace n ON c.relnamespace=n.oid WHERE c.relname=$1 AND n.nspname=$2 AND c.relkind='v' LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||
Ok(view_ddl(schema, name, &row.get::<_, String>(0)))
|
||||
}
|
||||
"materialized view" => {
|
||||
let row = client.query_one("SELECT definition FROM pg_matviews WHERE matviewname=$1 AND schemaname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||
Ok(matview_ddl(schema, name, &row.get::<_, String>(0)))
|
||||
}
|
||||
"sequence" => {
|
||||
let row = client.query_one("SELECT sequence_name, sequence_schema, start_value::text, minimum_value::text, maximum_value::text, increment::text, COALESCE(pg_catalog.pg_sequence_last_value(sequence_name::regclass)::text,'0'), cycle_option::text FROM information_schema.sequences WHERE sequence_name=$1 AND sequence_schema=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||
Ok(sequence_ddl(&crate::models::db_viewer::SequenceInfo { name: row.get(0), schema: row.get(1), start_value: row.get(2), min_value: row.get(3), max_value: row.get(4), increment: row.get(5), current_value: row.get(6), cycle: row.get::<_, String>(7).eq_ignore_ascii_case("YES") }))
|
||||
}
|
||||
"enum" => {
|
||||
let row = client.query_one("SELECT t.typname, n.nspname, ARRAY(SELECT e.enumlabel FROM pg_enum e WHERE e.enumtypid=t.oid ORDER BY e.enumsortorder) FROM pg_type t JOIN pg_namespace n ON t.typnamespace=n.oid WHERE t.typname=$1 AND n.nspname=$2 AND t.typtype='e' LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||
Ok(enum_ddl(&crate::models::db_viewer::EnumInfo { name: row.get(0), schema: row.get(1), labels: row.get::<_, Vec<String>>(2) }))
|
||||
}
|
||||
"extension" => {
|
||||
let row = client.query_one("SELECT e.extname, n.nspname, e.extversion::text FROM pg_extension e JOIN pg_namespace n ON e.extnamespace=n.oid WHERE e.extname=$1 AND n.nspname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||
Ok(extension_ddl(&crate::models::db_viewer::ExtensionInfo { name: row.get(0), schema: row.get(1), version: row.get(2), comment: None }))
|
||||
}
|
||||
"table" => {
|
||||
// Tables reuse the pg_dump path (bundled-aware); the command wrapper resolves the tool path via AppHandle.
|
||||
Err("table DDL uses get_table_ddl".into())
|
||||
}
|
||||
other => Err(format!("Unsupported object type for DDL: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_object_ddl(connection_id: String, schema: String, object_type: String, name: String, state: State<'_, crate::AppState>, app: tauri::AppHandle) -> Result<String, String> {
|
||||
if object_type == "table" || object_type == "TABLE" {
|
||||
return crate::commands::db_viewer::get_table_ddl(connection_id, schema, name, state, app).await;
|
||||
}
|
||||
get_object_ddl_inner(&state.pool_manager, &connection_id, &schema, &object_type.to_lowercase(), &name).await
|
||||
}
|
||||
|
||||
pub(crate) async fn get_object_dependencies_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, object_type: &str, name: &str) -> Result<Vec<DependencyInfo>, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
let client = match pm.get(connection_id) {
|
||||
Some(DbHandle::Postgresql(c, _)) => c,
|
||||
Some(_) => return Err("Dependencies are PostgreSQL-only".into()),
|
||||
None => return Err("Connection not found".into()),
|
||||
};
|
||||
if object_type.eq_ignore_ascii_case("schema") {
|
||||
let rows = client.query(&pg_schema_contents_query(), &[&name]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||
return Ok(rows.iter().map(|r| DependencyInfo {
|
||||
deptype: "n".into(), class: format!("pg_class: {}", r.get::<_, String>(1)), name: r.get(0),
|
||||
}).collect());
|
||||
}
|
||||
let oid_sql = pg_object_oid_query(&object_type.to_lowercase());
|
||||
if oid_sql.is_empty() { return Err(format!("Unsupported object type: {object_type}")); }
|
||||
let oid: tokio_postgres::types::Oid = client.query_one(&oid_sql, &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?.get(0);
|
||||
let rows = client.query(&pg_depend_query(), &[&oid]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||
Ok(rows.iter().map(|r| DependencyInfo {
|
||||
deptype: r.get(0), class: r.get(1), name: r.get::<_, String>(2),
|
||||
}).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_object_dependencies(connection_id: String, schema: String, object_type: String, name: String, state: State<'_, crate::AppState>) -> Result<Vec<DependencyInfo>, String> {
|
||||
get_object_dependencies_inner(&state.pool_manager, &connection_id, &schema, &object_type, &name).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "objects.test.rs"]
|
||||
mod tests;
|
||||
Reference in New Issue
Block a user