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");
}
+1
View File
@@ -1,5 +1,6 @@
pub mod introspection;
pub mod mysql;
pub mod object_ddl;
pub mod pool;
pub mod tls;
+261
View File
@@ -0,0 +1,261 @@
//! Pure builders for schema DDL, cross-object search, pg_depend lookups,
//! and synthesized object DDL. No DB I/O — deterministic string builders.
use crate::models::db_viewer::{SequenceInfo, EnumInfo, ExtensionInfo, ConstraintInfo};
/// Double-quote an identifier, doubling embedded quotes.
pub fn quote_ident(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
/// Reject empty / SQL-injection-prone names (mirrors schema_graph::validate_schema_name).
pub fn validate_object_name(name: &str) -> Result<(), String> {
if name.trim().is_empty() {
return Err("Name must not be empty".into());
}
for bad in [";", "--", "/*", "'", "\"", "\\"] {
if name.contains(bad) {
return Err(format!("Name contains forbidden character(s): {bad}"));
}
}
Ok(())
}
pub fn create_schema_sql(name: &str) -> Result<String, String> {
validate_object_name(name)?;
Ok(format!("CREATE SCHEMA {}", quote_ident(name)))
}
pub fn rename_schema_sql(old: &str, new: &str) -> Result<String, String> {
validate_object_name(old)?;
validate_object_name(new)?;
Ok(format!("ALTER SCHEMA {} RENAME TO {}", quote_ident(old), quote_ident(new)))
}
pub fn drop_schema_sql(name: &str, cascade: bool) -> Result<String, String> {
validate_object_name(name)?;
Ok(format!("DROP SCHEMA {}{}", quote_ident(name), if cascade { " CASCADE" } else { "" }))
}
/// UNION ALL of every browsable object type in `$2` (schema), substring-matching `$1` (needle)
/// via position() — case-insensitive, no wildcard-escaping pitfalls. LIMIT 100.
pub fn pg_object_search_query() -> String {
"SELECT name, schema, type FROM (
SELECT table_name AS name, table_schema AS schema, CASE WHEN table_type = 'VIEW' THEN 'VIEW' ELSE 'TABLE' END AS type FROM information_schema.tables WHERE table_schema=$2 AND position(lower($1) in lower(table_name))>0
UNION ALL
SELECT matviewname, schemaname, 'MATERIALIZED VIEW' FROM pg_matviews WHERE schemaname=$2 AND position(lower($1) in lower(matviewname))>0
UNION ALL
SELECT p.proname, n.nspname, CASE p.prokind WHEN 'f' THEN 'FUNCTION' WHEN 'p' THEN 'PROCEDURE' END FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE n.nspname=$2 AND position(lower($1) in lower(p.proname))>0
UNION ALL
SELECT t.tgname, cn.nspname, 'TRIGGER' FROM pg_trigger t JOIN pg_class c ON t.tgrelid=c.oid JOIN pg_namespace cn ON c.relnamespace=cn.oid WHERE cn.nspname=$2 AND NOT t.tgisinternal AND position(lower($1) in lower(t.tgname))>0
UNION ALL
SELECT sequence_name, sequence_schema, 'SEQUENCE' FROM information_schema.sequences WHERE sequence_schema=$2 AND position(lower($1) in lower(sequence_name))>0
UNION ALL
SELECT t.typname, n.nspname, 'ENUM' FROM pg_type t JOIN pg_namespace n ON t.typnamespace=n.oid WHERE t.typtype='e' AND n.nspname=$2 AND position(lower($1) in lower(t.typname))>0
UNION ALL
SELECT e.extname, n.nspname, 'EXTENSION' FROM pg_extension e JOIN pg_namespace n ON e.extnamespace=n.oid WHERE n.nspname=$2 AND position(lower($1) in lower(e.extname))>0
UNION ALL
SELECT i.relname, ns.nspname, 'INDEX' 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 ns ON t.relnamespace=ns.oid WHERE ns.nspname=$2 AND position(lower($1) in lower(i.relname))>0
UNION ALL
SELECT c.conname, ns.nspname, 'CONSTRAINT' 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=$2 AND c.contype IN ('c','u','x') AND position(lower($1) in lower(c.conname))>0
) AS hits ORDER BY type, name LIMIT 100".to_string()
}
/// Resolve a single object oid by type. $1=name, $2=schema. (Overloads: first match — see spec open questions.)
pub fn pg_object_oid_query(object_type: &str) -> String {
match object_type {
"table" | "view" | "materialized view" | "sequence" | "index" =>
"SELECT c.oid FROM pg_class c JOIN pg_namespace n ON c.relnamespace=n.oid WHERE c.relname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
"function" | "procedure" =>
"SELECT 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".to_string(),
"trigger" =>
"SELECT 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".to_string(),
"enum" =>
"SELECT t.oid 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".to_string(),
"extension" =>
"SELECT e.oid FROM pg_extension e JOIN pg_namespace n ON e.extnamespace=n.oid WHERE e.extname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
"constraint" =>
"SELECT c.oid FROM pg_constraint c JOIN pg_class cl ON c.conrelid=cl.oid JOIN pg_namespace n ON cl.relnamespace=n.oid WHERE c.conname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
_ => String::new(),
}
}
/// Objects that depend on `$1` (the target oid). Excludes internal/pinned deps; resolves readable name per classid.
pub fn pg_depend_query() -> String {
"SELECT d.deptype::text AS deptype,
CASE WHEN d.classid = 'pg_rewrite'::regclass THEN 'pg_class'
ELSE d.classid::regclass::text END AS class,
CASE
WHEN d.classid='pg_class'::regclass THEN (SELECT relname FROM pg_class WHERE oid=d.objid)
WHEN d.classid='pg_proc'::regclass THEN (SELECT proname FROM pg_proc WHERE oid=d.objid)
WHEN d.classid='pg_trigger'::regclass THEN (SELECT tgname FROM pg_trigger WHERE oid=d.objid)
WHEN d.classid='pg_type'::regclass THEN (SELECT typname FROM pg_type WHERE oid=d.objid)
WHEN d.classid='pg_constraint'::regclass THEN (SELECT conname FROM pg_constraint WHERE oid=d.objid)
WHEN d.classid='pg_rewrite'::regclass THEN (SELECT ev_class::regclass::text FROM pg_rewrite WHERE oid=d.objid)
ELSE ''
END AS name
FROM pg_depend d
WHERE d.refobjid = $1 AND d.deptype IN ('n', 'a')
ORDER BY d.deptype, name".to_string()
}
/// Objects contained in a schema (for the schema-drop dependency warning). $1=schema.
pub fn pg_schema_contents_query() -> String {
"SELECT c.relname, c.relkind::text FROM pg_class c JOIN pg_namespace n ON c.relnamespace=n.oid WHERE n.nspname=$1 AND c.relkind IN ('r','v','m','S','i','c') ORDER BY c.relkind, c.relname".to_string()
}
// --- Synthesized DDL ---
pub fn sequence_ddl(s: &SequenceInfo) -> String {
format!("CREATE SEQUENCE {}.{}\n INCREMENT BY {}\n MINVALUE {}\n MAXVALUE {}\n START WITH {}\n {}",
quote_ident(&s.schema), quote_ident(&s.name), s.increment, s.min_value, s.max_value, s.start_value,
if s.cycle { "CYCLE" } else { "NO CYCLE" })
}
pub fn enum_ddl(e: &EnumInfo) -> String {
let labels: Vec<String> = e.labels.iter().map(|l| format!("'{}'", l.replace('\'', "''"))).collect();
format!("CREATE TYPE {}.{} AS ENUM ({});", quote_ident(&e.schema), quote_ident(&e.name), labels.join(", "))
}
pub fn extension_ddl(x: &ExtensionInfo) -> String {
format!("CREATE EXTENSION IF NOT EXISTS {} WITH SCHEMA {} VERSION '{}';", quote_ident(&x.name), quote_ident(&x.schema), x.version.replace('\'', "''"))
}
pub fn view_ddl(schema: &str, name: &str, selectdef: &str) -> String {
format!("CREATE OR REPLACE VIEW {}.{} AS\n{}", quote_ident(schema), quote_ident(name), selectdef)
}
pub fn matview_ddl(schema: &str, name: &str, selectdef: &str) -> String {
format!("CREATE MATERIALIZED VIEW {}.{} AS\n{}", quote_ident(schema), quote_ident(name), selectdef)
}
pub fn constraint_ddl(c: &ConstraintInfo) -> String {
format!("ALTER TABLE {}.{} ADD CONSTRAINT {} {}",
quote_ident(&c.schema), quote_ident(&c.table), quote_ident(&c.name), c.definition)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quote_ident_doubles_embedded_quotes() {
assert_eq!(quote_ident("public"), "\"public\"");
assert_eq!(quote_ident("a\"b"), "\"a\"\"b\"");
}
#[test]
fn validate_object_name_rejects_dangerous_chars() {
assert!(validate_object_name("public").is_ok());
assert!(validate_object_name("").is_err());
assert!(validate_object_name("a; DROP").is_err());
assert!(validate_object_name("a--b").is_err());
assert!(validate_object_name("a'b").is_err());
assert!(validate_object_name("a\"b").is_err());
assert!(validate_object_name("a\\b").is_err());
assert!(validate_object_name("/*x*/").is_err());
}
#[test]
fn create_schema_sql_quotes_name() {
assert_eq!(create_schema_sql("my_schema").unwrap(), "CREATE SCHEMA \"my_schema\"");
assert!(create_schema_sql("bad; name").is_err());
}
#[test]
fn rename_schema_sql_quotes_both() {
assert_eq!(
rename_schema_sql("old", "new").unwrap(),
"ALTER SCHEMA \"old\" RENAME TO \"new\""
);
}
#[test]
fn drop_schema_sql_cascade_flag() {
assert_eq!(drop_schema_sql("s", false).unwrap(), "DROP SCHEMA \"s\"");
assert_eq!(drop_schema_sql("s", true).unwrap(), "DROP SCHEMA \"s\" CASCADE");
}
#[test]
fn object_search_query_unions_types_and_uses_position_match() {
let sql = pg_object_search_query();
assert!(sql.contains("position(lower($1) in lower("), "case-insensitive substring, no wildcard escaping: {sql}");
assert!(sql.contains("$2"), "schema is $2");
// covers every browsable type
for t in ["'TABLE'", "'VIEW'", "'MATERIALIZED VIEW'", "'FUNCTION'", "'PROCEDURE'", "'TRIGGER'", "'SEQUENCE'", "'ENUM'", "'EXTENSION'", "'INDEX'", "'CONSTRAINT'"] {
assert!(sql.contains(t), "search must cover {t}");
}
assert!(sql.contains("LIMIT 100"));
}
#[test]
fn object_oid_query_branches_per_type() {
assert!(pg_object_oid_query("table").contains("pg_class"));
assert!(pg_object_oid_query("function").contains("pg_proc"));
assert!(pg_object_oid_query("trigger").contains("pg_trigger"));
assert!(pg_object_oid_query("enum").contains("pg_type"));
assert!(pg_object_oid_query("extension").contains("pg_extension"));
assert!(pg_object_oid_query("constraint").contains("pg_constraint"));
}
#[test]
fn depend_query_filters_internal_and_resolves_names() {
let sql = pg_depend_query();
assert!(sql.contains("refobjid = $1"));
assert!(sql.contains("deptype IN ('n', 'a')"), "exclude internal 'i' / pinned 'p'");
assert!(sql.contains("pg_proc'::regclass"));
assert!(sql.contains("pg_trigger'::regclass"));
assert!(sql.contains("pg_constraint'::regclass"));
}
#[test]
fn depend_query_maps_rewrite_rules_to_the_view_class() {
// View dependencies surface in pg_depend as rewrite-rule rows
// (classid = pg_rewrite). The dependent object a user cares about is
// the VIEW itself, so the class must be reported as pg_class and the
// name resolved through ev_class (the view's relation).
let sql = pg_depend_query();
assert!(
sql.contains("WHEN d.classid = 'pg_rewrite'::regclass THEN 'pg_class'"),
"pg_rewrite rows must be reported as pg_class: {sql}"
);
assert!(
sql.contains("pg_rewrite'::regclass THEN (SELECT ev_class::regclass::text FROM pg_rewrite WHERE oid=d.objid)"),
"pg_rewrite name resolves through ev_class: {sql}"
);
}
#[test]
fn sequence_ddl_is_synthesized() {
let s = SequenceInfo { name: "users_id_seq".into(), schema: "public".into(),
start_value: "1".into(), min_value: "1".into(), max_value: "9".into(),
increment: "1".into(), current_value: "5".into(), cycle: false };
let ddl = sequence_ddl(&s);
assert!(ddl.starts_with("CREATE SEQUENCE \"public\".\"users_id_seq\""));
assert!(ddl.contains("INCREMENT BY 1"));
assert!(ddl.contains("NO CYCLE"));
}
#[test]
fn enum_ddl_lists_labels_quoted() {
let e = EnumInfo { name: "role".into(), schema: "public".into(), labels: vec!["admin".into(), "user's".into()] };
let ddl = enum_ddl(&e);
assert!(ddl.starts_with("CREATE TYPE \"public\".\"role\" AS ENUM ("));
assert!(ddl.contains("'admin'"));
assert!(ddl.contains("'user''s'"), "single quotes doubled");
}
#[test]
fn extension_ddl_is_synthesized() {
let x = ExtensionInfo { name: "pgcrypto".into(), schema: "public".into(), version: "1.3".into(), comment: None };
let ddl = extension_ddl(&x);
assert!(ddl.contains("CREATE EXTENSION IF NOT EXISTS \"pgcrypto\""));
assert!(ddl.contains("WITH SCHEMA \"public\""));
assert!(ddl.contains("VERSION '1.3'"));
}
#[test]
fn view_ddl_wraps_selectdef() {
assert_eq!(view_ddl("public", "v_users", "SELECT * FROM users"),
"CREATE OR REPLACE VIEW \"public\".\"v_users\" AS\nSELECT * FROM users");
}
#[test]
fn constraint_ddl_wraps_definition() {
let c = ConstraintInfo { name: "ck_pos".into(), schema: "public".into(), table: "orders".into(),
contype: "CHECK".into(), definition: "CHECK (amount > 0)".into(), deferrable: false, validated: true, columns: vec!["amount".into()] };
let ddl = constraint_ddl(&c);
assert_eq!(ddl, "ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"ck_pos\" CHECK (amount > 0)");
}
}
+8 -2
View File
@@ -20,8 +20,8 @@ pub struct AppState {
}
use commands::{
backup, connections, db_viewer, demo, folders, import_export, keychain, query, schema_graph,
settings, tags,
backup, connections, db_viewer, demo, folders, import_export, keychain, objects, query,
schema_graph, settings, tags,
};
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
@@ -116,6 +116,12 @@ pub fn run() {
db_viewer::get_extensions,
db_viewer::get_indexes,
db_viewer::get_constraints,
objects::create_schema,
objects::rename_schema,
objects::drop_schema,
objects::search_objects,
objects::get_object_ddl,
objects::get_object_dependencies,
keychain::save_connection_password,
keychain::get_connection_password,
keychain::delete_connection_password,
+20
View File
@@ -34,6 +34,16 @@ pub struct PgToolStatus {
pub pg_restore_found: bool,
pub pg_dump_version: Option<String>,
pub pg_restore_version: Option<String>,
pub pg_dump_source: Option<String>, // "system" | "bundled" | None
pub pg_restore_source: Option<String>,
}
/// Resolved on-disk paths for the three client tools (system-first, bundled-fallback).
#[derive(Debug, Clone)]
pub struct PgToolPaths {
pub pg_dump: String,
pub pg_restore: String,
pub psql: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -97,4 +107,14 @@ mod tests {
assert!(json.contains("dump"));
assert!(json.contains("completed"));
}
#[test]
fn pg_tool_status_reports_source() {
let s = PgToolStatus { pg_dump_found: true, pg_restore_found: true,
pg_dump_version: Some("pg_dump 16".into()), pg_restore_version: Some("pg_restore 16".into()),
pg_dump_source: Some("system".into()), pg_restore_source: Some("bundled".into()) };
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("\"pg_dump_source\":\"system\""));
assert!(json.contains("\"pg_restore_source\":\"bundled\""));
}
}
+31
View File
@@ -132,6 +132,20 @@ pub struct ExtensionInfo {
pub comment: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObjectSearchHit {
pub name: String,
pub schema: String,
pub object_type: String, // TABLE | VIEW | MATERIALIZED VIEW | FUNCTION | PROCEDURE | TRIGGER | SEQUENCE | ENUM | EXTENSION | INDEX | CONSTRAINT
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DependencyInfo {
pub deptype: String, // "n" (normal) | "a" (auto)
pub class: String, // pg_class | pg_proc | pg_trigger | pg_type | pg_constraint | pg_rewrite
pub name: String, // resolved dependent object name
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Change {
@@ -583,4 +597,21 @@ mod tests {
assert!(json.contains("users"), "should contain referenced table");
assert!(json.contains("id"), "should contain referenced column");
}
#[test]
fn object_search_hit_tagged_roundtrip() {
let hit = ObjectSearchHit { name: "users".into(), schema: "public".into(), object_type: "TABLE".into() };
let json = serde_json::to_string(&hit).unwrap();
assert!(json.contains("\"object_type\":\"TABLE\""));
let back: ObjectSearchHit = serde_json::from_str(&json).unwrap();
assert_eq!(back.name, "users");
}
#[test]
fn dependency_info_roundtrip() {
let d = DependencyInfo { deptype: "n".into(), class: "pg_class".into(), name: "v_users".into() };
let json = serde_json::to_string(&d).unwrap();
assert!(json.contains("\"deptype\":\"n\""));
assert!(json.contains("v_users"));
}
}