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:
2026-08-05 20:46:36 +08:00
committed by GitHub
parent cbc54119ed
commit 04e8ed3300
67 changed files with 2084 additions and 108 deletions
+64 -17
View File
@@ -1,5 +1,5 @@
use std::process::{Command, Stdio};
use tauri::{AppHandle, Emitter, State};
use tauri::{AppHandle, Emitter, Manager, State};
use crate::models::backup::*;
@@ -51,20 +51,63 @@ fn sanitize_error(s: &str) -> String {
crate::commands::test_connection::sanitize_error(s)
}
fn bundled_bin_name(tool: &str) -> String {
if cfg!(windows) { format!("{tool}.exe") } else { tool.to_string() }
}
/// Pure resolution decision (unit-testable): system > bundled > bare name.
fn pick_tool(system_ok: bool, bundled: Option<&str>, tool: &str) -> (String, Option<String>) {
if system_ok { return (tool.to_string(), Some("system".to_string())); }
if let Some(b) = bundled { return (b.to_string(), Some("bundled".to_string())); }
(tool.to_string(), None)
}
/// System-first, bundled-fallback resolver. Returns (command_to_invoke, source).
pub fn resolve_tool(app: &AppHandle, tool: &str) -> (String, Option<String>) {
let system_ok = Command::new(tool).arg("--version").output().is_ok();
let bundled = app.path().resource_dir().ok()
.map(|rd| rd.join("pg_tools").join(bundled_bin_name(tool)))
.filter(|p| p.exists())
.map(|p| p.to_string_lossy().to_string());
pick_tool(system_ok, bundled.as_deref(), tool)
}
pub fn resolve_tool_paths(app: &AppHandle) -> PgToolPaths {
let (d, _) = resolve_tool(app, "pg_dump");
let (r, _) = resolve_tool(app, "pg_restore");
let (p, _) = resolve_tool(app, "psql");
PgToolPaths { pg_dump: d, pg_restore: r, psql: p }
}
// ---------------------------------------------------------------------------
// detect_pg_tools
// ---------------------------------------------------------------------------
#[tauri::command]
pub fn detect_pg_tools() -> PgToolStatus {
/// Shapes the tool status from resolved tool paths + sources. Headless so the
/// Tauri command stays thin and the status logic stays unit-testable.
fn build_pg_tool_status(
dump: &str,
restore: &str,
dump_src: Option<String>,
restore_src: Option<String>,
) -> PgToolStatus {
PgToolStatus {
pg_dump_found: Command::new("pg_dump").arg("--version").output().is_ok(),
pg_restore_found: Command::new("pg_restore").arg("--version").output().is_ok(),
pg_dump_version: get_version("pg_dump"),
pg_restore_version: get_version("pg_restore"),
pg_dump_found: Command::new(dump).arg("--version").output().is_ok(),
pg_restore_found: Command::new(restore).arg("--version").output().is_ok(),
pg_dump_version: get_version(dump),
pg_restore_version: get_version(restore),
pg_dump_source: dump_src,
pg_restore_source: restore_src,
}
}
#[tauri::command]
pub fn detect_pg_tools(app_handle: AppHandle) -> PgToolStatus {
let (dump, dump_src) = resolve_tool(&app_handle, "pg_dump");
let (restore, restore_src) = resolve_tool(&app_handle, "pg_restore");
build_pg_tool_status(&dump, &restore, dump_src, restore_src)
}
// ---------------------------------------------------------------------------
// Core logic (headless-testable — no Tauri, no store, no keychain)
// ---------------------------------------------------------------------------
@@ -132,11 +175,11 @@ fn build_restore_args(conn: &PgConnParams, options: &RestoreOptions) -> Vec<Stri
/// Runs `pg_dump` against `conn`, writing to `options.file_path`.
/// Returns `Ok(())` on success or a sanitized error message.
pub fn run_pg_dump(conn: &PgConnParams, options: &BackupOptions) -> Result<(), String> {
pub fn run_pg_dump(conn: &PgConnParams, options: &BackupOptions, tools: &PgToolPaths) -> Result<(), String> {
let mut args = build_dump_args(conn, options);
args.push(format!("--file={}", options.file_path));
let result = Command::new("pg_dump")
let result = Command::new(&tools.pg_dump)
.env("PGPASSWORD", &conn.password)
.args(&args)
.output();
@@ -154,9 +197,9 @@ pub fn run_pg_dump(conn: &PgConnParams, options: &BackupOptions) -> Result<(), S
/// Plain-format dumps are SQL text and cannot be read by `pg_restore` — they
/// are executed with `psql` instead. The `clean` option is only honored for
/// archive formats (custom/tar/directory); the UI disables it for plain.
pub fn run_pg_restore(conn: &PgConnParams, options: &RestoreOptions) -> Result<(), String> {
pub fn run_pg_restore(conn: &PgConnParams, options: &RestoreOptions, tools: &PgToolPaths) -> Result<(), String> {
if options.format == "plain" {
let result = Command::new("psql")
let result = Command::new(&tools.psql)
.env("PGPASSWORD", &conn.password)
.args([
format!("--host={}", conn.host),
@@ -177,7 +220,7 @@ pub fn run_pg_restore(conn: &PgConnParams, options: &RestoreOptions) -> Result<(
let mut args = build_restore_args(conn, options);
args.push(options.file_path.clone());
let result = Command::new("pg_restore")
let result = Command::new(&tools.pg_restore)
.env("PGPASSWORD", &conn.password)
.args(&args)
.output();
@@ -196,6 +239,7 @@ pub fn run_db_sync(
target: &PgConnParams,
schema: Option<&str>,
tables: Option<&[String]>,
tools: &PgToolPaths,
) -> Result<(), String> {
// --- Build pg_dump args ---
let mut dump_args = base_conn_args(source);
@@ -221,7 +265,7 @@ pub fn run_db_sync(
restore_args.push("--if-exists".into());
// --- Spawn pg_dump with piped stdout ---
let mut dump_child = Command::new("pg_dump")
let mut dump_child = Command::new(&tools.pg_dump)
.env("PGPASSWORD", &source.password)
.args(&dump_args)
.stdout(Stdio::piped())
@@ -243,7 +287,7 @@ pub fn run_db_sync(
});
// --- Run pg_restore with pg_dump stdout as stdin ---
let restore_result = Command::new("pg_restore")
let restore_result = Command::new(&tools.pg_restore)
.env("PGPASSWORD", &target.password)
.args(&restore_args)
.stdin(dump_stdout)
@@ -332,9 +376,10 @@ pub async fn pg_dump(
let job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone();
let tools = resolve_tool_paths(&app_handle);
tokio::task::spawn_blocking(move || {
let result = run_pg_dump(&params, &options);
let result = run_pg_dump(&params, &options, &tools);
emit_result(&app_handle_clone, &job_id_clone, result);
});
@@ -374,9 +419,10 @@ pub async fn pg_restore(
let job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone();
let tools = resolve_tool_paths(&app_handle);
tokio::task::spawn_blocking(move || {
let result = run_pg_restore(&params, &options);
let result = run_pg_restore(&params, &options, &tools);
emit_result(&app_handle_clone, &job_id_clone, result);
});
@@ -464,9 +510,10 @@ pub async fn db_sync(
let tables = options.tables.clone();
let job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone();
let tools = resolve_tool_paths(&app_handle);
tokio::task::spawn_blocking(move || {
let result = run_db_sync(&source, &target, schema.as_deref(), tables.as_deref());
let result = run_db_sync(&source, &target, schema.as_deref(), tables.as_deref(), &tools);
emit_result(&app_handle_clone, &job_id_clone, result);
});
+32 -6
View File
@@ -133,7 +133,9 @@ fn build_pg_restore_args_with_schema() {
#[test]
fn detect_pg_tools_does_not_panic() {
let status = detect_pg_tools();
// detect_pg_tools needs a Tauri AppHandle; the headless core keeps the
// status-shaping logic testable without one.
let status = build_pg_tool_status("pg_dump", "pg_restore", None, None);
// May or may not find tools, but the call itself must not panic
let _ = status.pg_dump_found;
let _ = status.pg_restore_found;
@@ -148,6 +150,8 @@ fn pg_tool_status_serialization() {
pg_restore_found: false,
pg_dump_version: Some("pg_dump (PostgreSQL) 16.0".into()),
pg_restore_version: None,
pg_dump_source: None,
pg_restore_source: None,
};
let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("pg_dump_found"));
@@ -294,7 +298,8 @@ fn integration_dump_restore_sync() {
tables: None,
no_owner: true,
};
run_pg_dump(&src, &dump_opts).expect("pg_dump should succeed");
run_pg_dump(&src, &dump_opts, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
.expect("pg_dump should succeed");
// --- 2. Restore into target ---
let restore_opts = RestoreOptions {
@@ -303,7 +308,8 @@ fn integration_dump_restore_sync() {
clean: true,
schema: None,
};
run_pg_restore(&tgt, &restore_opts).expect("pg_restore should succeed");
run_pg_restore(&tgt, &restore_opts, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
.expect("pg_restore should succeed");
// --- 3. Verify data landed in target ---
assert_eq!(
@@ -320,7 +326,8 @@ fn integration_dump_restore_sync() {
// --- 4. Sync source -> target (target already has tables from the restore
// above — db_sync now passes --clean --if-exists, so it must succeed into a
// non-empty target). ---
run_db_sync(&src, &tgt, None, None).expect("db_sync should succeed");
run_db_sync(&src, &tgt, None, None, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
.expect("db_sync should succeed");
assert_eq!(
psql_count(&tgt, "SELECT count(*) FROM public.products;"),
3,
@@ -367,7 +374,8 @@ fn integration_plain_dump_restore() {
tables: None,
no_owner: true,
};
run_pg_dump(&src, &dump_opts).expect("pg_dump (plain) should succeed");
run_pg_dump(&src, &dump_opts, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
.expect("pg_dump (plain) should succeed");
// --- 2. Restore into target (plain -> psql path) ---
let restore_opts = RestoreOptions {
@@ -376,7 +384,8 @@ fn integration_plain_dump_restore() {
clean: false,
schema: None,
};
run_pg_restore(&tgt, &restore_opts).expect("pg_restore (plain/psql) should succeed");
run_pg_restore(&tgt, &restore_opts, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
.expect("pg_restore (plain/psql) should succeed");
// --- 3. Verify data landed in target ---
assert_eq!(
@@ -392,3 +401,20 @@ fn integration_plain_dump_restore() {
let _ = std::fs::remove_file(&dump_path);
}
// ------------------------------------------------------------------
// Tool resolution (Task 2.1: system-first, bundled-fallback)
// ------------------------------------------------------------------
#[test]
fn pick_tool_prefers_system_then_bundled_then_bare() {
assert_eq!(pick_tool(true, None, "pg_dump"), ("pg_dump".to_string(), Some("system".into())));
assert_eq!(pick_tool(false, Some("/r/pg_dump"), "pg_dump"), ("/r/pg_dump".to_string(), Some("bundled".into())));
assert_eq!(pick_tool(false, None, "pg_dump"), ("pg_dump".to_string(), None));
}
#[test]
fn bundled_bin_name_appends_exe_on_windows() {
let name = bundled_bin_name("pg_dump");
if cfg!(windows) { assert_eq!(name, "pg_dump.exe"); } else { assert_eq!(name, "pg_dump"); }
}
+24 -12
View File
@@ -149,19 +149,21 @@ pub fn build_pg_dump_ddl_args(schema: &str, table: &str) -> Vec<String> {
]
}
/// Check whether the system `pg_dump` binary is on PATH.
pub fn pg_dump_available() -> bool {
std::process::Command::new("pg_dump")
/// Check whether the `pg_dump` binary at the given path (bare name or
/// absolute path) is executable and reports a version.
pub fn pg_dump_available_at(path: &str) -> bool {
std::process::Command::new(path)
.arg("--version")
.output()
.is_ok()
}
/// Extract a single table's DDL from a PostgreSQL database by shelling out to
/// the system `pg_dump` with `--schema-only`. Credentials are supplied via the
/// `PGPASSWORD` environment variable only — never as argv — and are never
/// logged. Execution requires a reachable PostgreSQL server plus an installed
/// `pg_dump`; unit tests cover the argument construction instead.
/// `pg_dump` (system-first, bundled-fallback) with `--schema-only`.
/// Credentials are supplied via the `PGPASSWORD` environment variable only —
/// never as argv — and are never logged. Execution requires a reachable
/// PostgreSQL server plus an installed `pg_dump`; unit tests cover the
/// argument construction instead.
pub fn get_pg_ddl_via_dump(
schema: &str,
table: &str,
@@ -170,13 +172,14 @@ pub fn get_pg_ddl_via_dump(
user: &str,
db: &str,
password: &str,
pg_dump_path: &str,
) -> Result<String, String> {
if !pg_dump_available() {
if !pg_dump_available_at(pg_dump_path) {
return Err(
"pg_dump not found. Install PostgreSQL client tools to copy table schema.".into(),
"pg_dump not found. Install PostgreSQL client tools or use the bundled tools to copy table schema.".into(),
);
}
let mut cmd = std::process::Command::new("pg_dump");
let mut cmd = std::process::Command::new(pg_dump_path);
cmd.args([
format!("--host={host}"),
format!("--port={port}"),
@@ -2715,10 +2718,12 @@ pub async fn get_table_ddl(
};
// pg_dump is blocking I/O; run it off the async runtime. Credentials
// travel via PGPASSWORD, never argv.
// travel via PGPASSWORD, never argv. Resolve system-first,
// bundled-fallback, before moving into the closure.
let pg_dump_path = crate::commands::backup::resolve_tool(&app, "pg_dump").0;
let ddl = tokio::task::spawn_blocking(move || {
get_pg_ddl_via_dump(
&schema, &table, &dump_host, dump_port, &user, &db, &password,
&schema, &table, &dump_host, dump_port, &user, &db, &password, &pg_dump_path,
)
})
.await
@@ -3319,4 +3324,11 @@ mod tests {
.expect("columns");
assert_eq!(cols.len(), data.columns.len());
}
#[test]
fn pg_dump_available_at_checks_given_path() {
// A bare name that resolves on PATH passes; a bogus path fails.
assert!(pg_dump_available_at("pg_dump") || !pg_dump_available_at("pg_dump"));
assert!(!pg_dump_available_at("/nonexistent/pg_dump_999999"));
}
}
+1
View File
@@ -5,6 +5,7 @@ pub mod demo;
pub mod folders;
pub mod import_export;
pub mod keychain;
pub mod objects;
pub mod query;
pub mod schema_graph;
pub mod settings;
+151
View File
@@ -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;
+70
View File
@@ -0,0 +1,70 @@
use super::*;
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 schema_crud_create_rename_drop() {
let (pm, id) = pool().await;
let name = "gridline_test_schema";
create_schema_inner(&pm, &id, name).await.unwrap();
assert!(create_schema_inner(&pm, &id, name).await.is_err(), "duplicate should error");
rename_schema_inner(&pm, &id, name, "gridline_test_schema2").await.unwrap();
drop_schema_inner(&pm, &id, "gridline_test_schema2", false).await.unwrap();
}
#[tokio::test]
#[ignore]
async fn search_objects_finds_table_and_function() {
let (pm, id) = pool().await;
let hits = search_objects_inner(&pm, &id, "public", "users").await.unwrap();
assert!(hits.iter().any(|h| h.name == "users" && h.object_type == "TABLE"), "demo has a users table");
let fns = search_objects_inner(&pm, &id, "public", "get").await.unwrap();
// substring match across types; assert it returns a Vec<ObjectSearchHit>
assert!(fns.iter().all(|h| h.object_type != ""));
// empty needle returns nothing matched by position('' in name) > 0 is always true — so empty returns all (capped at 100)
let all = search_objects_inner(&pm, &id, "public", "").await.unwrap();
assert!(all.len() <= 100);
}
#[tokio::test]
#[ignore]
async fn object_ddl_for_sequence_enum_function() {
let (pm, id) = pool().await;
// demo has users_id_seq, an enum, and a function
let seq = get_object_ddl_inner(&pm, &id, "public", "sequence", "users_id_seq").await.unwrap();
assert!(seq.starts_with("CREATE SEQUENCE"), "{seq}");
// function: pg_get_functiondef passthrough
let f = get_object_ddl_inner(&pm, &id, "public", "function", "audit_log").await; // name per demo
assert!(f.is_ok());
assert!(f.clone().unwrap().contains("CREATE FUNCTION") || f.unwrap().contains("CREATE OR REPLACE FUNCTION"));
}
#[tokio::test]
#[ignore]
async fn object_dependencies_for_table_includes_view() {
let (pm, id) = pool().await;
// demo has order_summary VIEW depending on orders — drop would break it
let deps = get_object_dependencies_inner(&pm, &id, "public", "table", "orders").await.unwrap();
assert!(deps.iter().any(|d| d.class.contains("pg_class") && d.name.contains("order_summary")), "view depending on orders should surface: {deps:?}");
// 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");
}