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:
@@ -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(¶ms, &options);
|
||||
let result = run_pg_dump(¶ms, &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(¶ms, &options);
|
||||
let result = run_pg_restore(¶ms, &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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user