v0.7.5: bundled PG tools, schema CRUD, object search, copy-as-DDL, object dependencies (#11)

* chore: bump version to 0.7.5 (Task 1.1)

* feat(db): pure object DDL/search/depend builders (Task 1.2)

* feat(models): ObjectSearchHit, DependencyInfo, PgToolPaths, tool source fields (Task 1.3)

* feat(backup): bundle-aware pg tool resolution, system-first fallback (Task 2.1)

* feat(objects): schema CRUD commands + integration test (Task 2.2)

* feat(objects): search_objects command (current-schema, all types) (Task 2.3)

* feat(objects): get_object_ddl for all browsable types (Task 2.4)

* feat(objects): pg_depend object dependencies + schema contents (Task 2.5)

* feat(ipc): register object management commands (Task 3.1)

* feat(ipc): frontend wrappers + types for object management (Task 3.2)

* feat(build): declare bundled pg_tools resources (Task 3.3)

* test(capabilities): lock objects capability PG-only for search/ddl/dependencies (Task 3.4)

* feat(ui): Cmd+K object search palette in DB viewer (Task 4.1)

* feat(ui): schema CRUD menu + dependency dialog (Task 4.2)

* feat(ui): copy-as-DDL + dependency view context menu in Objects (Task 4.3)

* feat(ui): dependency check before table drop + bundled-tool status (Task 4.4)

* docs: v0.7.5 release notes + status table + bundled-tools (Task 5.1)

* ci: build/verify bundled pg client tools per platform before tauri build (Task 5.2)

* fix(objects): report pg_rewrite dependencies as the dependent view (pg_class)

pg_depend records view dependencies via the view's internal rewrite rule
(classid = pg_rewrite). The user-facing dependent object is the VIEW itself,
so map that classid to pg_class (name still resolved through ev_class).
Fixes the live-PG integration test object_dependencies_for_table_includes_view
and makes the dependency dialog readable for view dependents.

* test: add idempotent PG integration-test seed script

Seeds the first PG test db (GRIDLINE_TEST_SRC) with the objects the
#[ignore] integration tests assert against: users (+users_id_seq),
products (3 rows), orders (3 rows), order_summary view, audit_log +
get_user functions, user_role enum. Idempotent: DROP + recreate.

* fix(build): regenerate multi-resolution icons + roadmap Keychain item

- icon.ico previously contained a single 16x16 frame (Windows scaled it
  up -> blurry taskbar/start-menu icon). Regenerated from the 512px source
  via 'tauri icon': ICO now has 16/24/32/48/64/256 frames, icns has full
  @2x coverage up to 1024px, PNGs re-rendered from the same source.
- Added icons/icon.png (512px) to bundle.icon so Linux hicolor installs
  a high-DPI entry.
- ROADMAP: log the 'Enable Keychain' toggle (currently a form-only
  placeholder) under Next up -> Connection & credentials.

* docs(readme): dynamic version-free download links + 3-col table

- release.yml: set releaseAssetNamePattern to '[name]_[platform]_[arch][setup][ext]'
  (version-free), so asset filenames are stable across releases:
  Gridline_darwin_aarch64.dmg, Gridline_windows_x64-setup.exe,
  Gridline_linux_amd64.deb, Gridline_linux_x86_64.rpm, etc.
- README: both download tables now 3-column (OS | Architecture | Download)
  and link via GitHub's releases/latest/download/<file> redirect — they
  always point at the newest published release, no per-release edits.
- AGENTS.md: releases checklist updated — download links stay version-free.

* docs(readme): platform-per-column download table (macOS | Windows | Linux)

Table now mirrors the release layout: one column per OS with a logo row
and a download-links row underneath. Links stay version-free via
releases/latest/download/<file> (releaseAssetNamePattern in release.yml).

* docs(readme): revert download table to clean image-less 3-col layout

Logo row was hard to read on GitHub dark mode; the plain OS | Architecture
| Download table is cleaner and still uses dynamic releases/latest links.
This commit is contained in:
2026-08-05 20:46:36 +08:00
committed by GitHub
parent cbc54119ed
commit 04e8ed3300
67 changed files with 2084 additions and 108 deletions
+54 -1
View File
@@ -63,6 +63,53 @@ jobs:
- name: Install frontend dependencies - name: Install frontend dependencies
run: bun install --frozen-lockfile 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 - name: Build and upload to GitHub Release
uses: tauri-apps/tauri-action@v0 uses: tauri-apps/tauri-action@v0
env: env:
@@ -71,4 +118,10 @@ jobs:
tagName: ${{ github.ref_name }} tagName: ${{ github.ref_name }}
releaseName: 'Gridline ${{ github.ref_name }}' releaseName: 'Gridline ${{ github.ref_name }}'
releaseDraft: true releaseDraft: true
args: ${{ matrix.args }} args: ${{ matrix.args }}
# Version-free asset names (see README Download section): the README
# links via GitHub's releases/latest/download/<file> 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]'
+8 -2
View File
@@ -156,7 +156,8 @@ Cut a release from the **`prod`** branch (never feature branches) by tagging it
**Before tagging**, keep everything in sync: **Before tagging**, keep everything in sync:
- Version number across `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json` - 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 - `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/<file>` 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 ### Adding a Tauri Command
@@ -180,7 +181,7 @@ Cut a release from the **`prod`** branch (never feature branches) by tagging it
## Constraints & Guardrails ## 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** 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** render large query results in raw DOM — always use the virtualized grid component
- **Do NOT** log credentials, connection strings, or query data - **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 | | 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) | | 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' | | 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. | | 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 ### 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 | | 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` | | 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 | | 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 | ❌ | | | SQLite .dump | ❌ | |
| Table structure export (DDL) | ❌ | | | Table structure export (DDL) | ❌ | |
+36 -29
View File
@@ -31,25 +31,26 @@
## Download ## 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 | | OS | Architecture | 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** | 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_0.7.0_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.0/Gridline_0.7.0_x64.dmg) | | **macOS** | Intel | [Gridline_darwin_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_darwin_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) | | **Windows** | x64 | [Gridline_windows_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_windows_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) | | **Debian / Ubuntu** | amd64 | [Gridline_linux_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_linux_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) | | **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** | [Gridline_0.7.0_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.0/Gridline_0.7.0_amd64.AppImage) | | **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. > 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.
<!-- <!--
MAINTENANCE: The two download tables — the top "Download" section and the MAINTENANCE: The download links use GitHub's releases/latest/download/<file>
"Which file should I download?" section in Getting Started — HARDCODE the redirect, so they always point at the newest published release. This only
current version (v0.7.0) in the asset filenames and direct download links. works because release.yml sets releaseAssetNamePattern to a version-free
When cutting a new release, update BOTH tables to the new version BEFORE pattern ([name]_[platform]_[arch][setup][ext]). If the asset naming ever
tagging. See AGENTS.md → Development Workflow → Releases. changes, update BOTH tables here to match the new filenames. No version
bump is needed on release — do NOT re-add the version to these filenames.
--> -->
--- ---
@@ -81,7 +82,8 @@ Gridline is built for developers and small teams who manage multiple database en
## Recent Changes ## Recent Changes
- **2026-08-04:** v0.7.0revamped 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.5bundled `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-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 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. - **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. - **Functions & procedures** — syntax-highlighted source, argument signatures, overload disambiguation.
- **Triggers, sequences, enums, extensions** — unified **Objects** view with type switcher. - **Triggers, sequences, enums, extensions** — unified **Objects** view with type switcher.
- **Indexes & constraints** — per-table index details plus CHECK/UNIQUE constraints beyond PK/FK. - **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. - **Schema visualizer** — interactive ER diagram with auto-layout, cardinality legend, and collapsible columns.
### Data Grid ### 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 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. - **Visual Restore** — `pg_restore` wrapper with clean toggle and destructive confirmation.
- **DB-to-DB Sync** — pipe `pg_dump``pg_restore` between two connections. - **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) | | **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 | | **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 | | **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 | | **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 | | **Styling** | [Tailwind CSS](https://tailwindcss.com) | Utility-first, dark mode, glassmorphic design |
| **State** | [Zustand](https://zustand.docs.pmnd.rs) / Jotai | Domain stores | | **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? #### 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 | | Your system | Download this | Notes |
| :--- | :--- | :--- | | :--- | :--- | :--- |
| macOS **Apple Silicon** (M1/M2/M3/M4…) | `Gridline_0.7.0_aarch64.dmg` | `aarch64` = Apple's own chip | | 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_0.7.0_x64.dmg` | `x64` = Intel/AMD | | 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_0.7.0_x64-setup.exe` | The `.msi` is an alternate installer (for enterprises/IT admins) | | **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_0.7.0_amd64.deb` | Install: `sudo apt install ./Gridline_0.7.0_amd64.deb` | | **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-0.7.0-1.x86_64.rpm` | Install: `sudo dnf install Gridline-0.7.0-1.x86_64.rpm` | | **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_0.7.0_amd64.AppImage` | Works on every distro: `chmod +x` the file, then double-click it | | **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. **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 ```bash
git checkout prod && git pull git checkout prod && git pull
git tag v0.7.0 git tag v0.7.5
git push origin v0.7.0 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**. 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 - **Windows:** 10 or newer
- **Linux:** Ubuntu 22.04+ or equivalent modern distribution - **Linux:** Ubuntu 22.04+ or equivalent modern distribution
- **RAM:** 8 GB recommended - **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 ## 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: 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 - **Full Redis support** — key browser, type-aware value editors, TTL management
- **More database types** — MariaDB, TimescaleDB, and friends - **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 - **AI integration (BYOK)** — natural-language → SQL, chat, summaries, charts
**[View the full roadmap →](./ROADMAP.md)** **[View the full roadmap →](./ROADMAP.md)**
+18 -7
View File
@@ -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) ## ✅ 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. - **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 - 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 - **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) ### 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) - **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 - **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 ## 📋 In the queue
### Full Redis Support ### Full Redis Support
@@ -128,6 +134,11 @@ Deferred from 0.7.0, slated for this bucket:
## ✅ Shipped ## ✅ 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 - Tauri 2.0 + React 19 + TypeScript 5.8 project shell
- PostgreSQL and SQLite browse/query support - PostgreSQL and SQLite browse/query support
- Full MySQL DB viewer — connect, browse, query, edit + changes queue (v0.7.0) - Full MySQL DB viewer — connect, browse, query, edit + changes queue (v0.7.0)
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "gridline", "name": "gridline",
"private": true, "private": true,
"version": "0.7.0", "version": "0.7.5",
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond", "description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
"type": "module", "type": "module",
"scripts": { "scripts": {
+75
View File
@@ -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';
+1 -1
View File
@@ -1783,7 +1783,7 @@ dependencies = [
[[package]] [[package]]
name = "gridline" name = "gridline"
version = "0.7.0" version = "0.7.5"
dependencies = [ dependencies = [
"chrono", "chrono",
"deadpool-postgres", "deadpool-postgres",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "gridline" name = "gridline"
version = "0.7.0" version = "0.7.5"
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond" description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1002 B

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 74 KiB

+8
View File
@@ -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.
+64 -17
View File
@@ -1,5 +1,5 @@
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use tauri::{AppHandle, Emitter, State}; use tauri::{AppHandle, Emitter, Manager, State};
use crate::models::backup::*; use crate::models::backup::*;
@@ -51,20 +51,63 @@ fn sanitize_error(s: &str) -> String {
crate::commands::test_connection::sanitize_error(s) crate::commands::test_connection::sanitize_error(s)
} }
fn bundled_bin_name(tool: &str) -> String {
if cfg!(windows) { format!("{tool}.exe") } else { tool.to_string() }
}
/// Pure resolution decision (unit-testable): system > bundled > bare name.
fn pick_tool(system_ok: bool, bundled: Option<&str>, tool: &str) -> (String, Option<String>) {
if system_ok { return (tool.to_string(), Some("system".to_string())); }
if let Some(b) = bundled { return (b.to_string(), Some("bundled".to_string())); }
(tool.to_string(), None)
}
/// System-first, bundled-fallback resolver. Returns (command_to_invoke, source).
pub fn resolve_tool(app: &AppHandle, tool: &str) -> (String, Option<String>) {
let system_ok = Command::new(tool).arg("--version").output().is_ok();
let bundled = app.path().resource_dir().ok()
.map(|rd| rd.join("pg_tools").join(bundled_bin_name(tool)))
.filter(|p| p.exists())
.map(|p| p.to_string_lossy().to_string());
pick_tool(system_ok, bundled.as_deref(), tool)
}
pub fn resolve_tool_paths(app: &AppHandle) -> PgToolPaths {
let (d, _) = resolve_tool(app, "pg_dump");
let (r, _) = resolve_tool(app, "pg_restore");
let (p, _) = resolve_tool(app, "psql");
PgToolPaths { pg_dump: d, pg_restore: r, psql: p }
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// detect_pg_tools // detect_pg_tools
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[tauri::command] /// Shapes the tool status from resolved tool paths + sources. Headless so the
pub fn detect_pg_tools() -> PgToolStatus { /// Tauri command stays thin and the status logic stays unit-testable.
fn build_pg_tool_status(
dump: &str,
restore: &str,
dump_src: Option<String>,
restore_src: Option<String>,
) -> PgToolStatus {
PgToolStatus { PgToolStatus {
pg_dump_found: Command::new("pg_dump").arg("--version").output().is_ok(), pg_dump_found: Command::new(dump).arg("--version").output().is_ok(),
pg_restore_found: Command::new("pg_restore").arg("--version").output().is_ok(), pg_restore_found: Command::new(restore).arg("--version").output().is_ok(),
pg_dump_version: get_version("pg_dump"), pg_dump_version: get_version(dump),
pg_restore_version: get_version("pg_restore"), 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) // Core logic (headless-testable — no Tauri, no store, no keychain)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -132,11 +175,11 @@ fn build_restore_args(conn: &PgConnParams, options: &RestoreOptions) -> Vec<Stri
/// Runs `pg_dump` against `conn`, writing to `options.file_path`. /// Runs `pg_dump` against `conn`, writing to `options.file_path`.
/// Returns `Ok(())` on success or a sanitized error message. /// Returns `Ok(())` on success or a sanitized error message.
pub fn run_pg_dump(conn: &PgConnParams, options: &BackupOptions) -> Result<(), String> { pub fn run_pg_dump(conn: &PgConnParams, options: &BackupOptions, tools: &PgToolPaths) -> Result<(), String> {
let mut args = build_dump_args(conn, options); let mut args = build_dump_args(conn, options);
args.push(format!("--file={}", options.file_path)); args.push(format!("--file={}", options.file_path));
let result = Command::new("pg_dump") let result = Command::new(&tools.pg_dump)
.env("PGPASSWORD", &conn.password) .env("PGPASSWORD", &conn.password)
.args(&args) .args(&args)
.output(); .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 /// 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 /// are executed with `psql` instead. The `clean` option is only honored for
/// archive formats (custom/tar/directory); the UI disables it for plain. /// 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" { if options.format == "plain" {
let result = Command::new("psql") let result = Command::new(&tools.psql)
.env("PGPASSWORD", &conn.password) .env("PGPASSWORD", &conn.password)
.args([ .args([
format!("--host={}", conn.host), 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); let mut args = build_restore_args(conn, options);
args.push(options.file_path.clone()); args.push(options.file_path.clone());
let result = Command::new("pg_restore") let result = Command::new(&tools.pg_restore)
.env("PGPASSWORD", &conn.password) .env("PGPASSWORD", &conn.password)
.args(&args) .args(&args)
.output(); .output();
@@ -196,6 +239,7 @@ pub fn run_db_sync(
target: &PgConnParams, target: &PgConnParams,
schema: Option<&str>, schema: Option<&str>,
tables: Option<&[String]>, tables: Option<&[String]>,
tools: &PgToolPaths,
) -> Result<(), String> { ) -> Result<(), String> {
// --- Build pg_dump args --- // --- Build pg_dump args ---
let mut dump_args = base_conn_args(source); let mut dump_args = base_conn_args(source);
@@ -221,7 +265,7 @@ pub fn run_db_sync(
restore_args.push("--if-exists".into()); restore_args.push("--if-exists".into());
// --- Spawn pg_dump with piped stdout --- // --- 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) .env("PGPASSWORD", &source.password)
.args(&dump_args) .args(&dump_args)
.stdout(Stdio::piped()) .stdout(Stdio::piped())
@@ -243,7 +287,7 @@ pub fn run_db_sync(
}); });
// --- Run pg_restore with pg_dump stdout as stdin --- // --- 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) .env("PGPASSWORD", &target.password)
.args(&restore_args) .args(&restore_args)
.stdin(dump_stdout) .stdin(dump_stdout)
@@ -332,9 +376,10 @@ pub async fn pg_dump(
let job_id_clone = job_id.clone(); let job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone(); let app_handle_clone = app_handle.clone();
let tools = resolve_tool_paths(&app_handle);
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let result = run_pg_dump(&params, &options); let result = run_pg_dump(&params, &options, &tools);
emit_result(&app_handle_clone, &job_id_clone, result); 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 job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone(); let app_handle_clone = app_handle.clone();
let tools = resolve_tool_paths(&app_handle);
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let result = run_pg_restore(&params, &options); let result = run_pg_restore(&params, &options, &tools);
emit_result(&app_handle_clone, &job_id_clone, result); emit_result(&app_handle_clone, &job_id_clone, result);
}); });
@@ -464,9 +510,10 @@ pub async fn db_sync(
let tables = options.tables.clone(); let tables = options.tables.clone();
let job_id_clone = job_id.clone(); let job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone(); let app_handle_clone = app_handle.clone();
let tools = resolve_tool_paths(&app_handle);
tokio::task::spawn_blocking(move || { 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); emit_result(&app_handle_clone, &job_id_clone, result);
}); });
+32 -6
View File
@@ -133,7 +133,9 @@ fn build_pg_restore_args_with_schema() {
#[test] #[test]
fn detect_pg_tools_does_not_panic() { 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 // May or may not find tools, but the call itself must not panic
let _ = status.pg_dump_found; let _ = status.pg_dump_found;
let _ = status.pg_restore_found; let _ = status.pg_restore_found;
@@ -148,6 +150,8 @@ fn pg_tool_status_serialization() {
pg_restore_found: false, pg_restore_found: false,
pg_dump_version: Some("pg_dump (PostgreSQL) 16.0".into()), pg_dump_version: Some("pg_dump (PostgreSQL) 16.0".into()),
pg_restore_version: None, pg_restore_version: None,
pg_dump_source: None,
pg_restore_source: None,
}; };
let json = serde_json::to_string(&status).unwrap(); let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("pg_dump_found")); assert!(json.contains("pg_dump_found"));
@@ -294,7 +298,8 @@ fn integration_dump_restore_sync() {
tables: None, tables: None,
no_owner: true, 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 --- // --- 2. Restore into target ---
let restore_opts = RestoreOptions { let restore_opts = RestoreOptions {
@@ -303,7 +308,8 @@ fn integration_dump_restore_sync() {
clean: true, clean: true,
schema: None, 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 --- // --- 3. Verify data landed in target ---
assert_eq!( assert_eq!(
@@ -320,7 +326,8 @@ fn integration_dump_restore_sync() {
// --- 4. Sync source -> target (target already has tables from the restore // --- 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 // above — db_sync now passes --clean --if-exists, so it must succeed into a
// non-empty target). --- // 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!( assert_eq!(
psql_count(&tgt, "SELECT count(*) FROM public.products;"), psql_count(&tgt, "SELECT count(*) FROM public.products;"),
3, 3,
@@ -367,7 +374,8 @@ fn integration_plain_dump_restore() {
tables: None, tables: None,
no_owner: true, 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) --- // --- 2. Restore into target (plain -> psql path) ---
let restore_opts = RestoreOptions { let restore_opts = RestoreOptions {
@@ -376,7 +384,8 @@ fn integration_plain_dump_restore() {
clean: false, clean: false,
schema: None, 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 --- // --- 3. Verify data landed in target ---
assert_eq!( assert_eq!(
@@ -392,3 +401,20 @@ fn integration_plain_dump_restore() {
let _ = std::fs::remove_file(&dump_path); let _ = std::fs::remove_file(&dump_path);
} }
// ------------------------------------------------------------------
// Tool resolution (Task 2.1: system-first, bundled-fallback)
// ------------------------------------------------------------------
#[test]
fn pick_tool_prefers_system_then_bundled_then_bare() {
assert_eq!(pick_tool(true, None, "pg_dump"), ("pg_dump".to_string(), Some("system".into())));
assert_eq!(pick_tool(false, Some("/r/pg_dump"), "pg_dump"), ("/r/pg_dump".to_string(), Some("bundled".into())));
assert_eq!(pick_tool(false, None, "pg_dump"), ("pg_dump".to_string(), None));
}
#[test]
fn bundled_bin_name_appends_exe_on_windows() {
let name = bundled_bin_name("pg_dump");
if cfg!(windows) { assert_eq!(name, "pg_dump.exe"); } else { assert_eq!(name, "pg_dump"); }
}
+24 -12
View File
@@ -149,19 +149,21 @@ pub fn build_pg_dump_ddl_args(schema: &str, table: &str) -> Vec<String> {
] ]
} }
/// Check whether the system `pg_dump` binary is on PATH. /// Check whether the `pg_dump` binary at the given path (bare name or
pub fn pg_dump_available() -> bool { /// absolute path) is executable and reports a version.
std::process::Command::new("pg_dump") pub fn pg_dump_available_at(path: &str) -> bool {
std::process::Command::new(path)
.arg("--version") .arg("--version")
.output() .output()
.is_ok() .is_ok()
} }
/// Extract a single table's DDL from a PostgreSQL database by shelling out to /// 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 /// `pg_dump` (system-first, bundled-fallback) with `--schema-only`.
/// `PGPASSWORD` environment variable only — never as argv — and are never /// Credentials are supplied via the `PGPASSWORD` environment variable only —
/// logged. Execution requires a reachable PostgreSQL server plus an installed /// never as argv — and are never logged. Execution requires a reachable
/// `pg_dump`; unit tests cover the argument construction instead. /// PostgreSQL server plus an installed `pg_dump`; unit tests cover the
/// argument construction instead.
pub fn get_pg_ddl_via_dump( pub fn get_pg_ddl_via_dump(
schema: &str, schema: &str,
table: &str, table: &str,
@@ -170,13 +172,14 @@ pub fn get_pg_ddl_via_dump(
user: &str, user: &str,
db: &str, db: &str,
password: &str, password: &str,
pg_dump_path: &str,
) -> Result<String, String> { ) -> Result<String, String> {
if !pg_dump_available() { if !pg_dump_available_at(pg_dump_path) {
return Err( 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([ cmd.args([
format!("--host={host}"), format!("--host={host}"),
format!("--port={port}"), 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 // 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 || { let ddl = tokio::task::spawn_blocking(move || {
get_pg_ddl_via_dump( 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 .await
@@ -3319,4 +3324,11 @@ mod tests {
.expect("columns"); .expect("columns");
assert_eq!(cols.len(), data.columns.len()); assert_eq!(cols.len(), data.columns.len());
} }
#[test]
fn pg_dump_available_at_checks_given_path() {
// A bare name that resolves on PATH passes; a bogus path fails.
assert!(pg_dump_available_at("pg_dump") || !pg_dump_available_at("pg_dump"));
assert!(!pg_dump_available_at("/nonexistent/pg_dump_999999"));
}
} }
+1
View File
@@ -5,6 +5,7 @@ pub mod demo;
pub mod folders; pub mod folders;
pub mod import_export; pub mod import_export;
pub mod keychain; pub mod keychain;
pub mod objects;
pub mod query; pub mod query;
pub mod schema_graph; pub mod schema_graph;
pub mod settings; pub mod settings;
+151
View File
@@ -0,0 +1,151 @@
use tauri::State;
use crate::db::pool::{ConnectionPoolManager, DbHandle};
use crate::db::object_ddl::*;
use crate::models::db_viewer::{ObjectSearchHit, DependencyInfo};
fn sanitize(e: &str) -> String { crate::commands::db_viewer::sanitize_error(e) }
async fn exec_sql(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, sql: String) -> Result<(), String> {
let mut pm = pm.lock().await;
match pm.get(connection_id) {
Some(DbHandle::Postgresql(client, _)) => client.execute(&sql, &[]).await.map(|_| ()).map_err(|e| sanitize(&e.to_string())),
Some(_) => Err("Schema CRUD is PostgreSQL-only".into()),
None => Err("Connection not found".into()),
}
}
pub(crate) async fn create_schema_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, name: &str) -> Result<(), String> {
exec_sql(pm, connection_id, create_schema_sql(name)?).await
}
pub(crate) async fn rename_schema_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, old: &str, new: &str) -> Result<(), String> {
exec_sql(pm, connection_id, rename_schema_sql(old, new)?).await
}
pub(crate) async fn drop_schema_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, name: &str, cascade: bool) -> Result<(), String> {
exec_sql(pm, connection_id, drop_schema_sql(name, cascade)?).await
}
#[tauri::command]
pub async fn create_schema(connection_id: String, name: String, state: State<'_, crate::AppState>) -> Result<(), String> {
create_schema_inner(&state.pool_manager, &connection_id, &name).await
}
#[tauri::command]
pub async fn rename_schema(connection_id: String, old_name: String, new_name: String, state: State<'_, crate::AppState>) -> Result<(), String> {
rename_schema_inner(&state.pool_manager, &connection_id, &old_name, &new_name).await
}
#[tauri::command]
pub async fn drop_schema(connection_id: String, name: String, cascade: bool, state: State<'_, crate::AppState>) -> Result<(), String> {
drop_schema_inner(&state.pool_manager, &connection_id, &name, cascade).await
}
pub(crate) async fn search_objects_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, needle: &str) -> Result<Vec<ObjectSearchHit>, String> {
let sql = pg_object_search_query();
let mut pm = pm.lock().await;
match pm.get(connection_id) {
Some(DbHandle::Postgresql(client, _)) => {
let rows = client.query(&sql, &[&needle, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(rows.iter().map(|r| ObjectSearchHit {
name: r.get(0), schema: r.get(1), object_type: r.get(2),
}).collect())
}
Some(DbHandle::Sqlite(_)) | Some(DbHandle::MySql(_)) => Ok(vec![]),
None => Err("Connection not found".into()),
}
}
#[tauri::command]
pub async fn search_objects(connection_id: String, schema: String, query: String, state: State<'_, crate::AppState>) -> Result<Vec<ObjectSearchHit>, String> {
search_objects_inner(&state.pool_manager, &connection_id, &schema, &query).await
}
pub(crate) async fn get_object_ddl_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, object_type: &str, name: &str) -> Result<String, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c,
Some(_) => return Err("Copy-as-DDL is PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
match object_type {
"function" | "procedure" => {
let row = client.query_one("SELECT pg_get_functiondef(p.oid) FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE p.proname=$1 AND n.nspname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(row.get::<_, String>(0))
}
"trigger" => {
let row = client.query_one("SELECT pg_get_triggerdef(t.oid) FROM pg_trigger t JOIN pg_class c ON t.tgrelid=c.oid JOIN pg_namespace n ON c.relnamespace=n.oid WHERE t.tgname=$1 AND n.nspname=$2 AND NOT t.tgisinternal LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(row.get::<_, String>(0))
}
"index" => {
let row = client.query_one("SELECT pg_get_indexdef(ix.indexrelid) FROM pg_index ix JOIN pg_class i ON i.oid=ix.indexrelid JOIN pg_class t ON t.oid=ix.indrelid JOIN pg_namespace n ON t.relnamespace=n.oid WHERE i.relname=$1 AND n.nspname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(row.get::<_, String>(0))
}
"constraint" => {
let row = client.query_one("SELECT c.conname, ns.nspname, cl.relname, pg_get_constraintdef(c.oid) FROM pg_constraint c JOIN pg_class cl ON c.conrelid=cl.oid JOIN pg_namespace ns ON cl.relnamespace=ns.oid WHERE c.conname=$1 AND ns.nspname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(constraint_ddl(&crate::models::db_viewer::ConstraintInfo {
name: row.get(0), schema: row.get(1), table: row.get(2), contype: "CHECK".into(),
definition: row.get(3), deferrable: false, validated: true, columns: vec![] }))
}
"view" => {
let row = client.query_one("SELECT pg_get_viewdef(c.oid, true) FROM pg_class c JOIN pg_namespace n ON c.relnamespace=n.oid WHERE c.relname=$1 AND n.nspname=$2 AND c.relkind='v' LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(view_ddl(schema, name, &row.get::<_, String>(0)))
}
"materialized view" => {
let row = client.query_one("SELECT definition FROM pg_matviews WHERE matviewname=$1 AND schemaname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(matview_ddl(schema, name, &row.get::<_, String>(0)))
}
"sequence" => {
let row = client.query_one("SELECT sequence_name, sequence_schema, start_value::text, minimum_value::text, maximum_value::text, increment::text, COALESCE(pg_catalog.pg_sequence_last_value(sequence_name::regclass)::text,'0'), cycle_option::text FROM information_schema.sequences WHERE sequence_name=$1 AND sequence_schema=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(sequence_ddl(&crate::models::db_viewer::SequenceInfo { name: row.get(0), schema: row.get(1), start_value: row.get(2), min_value: row.get(3), max_value: row.get(4), increment: row.get(5), current_value: row.get(6), cycle: row.get::<_, String>(7).eq_ignore_ascii_case("YES") }))
}
"enum" => {
let row = client.query_one("SELECT t.typname, n.nspname, ARRAY(SELECT e.enumlabel FROM pg_enum e WHERE e.enumtypid=t.oid ORDER BY e.enumsortorder) FROM pg_type t JOIN pg_namespace n ON t.typnamespace=n.oid WHERE t.typname=$1 AND n.nspname=$2 AND t.typtype='e' LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(enum_ddl(&crate::models::db_viewer::EnumInfo { name: row.get(0), schema: row.get(1), labels: row.get::<_, Vec<String>>(2) }))
}
"extension" => {
let row = client.query_one("SELECT e.extname, n.nspname, e.extversion::text FROM pg_extension e JOIN pg_namespace n ON e.extnamespace=n.oid WHERE e.extname=$1 AND n.nspname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(extension_ddl(&crate::models::db_viewer::ExtensionInfo { name: row.get(0), schema: row.get(1), version: row.get(2), comment: None }))
}
"table" => {
// Tables reuse the pg_dump path (bundled-aware); the command wrapper resolves the tool path via AppHandle.
Err("table DDL uses get_table_ddl".into())
}
other => Err(format!("Unsupported object type for DDL: {other}")),
}
}
#[tauri::command]
pub async fn get_object_ddl(connection_id: String, schema: String, object_type: String, name: String, state: State<'_, crate::AppState>, app: tauri::AppHandle) -> Result<String, String> {
if object_type == "table" || object_type == "TABLE" {
return crate::commands::db_viewer::get_table_ddl(connection_id, schema, name, state, app).await;
}
get_object_ddl_inner(&state.pool_manager, &connection_id, &schema, &object_type.to_lowercase(), &name).await
}
pub(crate) async fn get_object_dependencies_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, object_type: &str, name: &str) -> Result<Vec<DependencyInfo>, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c,
Some(_) => return Err("Dependencies are PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
if object_type.eq_ignore_ascii_case("schema") {
let rows = client.query(&pg_schema_contents_query(), &[&name]).await.map_err(|e| sanitize(&e.to_string()))?;
return Ok(rows.iter().map(|r| DependencyInfo {
deptype: "n".into(), class: format!("pg_class: {}", r.get::<_, String>(1)), name: r.get(0),
}).collect());
}
let oid_sql = pg_object_oid_query(&object_type.to_lowercase());
if oid_sql.is_empty() { return Err(format!("Unsupported object type: {object_type}")); }
let oid: tokio_postgres::types::Oid = client.query_one(&oid_sql, &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?.get(0);
let rows = client.query(&pg_depend_query(), &[&oid]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(rows.iter().map(|r| DependencyInfo {
deptype: r.get(0), class: r.get(1), name: r.get::<_, String>(2),
}).collect())
}
#[tauri::command]
pub async fn get_object_dependencies(connection_id: String, schema: String, object_type: String, name: String, state: State<'_, crate::AppState>) -> Result<Vec<DependencyInfo>, String> {
get_object_dependencies_inner(&state.pool_manager, &connection_id, &schema, &object_type, &name).await
}
#[cfg(test)]
#[path = "objects.test.rs"]
mod tests;
+70
View File
@@ -0,0 +1,70 @@
use super::*;
use crate::db::pool::{ConnectionPoolManager, DbHandle};
/// Connect directly via tokio-postgres (no tunnel) so tests are Tauri-free.
async fn pool() -> (tokio::sync::Mutex<ConnectionPoolManager>, String) {
let h = std::env::var("GRIDLINE_TEST_PG_HOST").expect("set GRIDLINE_TEST_PG_HOST");
let p: u16 = std::env::var("GRIDLINE_TEST_PG_PORT").unwrap_or_else(|_| "5432".into()).parse().unwrap();
let u = std::env::var("GRIDLINE_TEST_PG_USER").expect("set GRIDLINE_TEST_PG_USER");
let d = std::env::var("GRIDLINE_TEST_PG_DB").expect("set GRIDLINE_TEST_PG_DB");
let pw = std::env::var("GRIDLINE_TEST_PG_PASSWORD").unwrap_or_default();
let (client, conn) = tokio_postgres::connect(
&format!("host={h} port={p} user={u} dbname={d} password={pw}"),
tokio_postgres::NoTls,
).await.expect("connect to test PG");
let handle = tokio::spawn(async move { let _ = conn.await; });
let mut pm = ConnectionPoolManager::new();
let id = "test-conn".to_string();
pm.register(&id, DbHandle::Postgresql(client, handle));
(tokio::sync::Mutex::new(pm), id)
}
#[tokio::test]
#[ignore]
async fn schema_crud_create_rename_drop() {
let (pm, id) = pool().await;
let name = "gridline_test_schema";
create_schema_inner(&pm, &id, name).await.unwrap();
assert!(create_schema_inner(&pm, &id, name).await.is_err(), "duplicate should error");
rename_schema_inner(&pm, &id, name, "gridline_test_schema2").await.unwrap();
drop_schema_inner(&pm, &id, "gridline_test_schema2", false).await.unwrap();
}
#[tokio::test]
#[ignore]
async fn search_objects_finds_table_and_function() {
let (pm, id) = pool().await;
let hits = search_objects_inner(&pm, &id, "public", "users").await.unwrap();
assert!(hits.iter().any(|h| h.name == "users" && h.object_type == "TABLE"), "demo has a users table");
let fns = search_objects_inner(&pm, &id, "public", "get").await.unwrap();
// substring match across types; assert it returns a Vec<ObjectSearchHit>
assert!(fns.iter().all(|h| h.object_type != ""));
// empty needle returns nothing matched by position('' in name) > 0 is always true — so empty returns all (capped at 100)
let all = search_objects_inner(&pm, &id, "public", "").await.unwrap();
assert!(all.len() <= 100);
}
#[tokio::test]
#[ignore]
async fn object_ddl_for_sequence_enum_function() {
let (pm, id) = pool().await;
// demo has users_id_seq, an enum, and a function
let seq = get_object_ddl_inner(&pm, &id, "public", "sequence", "users_id_seq").await.unwrap();
assert!(seq.starts_with("CREATE SEQUENCE"), "{seq}");
// function: pg_get_functiondef passthrough
let f = get_object_ddl_inner(&pm, &id, "public", "function", "audit_log").await; // name per demo
assert!(f.is_ok());
assert!(f.clone().unwrap().contains("CREATE FUNCTION") || f.unwrap().contains("CREATE OR REPLACE FUNCTION"));
}
#[tokio::test]
#[ignore]
async fn object_dependencies_for_table_includes_view() {
let (pm, id) = pool().await;
// demo has order_summary VIEW depending on orders — drop would break it
let deps = get_object_dependencies_inner(&pm, &id, "public", "table", "orders").await.unwrap();
assert!(deps.iter().any(|d| d.class.contains("pg_class") && d.name.contains("order_summary")), "view depending on orders should surface: {deps:?}");
// schema contents path
let contents = get_object_dependencies_inner(&pm, &id, "public", "schema", "public").await.unwrap();
assert!(!contents.is_empty(), "public schema should list contents");
}
+1
View File
@@ -1,5 +1,6 @@
pub mod introspection; pub mod introspection;
pub mod mysql; pub mod mysql;
pub mod object_ddl;
pub mod pool; pub mod pool;
pub mod tls; pub mod tls;
+261
View File
@@ -0,0 +1,261 @@
//! Pure builders for schema DDL, cross-object search, pg_depend lookups,
//! and synthesized object DDL. No DB I/O — deterministic string builders.
use crate::models::db_viewer::{SequenceInfo, EnumInfo, ExtensionInfo, ConstraintInfo};
/// Double-quote an identifier, doubling embedded quotes.
pub fn quote_ident(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
/// Reject empty / SQL-injection-prone names (mirrors schema_graph::validate_schema_name).
pub fn validate_object_name(name: &str) -> Result<(), String> {
if name.trim().is_empty() {
return Err("Name must not be empty".into());
}
for bad in [";", "--", "/*", "'", "\"", "\\"] {
if name.contains(bad) {
return Err(format!("Name contains forbidden character(s): {bad}"));
}
}
Ok(())
}
pub fn create_schema_sql(name: &str) -> Result<String, String> {
validate_object_name(name)?;
Ok(format!("CREATE SCHEMA {}", quote_ident(name)))
}
pub fn rename_schema_sql(old: &str, new: &str) -> Result<String, String> {
validate_object_name(old)?;
validate_object_name(new)?;
Ok(format!("ALTER SCHEMA {} RENAME TO {}", quote_ident(old), quote_ident(new)))
}
pub fn drop_schema_sql(name: &str, cascade: bool) -> Result<String, String> {
validate_object_name(name)?;
Ok(format!("DROP SCHEMA {}{}", quote_ident(name), if cascade { " CASCADE" } else { "" }))
}
/// UNION ALL of every browsable object type in `$2` (schema), substring-matching `$1` (needle)
/// via position() — case-insensitive, no wildcard-escaping pitfalls. LIMIT 100.
pub fn pg_object_search_query() -> String {
"SELECT name, schema, type FROM (
SELECT table_name AS name, table_schema AS schema, CASE WHEN table_type = 'VIEW' THEN 'VIEW' ELSE 'TABLE' END AS type FROM information_schema.tables WHERE table_schema=$2 AND position(lower($1) in lower(table_name))>0
UNION ALL
SELECT matviewname, schemaname, 'MATERIALIZED VIEW' FROM pg_matviews WHERE schemaname=$2 AND position(lower($1) in lower(matviewname))>0
UNION ALL
SELECT p.proname, n.nspname, CASE p.prokind WHEN 'f' THEN 'FUNCTION' WHEN 'p' THEN 'PROCEDURE' END FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE n.nspname=$2 AND position(lower($1) in lower(p.proname))>0
UNION ALL
SELECT t.tgname, cn.nspname, 'TRIGGER' FROM pg_trigger t JOIN pg_class c ON t.tgrelid=c.oid JOIN pg_namespace cn ON c.relnamespace=cn.oid WHERE cn.nspname=$2 AND NOT t.tgisinternal AND position(lower($1) in lower(t.tgname))>0
UNION ALL
SELECT sequence_name, sequence_schema, 'SEQUENCE' FROM information_schema.sequences WHERE sequence_schema=$2 AND position(lower($1) in lower(sequence_name))>0
UNION ALL
SELECT t.typname, n.nspname, 'ENUM' FROM pg_type t JOIN pg_namespace n ON t.typnamespace=n.oid WHERE t.typtype='e' AND n.nspname=$2 AND position(lower($1) in lower(t.typname))>0
UNION ALL
SELECT e.extname, n.nspname, 'EXTENSION' FROM pg_extension e JOIN pg_namespace n ON e.extnamespace=n.oid WHERE n.nspname=$2 AND position(lower($1) in lower(e.extname))>0
UNION ALL
SELECT i.relname, ns.nspname, 'INDEX' FROM pg_index ix JOIN pg_class i ON i.oid=ix.indexrelid JOIN pg_class t ON t.oid=ix.indrelid JOIN pg_namespace ns ON t.relnamespace=ns.oid WHERE ns.nspname=$2 AND position(lower($1) in lower(i.relname))>0
UNION ALL
SELECT c.conname, ns.nspname, 'CONSTRAINT' FROM pg_constraint c JOIN pg_class cl ON c.conrelid=cl.oid JOIN pg_namespace ns ON cl.relnamespace=ns.oid WHERE ns.nspname=$2 AND c.contype IN ('c','u','x') AND position(lower($1) in lower(c.conname))>0
) AS hits ORDER BY type, name LIMIT 100".to_string()
}
/// Resolve a single object oid by type. $1=name, $2=schema. (Overloads: first match — see spec open questions.)
pub fn pg_object_oid_query(object_type: &str) -> String {
match object_type {
"table" | "view" | "materialized view" | "sequence" | "index" =>
"SELECT c.oid FROM pg_class c JOIN pg_namespace n ON c.relnamespace=n.oid WHERE c.relname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
"function" | "procedure" =>
"SELECT p.oid FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE p.proname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
"trigger" =>
"SELECT t.oid FROM pg_trigger t JOIN pg_class c ON t.tgrelid=c.oid JOIN pg_namespace n ON c.relnamespace=n.oid WHERE t.tgname=$1 AND n.nspname=$2 AND NOT t.tgisinternal LIMIT 1".to_string(),
"enum" =>
"SELECT t.oid FROM pg_type t JOIN pg_namespace n ON t.typnamespace=n.oid WHERE t.typname=$1 AND n.nspname=$2 AND t.typtype='e' LIMIT 1".to_string(),
"extension" =>
"SELECT e.oid FROM pg_extension e JOIN pg_namespace n ON e.extnamespace=n.oid WHERE e.extname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
"constraint" =>
"SELECT c.oid FROM pg_constraint c JOIN pg_class cl ON c.conrelid=cl.oid JOIN pg_namespace n ON cl.relnamespace=n.oid WHERE c.conname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
_ => String::new(),
}
}
/// Objects that depend on `$1` (the target oid). Excludes internal/pinned deps; resolves readable name per classid.
pub fn pg_depend_query() -> String {
"SELECT d.deptype::text AS deptype,
CASE WHEN d.classid = 'pg_rewrite'::regclass THEN 'pg_class'
ELSE d.classid::regclass::text END AS class,
CASE
WHEN d.classid='pg_class'::regclass THEN (SELECT relname FROM pg_class WHERE oid=d.objid)
WHEN d.classid='pg_proc'::regclass THEN (SELECT proname FROM pg_proc WHERE oid=d.objid)
WHEN d.classid='pg_trigger'::regclass THEN (SELECT tgname FROM pg_trigger WHERE oid=d.objid)
WHEN d.classid='pg_type'::regclass THEN (SELECT typname FROM pg_type WHERE oid=d.objid)
WHEN d.classid='pg_constraint'::regclass THEN (SELECT conname FROM pg_constraint WHERE oid=d.objid)
WHEN d.classid='pg_rewrite'::regclass THEN (SELECT ev_class::regclass::text FROM pg_rewrite WHERE oid=d.objid)
ELSE ''
END AS name
FROM pg_depend d
WHERE d.refobjid = $1 AND d.deptype IN ('n', 'a')
ORDER BY d.deptype, name".to_string()
}
/// Objects contained in a schema (for the schema-drop dependency warning). $1=schema.
pub fn pg_schema_contents_query() -> String {
"SELECT c.relname, c.relkind::text FROM pg_class c JOIN pg_namespace n ON c.relnamespace=n.oid WHERE n.nspname=$1 AND c.relkind IN ('r','v','m','S','i','c') ORDER BY c.relkind, c.relname".to_string()
}
// --- Synthesized DDL ---
pub fn sequence_ddl(s: &SequenceInfo) -> String {
format!("CREATE SEQUENCE {}.{}\n INCREMENT BY {}\n MINVALUE {}\n MAXVALUE {}\n START WITH {}\n {}",
quote_ident(&s.schema), quote_ident(&s.name), s.increment, s.min_value, s.max_value, s.start_value,
if s.cycle { "CYCLE" } else { "NO CYCLE" })
}
pub fn enum_ddl(e: &EnumInfo) -> String {
let labels: Vec<String> = e.labels.iter().map(|l| format!("'{}'", l.replace('\'', "''"))).collect();
format!("CREATE TYPE {}.{} AS ENUM ({});", quote_ident(&e.schema), quote_ident(&e.name), labels.join(", "))
}
pub fn extension_ddl(x: &ExtensionInfo) -> String {
format!("CREATE EXTENSION IF NOT EXISTS {} WITH SCHEMA {} VERSION '{}';", quote_ident(&x.name), quote_ident(&x.schema), x.version.replace('\'', "''"))
}
pub fn view_ddl(schema: &str, name: &str, selectdef: &str) -> String {
format!("CREATE OR REPLACE VIEW {}.{} AS\n{}", quote_ident(schema), quote_ident(name), selectdef)
}
pub fn matview_ddl(schema: &str, name: &str, selectdef: &str) -> String {
format!("CREATE MATERIALIZED VIEW {}.{} AS\n{}", quote_ident(schema), quote_ident(name), selectdef)
}
pub fn constraint_ddl(c: &ConstraintInfo) -> String {
format!("ALTER TABLE {}.{} ADD CONSTRAINT {} {}",
quote_ident(&c.schema), quote_ident(&c.table), quote_ident(&c.name), c.definition)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quote_ident_doubles_embedded_quotes() {
assert_eq!(quote_ident("public"), "\"public\"");
assert_eq!(quote_ident("a\"b"), "\"a\"\"b\"");
}
#[test]
fn validate_object_name_rejects_dangerous_chars() {
assert!(validate_object_name("public").is_ok());
assert!(validate_object_name("").is_err());
assert!(validate_object_name("a; DROP").is_err());
assert!(validate_object_name("a--b").is_err());
assert!(validate_object_name("a'b").is_err());
assert!(validate_object_name("a\"b").is_err());
assert!(validate_object_name("a\\b").is_err());
assert!(validate_object_name("/*x*/").is_err());
}
#[test]
fn create_schema_sql_quotes_name() {
assert_eq!(create_schema_sql("my_schema").unwrap(), "CREATE SCHEMA \"my_schema\"");
assert!(create_schema_sql("bad; name").is_err());
}
#[test]
fn rename_schema_sql_quotes_both() {
assert_eq!(
rename_schema_sql("old", "new").unwrap(),
"ALTER SCHEMA \"old\" RENAME TO \"new\""
);
}
#[test]
fn drop_schema_sql_cascade_flag() {
assert_eq!(drop_schema_sql("s", false).unwrap(), "DROP SCHEMA \"s\"");
assert_eq!(drop_schema_sql("s", true).unwrap(), "DROP SCHEMA \"s\" CASCADE");
}
#[test]
fn object_search_query_unions_types_and_uses_position_match() {
let sql = pg_object_search_query();
assert!(sql.contains("position(lower($1) in lower("), "case-insensitive substring, no wildcard escaping: {sql}");
assert!(sql.contains("$2"), "schema is $2");
// covers every browsable type
for t in ["'TABLE'", "'VIEW'", "'MATERIALIZED VIEW'", "'FUNCTION'", "'PROCEDURE'", "'TRIGGER'", "'SEQUENCE'", "'ENUM'", "'EXTENSION'", "'INDEX'", "'CONSTRAINT'"] {
assert!(sql.contains(t), "search must cover {t}");
}
assert!(sql.contains("LIMIT 100"));
}
#[test]
fn object_oid_query_branches_per_type() {
assert!(pg_object_oid_query("table").contains("pg_class"));
assert!(pg_object_oid_query("function").contains("pg_proc"));
assert!(pg_object_oid_query("trigger").contains("pg_trigger"));
assert!(pg_object_oid_query("enum").contains("pg_type"));
assert!(pg_object_oid_query("extension").contains("pg_extension"));
assert!(pg_object_oid_query("constraint").contains("pg_constraint"));
}
#[test]
fn depend_query_filters_internal_and_resolves_names() {
let sql = pg_depend_query();
assert!(sql.contains("refobjid = $1"));
assert!(sql.contains("deptype IN ('n', 'a')"), "exclude internal 'i' / pinned 'p'");
assert!(sql.contains("pg_proc'::regclass"));
assert!(sql.contains("pg_trigger'::regclass"));
assert!(sql.contains("pg_constraint'::regclass"));
}
#[test]
fn depend_query_maps_rewrite_rules_to_the_view_class() {
// View dependencies surface in pg_depend as rewrite-rule rows
// (classid = pg_rewrite). The dependent object a user cares about is
// the VIEW itself, so the class must be reported as pg_class and the
// name resolved through ev_class (the view's relation).
let sql = pg_depend_query();
assert!(
sql.contains("WHEN d.classid = 'pg_rewrite'::regclass THEN 'pg_class'"),
"pg_rewrite rows must be reported as pg_class: {sql}"
);
assert!(
sql.contains("pg_rewrite'::regclass THEN (SELECT ev_class::regclass::text FROM pg_rewrite WHERE oid=d.objid)"),
"pg_rewrite name resolves through ev_class: {sql}"
);
}
#[test]
fn sequence_ddl_is_synthesized() {
let s = SequenceInfo { name: "users_id_seq".into(), schema: "public".into(),
start_value: "1".into(), min_value: "1".into(), max_value: "9".into(),
increment: "1".into(), current_value: "5".into(), cycle: false };
let ddl = sequence_ddl(&s);
assert!(ddl.starts_with("CREATE SEQUENCE \"public\".\"users_id_seq\""));
assert!(ddl.contains("INCREMENT BY 1"));
assert!(ddl.contains("NO CYCLE"));
}
#[test]
fn enum_ddl_lists_labels_quoted() {
let e = EnumInfo { name: "role".into(), schema: "public".into(), labels: vec!["admin".into(), "user's".into()] };
let ddl = enum_ddl(&e);
assert!(ddl.starts_with("CREATE TYPE \"public\".\"role\" AS ENUM ("));
assert!(ddl.contains("'admin'"));
assert!(ddl.contains("'user''s'"), "single quotes doubled");
}
#[test]
fn extension_ddl_is_synthesized() {
let x = ExtensionInfo { name: "pgcrypto".into(), schema: "public".into(), version: "1.3".into(), comment: None };
let ddl = extension_ddl(&x);
assert!(ddl.contains("CREATE EXTENSION IF NOT EXISTS \"pgcrypto\""));
assert!(ddl.contains("WITH SCHEMA \"public\""));
assert!(ddl.contains("VERSION '1.3'"));
}
#[test]
fn view_ddl_wraps_selectdef() {
assert_eq!(view_ddl("public", "v_users", "SELECT * FROM users"),
"CREATE OR REPLACE VIEW \"public\".\"v_users\" AS\nSELECT * FROM users");
}
#[test]
fn constraint_ddl_wraps_definition() {
let c = ConstraintInfo { name: "ck_pos".into(), schema: "public".into(), table: "orders".into(),
contype: "CHECK".into(), definition: "CHECK (amount > 0)".into(), deferrable: false, validated: true, columns: vec!["amount".into()] };
let ddl = constraint_ddl(&c);
assert_eq!(ddl, "ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"ck_pos\" CHECK (amount > 0)");
}
}
+8 -2
View File
@@ -20,8 +20,8 @@ pub struct AppState {
} }
use commands::{ use commands::{
backup, connections, db_viewer, demo, folders, import_export, keychain, query, schema_graph, backup, connections, db_viewer, demo, folders, import_export, keychain, objects, query,
settings, tags, schema_graph, settings, tags,
}; };
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ // 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_extensions,
db_viewer::get_indexes, db_viewer::get_indexes,
db_viewer::get_constraints, 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::save_connection_password,
keychain::get_connection_password, keychain::get_connection_password,
keychain::delete_connection_password, keychain::delete_connection_password,
+20
View File
@@ -34,6 +34,16 @@ pub struct PgToolStatus {
pub pg_restore_found: bool, pub pg_restore_found: bool,
pub pg_dump_version: Option<String>, pub pg_dump_version: Option<String>,
pub pg_restore_version: Option<String>, pub pg_restore_version: Option<String>,
pub pg_dump_source: Option<String>, // "system" | "bundled" | None
pub pg_restore_source: Option<String>,
}
/// Resolved on-disk paths for the three client tools (system-first, bundled-fallback).
#[derive(Debug, Clone)]
pub struct PgToolPaths {
pub pg_dump: String,
pub pg_restore: String,
pub psql: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -97,4 +107,14 @@ mod tests {
assert!(json.contains("dump")); assert!(json.contains("dump"));
assert!(json.contains("completed")); assert!(json.contains("completed"));
} }
#[test]
fn pg_tool_status_reports_source() {
let s = PgToolStatus { pg_dump_found: true, pg_restore_found: true,
pg_dump_version: Some("pg_dump 16".into()), pg_restore_version: Some("pg_restore 16".into()),
pg_dump_source: Some("system".into()), pg_restore_source: Some("bundled".into()) };
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("\"pg_dump_source\":\"system\""));
assert!(json.contains("\"pg_restore_source\":\"bundled\""));
}
} }
+31
View File
@@ -132,6 +132,20 @@ pub struct ExtensionInfo {
pub comment: Option<String>, pub comment: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObjectSearchHit {
pub name: String,
pub schema: String,
pub object_type: String, // TABLE | VIEW | MATERIALIZED VIEW | FUNCTION | PROCEDURE | TRIGGER | SEQUENCE | ENUM | EXTENSION | INDEX | CONSTRAINT
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DependencyInfo {
pub deptype: String, // "n" (normal) | "a" (auto)
pub class: String, // pg_class | pg_proc | pg_trigger | pg_type | pg_constraint | pg_rewrite
pub name: String, // resolved dependent object name
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum Change { pub enum Change {
@@ -583,4 +597,21 @@ mod tests {
assert!(json.contains("users"), "should contain referenced table"); assert!(json.contains("users"), "should contain referenced table");
assert!(json.contains("id"), "should contain referenced column"); 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"));
}
} }
+3 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Gridline", "productName": "Gridline",
"version": "0.7.0", "version": "0.7.5",
"identifier": "com.adrianbonpin.gridline", "identifier": "com.adrianbonpin.gridline",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
@@ -26,10 +26,12 @@
"bundle": { "bundle": {
"active": true, "active": true,
"targets": "all", "targets": "all",
"resources": ["resources/pg_tools/*"],
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
"icons/128x128@2x.png", "icons/128x128@2x.png",
"icons/icon.png",
"icons/icon.icns", "icons/icon.icns",
"icons/icon.ico" "icons/icon.ico"
] ]
+1 -1
View File
@@ -58,7 +58,7 @@ export function BackupDialog({ open, connectionId, onClose }: BackupDialogProps)
setCheckingTools(true); setCheckingTools(true);
detectPgTools() detectPgTools()
.then((status) => setToolStatus(status)) .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)); .finally(() => setCheckingTools(false));
}, [open]); }, [open]);
@@ -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(<BackupPage connectionId="c1" />);
await waitFor(() => expect(screen.queryByText(/checking for pg_dump/i)).not.toBeInTheDocument());
expect(screen.queryByText(/brew install|apt install/i)).toBeNull();
});
});
+4 -1
View File
@@ -79,6 +79,8 @@ export function BackupPage({ connectionId }: BackupPageProps) {
pg_restore_found: false, pg_restore_found: false,
pg_dump_version: null, pg_dump_version: null,
pg_restore_version: null, pg_restore_version: null,
pg_dump_source: null,
pg_restore_source: null,
}), }),
) )
.finally(() => setCheckingTools(false)); .finally(() => setCheckingTools(false));
@@ -136,6 +138,7 @@ export function BackupPage({ connectionId }: BackupPageProps) {
}, [filePath, format, schema, noOwner, connectionId, startJob, notify]); }, [filePath, format, schema, noOwner, connectionId, startJob, notify]);
const toolsMissing = toolStatus && !toolStatus.pg_dump_found; const toolsMissing = toolStatus && !toolStatus.pg_dump_found;
const toolsBundled = toolStatus?.pg_dump_source === "bundled";
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
@@ -160,7 +163,7 @@ export function BackupPage({ connectionId }: BackupPageProps) {
</div> </div>
)} )}
{toolsMissing && ( {toolsMissing && !toolsBundled && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2"> <div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
<p className="text-amber-300 text-sm font-semibold"> <p className="text-amber-300 text-sm font-semibold">
pg_dump not found pg_dump not found
@@ -30,6 +30,7 @@ import { ToolsPage } from "./ToolsPage";
import { SchemaVisualizerPage } from "./SchemaVisualizerPage"; import { SchemaVisualizerPage } from "./SchemaVisualizerPage";
import { QueriesPanel } from "../queries/QueriesPanel"; import { QueriesPanel } from "../queries/QueriesPanel";
import { useQueryStore } from "../../stores/queryStore"; import { useQueryStore } from "../../stores/queryStore";
import { ObjectSearchPalette } from "./ObjectSearchPalette";
import * as cmd from "../../lib/commands"; import * as cmd from "../../lib/commands";
import type { EnumInfo } from "../../lib/types"; import type { EnumInfo } from "../../lib/types";
import type { FkOption } from "../grid/CellEditor"; import type { FkOption } from "../grid/CellEditor";
@@ -186,6 +187,8 @@ export function DbViewerScreen({
const toggleHiddenColumn = useDbViewerStore((s) => s.toggleHiddenColumn); const toggleHiddenColumn = useDbViewerStore((s) => s.toggleHiddenColumn);
const setSmartSortApplied = useDbViewerStore((s) => s.setSmartSortApplied); const setSmartSortApplied = useDbViewerStore((s) => s.setSmartSortApplied);
const setObjectSearchOpen = useDbViewerStore((s) => s.setObjectSearchOpen);
// Sync settings defaults to store // Sync settings defaults to store
useEffect(() => { useEffect(() => {
if (settings?.table_page_size) { if (settings?.table_page_size) {
@@ -423,6 +426,11 @@ export function DbViewerScreen({
onHome(); onHome();
} }
}); });
useShortcut("command_palette", () => {
if (capabilities.objects) {
setObjectSearchOpen(true);
}
});
useEffect(() => { useEffect(() => {
if (!activeTab) return; if (!activeTab) return;
if (activeTab.tabType !== "table") return; if (activeTab.tabType !== "table") return;
@@ -1455,6 +1463,9 @@ const onQueriesPanelResizeStart = useCallback(
onSaved={() => {}} onSaved={() => {}}
/> />
)} )}
{capabilities.objects && (
<ObjectSearchPalette connectionId={connectionId} />
)}
</div> </div>
</TooltipProvider> </TooltipProvider>
); );
@@ -12,6 +12,7 @@ import { SelectDropdown } from "../ui/SelectDropdown";
import { Tooltip } from "../ui/Tooltip"; import { Tooltip } from "../ui/Tooltip";
import { useDbViewerStore } from "../../stores/dbViewerStore"; import { useDbViewerStore } from "../../stores/dbViewerStore";
import * as cmd from "../../lib/commands"; import * as cmd from "../../lib/commands";
import { SchemaMenu } from "./SchemaMenu";
export function DbViewerToolbar({ export function DbViewerToolbar({
databases, databases,
@@ -227,6 +228,11 @@ export function DbViewerToolbar({
disabled={schemaTreeLoading} disabled={schemaTreeLoading}
/> />
)} )}
<SchemaMenu
connectionId={connectionId ?? ""}
schema={currentSchema ?? undefined}
onRefresh={handleRefresh}
/>
</div> </div>
)} )}
</div> </div>
@@ -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(<DependencyDialog open deps={[]} onProceed={() => {}} 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(<DependencyDialog open deps={deps} onProceed={() => {}} 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();
});
});
@@ -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 (
<AnimatedModal open={open} onClose={onCancel}>
<div className="w-[420px]">
<h3 className="font-heading text-text text-lg mb-3">Dependencies</h3>
{hasDeps ? (
<>
<p className="text-sm text-red-400 mb-2">
The following depend on this object and will be removed with CASCADE:
</p>
<ul className="max-h-48 overflow-auto my-2 space-y-1 pr-1">
{deps.map((d, i) => (
<li key={i} className="text-sm text-text">
{d.name}{" "}
<span className="text-text-subtle">({d.class})</span>
</li>
))}
</ul>
<label className="flex items-center gap-2 text-sm text-text-muted mt-3 cursor-pointer">
<input
type="checkbox"
checked={ack}
onChange={(e) => setAck(e.target.checked)}
className="accent-accent h-4 w-4"
/>
I understand these will be dropped.
</label>
</>
) : (
<p className="text-sm text-text-muted">No dependencies safe to drop.</p>
)}
<div className="flex justify-end gap-2 mt-5">
<Button variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button
variant="primary"
onClick={onProceed}
disabled={hasDeps && !ack}
>
Proceed
</Button>
</div>
</div>
</AnimatedModal>
);
}
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; 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 userEvent from "@testing-library/user-event";
import { ObjectExplorerPage } from "./ObjectExplorerPage"; import { ObjectExplorerPage } from "./ObjectExplorerPage";
import { useDbViewerStore } from "../../stores/dbViewerStore"; import { useDbViewerStore } from "../../stores/dbViewerStore";
@@ -189,4 +189,57 @@ describe("ObjectExplorerPage", () => {
); );
expect(screen.queryByText("calc")).not.toBeInTheDocument(); 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(<ObjectExplorerPage connectionId="c1" />);
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(<ObjectExplorerPage connectionId="c1" />);
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(<ObjectExplorerPage connectionId="c1" />);
expect(screen.getByText("Sequences")).toBeTruthy();
});
}); });
+118 -12
View File
@@ -6,6 +6,7 @@ import {
GitBranch, GitBranch,
ListChecks, ListChecks,
ListOrdered, ListOrdered,
MoreVertical,
SquareFunction, SquareFunction,
Tag, Tag,
Puzzle, Puzzle,
@@ -15,6 +16,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore"; import { useDbViewerStore } from "../../stores/dbViewerStore";
import { SelectDropdown } from "../ui/SelectDropdown"; import { SelectDropdown } from "../ui/SelectDropdown";
import { DependencyDialog } from "./DependencyDialog";
import * as cmd from "../../lib/commands"; import * as cmd from "../../lib/commands";
import type { import type {
FunctionInfo, FunctionInfo,
@@ -24,17 +26,11 @@ import type {
ExtensionInfo, ExtensionInfo,
IndexInfo, IndexInfo,
ConstraintInfo, ConstraintInfo,
ObjectType,
DependencyInfo,
} from "../../lib/types"; } from "../../lib/types";
export type ObjectType =
| "functions"
| "triggers"
| "sequences"
| "enums"
| "extensions"
| "indexes"
| "constraints"
| "procedures";
interface ObjectExplorerPageProps { interface ObjectExplorerPageProps {
connectionId: string; connectionId: string;
@@ -120,6 +116,28 @@ function itemLabel(item: AnyObject): string {
return name; 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<ObjectType, string> = {
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 ────────────── // ─── syntax highlighting for PL/pgSQL / SQL ──────────────
const SQL_KEYWORDS = new Set([ const SQL_KEYWORDS = new Set([
@@ -1020,7 +1038,8 @@ function renderDetail(type: ObjectType, item: AnyObject) {
} }
export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) { export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
const [type, setType] = useState<ObjectType>("functions"); const selectedObjectType = useDbViewerStore((s) => s.selectedObjectType);
const [type, setType] = useState<ObjectType>(selectedObjectType ?? "functions");
const [panelWidth, setPanelWidth] = useState(280); const [panelWidth, setPanelWidth] = useState(280);
const panelResizeRef = useRef<{ startX: number; startW: number } | null>( const panelResizeRef = useRef<{ startX: number; startW: number } | null>(
null, null,
@@ -1061,6 +1080,9 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedItem, setSelectedItem] = useState<AnyObject | null>(null); const [selectedItem, setSelectedItem] = useState<AnyObject | null>(null);
const [openKey, setOpenKey] = useState<string | null>(null);
const [depOpen, setDepOpen] = useState(false);
const [depDeps, setDepDeps] = useState<DependencyInfo[]>([]);
const [searchOpen, setSearchOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
@@ -1173,14 +1195,25 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
// Switching object type: reset selection/search, clear the stale list so // Switching object type: reset selection/search, clear the stale list so
// the loading state renders (no flash of the previous type's objects), and // 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. // reset the last-fetched-schema marker so the fetch effect re-runs.
const handleTypeChange = (next: ObjectType) => { const handleTypeChange = useCallback((next: ObjectType) => {
setType(next); setType(next);
setSearchQuery(""); setSearchQuery("");
setSelectedItem(null); setSelectedItem(null);
setOpenKey(null);
setItems(null); setItems(null);
setLoading(true); setLoading(true);
lastSchemaRef.current = undefined; 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 ( return (
<div className="flex flex-1 min-h-0 overflow-hidden"> <div className="flex flex-1 min-h-0 overflow-hidden">
@@ -1326,6 +1359,34 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
const isSelected = const isSelected =
selectedItem !== null && selectedItem !== null &&
itemKey(selectedItem) === itemKey(item); 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 ( return (
<div <div
@@ -1341,6 +1402,44 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
<span className="flex-1 text-sm truncate"> <span className="flex-1 text-sm truncate">
{name} {name}
</span> </span>
<div
onClick={(e) => e.stopPropagation()}
className="relative"
>
<button
aria-label="options"
onClick={() =>
setOpenKey((k) =>
k === key ? null : key,
)
}
className={`w-6 h-6 rounded flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer transition-opacity ${
menuOpen
? "opacity-100"
: "opacity-0 group-hover:opacity-100"
}`}
>
<MoreVertical size={14} />
</button>
{menuOpen && (
<div className="absolute right-0 mt-1 z-20 min-w-[140px] rounded-md bg-surface border border-border shadow-lg py-1">
<button
type="button"
onClick={handleCopyDdl}
className="w-full text-left px-3 py-1.5 text-xs text-text hover:bg-surface-raised cursor-pointer"
>
Copy DDL
</button>
<button
type="button"
onClick={handleViewDependencies}
className="w-full text-left px-3 py-1.5 text-xs text-text hover:bg-surface-raised cursor-pointer"
>
Dependencies
</button>
</div>
)}
</div>
<ChevronRight <ChevronRight
size={14} size={14}
className={`text-text-muted shrink-0 transition-transform ${ className={`text-text-muted shrink-0 transition-transform ${
@@ -1399,6 +1498,13 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
</div> </div>
)} )}
</div> </div>
<DependencyDialog
open={depOpen}
deps={depDeps}
onProceed={() => setDepOpen(false)}
onCancel={() => setDepOpen(false)}
/>
</div> </div>
); );
} }
@@ -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(<ObjectSearchPalette connectionId="c1" />);
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(<ObjectSearchPalette connectionId="c1" />);
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(<ObjectSearchPalette connectionId="c1" />);
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(<ObjectSearchPalette connectionId="c1" />);
fireEvent.keyDown(window, { key: "Escape" });
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
});
it("backdrop click closes the palette", () => {
render(<ObjectSearchPalette connectionId="c1" />);
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(<ObjectSearchPalette connectionId="c1" />);
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(<ObjectSearchPalette connectionId="c1" />);
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(<ObjectSearchPalette connectionId="c1" />);
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(<ObjectSearchPalette connectionId="c1" />);
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(<ObjectSearchPalette connectionId="c1" />);
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(<ObjectSearchPalette connectionId="c1" />);
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
target: { value: "users" },
});
await waitFor(() => screen.getByText("users"));
mockState = { ...baseMockState, objectSearchOpen: false };
rerender(<ObjectSearchPalette connectionId="c1" />);
mockState = { ...baseMockState, objectSearchOpen: true };
rerender(<ObjectSearchPalette connectionId="c1" />);
expect(screen.queryByText("users")).not.toBeInTheDocument();
expect(screen.getByPlaceholderText(/search objects/i)).toHaveValue("");
});
});
@@ -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<string, ObjectType> = {
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<ObjectSearchHit[]>([]);
const [loading, setLoading] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | 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<Record<string, ObjectSearchHit[]>>((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 (
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 pt-24"
onClick={() => setOpen(false)}
role="dialog"
aria-label="Search objects"
>
<div
className="w-[520px] max-h-[60vh] overflow-auto rounded-xl border border-border bg-surface shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<Search size={14} className="text-text-muted" />
<input
autoFocus
type="text"
value={query}
onChange={(e) => 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 && (
<span className="text-xs text-text-muted">loading</span>
)}
</div>
{Object.entries(grouped).map(([type, list]) => (
<div key={type}>
<div className="px-3 py-1 text-[10px] uppercase text-text-subtle">
{type}
</div>
{list.map((hit) => (
<button
key={`${hit.object_type}:${hit.schema}:${hit.name}`}
type="button"
onClick={() => handleSelect(hit)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-text hover:bg-surface-raised"
>
<span className="truncate">{hit.name}</span>
<span className="text-[10px] text-text-subtle">
{hit.schema}
</span>
</button>
))}
</div>
))}
{!loading && query.trim() && hits.length === 0 && (
<div className="px-3 py-4 text-sm text-text-muted">No matches</div>
)}
</div>
</div>
);
}
+1 -1
View File
@@ -50,7 +50,7 @@ export function RestoreDialog({ open, connectionId, onClose }: RestoreDialogProp
setConfirmed(false); setConfirmed(false);
detectPgTools() detectPgTools()
.then((status) => setToolStatus(status)) .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)); .finally(() => setCheckingTools(false));
}, [open]); }, [open]);
+4 -1
View File
@@ -76,6 +76,8 @@ export function RestorePage({ connectionId }: RestorePageProps) {
pg_restore_found: false, pg_restore_found: false,
pg_dump_version: null, pg_dump_version: null,
pg_restore_version: null, pg_restore_version: null,
pg_dump_source: null,
pg_restore_source: null,
}), }),
) )
.finally(() => setCheckingTools(false)); .finally(() => setCheckingTools(false));
@@ -121,6 +123,7 @@ export function RestorePage({ connectionId }: RestorePageProps) {
}, [filePath, format, clean, schema, connectionId, startJob, notify]); }, [filePath, format, clean, schema, connectionId, startJob, notify]);
const toolsMissing = toolStatus && !toolStatus.pg_restore_found; const toolsMissing = toolStatus && !toolStatus.pg_restore_found;
const toolsBundled = toolStatus?.pg_restore_source === "bundled";
const canStart = filePath && confirmed && !isRunning; const canStart = filePath && confirmed && !isRunning;
return ( return (
@@ -146,7 +149,7 @@ export function RestorePage({ connectionId }: RestorePageProps) {
</div> </div>
)} )}
{toolsMissing && ( {toolsMissing && !toolsBundled && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2"> <div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
<p className="text-amber-300 text-sm font-semibold"> <p className="text-amber-300 text-sm font-semibold">
pg_restore not found pg_restore not found
@@ -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(<SchemaMenu connectionId="c1" onRefresh={refresh} />);
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(<SchemaMenu connectionId="c1" schema="s" onRefresh={refresh} />);
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());
});
});
+295
View File
@@ -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<DependencyInfo[]>([]);
const [typed, setTyped] = useState("");
const [err, setErr] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement>(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 (
<div className="flex items-center gap-1" ref={menuRef}>
<button
aria-label="New schema"
onClick={() => {
setCreating(true);
setName("");
setErr(null);
}}
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
>
<Plus size={14} />
</button>
{schema && (
<button
aria-label="Schema menu"
onClick={() => setMenuOpen((v) => !v)}
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
>
<MoreVertical size={14} />
</button>
)}
{menuOpen && (
<div className="absolute right-0 top-8 mt-1 rounded-xl bg-surface border border-border py-1 z-20 min-w-[160px] shadow-lg">
<button
type="button"
onClick={() => {
setMenuOpen(false);
setRenaming(true);
setNewName("");
setErr(null);
}}
className="flex items-center px-3 py-2 text-sm w-full text-left transition-colors cursor-pointer text-text-muted hover:text-text hover:bg-surface-raised"
>
Rename
</button>
<button
type="button"
onClick={startDrop}
className="flex items-center px-3 py-2 text-sm w-full text-left transition-colors cursor-pointer text-red-400 hover:bg-red-500/10 hover:text-red-300"
>
Drop
</button>
</div>
)}
{/* New Schema */}
<AnimatedModal open={creating} onClose={() => setCreating(false)}>
<div className="w-80">
<h3 className="font-heading text-text text-lg mb-3">New Schema</h3>
<p className="text-sm text-text-muted mb-3">Create a new schema in the current database.</p>
<input
placeholder="Schema name"
value={name}
onChange={(e) => setName(e.target.value)}
className={inputClass}
/>
{err && <p className="text-red-400 text-xs mt-2">{err}</p>}
<div className="flex justify-end gap-2 mt-5">
<Button variant="ghost" onClick={() => setCreating(false)}>
Cancel
</Button>
<Button variant="primary" onClick={create}>
Create
</Button>
</div>
</div>
</AnimatedModal>
{/* Rename Schema */}
<AnimatedModal open={renaming} onClose={() => setRenaming(false)}>
<div className="w-80">
<h3 className="font-heading text-text text-lg mb-3">Rename {schema}</h3>
<p className="text-sm text-text-muted mb-3">Enter the new name for this schema.</p>
<input
placeholder="New name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className={inputClass}
/>
{err && <p className="text-red-400 text-xs mt-2">{err}</p>}
<div className="flex justify-end gap-2 mt-5">
<Button variant="ghost" onClick={() => setRenaming(false)}>
Cancel
</Button>
<Button variant="primary" onClick={rename}>
Rename
</Button>
</div>
</div>
</AnimatedModal>
{/* Dependency warning */}
{dropOpen && (
<DependencyDialog
open={dropOpen}
deps={deps}
onCancel={() => {
setDropOpen(false);
setTyped("");
setDeps([]);
setErr(null);
}}
onProceed={() => {}}
/>
)}
{/* Typed-name confirmation for CASCADE drop */}
{dropOpen && hasDeps && (
<AnimatedModal
open={dropOpen}
onClose={() => {
setDropOpen(false);
setTyped("");
setErr(null);
}}
>
<div className="w-80">
<h3 className="font-heading text-text text-lg mb-3">Drop Schema: {schema}</h3>
<p className="text-sm text-text-muted mb-3">
Type the schema name to confirm the CASCADE drop.
</p>
<input
placeholder="Type the schema name"
value={typed}
onChange={(e) => {
setTyped(e.target.value);
if (err) setErr(null);
}}
className={inputClass}
/>
{err && <p className="text-red-400 text-xs mt-2">{err}</p>}
<div className="flex justify-end gap-2 mt-5">
<Button
variant="ghost"
onClick={() => {
setDropOpen(false);
setTyped("");
setErr(null);
}}
>
Cancel
</Button>
<Button
variant="primary"
onClick={() => {
if (typed !== schema) {
setErr("Name does not match");
return;
}
void confirmDrop(true);
}}
>
Drop Schema
</Button>
</div>
</div>
</AnimatedModal>
)}
{/* Empty-schema confirmation (no deps) */}
{dropOpen && !hasDeps && (
<AnimatedModal
open={dropOpen}
onClose={() => {
setDropOpen(false);
setErr(null);
}}
>
<div className="w-80">
<h3 className="font-heading text-text text-lg mb-3">Drop Schema: {schema}</h3>
<p className="text-sm text-text-muted mb-3">No dependencies drop this empty schema?</p>
{err && <p className="text-red-400 text-xs mt-2">{err}</p>}
<div className="flex justify-end gap-2 mt-5">
<Button
variant="ghost"
onClick={() => {
setDropOpen(false);
setErr(null);
}}
>
Cancel
</Button>
<Button variant="primary" onClick={() => void confirmDrop(false)}>
Drop Schema
</Button>
</div>
</div>
</AnimatedModal>
)}
</div>
);
}
+1 -1
View File
@@ -36,7 +36,7 @@ export function SyncDialog({ open, onClose }: SyncDialogProps) {
setConfirmed(false); setConfirmed(false);
detectPgTools() detectPgTools()
.then((status) => setToolStatus(status)) .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)); .finally(() => setCheckingTools(false));
}, [open]); }, [open]);
+6 -1
View File
@@ -54,6 +54,8 @@ export function SyncPage() {
pg_restore_found: false, pg_restore_found: false,
pg_dump_version: null, pg_dump_version: null,
pg_restore_version: null, pg_restore_version: null,
pg_dump_source: null,
pg_restore_source: null,
}), }),
) )
.finally(() => setCheckingTools(false)); .finally(() => setCheckingTools(false));
@@ -100,6 +102,9 @@ export function SyncPage() {
const toolsMissing = const toolsMissing =
toolStatus && toolStatus &&
(!toolStatus.pg_dump_found || !toolStatus.pg_restore_found); (!toolStatus.pg_dump_found || !toolStatus.pg_restore_found);
const toolsBundled =
toolStatus?.pg_dump_source === "bundled" &&
toolStatus?.pg_restore_source === "bundled";
const canStart = const canStart =
sourceConnectionId && targetConnectionId && confirmed && !isRunning; sourceConnectionId && targetConnectionId && confirmed && !isRunning;
@@ -130,7 +135,7 @@ export function SyncPage() {
</div> </div>
)} )}
{toolsMissing && ( {toolsMissing && !toolsBundled && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2"> <div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
<p className="text-amber-300 text-sm font-semibold"> <p className="text-amber-300 text-sm font-semibold">
PostgreSQL tools not found PostgreSQL tools not found
@@ -63,9 +63,11 @@ describe("TableOverflowMenu", () => {
}); });
it("Delete Table opens confirm then stages a drop_table change", async () => { it("Delete Table opens confirm then stages a drop_table change", async () => {
vi.spyOn(commands, "getObjectDependencies").mockResolvedValue([]);
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} />); render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} />);
fireEvent.click(screen.getByLabelText(/table options/i)); fireEvent.click(screen.getByLabelText(/table options/i));
fireEvent.click(screen.getByText(/delete table/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 })); fireEvent.click(screen.getByRole("button", { name: /delete table/i }));
await waitFor(() => { await waitFor(() => {
const q = useDbViewerStore.getState().changesQueue; 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(<TableOverflowMenu schema="public" table="orders" connectionId="c1" onOpenTab={() => "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 () => { it("Export data calls exportData when rows and columns are provided", async () => {
const spy = vi.spyOn(exportData, "exportData"); 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 }]; 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 }];
+21 -2
View File
@@ -6,7 +6,8 @@ import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useUiStore } from "../../stores/uiStore"; import { useUiStore } from "../../stores/uiStore";
import { exportData } from "../../lib/exportData"; import { exportData } from "../../lib/exportData";
import * as cmd from "../../lib/commands"; 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 { interface TableOverflowMenuProps {
schema: string; schema: string;
@@ -37,6 +38,7 @@ export function TableOverflowMenu({
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [confirmAction, setConfirmAction] = useState<"empty" | "delete" | null>(null); const [confirmAction, setConfirmAction] = useState<"empty" | "delete" | null>(null);
const [dropDeps, setDropDeps] = useState<DependencyInfo[]>([]);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@@ -98,10 +100,18 @@ export function TableOverflowMenu({
setConfirmAction("empty"); setConfirmAction("empty");
setOpen(false); setOpen(false);
break; break;
case "delete": case "delete": {
if (!connectionId) break;
try {
const deps = await cmd.getObjectDependencies(connectionId, schema, "table", table);
setDropDeps(deps);
} catch {
setDropDeps([]);
}
setConfirmAction("delete"); setConfirmAction("delete");
setOpen(false); setOpen(false);
break; break;
}
default: default:
break; break;
} }
@@ -146,6 +156,15 @@ export function TableOverflowMenu({
</div> </div>
)} )}
{confirmAction === "delete" && dropDeps.length > 0 && (
<DependencyDialog
open
deps={dropDeps}
onProceed={() => setConfirmAction(null)}
onCancel={() => setConfirmAction(null)}
/>
)}
{confirmAction === "empty" && ( {confirmAction === "empty" && (
<ConfirmDialog <ConfirmDialog
open open
+11
View File
@@ -0,0 +1,11 @@
import { describe, it, expect } from "vitest";
import tauriConf from "../../src-tauri/tauri.conf.json";
describe("tauri bundle config (v0.7.5)", () => {
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");
});
});
+41
View File
@@ -30,6 +30,12 @@ import {
clearRecentConnections, clearRecentConnections,
getIndexes, getIndexes,
getConstraints, getConstraints,
createSchema,
renameSchema,
dropSchema,
searchObjects,
getObjectDdl,
getObjectDependencies,
} from "./commands"; } from "./commands";
import type { SchemaGraph } from "./types"; import type { SchemaGraph } from "./types";
import type { QueryHistoryEntry } from "./commands"; import type { QueryHistoryEntry } from "./commands";
@@ -291,4 +297,39 @@ describe("v0.5.0 command wrappers", () => {
await getConstraints("c1", "public"); await getConstraints("c1", "public");
expect(mockInvoke).toHaveBeenCalledWith("get_constraints", { connectionId: "c1", schema: "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" });
});
}); });
+22 -1
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core"; 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 { FilterRule, SortRule } from "../stores/dbViewerStore";
import type { ChangePayload } from "./changePayload"; 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<ConstraintInfo[]> { export async function getConstraints(connectionId: string, schema?: string): Promise<ConstraintInfo[]> {
return invoke<ConstraintInfo[]>("get_constraints", { connectionId, schema }); return invoke<ConstraintInfo[]>("get_constraints", { connectionId, schema });
}
// ─── v0.7.5: Object management (schemas, search, DDL, dependencies) ──
export async function createSchema(connectionId: string, name: string): Promise<void> {
return invoke<void>("create_schema", { connectionId, name });
}
export async function renameSchema(connectionId: string, oldName: string, newName: string): Promise<void> {
return invoke<void>("rename_schema", { connectionId, oldName, newName });
}
export async function dropSchema(connectionId: string, name: string, cascade: boolean): Promise<void> {
return invoke<void>("drop_schema", { connectionId, name, cascade });
}
export async function searchObjects(connectionId: string, schema: string, query: string): Promise<ObjectSearchHit[]> {
return invoke<ObjectSearchHit[]>("search_objects", { connectionId, schema, query });
}
export async function getObjectDdl(connectionId: string, schema: string, objectType: string, name: string): Promise<string> {
return invoke<string>("get_object_ddl", { connectionId, schema, objectType, name });
}
export async function getObjectDependencies(connectionId: string, schema: string, objectType: string, name: string): Promise<DependencyInfo[]> {
return invoke<DependencyInfo[]>("get_object_dependencies", { connectionId, schema, objectType, name });
} }
+7
View File
@@ -50,4 +50,11 @@ describe("dbCapabilities", () => {
expect(getCapabilities("postgresql")).toBe(DB_CAPABILITIES.postgresql); expect(getCapabilities("postgresql")).toBe(DB_CAPABILITIES.postgresql);
expect(getCapabilities("redis")).toBe(DB_CAPABILITIES.redis); 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);
});
}); });
+18 -3
View File
@@ -4,7 +4,7 @@ import { describe, it, expect } from "vitest";
import agents from "../../AGENTS.md?raw"; import agents from "../../AGENTS.md?raw";
import readme from "../../README.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", () => { it("AGENTS.md marks inline cell editing complete", () => {
expect(agents).toContain("Inline cell editing"); expect(agents).toContain("Inline cell editing");
expect(agents).toMatch(/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(/Connection status indicator on cards \| ✅/);
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/); expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
}); });
it("README declares v0.7.0", () => { it("README declares v0.7.5", () => {
expect(readme).toContain("0.7.0"); 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)", () => { it("README marks inline editing complete (not Upcoming)", () => {
// Key Features lists inline editing as a shipped feature // Key Features lists inline editing as a shipped feature
+28
View File
@@ -305,6 +305,34 @@ export interface PgToolStatus {
pg_restore_found: boolean; pg_restore_found: boolean;
pg_dump_version: string | null; pg_dump_version: string | null;
pg_restore_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 { export interface BackupJob {
+2 -2
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import pkg from "../../package.json"; import pkg from "../../package.json";
describe("version", () => { describe("version", () => {
it("declares v0.7.0 across the app shell", () => { it("declares v0.7.5 across the app shell", () => {
expect(pkg.version).toBe("0.7.0"); expect(pkg.version).toBe("0.7.5");
}); });
}); });
+13
View File
@@ -24,9 +24,22 @@ describe("dbViewerStore", () => {
expect(state.tables).toEqual([]); expect(state.tables).toEqual([]);
expect(state.currentDatabase).toBeNull(); expect(state.currentDatabase).toBeNull();
expect(state.currentSchema).toBeNull(); expect(state.currentSchema).toBeNull();
expect(state.objectSearchOpen).toBe(false);
expect(state.selectedObjectType).toBeNull();
expect(state.schemaTreeLoading).toBe(false); 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", () => { it("openTab adds a new tab", () => {
const store = useDbViewerStore.getState(); const store = useDbViewerStore.getState();
store.openTab("public", "users"); store.openTab("public", "users");
+9 -1
View File
@@ -1,5 +1,5 @@
import { create } from "zustand"; 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"; import { getDatabases, getSchemas, getTables } from "../lib/commands";
// ─── Local types ──────────────────────────────────────────────── // ─── Local types ────────────────────────────────────────────────
@@ -92,6 +92,8 @@ interface DbViewerState {
schemaTreeLoading: boolean; schemaTreeLoading: boolean;
currentDatabase: string | null; currentDatabase: string | null;
currentSchema: string | null; currentSchema: string | null;
objectSearchOpen: boolean;
selectedObjectType: ObjectType | null;
functions: FunctionInfo[] | null; functions: FunctionInfo[] | null;
triggers: TriggerInfo[] | null; triggers: TriggerInfo[] | null;
sequences: SequenceInfo[] | null; sequences: SequenceInfo[] | null;
@@ -141,6 +143,8 @@ interface DbViewerState {
toggleChangesPanel: () => void; toggleChangesPanel: () => void;
setCurrentDatabase: (db: string | null) => void; setCurrentDatabase: (db: string | null) => void;
setCurrentSchema: (schema: string | null) => void; setCurrentSchema: (schema: string | null) => void;
setObjectSearchOpen: (open: boolean) => void;
setSelectedObjectType: (t: ObjectType | null) => void;
setFunctions: (functions: FunctionInfo[]) => void; setFunctions: (functions: FunctionInfo[]) => void;
setTriggers: (triggers: TriggerInfo[]) => void; setTriggers: (triggers: TriggerInfo[]) => void;
setSequences: (sequences: SequenceInfo[]) => void; setSequences: (sequences: SequenceInfo[]) => void;
@@ -179,6 +183,8 @@ const initialState = {
tables: [] as TableInfo[], tables: [] as TableInfo[],
currentDatabase: null as string | null, currentDatabase: null as string | null,
currentSchema: null as string | null, currentSchema: null as string | null,
objectSearchOpen: false,
selectedObjectType: null as ObjectType | null,
functions: null as FunctionInfo[] | null, functions: null as FunctionInfo[] | null,
triggers: null as TriggerInfo[] | null, triggers: null as TriggerInfo[] | null,
sequences: null as SequenceInfo[] | null, sequences: null as SequenceInfo[] | null,
@@ -433,6 +439,8 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
setCurrentDatabase: (db) => set({ currentDatabase: db }), setCurrentDatabase: (db) => set({ currentDatabase: db }),
setCurrentSchema: (schema) => set({ currentSchema: schema }), setCurrentSchema: (schema) => set({ currentSchema: schema }),
setObjectSearchOpen: (open) => set({ objectSearchOpen: open }),
setSelectedObjectType: (t) => set({ selectedObjectType: t }),
setFunctions: (functions) => set({ functions }), setFunctions: (functions) => set({ functions }),
setTriggers: (triggers) => set({ triggers }), setTriggers: (triggers) => set({ triggers }),
setSequences: (sequences) => set({ sequences }), setSequences: (sequences) => set({ sequences }),