diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 757feaf..1340c7c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,6 +63,53 @@ jobs: - name: Install frontend dependencies run: bun install --frozen-lockfile + - name: Build PostgreSQL client tools (bundled) + shell: bash + run: | + set -euo pipefail + PG_VER="16.4" + OUT="src-tauri/resources/pg_tools" + mkdir -p "$OUT" + case "${{ matrix.platform }}" in + ubuntu-22.04) + sudo apt-get update -y + sudo apt-get install -y build-essential libreadline-dev zlib1g-dev flex bison + curl -fsSL "https://ftp.postgresql.org/pub/source/v${PG_VER}/postgresql-${PG_VER}.tar.bz2" -o /tmp/pg.tar.bz2 + tar -xf /tmp/pg.tar.bz2 -C /tmp + cd /tmp/postgresql-${PG_VER} + ./configure --prefix=/tmp/pgbuild --without-readline --without-icu --disable-shared CFLAGS="-O2" + make -j"$(nproc)" -C src/bin/pg_dump all + make -j"$(nproc)" -C src/bin/psql all + cp src/bin/pg_dump/pg_dump src/bin/pg_dump/pg_restore "$OUT"/ + cp src/bin/psql/psql "$OUT"/ + ;; + macos-latest|macos-15-intel) + curl -fsSL "https://ftp.postgresql.org/pub/source/v${PG_VER}/postgresql-${PG_VER}.tar.bz2" -o /tmp/pg.tar.bz2 + tar -xf /tmp/pg.tar.bz2 -C /tmp + cd /tmp/postgresql-${PG_VER} + ./configure --prefix=/tmp/pgbuild --without-readline --without-icu --disable-shared CFLAGS="-O2" + make -j"$(sysctl -n hw.ncpu)" -C src/bin/pg_dump all + make -j"$(sysctl -n hw.ncpu)" -C src/bin/psql all + cp src/bin/pg_dump/pg_dump src/bin/pg_dump/pg_restore "$OUT"/ + cp src/bin/psql/psql "$OUT"/ + ;; + windows-latest) + URL="https://get.enterprisedb.com/postgresql/postgresql-${PG_VER}-1-windows-x64-binaries.zip" + curl -fsSL "$URL" -o /tmp/pg.zip + EXPECTED="3508d8f085bc3980f38211a82e3f31e5fcae9952105d3dc2f8be67b64a822baa" + echo "$EXPECTED /tmp/pg.zip" | sha256sum -c - + sha256sum /tmp/pg.zip + unzip -o /tmp/pg.zip -d /tmp/pg + cp /tmp/pg/pgsql/bin/pg_dump.exe /tmp/pg/pgsql/bin/pg_restore.exe /tmp/pg/pgsql/bin/psql.exe "$OUT"/ + cp /tmp/pg/pgsql/bin/libpq.dll "$OUT"/ + ;; + esac + (cd "$OUT" && sha256sum * | tee checksums.txt) + for b in pg_dump pg_restore psql; do + f="$OUT/${b}$( [ "${{ matrix.platform }}" = windows-latest ] && echo .exe )" + test -f "$f" || { echo "missing $f"; exit 1; } + done + - name: Build and upload to GitHub Release uses: tauri-apps/tauri-action@v0 env: @@ -71,4 +118,10 @@ jobs: tagName: ${{ github.ref_name }} releaseName: 'Gridline ${{ github.ref_name }}' releaseDraft: true - args: ${{ matrix.args }} \ No newline at end of file + args: ${{ matrix.args }} + # Version-free asset names (see README Download section): the README + # links via GitHub's releases/latest/download/ redirect, which + # only works if filenames are identical across releases. Omitting + # [version] gives stable names: Gridline_darwin_aarch64.dmg, + # Gridline_windows_x64-setup.exe, Gridline_linux_amd64.deb, etc. + releaseAssetNamePattern: '[name]_[platform]_[arch][setup][ext]' \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index cf3405a..1961090 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,7 +156,8 @@ Cut a release from the **`prod`** branch (never feature branches) by tagging it **Before tagging**, keep everything in sync: - Version number across `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json` - `src/lib/version.test.ts` and `src/lib/docs-coverage.test.ts` if they assert the version -- **Both README download tables** — the top **Download** section and the **Which file should I download?** section in Getting Started — they **hardcode** the current version in the asset filenames + direct `releases/download/...` links and must be bumped to the new version +- **README download links stay version-free** — both download tables (top **Download** section + **Which file should I download?**) link via GitHub's `releases/latest/download/` redirect, which only works because `release.yml` sets `releaseAssetNamePattern` to a version-free pattern (`[name]_[platform]_[arch][setup][ext]`). Do NOT re-add the version to these filenames on release. +- **Bundled pg tools:** `tauri.conf.json` `bundle.resources` lists `resources/pg_tools/*`; the `release.yml` matrix builds/downloads + checksum-verifies the static binaries before the Tauri build step. ### Adding a Tauri Command @@ -180,7 +181,7 @@ Cut a release from the **`prod`** branch (never feature branches) by tagging it ## Constraints & Guardrails -- **Do NOT** implement `pg_dump` file format parsing — always shell out to system binaries +- **Do NOT** implement `pg_dump` file format parsing — always shell out to (bundled or system) binaries - **Do NOT** store passwords in SQLite or local files — use OS keychain APIs exclusively - **Do NOT** render large query results in raw DOM — always use the virtualized grid component - **Do NOT** log credentials, connection strings, or query data @@ -285,6 +286,10 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr | Constraints (CHECK, UNIQUE beyond PK/FK) | ✅ | CHECK/UNIQUE constraints beyond PK/FK, introspected via information_schema | | Materialized views | ✅ | Distinct icon in table tree, browsable, read-only (via pg_matviews) | | Stored procedures | ✅ | Procedures object type filters prokind='p'; Functions now filters kind='f' | +| Schema CRUD | ✅ | Create / rename / drop schemas from the object tree; CASCADE drop with typed-name confirm + dependency warning (v0.7.5) | +| Global object search | ✅ | Cmd+K palette in the DB viewer, current-schema scope, all object types; results open a table tab or jump to the Objects view (v0.7.5) | +| Copy as DDL for any object | ✅ | `CREATE` DDL for every browsable type (tables via pg_dump; pg_get_*def passthrough; sequences/enums/extensions/views synthesized) (v0.7.5) | +| Object dependencies | ✅ | `pg_depend` "what depends on this?" view, shown before destructive drops (table drop, schema drop) (v0.7.5) | | Schema visualizer (ER diagram) | ✅ | Full React Flow ER diagram with dagre auto-layout, crow's foot notation, schema selector, legend with cardinality colors, collapsible columns (PK/FK/unique-only), cross-schema FK support. PostgreSQL (single round-trip LATERAL query) + SQLite (PRAGMA). Uses @xyflow/react + dagre. | ### Query Editor @@ -310,6 +315,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr | Restore UI | ✅ | In-page view: file browse, format, clean toggle, destructive confirmation checkbox, progress bar. **Clean toggle is disabled for plain format** (psql can't DROP-before-CREATE) with a hint to use Custom Archive | | DB-to-DB sync | ✅ | In-page view: source/target connection pickers, schema dropdown, pipe-based pg_dump → pg_restore. **pg_restore side passes `--clean --if-exists`**, so sync works into a non-empty target (UI already requires destructive-overwrite confirmation). Core logic in headless-testable `run_db_sync` | | Unified Tools view | ✅ | Backup / Restore / DB Sync merged into a single **Tools** nav item; operation-switcher dropdown in the view toolbar, existing forms rendered below | +| Bundled PostgreSQL client tools | ✅ | Static pg_dump/pg_restore/psql shipped as Tauri resources; system-first, bundled-fallback resolution via resource_dir (v0.7.5) | | SQLite .dump | ❌ | | | Table structure export (DDL) | ❌ | | diff --git a/README.md b/README.md index 997b8db..85c5540 100644 --- a/README.md +++ b/README.md @@ -31,25 +31,26 @@ ## Download -Grab the installer for your OS from the latest release (v0.7.0): +Grab the installer for your OS from the [latest release](https://github.com/AdrianBonpin/gridline/releases/latest) — links always point to the newest build: -| OS | Download | -| :--- | :--- | -| **macOS** · Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.0_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.0/Gridline_0.7.0_aarch64.dmg) | -| **macOS** · Intel | [Gridline_0.7.0_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.0/Gridline_0.7.0_x64.dmg) | -| **Windows** | [Gridline_0.7.0_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.0/Gridline_0.7.0_x64-setup.exe) | -| **Debian / Ubuntu** | [Gridline_0.7.0_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.0/Gridline_0.7.0_amd64.deb) | -| **Fedora / RHEL / openSUSE** | [Gridline-0.7.0-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.0/Gridline-0.7.0-1.x86_64.rpm) | -| **Other Linux** | [Gridline_0.7.0_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.0/Gridline_0.7.0_amd64.AppImage) | +| OS | Architecture | Download | +| :--- | :--- | :--- | +| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_darwin_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_darwin_aarch64.dmg) | +| **macOS** | Intel | [Gridline_darwin_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_darwin_x64.dmg) | +| **Windows** | x64 | [Gridline_windows_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_windows_x64-setup.exe) | +| **Debian / Ubuntu** | amd64 | [Gridline_linux_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_linux_amd64.deb) | +| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline_linux_x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_linux_x86_64.rpm) | +| **Other Linux** | amd64 | [Gridline_linux_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_linux_amd64.AppImage) | > Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below. All installers are **unsigned** — see the [notes](#which-file-should-i-download) on first-launch warnings. --- @@ -81,7 +82,8 @@ Gridline is built for developers and small teams who manage multiple database en ## Recent Changes -- **2026-08-04:** v0.7.0 — revamped the New Connection screen into a two-stage flow with a 6-provider grid (PostgreSQL, MySQL, SQLite, Redis, Supabase, NeonDB; managed presets ship with setup guides + SSL hints) and added full MySQL DB viewer support (connect, browse, query, inline cell editing + changes queue, DDL copy). +- **2026-08-05:** v0.7.5 — bundled `pg_dump`/`pg_restore`/`psql` (system-first, bundled fallback) so admin features work with no separate install; schema CRUD (create/rename/drop with CASCADE + dependency warning); Cmd+K object search (current schema, all object types); copy-as-DDL for every browsable object type; `pg_depend` object-dependency view shown before destructive drops. +- **2026-08-04:** v0.7.5 — revamped the New Connection screen into a two-stage flow with a 6-provider grid (PostgreSQL, MySQL, SQLite, Redis, Supabase, NeonDB; managed presets ship with setup guides + SSL hints) and added full MySQL DB viewer support (connect, browse, query, inline cell editing + changes queue, DDL copy). - **2026-08-04:** Revamped the built-in SQLite demo database with realistic e-commerce data (20 users, 24 products, 50 orders, 100 page views, 500 audit rows) and renamed it to **Gridline Demo (SQLite)**. - **2026-07-XX:** Added inline cell editing with a stage-first changes queue, row-detail drawer, keyboard navigation, and cell-level copy. - **2026-07-XX:** Added visual filter builder with drag-and-drop column palette and type-aware operators. @@ -112,6 +114,10 @@ Gridline is built for developers and small teams who manage multiple database en - **Functions & procedures** — syntax-highlighted source, argument signatures, overload disambiguation. - **Triggers, sequences, enums, extensions** — unified **Objects** view with type switcher. - **Indexes & constraints** — per-table index details plus CHECK/UNIQUE constraints beyond PK/FK. +- **Schema CRUD** — create/rename/drop schemas from the object tree, with typed-name + dependency warning on CASCADE drops. +- **Global object search** — ⌘K, current schema, all object types; results open a table tab or jump to the Objects view. +- **Copy as DDL** — `CREATE` DDL for every browsable object type. +- **Object dependencies** — `pg_depend` "what depends on this?" view before destructive drops. - **Schema visualizer** — interactive ER diagram with auto-layout, cardinality legend, and collapsible columns. ### Data Grid @@ -140,6 +146,7 @@ Gridline is built for developers and small teams who manage multiple database en - **Visual Backup** — `pg_dump` wrapper with format selector, schema filter, no-owner toggle, real-time progress. - **Visual Restore** — `pg_restore` wrapper with clean toggle and destructive confirmation. - **DB-to-DB Sync** — pipe `pg_dump` → `pg_restore` between two connections. +- **Bundled client tools** — `pg_dump`/`pg_restore`/`psql` ship with the app; system tools are preferred when present, bundled tools are the fallback. --- @@ -218,7 +225,7 @@ Capabilities below are fact-checked against each vendor's official docs and pric | **Desktop shell** | [Tauri 2.0](https://tauri.app) | Native webview container (~40 MB baseline) | | **Backend** | Rust + [tokio](https://tokio.rs) | Async runtime, connection pooling, CLI execution | | **DB drivers** | [sqlx](https://github.com/launchbadge/sqlx) / [tokio-postgres](https://github.com/sfackler/rust-postgres) | PostgreSQL via tokio-postgres; MySQL via sqlx; SQLite via rusqlite | -| **CLI integration** | `std::process::Command` | Wraps system `pg_dump` / `pg_restore` | +| **CLI integration** | `std::process::Command` | Wraps bundled or system `pg_dump` / `pg_restore` | | **Frontend** | [React 19](https://react.dev) + [TypeScript](https://www.typescriptlang.org) | Component-based UI | | **Styling** | [Tailwind CSS](https://tailwindcss.com) | Utility-first, dark mode, glassmorphic design | | **State** | [Zustand](https://zustand.docs.pmnd.rs) / Jotai | Domain stores | @@ -248,16 +255,16 @@ Code signing **will be added in the future** (Apple Developer Program + a Window #### Which file should I download? -Each release contains **one file per platform** — you only need the one that matches your computer. (If a newer version is available, swap `0.7.0` for the version shown in the release title.) +Each release contains **one file per platform** — you only need the one that matches your computer. The links below always point at the newest build (the asset names are version-free, so they never go stale): | Your system | Download this | Notes | | :--- | :--- | :--- | -| macOS **Apple Silicon** (M1/M2/M3/M4…) | `Gridline_0.7.0_aarch64.dmg` | `aarch64` = Apple's own chip | -| macOS **Intel** | `Gridline_0.7.0_x64.dmg` | `x64` = Intel/AMD | -| **Windows** (most PCs) | `Gridline_0.7.0_x64-setup.exe` | The `.msi` is an alternate installer (for enterprises/IT admins) | -| **Debian / Ubuntu** | `Gridline_0.7.0_amd64.deb` | Install: `sudo apt install ./Gridline_0.7.0_amd64.deb` | -| **Fedora / RHEL / openSUSE** | `Gridline-0.7.0-1.x86_64.rpm` | Install: `sudo dnf install Gridline-0.7.0-1.x86_64.rpm` | -| **Any other Linux** | `Gridline_0.7.0_amd64.AppImage` | Works on every distro: `chmod +x` the file, then double-click it | +| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_darwin_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_darwin_aarch64.dmg) | `aarch64` = Apple's own chip | +| macOS **Intel** | [Gridline_darwin_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_darwin_x64.dmg) | `x64` = Intel/AMD | +| **Windows** (most PCs) | [Gridline_windows_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_windows_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) | +| **Debian / Ubuntu** | [Gridline_linux_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_linux_amd64.deb) | Install: `sudo apt install ./Gridline_linux_amd64.deb` | +| **Fedora / RHEL / openSUSE** | [Gridline_linux_x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_linux_x86_64.rpm) | Install: `sudo dnf install Gridline_linux_x86_64.rpm` | +| **Any other Linux** | [Gridline_linux_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_linux_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it | **Not sure if your Mac is Intel or Apple Silicon?** Click the **Apple menu** → **About This Mac**. If it shows "Apple M1/M2/M3/M4…" download the `aarch64` file; if it shows an Intel chip, download `x64`. Downloading the wrong one won't run. @@ -267,8 +274,8 @@ Cutting a release is one command — CI builds everything. **Releases are cut fr ```bash git checkout prod && git pull -git tag v0.7.0 -git push origin v0.7.0 +git tag v0.7.5 +git push origin v0.7.5 ``` GitHub Actions (`.github/workflows/release.yml`) builds installers for **Apple Silicon, Intel Macs, Windows, and Linux**, then opens a **draft release** on the [Releases](https://github.com/adrianbonpin/gridline/releases) page — review it and hit **Publish release**. @@ -298,7 +305,7 @@ bun run tauri build - **Windows:** 10 or newer - **Linux:** Ubuntu 22.04+ or equivalent modern distribution - **RAM:** 8 GB recommended -- **PostgreSQL client tools:** `pg_dump` and `pg_restore` are required for admin features +- **PostgreSQL client tools:** `pg_dump`/`pg_restore`/`psql` are **bundled** with Gridline — no separate install required for backup/restore/sync. (System tools, if installed, are preferred.) --- @@ -352,14 +359,14 @@ gridline/ ## Roadmap -The full plan — in-development (v0.7.0), next-up, queue, and shipped history — lives in **[ROADMAP.md](./ROADMAP.md)**. +The full plan — in-development (v0.7.5), next-up, queue, and shipped history — lives in **[ROADMAP.md](./ROADMAP.md)**. Highlights of what's next: -- **Full Object Management** — CRUD on functions, triggers, sequences, enums, extensions, and views without the Query tab, plus schema CRUD, global object search, and copy-as-DDL +- **Full Object Management** — CRUD on functions, triggers, sequences, enums, extensions, and views without the Query tab (schema CRUD, global object search, copy-as-DDL, and object dependencies shipped in v0.7.5) - **Full Redis support** — key browser, type-aware value editors, TTL management - **More database types** — MariaDB, TimescaleDB, and friends -- **Managed DB support** — PlanetScale, Turso (Supabase/Neon presets shipped in v0.7.0) +- **Managed DB support** — PlanetScale, Turso (Supabase/Neon presets shipped in v0.7.5) - **AI integration (BYOK)** — natural-language → SQL, chat, summaries, charts ✅ **[View the full roadmap →](./ROADMAP.md)** diff --git a/ROADMAP.md b/ROADMAP.md index 210fb97..42d7ced 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,6 +6,15 @@ This file is the **source of truth** for what Gridline is building. [AGENTS.md]( --- +## ✅ Shipped (0.7.5) + +- **Bundled PG client tools** — static `pg_dump`/`pg_restore`/`psql` ship with the app (system-first, bundled fallback) so backup/restore/sync work with no separate install. +- **Schema CRUD** — create/rename/drop schemas from the object tree; CASCADE drop with typed-name confirm + dependency warning. +- **Global object search** — Cmd+K palette in the DB viewer, current-schema scope, all object types; results open a table tab or jump to the Objects view. +- **Copy as DDL for any object** — `CREATE` DDL for every browsable type (tables via pg_dump; pg_get_*def passthrough; sequences/enums/extensions/views synthesized). +- **Object dependencies** — `pg_depend` "what depends on this?" view, shown before destructive drops (table drop, schema drop). +- **Version bump** 0.7.0 → **0.7.5**. + ## ✅ Shipped (0.7.0) - **New Connection screen revamp** — a single progressive flow: connection-string input + 2-column provider tab grid → expands into the full configuration form (label, tags/env/folder, General + SSH·SSL tabs). Removes the simple/detailed toggle. @@ -36,18 +45,15 @@ Gridline can already **browse** every PostgreSQL object type (functions, trigger - Staged through the changes queue (with confirmation) — never a surprise DDL - **Out of scope this release:** the MySQL "Objects" view stays deferred (see In the queue) — this iteration is PostgreSQL-only -### Shipping alongside (companions) - -- **Schema CRUD** — create/rename/drop schemas from the object tree -- **Global object search** — Cmd+K-style search across tables, functions, triggers, sequences, and extensions by name -- **Copy as DDL for any object** — `CREATE FUNCTION` / `CREATE TRIGGER` / … via the same DDL-generation used for edit/drop previews (also covers the "Table structure export" queue item) -- **Object dependencies** — `pg_depend`-based "what depends on this object?" view, shown before drops so nothing breaks silently - ### Admin follow-up (after object management) - **PostgreSQL users/roles + grants management** — create roles and set privileges from a UI (DB Pro has this at 0% on their roadmap — a differentiator to hold) - **Maintenance actions** — right-click table → VACUUM / ANALYZE / REINDEX +### Connection & credentials + +- **Wire up the "Enable Keychain" toggle** — currently a form-only placeholder: the flag is submitted and stored with the connection record, but the Rust backend never reads it and the frontend store unconditionally calls `saveConnectionPassword`. Decide the intended behavior (e.g. off = store the password with the connection record / don't persist at all, on = OS keychain as today) and implement the conditional path + migration for existing records. + ## 📋 In the queue ### Full Redis Support @@ -128,6 +134,11 @@ Deferred from 0.7.0, slated for this bucket: ## ✅ Shipped +- Bundled PG client tools — `pg_dump`/`pg_restore`/`psql` shipped with the app, system-first with bundled fallback (v0.7.5) +- Schema CRUD — create/rename/drop schemas with CASCADE + dependency warning (v0.7.5) +- Global object search — Cmd+K across all object types in the current schema (v0.7.5) +- Copy as DDL for any object (v0.7.5) +- Object dependencies — `pg_depend` view before destructive drops (v0.7.5) - Tauri 2.0 + React 19 + TypeScript 5.8 project shell - PostgreSQL and SQLite browse/query support - Full MySQL DB viewer — connect, browse, query, edit + changes queue (v0.7.0) diff --git a/package.json b/package.json index 83e250d..99e8c3a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gridline", "private": true, - "version": "0.7.0", + "version": "0.7.5", "description": "An open-source, high-performance database GUI client for PostgreSQL and beyond", "type": "module", "scripts": { diff --git a/scripts/seed-pg-test.sql b/scripts/seed-pg-test.sql new file mode 100644 index 0000000..113c2d3 --- /dev/null +++ b/scripts/seed-pg-test.sql @@ -0,0 +1,75 @@ +-- Gridline v0.7.5 integration-test seed (idempotent). +-- Target: the FIRST PG test DB (GRIDLINE_TEST_SRC @ :7501), db=postgres. +-- Objects are chosen to satisfy the assertions in src-tauri/src/commands/objects.test.rs: +-- search_objects: 'users' TABLE hit, 'get*' function hit, empty-needle <= 100 +-- get_object_ddl: users_id_seq -> CREATE SEQUENCE; audit_log -> CREATE FUNCTION +-- get_object_dependencies: orders -> order_summary VIEW; schema contents non-empty +-- Run with: psql ... -f scripts/seed-pg-test.sql + +BEGIN; + +-- 1. users table with serial -> creates users_id_seq in public +DROP VIEW IF EXISTS order_summary CASCADE; +DROP TABLE IF EXISTS orders CASCADE; +DROP TABLE IF EXISTS products CASCADE; +DROP TABLE IF EXISTS users CASCADE; +DROP TYPE IF EXISTS user_role CASCADE; +DROP FUNCTION IF EXISTS audit_log(text) CASCADE; +DROP FUNCTION IF EXISTS get_user(bigint) CASCADE; + +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + role TEXT NOT NULL DEFAULT 'member', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +INSERT INTO users (email, role) VALUES ('a@example.com', 'admin'), ('b@example.com', 'member'); + +-- 2. enum type (for enum search + enum DDL) +CREATE TYPE user_role AS ENUM ('admin', 'member', 'guest'); + +-- 3. products (backup tests assert 3 rows) +CREATE TABLE products ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + price NUMERIC(10,2) NOT NULL +); +INSERT INTO products (name, price) VALUES ('Widget', 9.99), ('Gadget', 19.99), ('Gizmo', 29.99); + +-- 4. orders + a dependent VIEW (what-depends-on-this test: dropping orders must surface order_summary) +CREATE TABLE orders ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id), + total NUMERIC(10,2) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +INSERT INTO orders (user_id, total) VALUES (1, 99.50), (2, 12.00), (1, 45.25); + +CREATE VIEW order_summary AS + SELECT u.email, count(o.id) AS order_count, sum(o.total) AS total + FROM users u LEFT JOIN orders o ON o.user_id = u.id + GROUP BY u.email; + +-- 4. functions: audit_log (DDL test) + get_user (search 'get' hit) +CREATE FUNCTION audit_log(msg text) RETURNS void AS $$ + SELECT pg_sleep(0); -- placeholder body so the function is real +$$ LANGUAGE sql; + +CREATE FUNCTION get_user(uid bigint) RETURNS TABLE(email text, role text) AS $$ + SELECT u.email, u.role FROM users u WHERE u.id = uid; +$$ LANGUAGE sql STABLE; + +COMMIT; + +-- Verify +\echo '--- seeded objects ---' +SELECT c.relkind::text || ' ' || c.relname +FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid +WHERE n.nspname = 'public' AND c.relkind IN ('r','v','S') +ORDER BY 1; +\echo '--- public functions ---' +SELECT p.proname FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid +WHERE n.nspname = 'public' ORDER BY 1; +\echo '--- enums ---' +SELECT t.typname FROM pg_type t JOIN pg_namespace n ON t.typnamespace = n.oid +WHERE n.nspname = 'public' AND t.typtype = 'e'; \ No newline at end of file diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 84b3ff8..fd7d205 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1783,7 +1783,7 @@ dependencies = [ [[package]] name = "gridline" -version = "0.7.0" +version = "0.7.5" dependencies = [ "chrono", "deadpool-postgres", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f5bb29e..f4d5613 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gridline" -version = "0.7.0" +version = "0.7.5" description = "An open-source, high-performance database GUI client for PostgreSQL and beyond" authors = ["you"] edition = "2021" diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 04d16f1..f518e2b 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index 6c42f4c..56b88c4 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index 0015e4b..ac23d27 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png index 5d6a60e..ffe865c 100644 Binary files a/src-tauri/icons/64x64.png and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index 09d0a38..3e7ad58 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index 97c5c6b..9ba93c5 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index f5d0634..2e5db62 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index 219c15f..9e6aa18 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index 2c2717c..320969f 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index 5d76017..83e38f8 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index 8d1c000..2fe1cfa 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index aa9d50f..5a3bee0 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index 4336b63..ad436c2 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index fac3ced..2994200 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index d9532a6..1b61d30 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index 792eda3..de821ac 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index 140fea8..3729f95 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/resources/pg_tools/.gitkeep b/src-tauri/resources/pg_tools/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src-tauri/resources/pg_tools/README.md b/src-tauri/resources/pg_tools/README.md new file mode 100644 index 0000000..5837859 --- /dev/null +++ b/src-tauri/resources/pg_tools/README.md @@ -0,0 +1,8 @@ +# Bundled PostgreSQL client tools + +This directory is populated **at build time by CI** (`.github/workflows/release.yml`) +with statically-linked `pg_dump`, `pg_restore`, and `psql` for the current target. +Binaries are NOT committed to the repo. + +In `tauri dev`, this dir is usually empty — the app falls back to any `pg_dump`/`pg_restore` +on `PATH` (system-first resolution). Admin features degrade with a clear error if neither is present. \ No newline at end of file diff --git a/src-tauri/src/commands/backup.rs b/src-tauri/src/commands/backup.rs index 80f96d3..6bd1670 100644 --- a/src-tauri/src/commands/backup.rs +++ b/src-tauri/src/commands/backup.rs @@ -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) { + 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) { + 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, + restore_src: Option, +) -> 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 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); }); diff --git a/src-tauri/src/commands/backup.test.rs b/src-tauri/src/commands/backup.test.rs index c0fd32e..2100b20 100644 --- a/src-tauri/src/commands/backup.test.rs +++ b/src-tauri/src/commands/backup.test.rs @@ -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"); } +} diff --git a/src-tauri/src/commands/db_viewer.rs b/src-tauri/src/commands/db_viewer.rs index c2ff8ae..a09b192 100644 --- a/src-tauri/src/commands/db_viewer.rs +++ b/src-tauri/src/commands/db_viewer.rs @@ -149,19 +149,21 @@ pub fn build_pg_dump_ddl_args(schema: &str, table: &str) -> Vec { ] } -/// 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 { - 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")); + } } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 998f3f0..dd966ac 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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; diff --git a/src-tauri/src/commands/objects.rs b/src-tauri/src/commands/objects.rs new file mode 100644 index 0000000..0dd1862 --- /dev/null +++ b/src-tauri/src/commands/objects.rs @@ -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, 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, 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, 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, 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, connection_id: &str, schema: &str, needle: &str) -> Result, 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, String> { + search_objects_inner(&state.pool_manager, &connection_id, &schema, &query).await +} + +pub(crate) async fn get_object_ddl_inner(pm: &tokio::sync::Mutex, connection_id: &str, schema: &str, object_type: &str, name: &str) -> Result { + 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>(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 { + 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, connection_id: &str, schema: &str, object_type: &str, name: &str) -> Result, 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, String> { + get_object_dependencies_inner(&state.pool_manager, &connection_id, &schema, &object_type, &name).await +} + +#[cfg(test)] +#[path = "objects.test.rs"] +mod tests; \ No newline at end of file diff --git a/src-tauri/src/commands/objects.test.rs b/src-tauri/src/commands/objects.test.rs new file mode 100644 index 0000000..751ce16 --- /dev/null +++ b/src-tauri/src/commands/objects.test.rs @@ -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, 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 + 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"); +} \ No newline at end of file diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index b0526ed..8670b2e 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -1,5 +1,6 @@ pub mod introspection; pub mod mysql; +pub mod object_ddl; pub mod pool; pub mod tls; diff --git a/src-tauri/src/db/object_ddl.rs b/src-tauri/src/db/object_ddl.rs new file mode 100644 index 0000000..b986491 --- /dev/null +++ b/src-tauri/src/db/object_ddl.rs @@ -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 { + validate_object_name(name)?; + Ok(format!("CREATE SCHEMA {}", quote_ident(name))) +} +pub fn rename_schema_sql(old: &str, new: &str) -> Result { + 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 { + 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 = 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)"); + } +} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dd93baf..a76a887 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/models/backup.rs b/src-tauri/src/models/backup.rs index b2616d7..9b94c2c 100644 --- a/src-tauri/src/models/backup.rs +++ b/src-tauri/src/models/backup.rs @@ -34,6 +34,16 @@ pub struct PgToolStatus { pub pg_restore_found: bool, pub pg_dump_version: Option, pub pg_restore_version: Option, + pub pg_dump_source: Option, // "system" | "bundled" | None + pub pg_restore_source: Option, +} + +/// 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\"")); + } } diff --git a/src-tauri/src/models/db_viewer.rs b/src-tauri/src/models/db_viewer.rs index 570bca8..3c8d033 100644 --- a/src-tauri/src/models/db_viewer.rs +++ b/src-tauri/src/models/db_viewer.rs @@ -132,6 +132,20 @@ pub struct ExtensionInfo { pub comment: Option, } +#[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")); + } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index bfdd95a..02ae87e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Gridline", - "version": "0.7.0", + "version": "0.7.5", "identifier": "com.adrianbonpin.gridline", "build": { "beforeDevCommand": "bun run dev", @@ -26,10 +26,12 @@ "bundle": { "active": true, "targets": "all", + "resources": ["resources/pg_tools/*"], "icon": [ "icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", + "icons/icon.png", "icons/icon.icns", "icons/icon.ico" ] diff --git a/src/components/db-viewer/BackupDialog.tsx b/src/components/db-viewer/BackupDialog.tsx index 60d3885..d87247b 100644 --- a/src/components/db-viewer/BackupDialog.tsx +++ b/src/components/db-viewer/BackupDialog.tsx @@ -58,7 +58,7 @@ export function BackupDialog({ open, connectionId, onClose }: BackupDialogProps) setCheckingTools(true); detectPgTools() .then((status) => setToolStatus(status)) - .catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null })) + .catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null, pg_dump_source: null, pg_restore_source: null })) .finally(() => setCheckingTools(false)); }, [open]); diff --git a/src/components/db-viewer/BackupPage.test.tsx b/src/components/db-viewer/BackupPage.test.tsx new file mode 100644 index 0000000..5a48289 --- /dev/null +++ b/src/components/db-viewer/BackupPage.test.tsx @@ -0,0 +1,32 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { BackupPage } from "./BackupPage"; +import { useBackupStore } from "../../stores/backupStore"; +import { useNotificationStore } from "../../stores/notificationStore"; +import * as commands from "../../lib/commands"; + +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn().mockReturnValue(Promise.resolve(() => {})) })); +vi.mock("@tauri-apps/plugin-dialog", () => ({ save: vi.fn().mockResolvedValue("/tmp/backup.dump") })); + +describe("BackupPage", () => { + beforeEach(() => { + vi.restoreAllMocks(); + useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 }); + useNotificationStore.setState({ notifications: [] }); + vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]); + }); + + it("hides install instructions when tools are bundled", async () => { + vi.spyOn(commands, "detectPgTools").mockResolvedValue({ + pg_dump_found: false, + pg_restore_found: false, + pg_dump_version: null, + pg_restore_version: null, + pg_dump_source: "bundled", + pg_restore_source: "bundled", + }); + render(); + await waitFor(() => expect(screen.queryByText(/checking for pg_dump/i)).not.toBeInTheDocument()); + expect(screen.queryByText(/brew install|apt install/i)).toBeNull(); + }); +}); \ No newline at end of file diff --git a/src/components/db-viewer/BackupPage.tsx b/src/components/db-viewer/BackupPage.tsx index 38338a2..4b5a905 100644 --- a/src/components/db-viewer/BackupPage.tsx +++ b/src/components/db-viewer/BackupPage.tsx @@ -79,6 +79,8 @@ export function BackupPage({ connectionId }: BackupPageProps) { pg_restore_found: false, pg_dump_version: null, pg_restore_version: null, + pg_dump_source: null, + pg_restore_source: null, }), ) .finally(() => setCheckingTools(false)); @@ -136,6 +138,7 @@ export function BackupPage({ connectionId }: BackupPageProps) { }, [filePath, format, schema, noOwner, connectionId, startJob, notify]); const toolsMissing = toolStatus && !toolStatus.pg_dump_found; + const toolsBundled = toolStatus?.pg_dump_source === "bundled"; return (
@@ -160,7 +163,7 @@ export function BackupPage({ connectionId }: BackupPageProps) {
)} - {toolsMissing && ( + {toolsMissing && !toolsBundled && (

pg_dump not found diff --git a/src/components/db-viewer/DbViewerScreen.tsx b/src/components/db-viewer/DbViewerScreen.tsx index e370f33..3d2ead0 100644 --- a/src/components/db-viewer/DbViewerScreen.tsx +++ b/src/components/db-viewer/DbViewerScreen.tsx @@ -30,6 +30,7 @@ import { ToolsPage } from "./ToolsPage"; import { SchemaVisualizerPage } from "./SchemaVisualizerPage"; import { QueriesPanel } from "../queries/QueriesPanel"; import { useQueryStore } from "../../stores/queryStore"; +import { ObjectSearchPalette } from "./ObjectSearchPalette"; import * as cmd from "../../lib/commands"; import type { EnumInfo } from "../../lib/types"; import type { FkOption } from "../grid/CellEditor"; @@ -186,6 +187,8 @@ export function DbViewerScreen({ const toggleHiddenColumn = useDbViewerStore((s) => s.toggleHiddenColumn); const setSmartSortApplied = useDbViewerStore((s) => s.setSmartSortApplied); + const setObjectSearchOpen = useDbViewerStore((s) => s.setObjectSearchOpen); + // Sync settings defaults to store useEffect(() => { if (settings?.table_page_size) { @@ -423,6 +426,11 @@ export function DbViewerScreen({ onHome(); } }); + useShortcut("command_palette", () => { + if (capabilities.objects) { + setObjectSearchOpen(true); + } + }); useEffect(() => { if (!activeTab) return; if (activeTab.tabType !== "table") return; @@ -1455,6 +1463,9 @@ const onQueriesPanelResizeStart = useCallback( onSaved={() => {}} /> )} + {capabilities.objects && ( + + )}

); diff --git a/src/components/db-viewer/DbViewerToolbar.tsx b/src/components/db-viewer/DbViewerToolbar.tsx index c7cc8be..852634f 100644 --- a/src/components/db-viewer/DbViewerToolbar.tsx +++ b/src/components/db-viewer/DbViewerToolbar.tsx @@ -12,6 +12,7 @@ import { SelectDropdown } from "../ui/SelectDropdown"; import { Tooltip } from "../ui/Tooltip"; import { useDbViewerStore } from "../../stores/dbViewerStore"; import * as cmd from "../../lib/commands"; +import { SchemaMenu } from "./SchemaMenu"; export function DbViewerToolbar({ databases, @@ -227,6 +228,11 @@ export function DbViewerToolbar({ disabled={schemaTreeLoading} /> )} + )} diff --git a/src/components/db-viewer/DependencyDialog.test.tsx b/src/components/db-viewer/DependencyDialog.test.tsx new file mode 100644 index 0000000..72660c0 --- /dev/null +++ b/src/components/db-viewer/DependencyDialog.test.tsx @@ -0,0 +1,19 @@ +import { describe, it, expect } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { DependencyDialog } from "./DependencyDialog"; + +describe("DependencyDialog", () => { + it("empty list shows no-dependencies message", () => { + render( {}} onCancel={() => {}} />); + expect(screen.getByText(/no dependencies/i)).toBeTruthy(); + }); + + it("non-empty lists rows and requires checkbox to proceed", () => { + const deps = [{ deptype: "n", class: "pg_class", name: "v_orders" }]; + render( {}} onCancel={() => {}} />); + expect(screen.getByText("v_orders")).toBeTruthy(); + expect(screen.getByRole("button", { name: /proceed/i })).toBeDisabled(); + fireEvent.click(screen.getByRole("checkbox")); + expect(screen.getByRole("button", { name: /proceed/i })).toBeEnabled(); + }); +}); \ No newline at end of file diff --git a/src/components/db-viewer/DependencyDialog.tsx b/src/components/db-viewer/DependencyDialog.tsx new file mode 100644 index 0000000..cf50896 --- /dev/null +++ b/src/components/db-viewer/DependencyDialog.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; +import { AnimatedModal } from "../ui/AnimatedModal"; +import { Button } from "../ui/Button"; +import type { DependencyInfo } from "../../lib/types"; + +interface DependencyDialogProps { + open: boolean; + deps: DependencyInfo[]; + onProceed: () => void; + onCancel: () => void; +} + +export function DependencyDialog({ open, deps, onProceed, onCancel }: DependencyDialogProps) { + const [ack, setAck] = useState(false); + const hasDeps = deps.length > 0; + + return ( + +
+

Dependencies

+ {hasDeps ? ( + <> +

+ The following depend on this object and will be removed with CASCADE: +

+
    + {deps.map((d, i) => ( +
  • + {d.name}{" "} + ({d.class}) +
  • + ))} +
+ + + ) : ( +

No dependencies — safe to drop.

+ )} +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/db-viewer/ObjectExplorerPage.test.tsx b/src/components/db-viewer/ObjectExplorerPage.test.tsx index fcfcf02..edbe61b 100644 --- a/src/components/db-viewer/ObjectExplorerPage.test.tsx +++ b/src/components/db-viewer/ObjectExplorerPage.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ObjectExplorerPage } from "./ObjectExplorerPage"; import { useDbViewerStore } from "../../stores/dbViewerStore"; @@ -189,4 +189,57 @@ describe("ObjectExplorerPage", () => { ); expect(screen.queryByText("calc")).not.toBeInTheDocument(); }); + + it("Copy DDL calls getObjectDdl and writes to clipboard", async () => { + vi.spyOn(commands, "getEnums").mockResolvedValue([ + { name: "role", schema: "public", labels: ["a"] }, + ]); + vi.spyOn(commands, "getObjectDdl").mockResolvedValue("CREATE TYPE ..."); + const writeText = vi.fn(); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + render(); + fireEvent.click(screen.getByLabelText("Object type")); + fireEvent.click(screen.getByText("Enums")); + await waitFor(() => screen.getByText("role")); + fireEvent.click(screen.getAllByLabelText(/options/i)[0]); + fireEvent.click(screen.getByText(/copy ddl/i)); + await waitFor(() => + expect(commands.getObjectDdl).toHaveBeenCalledWith("c1", "public", "enum", "role"), + ); + expect(writeText).toHaveBeenCalledWith("CREATE TYPE ..."); + }); + + it("View dependencies opens DependencyDialog", async () => { + vi.spyOn(commands, "getFunctions").mockResolvedValue([ + { + name: "add_one", + schema: "public", + return_type: "int", + argument_types: ["int"], + argument_names: ["x"], + argument_modes: ["IN"], + language: "sql", + source: "SELECT $1 + 1", + kind: "f", + }, + ]); + vi.spyOn(commands, "getObjectDependencies").mockResolvedValue([ + { deptype: "n", class: "pg_class", name: "v" }, + ]); + render(); + await waitFor(() => screen.getByText("add_one(int)")); + fireEvent.click(screen.getAllByLabelText(/options/i)[0]); + fireEvent.click(screen.getByText(/dependencies/i)); + await waitFor(() => expect(commands.getObjectDependencies).toHaveBeenCalled()); + await waitFor(() => expect(screen.getByText("v")).toBeTruthy()); + }); + + it("preselects type from store on mount", () => { + useDbViewerStore.setState({ selectedObjectType: "sequences" }); + render(); + expect(screen.getByText("Sequences")).toBeTruthy(); + }); }); \ No newline at end of file diff --git a/src/components/db-viewer/ObjectExplorerPage.tsx b/src/components/db-viewer/ObjectExplorerPage.tsx index 510b4d7..2af4dfc 100644 --- a/src/components/db-viewer/ObjectExplorerPage.tsx +++ b/src/components/db-viewer/ObjectExplorerPage.tsx @@ -6,6 +6,7 @@ import { GitBranch, ListChecks, ListOrdered, + MoreVertical, SquareFunction, Tag, Puzzle, @@ -15,6 +16,7 @@ import { } from "lucide-react"; import { useDbViewerStore } from "../../stores/dbViewerStore"; import { SelectDropdown } from "../ui/SelectDropdown"; +import { DependencyDialog } from "./DependencyDialog"; import * as cmd from "../../lib/commands"; import type { FunctionInfo, @@ -24,17 +26,11 @@ import type { ExtensionInfo, IndexInfo, ConstraintInfo, + ObjectType, + DependencyInfo, } from "../../lib/types"; -export type ObjectType = - | "functions" - | "triggers" - | "sequences" - | "enums" - | "extensions" - | "indexes" - | "constraints" - | "procedures"; + interface ObjectExplorerPageProps { connectionId: string; @@ -120,6 +116,28 @@ function itemLabel(item: AnyObject): string { return name; } +function schemaOf(item: AnyObject): string { + return item.schema; +} + +function objectName(item: AnyObject): string { + return item.name; +} + +function typeToDdlType(type: ObjectType): string { + const map: Record = { + functions: "function", + procedures: "procedure", + triggers: "trigger", + sequences: "sequence", + enums: "enum", + extensions: "extension", + indexes: "index", + constraints: "constraint", + }; + return map[type]; +} + // ─── syntax highlighting for PL/pgSQL / SQL ────────────── const SQL_KEYWORDS = new Set([ @@ -1020,7 +1038,8 @@ function renderDetail(type: ObjectType, item: AnyObject) { } export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) { - const [type, setType] = useState("functions"); + const selectedObjectType = useDbViewerStore((s) => s.selectedObjectType); + const [type, setType] = useState(selectedObjectType ?? "functions"); const [panelWidth, setPanelWidth] = useState(280); const panelResizeRef = useRef<{ startX: number; startW: number } | null>( null, @@ -1061,6 +1080,9 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [selectedItem, setSelectedItem] = useState(null); + const [openKey, setOpenKey] = useState(null); + const [depOpen, setDepOpen] = useState(false); + const [depDeps, setDepDeps] = useState([]); const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const searchInputRef = useRef(null); @@ -1173,14 +1195,25 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) { // Switching object type: reset selection/search, clear the stale list so // the loading state renders (no flash of the previous type's objects), and // reset the last-fetched-schema marker so the fetch effect re-runs. - const handleTypeChange = (next: ObjectType) => { + const handleTypeChange = useCallback((next: ObjectType) => { setType(next); setSearchQuery(""); setSelectedItem(null); + setOpenKey(null); setItems(null); setLoading(true); lastSchemaRef.current = undefined; - }; + }, []); + + // Consume any object-type preselection from the Cmd+K palette. + useEffect(() => { + if (selectedObjectType && selectedObjectType !== type) { + handleTypeChange(selectedObjectType); + } + if (selectedObjectType) { + useDbViewerStore.getState().setSelectedObjectType(null); + } + }, [selectedObjectType, type, handleTypeChange]); return (
@@ -1326,6 +1359,34 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) { const isSelected = selectedItem !== null && itemKey(selectedItem) === itemKey(item); + const menuOpen = openKey === key; + + const handleCopyDdl = async () => { + setOpenKey(null); + const ddl = await cmd.getObjectDdl( + connectionId, + schemaOf(item), + typeToDdlType(type), + objectName(item), + ); + try { + await navigator.clipboard?.writeText(ddl); + } catch { + // Ignore clipboard errors. + } + }; + + const handleViewDependencies = async () => { + setOpenKey(null); + const deps = await cmd.getObjectDependencies( + connectionId, + schemaOf(item), + typeToDdlType(type), + objectName(item), + ); + setDepDeps(deps); + setDepOpen(true); + }; return (
{name} +
e.stopPropagation()} + className="relative" + > + + {menuOpen && ( +
+ + +
+ )} +
)}
+ + setDepOpen(false)} + onCancel={() => setDepOpen(false)} + />
); } diff --git a/src/components/db-viewer/ObjectSearchPalette.test.tsx b/src/components/db-viewer/ObjectSearchPalette.test.tsx new file mode 100644 index 0000000..6fc0639 --- /dev/null +++ b/src/components/db-viewer/ObjectSearchPalette.test.tsx @@ -0,0 +1,206 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { ObjectSearchPalette } from "./ObjectSearchPalette"; +import * as cmd from "../../lib/commands"; +import type { ObjectSearchHit } from "../../lib/types"; + +vi.mock("../../lib/commands"); + +const mockSetObjectSearchOpen = vi.fn(); +const mockSetCurrentSchema = vi.fn(); +const mockSetSelectedObjectType = vi.fn(); +const mockOpenTab = vi.fn(); + +const baseMockState = { + objectSearchOpen: true, + setObjectSearchOpen: mockSetObjectSearchOpen, + currentSchema: "public" as string | null, + setCurrentSchema: mockSetCurrentSchema, + setSelectedObjectType: mockSetSelectedObjectType, + openTab: mockOpenTab, +}; + +let mockState: typeof baseMockState = { ...baseMockState }; + +vi.mock("../../stores/dbViewerStore", () => ({ + useDbViewerStore: (selector: unknown) => { + return typeof selector === "function" + ? (selector as (s: typeof mockState) => unknown)(mockState) + : mockState[selector as keyof typeof mockState]; + }, +})); + +describe("ObjectSearchPalette", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockState = { ...baseMockState }; + }); + + it("renders nothing when closed", () => { + mockState = { ...baseMockState, objectSearchOpen: false }; + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("debounces and groups results by type", async () => { + const hits: ObjectSearchHit[] = [ + { name: "users", schema: "public", object_type: "TABLE" }, + { name: "get_user", schema: "public", object_type: "FUNCTION" }, + ]; + vi.mocked(cmd.searchObjects).mockResolvedValue(hits); + + render(); + fireEvent.change(screen.getByPlaceholderText(/search objects/i), { + target: { value: "user" }, + }); + + await waitFor(() => + expect(cmd.searchObjects).toHaveBeenCalledWith("c1", "public", "user"), + ); + await waitFor(() => { + expect(screen.getByText("TABLE")).toBeInTheDocument(); + expect(screen.getByText("FUNCTION")).toBeInTheDocument(); + }); + }); + + it("uses currentSchema fallback when store value is null", async () => { + vi.mocked(cmd.searchObjects).mockResolvedValue([]); + mockState = { ...baseMockState, currentSchema: null }; + + render(); + fireEvent.change(screen.getByPlaceholderText(/search objects/i), { + target: { value: "x" }, + }); + + await waitFor(() => + expect(cmd.searchObjects).toHaveBeenCalledWith("c1", "public", "x"), + ); + }); + + it("Esc closes the palette", () => { + render(); + fireEvent.keyDown(window, { key: "Escape" }); + expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false); + }); + + it("backdrop click closes the palette", () => { + render(); + const backdrop = screen.getByLabelText(/search objects/i).closest( + "div[class*='fixed inset-0']", + ) as HTMLElement; + fireEvent.click(backdrop); + expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false); + }); + + it("selecting a table opens a tab and closes", async () => { + vi.mocked(cmd.searchObjects).mockResolvedValue([ + { name: "users", schema: "public", object_type: "TABLE" }, + ]); + + render(); + fireEvent.change(screen.getByPlaceholderText(/search objects/i), { + target: { value: "users" }, + }); + await waitFor(() => screen.getByText("users")); + fireEvent.click(screen.getByText("users")); + + expect(mockOpenTab).toHaveBeenCalledWith("public", "users"); + expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false); + expect(mockSetCurrentSchema).not.toHaveBeenCalled(); + expect(mockSetSelectedObjectType).not.toHaveBeenCalled(); + }); + + it("selecting a view opens a tab and closes", async () => { + vi.mocked(cmd.searchObjects).mockResolvedValue([ + { name: "active_users", schema: "public", object_type: "VIEW" }, + ]); + + render(); + fireEvent.change(screen.getByPlaceholderText(/search objects/i), { + target: { value: "active" }, + }); + await waitFor(() => screen.getByText("active_users")); + fireEvent.click(screen.getByText("active_users")); + + expect(mockOpenTab).toHaveBeenCalledWith("public", "active_users"); + expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false); + }); + + it("selecting a matview opens a tab and closes", async () => { + vi.mocked(cmd.searchObjects).mockResolvedValue([ + { name: "mv_users", schema: "public", object_type: "MATERIALIZED VIEW" }, + ]); + + render(); + fireEvent.change(screen.getByPlaceholderText(/search objects/i), { + target: { value: "mv" }, + }); + await waitFor(() => screen.getByText("mv_users")); + fireEvent.click(screen.getByText("mv_users")); + + expect(mockOpenTab).toHaveBeenCalledWith("public", "mv_users"); + expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false); + }); + + it.each([ + ["FUNCTION", "functions"], + ["PROCEDURE", "procedures"], + ["TRIGGER", "triggers"], + ["SEQUENCE", "sequences"], + ["ENUM", "enums"], + ["EXTENSION", "extensions"], + ["INDEX", "indexes"], + ["CONSTRAINT", "constraints"], + ] as const)( + "selecting a %s switches the objects view and closes", + async (objectType, mappedType) => { + vi.mocked(cmd.searchObjects).mockResolvedValue([ + { name: "item", schema: "app", object_type: objectType }, + ]); + + render(); + fireEvent.change(screen.getByPlaceholderText(/search objects/i), { + target: { value: "item" }, + }); + await waitFor(() => screen.getByText("item")); + fireEvent.click(screen.getByText("item")); + + expect(mockSetCurrentSchema).toHaveBeenCalledWith("app"); + expect(mockSetSelectedObjectType).toHaveBeenCalledWith(mappedType); + expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false); + expect(mockOpenTab).not.toHaveBeenCalled(); + }, + ); + + it("shows an empty state when no results match", async () => { + vi.mocked(cmd.searchObjects).mockResolvedValue([]); + render(); + fireEvent.change(screen.getByPlaceholderText(/search objects/i), { + target: { value: "nomatch" }, + }); + await waitFor(() => expect(cmd.searchObjects).toHaveBeenCalled()); + await waitFor(() => + expect(screen.getByText(/no matches/i)).toBeInTheDocument(), + ); + }); + + it("clears query and results when reopened", async () => { + vi.mocked(cmd.searchObjects).mockResolvedValue([ + { name: "users", schema: "public", object_type: "TABLE" }, + ]); + const { rerender } = render(); + fireEvent.change(screen.getByPlaceholderText(/search objects/i), { + target: { value: "users" }, + }); + await waitFor(() => screen.getByText("users")); + + mockState = { ...baseMockState, objectSearchOpen: false }; + rerender(); + + mockState = { ...baseMockState, objectSearchOpen: true }; + rerender(); + + expect(screen.queryByText("users")).not.toBeInTheDocument(); + expect(screen.getByPlaceholderText(/search objects/i)).toHaveValue(""); + }); +}); \ No newline at end of file diff --git a/src/components/db-viewer/ObjectSearchPalette.tsx b/src/components/db-viewer/ObjectSearchPalette.tsx new file mode 100644 index 0000000..50fbb2b --- /dev/null +++ b/src/components/db-viewer/ObjectSearchPalette.tsx @@ -0,0 +1,171 @@ +import { useEffect, useRef, useState } from "react"; +import { Search } from "lucide-react"; +import { useDbViewerStore } from "../../stores/dbViewerStore"; +import * as cmd from "../../lib/commands"; +import type { ObjectSearchHit, ObjectType } from "../../lib/types"; + +const TYPE_TO_OBJECTS: Record = { + FUNCTION: "functions", + PROCEDURE: "procedures", + TRIGGER: "triggers", + SEQUENCE: "sequences", + ENUM: "enums", + EXTENSION: "extensions", + INDEX: "indexes", + CONSTRAINT: "constraints", +}; + +interface ObjectSearchPaletteProps { + connectionId: string; +} + +export function ObjectSearchPalette({ connectionId }: ObjectSearchPaletteProps) { + const open = useDbViewerStore((s) => s.objectSearchOpen); + const setOpen = useDbViewerStore((s) => s.setObjectSearchOpen); + const storeSchema = useDbViewerStore((s) => s.currentSchema); + const currentSchema = storeSchema ?? "public"; + const openTab = useDbViewerStore((s) => s.openTab); + const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema); + const setSelectedObjectType = useDbViewerStore((s) => s.setSelectedObjectType); + + const [query, setQuery] = useState(""); + const [hits, setHits] = useState([]); + const [loading, setLoading] = useState(false); + const timerRef = useRef | null>(null); + + // Clear the transient state whenever the palette is closed so it reopens + // with an empty search and no stale results. + useEffect(() => { + if (!open) { + setQuery(""); + setHits([]); + } + }, [open]); + + // Debounced search against the current schema. + useEffect(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + + if (!query.trim()) { + setHits([]); + setLoading(false); + return; + } + + timerRef.current = setTimeout(async () => { + setLoading(true); + try { + const results = await cmd.searchObjects( + connectionId, + currentSchema, + query, + ); + setHits(results); + } catch { + setHits([]); + } finally { + setLoading(false); + } + }, 150); + + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [query, connectionId, currentSchema]); + + // Esc closes the palette. + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setOpen(false); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [setOpen]); + + if (!open) return null; + + const grouped = hits.reduce>((acc, hit) => { + const list = acc[hit.object_type] ?? []; + list.push(hit); + acc[hit.object_type] = list; + return acc; + }, {}); + + const handleSelect = (hit: ObjectSearchHit) => { + if ( + hit.object_type === "TABLE" || + hit.object_type === "VIEW" || + hit.object_type === "MATERIALIZED VIEW" + ) { + openTab(hit.schema, hit.name); + } else { + setCurrentSchema(hit.schema); + setSelectedObjectType( + TYPE_TO_OBJECTS[hit.object_type] ?? "functions", + ); + } + setOpen(false); + }; + + return ( +
setOpen(false)} + role="dialog" + aria-label="Search objects" + > +
e.stopPropagation()} + > +
+ + setQuery(e.target.value)} + placeholder="Search objects in current schema…" + className="flex-1 bg-transparent text-sm text-text outline-none placeholder:text-text-muted" + /> + {loading && ( + loading + )} +
+ + {Object.entries(grouped).map(([type, list]) => ( +
+
+ {type} +
+ {list.map((hit) => ( + + ))} +
+ ))} + + {!loading && query.trim() && hits.length === 0 && ( +
No matches
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/db-viewer/RestoreDialog.tsx b/src/components/db-viewer/RestoreDialog.tsx index 7c47cdb..8977482 100644 --- a/src/components/db-viewer/RestoreDialog.tsx +++ b/src/components/db-viewer/RestoreDialog.tsx @@ -50,7 +50,7 @@ export function RestoreDialog({ open, connectionId, onClose }: RestoreDialogProp setConfirmed(false); detectPgTools() .then((status) => setToolStatus(status)) - .catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null })) + .catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null, pg_dump_source: null, pg_restore_source: null })) .finally(() => setCheckingTools(false)); }, [open]); diff --git a/src/components/db-viewer/RestorePage.tsx b/src/components/db-viewer/RestorePage.tsx index 4c434f4..2505000 100644 --- a/src/components/db-viewer/RestorePage.tsx +++ b/src/components/db-viewer/RestorePage.tsx @@ -76,6 +76,8 @@ export function RestorePage({ connectionId }: RestorePageProps) { pg_restore_found: false, pg_dump_version: null, pg_restore_version: null, + pg_dump_source: null, + pg_restore_source: null, }), ) .finally(() => setCheckingTools(false)); @@ -121,6 +123,7 @@ export function RestorePage({ connectionId }: RestorePageProps) { }, [filePath, format, clean, schema, connectionId, startJob, notify]); const toolsMissing = toolStatus && !toolStatus.pg_restore_found; + const toolsBundled = toolStatus?.pg_restore_source === "bundled"; const canStart = filePath && confirmed && !isRunning; return ( @@ -146,7 +149,7 @@ export function RestorePage({ connectionId }: RestorePageProps) { )} - {toolsMissing && ( + {toolsMissing && !toolsBundled && (

pg_restore not found diff --git a/src/components/db-viewer/SchemaMenu.test.tsx b/src/components/db-viewer/SchemaMenu.test.tsx new file mode 100644 index 0000000..84cf9ba --- /dev/null +++ b/src/components/db-viewer/SchemaMenu.test.tsx @@ -0,0 +1,42 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import * as cmd from "../../lib/commands"; +import { SchemaMenu } from "./SchemaMenu"; + +vi.mock("../../lib/commands"); + +describe("SchemaMenu", () => { + beforeEach(() => { + vi.mocked(cmd.createSchema).mockReset(); + vi.mocked(cmd.renameSchema).mockReset(); + vi.mocked(cmd.dropSchema).mockReset(); + vi.mocked(cmd.getObjectDependencies).mockReset(); + }); + + it("create happy path calls createSchema then refreshTree", async () => { + vi.mocked(cmd.createSchema).mockResolvedValue(undefined); + const refresh = vi.fn(); + render(); + fireEvent.click(screen.getByLabelText(/new schema/i)); + fireEvent.change(screen.getByPlaceholderText(/schema name/i), { target: { value: "myschema" } }); + fireEvent.click(screen.getByRole("button", { name: /create/i })); + await waitFor(() => expect(cmd.createSchema).toHaveBeenCalledWith("c1", "myschema")); + await waitFor(() => expect(refresh).toHaveBeenCalled()); + }); + + it("drop non-empty requires cascade checkbox + typed confirm", async () => { + vi.mocked(cmd.getObjectDependencies).mockResolvedValue([{ deptype: "n", class: "pg_class", name: "users" }]); + vi.mocked(cmd.dropSchema).mockResolvedValue(undefined); + const refresh = vi.fn(); + render(); + fireEvent.click(screen.getByLabelText(/schema menu/i)); + fireEvent.click(screen.getByText(/drop/i)); + await waitFor(() => expect(cmd.getObjectDependencies).toHaveBeenCalledWith("c1", "s", "schema", "s")); + await waitFor(() => expect(screen.getByText("users")).toBeTruthy()); + fireEvent.click(screen.getByRole("checkbox")); + fireEvent.change(screen.getByPlaceholderText(/type the schema name/i), { target: { value: "s" } }); + fireEvent.click(screen.getByRole("button", { name: /drop schema/i })); + await waitFor(() => expect(cmd.dropSchema).toHaveBeenCalledWith("c1", "s", true)); + await waitFor(() => expect(refresh).toHaveBeenCalled()); + }); +}); \ No newline at end of file diff --git a/src/components/db-viewer/SchemaMenu.tsx b/src/components/db-viewer/SchemaMenu.tsx new file mode 100644 index 0000000..b32b4b2 --- /dev/null +++ b/src/components/db-viewer/SchemaMenu.tsx @@ -0,0 +1,295 @@ +import { useState, useRef, useEffect } from "react"; +import { Plus, MoreVertical } from "lucide-react"; +import * as cmd from "../../lib/commands"; +import type { DependencyInfo } from "../../lib/types"; +import { AnimatedModal } from "../ui/AnimatedModal"; +import { Button } from "../ui/Button"; +import { DependencyDialog } from "./DependencyDialog"; + +interface SchemaMenuProps { + connectionId: string; + schema?: string; + onRefresh: () => void; +} + +export function SchemaMenu({ connectionId, schema, onRefresh }: SchemaMenuProps) { + const [menuOpen, setMenuOpen] = useState(false); + + const [creating, setCreating] = useState(false); + const [name, setName] = useState(""); + + const [renaming, setRenaming] = useState(false); + const [newName, setNewName] = useState(""); + + const [dropOpen, setDropOpen] = useState(false); + const [deps, setDeps] = useState([]); + const [typed, setTyped] = useState(""); + + const [err, setErr] = useState(null); + const menuRef = useRef(null); + + useEffect(() => { + if (!menuOpen) return; + const handleClick = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setMenuOpen(false); + } + }; + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [menuOpen]); + + const resetErrors = () => setErr(null); + + const create = async () => { + resetErrors(); + try { + await cmd.createSchema(connectionId, name); + setCreating(false); + setName(""); + onRefresh(); + } catch (e) { + setErr((e as Error)?.message ?? String(e)); + } + }; + + const rename = async () => { + resetErrors(); + if (!schema) return; + try { + await cmd.renameSchema(connectionId, schema, newName); + setRenaming(false); + setNewName(""); + onRefresh(); + } catch (e) { + setErr((e as Error)?.message ?? String(e)); + } + }; + + const startDrop = async () => { + setMenuOpen(false); + if (!schema) return; + try { + const d = await cmd.getObjectDependencies(connectionId, schema, "schema", schema); + setDeps(d); + setDropOpen(true); + setTyped(""); + setErr(null); + } catch (e) { + setErr((e as Error)?.message ?? String(e)); + } + }; + + const confirmDrop = async (cascade: boolean) => { + if (!schema) return; + try { + await cmd.dropSchema(connectionId, schema, cascade); + setDropOpen(false); + setTyped(""); + setDeps([]); + onRefresh(); + } catch (e) { + setErr((e as Error)?.message ?? String(e)); + } + }; + + const hasDeps = deps.length > 0; + + const inputClass = + "w-full bg-surface border border-border rounded-md px-3 py-2 text-sm text-text placeholder:text-text-muted outline-none focus:border-accent/50 transition-colors"; + + return ( +

+ + + {schema && ( + + )} + + {menuOpen && ( +
+ + +
+ )} + + {/* New Schema */} + setCreating(false)}> +
+

New Schema

+

Create a new schema in the current database.

+ setName(e.target.value)} + className={inputClass} + /> + {err &&

{err}

} +
+ + +
+
+
+ + {/* Rename Schema */} + setRenaming(false)}> +
+

Rename {schema}

+

Enter the new name for this schema.

+ setNewName(e.target.value)} + className={inputClass} + /> + {err &&

{err}

} +
+ + +
+
+
+ + {/* Dependency warning */} + {dropOpen && ( + { + setDropOpen(false); + setTyped(""); + setDeps([]); + setErr(null); + }} + onProceed={() => {}} + /> + )} + + {/* Typed-name confirmation for CASCADE drop */} + {dropOpen && hasDeps && ( + { + setDropOpen(false); + setTyped(""); + setErr(null); + }} + > +
+

Drop Schema: {schema}

+

+ Type the schema name to confirm the CASCADE drop. +

+ { + setTyped(e.target.value); + if (err) setErr(null); + }} + className={inputClass} + /> + {err &&

{err}

} +
+ + +
+
+
+ )} + + {/* Empty-schema confirmation (no deps) */} + {dropOpen && !hasDeps && ( + { + setDropOpen(false); + setErr(null); + }} + > +
+

Drop Schema: {schema}

+

No dependencies — drop this empty schema?

+ {err &&

{err}

} +
+ + +
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/db-viewer/SyncDialog.tsx b/src/components/db-viewer/SyncDialog.tsx index 8fcec0f..83d58bd 100644 --- a/src/components/db-viewer/SyncDialog.tsx +++ b/src/components/db-viewer/SyncDialog.tsx @@ -36,7 +36,7 @@ export function SyncDialog({ open, onClose }: SyncDialogProps) { setConfirmed(false); detectPgTools() .then((status) => setToolStatus(status)) - .catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null })) + .catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null, pg_dump_source: null, pg_restore_source: null })) .finally(() => setCheckingTools(false)); }, [open]); diff --git a/src/components/db-viewer/SyncPage.tsx b/src/components/db-viewer/SyncPage.tsx index 7202298..a8efc3b 100644 --- a/src/components/db-viewer/SyncPage.tsx +++ b/src/components/db-viewer/SyncPage.tsx @@ -54,6 +54,8 @@ export function SyncPage() { pg_restore_found: false, pg_dump_version: null, pg_restore_version: null, + pg_dump_source: null, + pg_restore_source: null, }), ) .finally(() => setCheckingTools(false)); @@ -100,6 +102,9 @@ export function SyncPage() { const toolsMissing = toolStatus && (!toolStatus.pg_dump_found || !toolStatus.pg_restore_found); + const toolsBundled = + toolStatus?.pg_dump_source === "bundled" && + toolStatus?.pg_restore_source === "bundled"; const canStart = sourceConnectionId && targetConnectionId && confirmed && !isRunning; @@ -130,7 +135,7 @@ export function SyncPage() {
)} - {toolsMissing && ( + {toolsMissing && !toolsBundled && (

PostgreSQL tools not found diff --git a/src/components/db-viewer/TableOverflowMenu.test.tsx b/src/components/db-viewer/TableOverflowMenu.test.tsx index 0a433a6..488fee8 100644 --- a/src/components/db-viewer/TableOverflowMenu.test.tsx +++ b/src/components/db-viewer/TableOverflowMenu.test.tsx @@ -63,9 +63,11 @@ describe("TableOverflowMenu", () => { }); it("Delete Table opens confirm then stages a drop_table change", async () => { + vi.spyOn(commands, "getObjectDependencies").mockResolvedValue([]); render( "tab-1"} />); fireEvent.click(screen.getByLabelText(/table options/i)); fireEvent.click(screen.getByText(/delete table/i)); + await waitFor(() => expect(screen.queryByText(/open in new tab/i)).not.toBeInTheDocument()); fireEvent.click(screen.getByRole("button", { name: /delete table/i })); await waitFor(() => { const q = useDbViewerStore.getState().changesQueue; @@ -73,6 +75,15 @@ describe("TableOverflowMenu", () => { }); }); + it("Delete Table fetches dependencies and shows DependencyDialog before confirming", async () => { + vi.spyOn(commands, "getObjectDependencies").mockResolvedValue([{ deptype: "n", class: "pg_class", name: "v_orders" }]); + render( "tab-1"} />); + fireEvent.click(screen.getByLabelText(/table options/i)); + fireEvent.click(screen.getByText(/delete table/i)); + await waitFor(() => expect(commands.getObjectDependencies).toHaveBeenCalledWith("c1", "public", "table", "orders")); + await waitFor(() => expect(screen.getByText("v_orders")).toBeInTheDocument()); + }); + it("Export data calls exportData when rows and columns are provided", async () => { const spy = vi.spyOn(exportData, "exportData"); const columns = [{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }]; diff --git a/src/components/db-viewer/TableOverflowMenu.tsx b/src/components/db-viewer/TableOverflowMenu.tsx index dd4f3e7..4b5e323 100644 --- a/src/components/db-viewer/TableOverflowMenu.tsx +++ b/src/components/db-viewer/TableOverflowMenu.tsx @@ -6,7 +6,8 @@ import { useDbViewerStore } from "../../stores/dbViewerStore"; import { useUiStore } from "../../stores/uiStore"; import { exportData } from "../../lib/exportData"; import * as cmd from "../../lib/commands"; -import type { ColumnInfo } from "../../lib/types"; +import { DependencyDialog } from "./DependencyDialog"; +import type { ColumnInfo, DependencyInfo } from "../../lib/types"; interface TableOverflowMenuProps { schema: string; @@ -37,6 +38,7 @@ export function TableOverflowMenu({ const [open, setOpen] = useState(false); const [confirmAction, setConfirmAction] = useState<"empty" | "delete" | null>(null); + const [dropDeps, setDropDeps] = useState([]); const [importOpen, setImportOpen] = useState(false); const menuRef = useRef(null); @@ -98,10 +100,18 @@ export function TableOverflowMenu({ setConfirmAction("empty"); setOpen(false); break; - case "delete": + case "delete": { + if (!connectionId) break; + try { + const deps = await cmd.getObjectDependencies(connectionId, schema, "table", table); + setDropDeps(deps); + } catch { + setDropDeps([]); + } setConfirmAction("delete"); setOpen(false); break; + } default: break; } @@ -146,6 +156,15 @@ export function TableOverflowMenu({

)} + {confirmAction === "delete" && dropDeps.length > 0 && ( + setConfirmAction(null)} + onCancel={() => setConfirmAction(null)} + /> + )} + {confirmAction === "empty" && ( { + it("declares bundled pg_tools resources", () => { + expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*"); + }); + it("version is 0.7.5", () => { + expect(tauriConf.version).toBe("0.7.5"); + }); +}); \ No newline at end of file diff --git a/src/lib/commands.test.ts b/src/lib/commands.test.ts index 0839877..507ea37 100644 --- a/src/lib/commands.test.ts +++ b/src/lib/commands.test.ts @@ -30,6 +30,12 @@ import { clearRecentConnections, getIndexes, getConstraints, + createSchema, + renameSchema, + dropSchema, + searchObjects, + getObjectDdl, + getObjectDependencies, } from "./commands"; import type { SchemaGraph } from "./types"; import type { QueryHistoryEntry } from "./commands"; @@ -291,4 +297,39 @@ describe("v0.5.0 command wrappers", () => { await getConstraints("c1", "public"); expect(mockInvoke).toHaveBeenCalledWith("get_constraints", { connectionId: "c1", schema: "public" }); }); +}); + +describe("object management commands (v0.7.5)", () => { + afterEach(() => vi.restoreAllMocks()); + + it("createSchema calls invoke with name", async () => { + vi.mocked(invoke).mockResolvedValueOnce(undefined); + await createSchema("c1", "my_schema"); + expect(invoke).toHaveBeenCalledWith("create_schema", { connectionId: "c1", name: "my_schema" }); + }); + it("renameSchema maps old/new names", async () => { + vi.mocked(invoke).mockResolvedValueOnce(undefined); + await renameSchema("c1", "old", "new"); + expect(invoke).toHaveBeenCalledWith("rename_schema", { connectionId: "c1", oldName: "old", newName: "new" }); + }); + it("dropSchema passes cascade", async () => { + vi.mocked(invoke).mockResolvedValueOnce(undefined); + await dropSchema("c1", "s", true); + expect(invoke).toHaveBeenCalledWith("drop_schema", { connectionId: "c1", name: "s", cascade: true }); + }); + it("searchObjects maps query+schema", async () => { + vi.mocked(invoke).mockResolvedValueOnce([]); + await searchObjects("c1", "public", "user"); + expect(invoke).toHaveBeenCalledWith("search_objects", { connectionId: "c1", schema: "public", query: "user" }); + }); + it("getObjectDdl maps objectType+name", async () => { + vi.mocked(invoke).mockResolvedValueOnce("CREATE SEQUENCE ..."); + await getObjectDdl("c1", "public", "sequence", "users_id_seq"); + expect(invoke).toHaveBeenCalledWith("get_object_ddl", { connectionId: "c1", schema: "public", objectType: "sequence", name: "users_id_seq" }); + }); + it("getObjectDependencies maps objectType+name", async () => { + vi.mocked(invoke).mockResolvedValueOnce([]); + await getObjectDependencies("c1", "public", "table", "orders"); + expect(invoke).toHaveBeenCalledWith("get_object_dependencies", { connectionId: "c1", schema: "public", objectType: "table", name: "orders" }); + }); }); \ No newline at end of file diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 2bbbae0..ff21bac 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection } from "./types"; +import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo } from "./types"; import type { FilterRule, SortRule } from "../stores/dbViewerStore"; import type { ChangePayload } from "./changePayload"; @@ -298,4 +298,25 @@ export async function getIndexes(connectionId: string, schema?: string): Promise export async function getConstraints(connectionId: string, schema?: string): Promise { return invoke("get_constraints", { connectionId, schema }); +} + +// ─── v0.7.5: Object management (schemas, search, DDL, dependencies) ── + +export async function createSchema(connectionId: string, name: string): Promise { + return invoke("create_schema", { connectionId, name }); +} +export async function renameSchema(connectionId: string, oldName: string, newName: string): Promise { + return invoke("rename_schema", { connectionId, oldName, newName }); +} +export async function dropSchema(connectionId: string, name: string, cascade: boolean): Promise { + return invoke("drop_schema", { connectionId, name, cascade }); +} +export async function searchObjects(connectionId: string, schema: string, query: string): Promise { + return invoke("search_objects", { connectionId, schema, query }); +} +export async function getObjectDdl(connectionId: string, schema: string, objectType: string, name: string): Promise { + return invoke("get_object_ddl", { connectionId, schema, objectType, name }); +} +export async function getObjectDependencies(connectionId: string, schema: string, objectType: string, name: string): Promise { + return invoke("get_object_dependencies", { connectionId, schema, objectType, name }); } \ No newline at end of file diff --git a/src/lib/dbCapabilities.test.ts b/src/lib/dbCapabilities.test.ts index e018136..3ba7bbb 100644 --- a/src/lib/dbCapabilities.test.ts +++ b/src/lib/dbCapabilities.test.ts @@ -50,4 +50,11 @@ describe("dbCapabilities", () => { expect(getCapabilities("postgresql")).toBe(DB_CAPABILITIES.postgresql); expect(getCapabilities("redis")).toBe(DB_CAPABILITIES.redis); }); + + it("objects capability (search/ddl/dependencies) is PG-only", () => { + expect(getCapabilities("postgresql").objects).toBe(true); + expect(getCapabilities("mysql").objects).toBe(false); + expect(getCapabilities("sqlite").objects).toBe(false); + expect(getCapabilities("redis").objects).toBe(false); + }); }); \ No newline at end of file diff --git a/src/lib/docs-coverage.test.ts b/src/lib/docs-coverage.test.ts index 6d4b844..eabf901 100644 --- a/src/lib/docs-coverage.test.ts +++ b/src/lib/docs-coverage.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect } from "vitest"; import agents from "../../AGENTS.md?raw"; import readme from "../../README.md?raw"; -describe("v0.7.0 docs coverage", () => { +describe("v0.7.5 docs coverage", () => { it("AGENTS.md marks inline cell editing complete", () => { expect(agents).toContain("Inline cell editing"); expect(agents).toMatch(/Inline cell editing \| ✅/); @@ -24,8 +24,23 @@ describe("v0.7.0 docs coverage", () => { expect(agents).toMatch(/Connection status indicator on cards \| ✅/); expect(agents).toMatch(/Move-to-folder bulk action \| ✅/); }); - it("README declares v0.7.0", () => { - expect(readme).toContain("0.7.0"); + it("README declares v0.7.5", () => { + expect(readme).toContain("0.7.5"); + }); + it("AGENTS.md marks schema CRUD complete", () => { + expect(agents).toMatch(/Schema CRUD \| ✅/); + }); + it("AGENTS.md marks global object search complete", () => { + expect(agents).toMatch(/Global object search \| ✅/); + }); + it("AGENTS.md marks copy-as-DDL complete", () => { + expect(agents).toMatch(/Copy as DDL for any object \| ✅/); + }); + it("AGENTS.md marks object dependencies complete", () => { + expect(agents).toMatch(/Object dependencies \| ✅/); + }); + it("README notes bundled PostgreSQL tools", () => { + expect(readme.toLowerCase()).toContain("bundled"); }); it("README marks inline editing complete (not Upcoming)", () => { // Key Features lists inline editing as a shipped feature diff --git a/src/lib/types.ts b/src/lib/types.ts index ac57261..f041530 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -305,6 +305,34 @@ export interface PgToolStatus { pg_restore_found: boolean; pg_dump_version: string | null; pg_restore_version: string | null; + pg_dump_source: string | null; + pg_restore_source: string | null; +} + +export type PgObjectType = + | "table" | "view" | "materialized view" | "function" | "procedure" + | "trigger" | "sequence" | "enum" | "extension" | "index" | "constraint"; + +export type ObjectType = + | "functions" + | "triggers" + | "sequences" + | "enums" + | "extensions" + | "indexes" + | "constraints" + | "procedures"; + +export interface ObjectSearchHit { + name: string; + schema: string; + object_type: string; // TABLE | VIEW | MATERIALIZED VIEW | FUNCTION | PROCEDURE | TRIGGER | SEQUENCE | ENUM | EXTENSION | INDEX | CONSTRAINT +} + +export interface DependencyInfo { + deptype: string; + class: string; + name: string; } export interface BackupJob { diff --git a/src/lib/version.test.ts b/src/lib/version.test.ts index 2ad9dab..eb04fb4 100644 --- a/src/lib/version.test.ts +++ b/src/lib/version.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import pkg from "../../package.json"; describe("version", () => { - it("declares v0.7.0 across the app shell", () => { - expect(pkg.version).toBe("0.7.0"); + it("declares v0.7.5 across the app shell", () => { + expect(pkg.version).toBe("0.7.5"); }); }); \ No newline at end of file diff --git a/src/stores/dbViewerStore.test.ts b/src/stores/dbViewerStore.test.ts index 5ce22b5..5a1f60c 100644 --- a/src/stores/dbViewerStore.test.ts +++ b/src/stores/dbViewerStore.test.ts @@ -24,9 +24,22 @@ describe("dbViewerStore", () => { expect(state.tables).toEqual([]); expect(state.currentDatabase).toBeNull(); expect(state.currentSchema).toBeNull(); + expect(state.objectSearchOpen).toBe(false); + expect(state.selectedObjectType).toBeNull(); expect(state.schemaTreeLoading).toBe(false); }); + it("object search open + selected object type setters", () => { + const { setObjectSearchOpen, setSelectedObjectType } = + useDbViewerStore.getState(); + setObjectSearchOpen(true); + expect(useDbViewerStore.getState().objectSearchOpen).toBe(true); + setSelectedObjectType("functions"); + expect(useDbViewerStore.getState().selectedObjectType).toBe("functions"); + setSelectedObjectType(null); + expect(useDbViewerStore.getState().selectedObjectType).toBeNull(); + }); + it("openTab adds a new tab", () => { const store = useDbViewerStore.getState(); store.openTab("public", "users"); diff --git a/src/stores/dbViewerStore.ts b/src/stores/dbViewerStore.ts index d8b2cdc..12439b4 100644 --- a/src/stores/dbViewerStore.ts +++ b/src/stores/dbViewerStore.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, IndexInfo, ConstraintInfo } from "../lib/types"; +import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, IndexInfo, ConstraintInfo, ObjectType } from "../lib/types"; import { getDatabases, getSchemas, getTables } from "../lib/commands"; // ─── Local types ──────────────────────────────────────────────── @@ -92,6 +92,8 @@ interface DbViewerState { schemaTreeLoading: boolean; currentDatabase: string | null; currentSchema: string | null; + objectSearchOpen: boolean; + selectedObjectType: ObjectType | null; functions: FunctionInfo[] | null; triggers: TriggerInfo[] | null; sequences: SequenceInfo[] | null; @@ -141,6 +143,8 @@ interface DbViewerState { toggleChangesPanel: () => void; setCurrentDatabase: (db: string | null) => void; setCurrentSchema: (schema: string | null) => void; + setObjectSearchOpen: (open: boolean) => void; + setSelectedObjectType: (t: ObjectType | null) => void; setFunctions: (functions: FunctionInfo[]) => void; setTriggers: (triggers: TriggerInfo[]) => void; setSequences: (sequences: SequenceInfo[]) => void; @@ -179,6 +183,8 @@ const initialState = { tables: [] as TableInfo[], currentDatabase: null as string | null, currentSchema: null as string | null, + objectSearchOpen: false, + selectedObjectType: null as ObjectType | null, functions: null as FunctionInfo[] | null, triggers: null as TriggerInfo[] | null, sequences: null as SequenceInfo[] | null, @@ -433,6 +439,8 @@ export const useDbViewerStore = create((set, get) => ({ setCurrentDatabase: (db) => set({ currentDatabase: db }), setCurrentSchema: (schema) => set({ currentSchema: schema }), + setObjectSearchOpen: (open) => set({ objectSearchOpen: open }), + setSelectedObjectType: (t) => set({ selectedObjectType: t }), setFunctions: (functions) => set({ functions }), setTriggers: (triggers) => set({ triggers }), setSequences: (sequences) => set({ sequences }),