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
+53
View File
@@ -63,6 +63,53 @@ jobs:
- name: Install frontend dependencies
run: bun install --frozen-lockfile
- name: Build PostgreSQL client tools (bundled)
shell: bash
run: |
set -euo pipefail
PG_VER="16.4"
OUT="src-tauri/resources/pg_tools"
mkdir -p "$OUT"
case "${{ matrix.platform }}" in
ubuntu-22.04)
sudo apt-get update -y
sudo apt-get install -y build-essential libreadline-dev zlib1g-dev flex bison
curl -fsSL "https://ftp.postgresql.org/pub/source/v${PG_VER}/postgresql-${PG_VER}.tar.bz2" -o /tmp/pg.tar.bz2
tar -xf /tmp/pg.tar.bz2 -C /tmp
cd /tmp/postgresql-${PG_VER}
./configure --prefix=/tmp/pgbuild --without-readline --without-icu --disable-shared CFLAGS="-O2"
make -j"$(nproc)" -C src/bin/pg_dump all
make -j"$(nproc)" -C src/bin/psql all
cp src/bin/pg_dump/pg_dump src/bin/pg_dump/pg_restore "$OUT"/
cp src/bin/psql/psql "$OUT"/
;;
macos-latest|macos-15-intel)
curl -fsSL "https://ftp.postgresql.org/pub/source/v${PG_VER}/postgresql-${PG_VER}.tar.bz2" -o /tmp/pg.tar.bz2
tar -xf /tmp/pg.tar.bz2 -C /tmp
cd /tmp/postgresql-${PG_VER}
./configure --prefix=/tmp/pgbuild --without-readline --without-icu --disable-shared CFLAGS="-O2"
make -j"$(sysctl -n hw.ncpu)" -C src/bin/pg_dump all
make -j"$(sysctl -n hw.ncpu)" -C src/bin/psql all
cp src/bin/pg_dump/pg_dump src/bin/pg_dump/pg_restore "$OUT"/
cp src/bin/psql/psql "$OUT"/
;;
windows-latest)
URL="https://get.enterprisedb.com/postgresql/postgresql-${PG_VER}-1-windows-x64-binaries.zip"
curl -fsSL "$URL" -o /tmp/pg.zip
EXPECTED="3508d8f085bc3980f38211a82e3f31e5fcae9952105d3dc2f8be67b64a822baa"
echo "$EXPECTED /tmp/pg.zip" | sha256sum -c -
sha256sum /tmp/pg.zip
unzip -o /tmp/pg.zip -d /tmp/pg
cp /tmp/pg/pgsql/bin/pg_dump.exe /tmp/pg/pgsql/bin/pg_restore.exe /tmp/pg/pgsql/bin/psql.exe "$OUT"/
cp /tmp/pg/pgsql/bin/libpq.dll "$OUT"/
;;
esac
(cd "$OUT" && sha256sum * | tee checksums.txt)
for b in pg_dump pg_restore psql; do
f="$OUT/${b}$( [ "${{ matrix.platform }}" = windows-latest ] && echo .exe )"
test -f "$f" || { echo "missing $f"; exit 1; }
done
- name: Build and upload to GitHub Release
uses: tauri-apps/tauri-action@v0
env:
@@ -72,3 +119,9 @@ jobs:
releaseName: 'Gridline ${{ github.ref_name }}'
releaseDraft: true
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:
- Version number across `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json`
- `src/lib/version.test.ts` and `src/lib/docs-coverage.test.ts` if they assert the version
- **Both README download tables** — the top **Download** section and the **Which file should I download?** section in Getting Started — they **hardcode** the current version in the asset filenames + direct `releases/download/...` links and must be bumped to the new version
- **README download links stay version-free** — both download tables (top **Download** section + **Which file should I download?**) link via GitHub's `releases/latest/download/<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
@@ -180,7 +181,7 @@ Cut a release from the **`prod`** branch (never feature branches) by tagging it
## Constraints & Guardrails
- **Do NOT** implement `pg_dump` file format parsing — always shell out to system binaries
- **Do NOT** implement `pg_dump` file format parsing — always shell out to (bundled or system) binaries
- **Do NOT** store passwords in SQLite or local files — use OS keychain APIs exclusively
- **Do NOT** render large query results in raw DOM — always use the virtualized grid component
- **Do NOT** log credentials, connection strings, or query data
@@ -285,6 +286,10 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| Constraints (CHECK, UNIQUE beyond PK/FK) | ✅ | CHECK/UNIQUE constraints beyond PK/FK, introspected via information_schema |
| Materialized views | ✅ | Distinct icon in table tree, browsable, read-only (via pg_matviews) |
| Stored procedures | ✅ | Procedures object type filters prokind='p'; Functions now filters kind='f' |
| Schema CRUD | ✅ | Create / rename / drop schemas from the object tree; CASCADE drop with typed-name confirm + dependency warning (v0.7.5) |
| Global object search | ✅ | Cmd+K palette in the DB viewer, current-schema scope, all object types; results open a table tab or jump to the Objects view (v0.7.5) |
| Copy as DDL for any object | ✅ | `CREATE` DDL for every browsable type (tables via pg_dump; pg_get_*def passthrough; sequences/enums/extensions/views synthesized) (v0.7.5) |
| Object dependencies | ✅ | `pg_depend` "what depends on this?" view, shown before destructive drops (table drop, schema drop) (v0.7.5) |
| Schema visualizer (ER diagram) | ✅ | Full React Flow ER diagram with dagre auto-layout, crow's foot notation, schema selector, legend with cardinality colors, collapsible columns (PK/FK/unique-only), cross-schema FK support. PostgreSQL (single round-trip LATERAL query) + SQLite (PRAGMA). Uses @xyflow/react + dagre. |
### Query Editor
@@ -310,6 +315,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| Restore UI | ✅ | In-page view: file browse, format, clean toggle, destructive confirmation checkbox, progress bar. **Clean toggle is disabled for plain format** (psql can't DROP-before-CREATE) with a hint to use Custom Archive |
| DB-to-DB sync | ✅ | In-page view: source/target connection pickers, schema dropdown, pipe-based pg_dump → pg_restore. **pg_restore side passes `--clean --if-exists`**, so sync works into a non-empty target (UI already requires destructive-overwrite confirmation). Core logic in headless-testable `run_db_sync` |
| Unified Tools view | ✅ | Backup / Restore / DB Sync merged into a single **Tools** nav item; operation-switcher dropdown in the view toolbar, existing forms rendered below |
| Bundled PostgreSQL client tools | ✅ | Static pg_dump/pg_restore/psql shipped as Tauri resources; system-first, bundled-fallback resolution via resource_dir (v0.7.5) |
| SQLite .dump | ❌ | |
| Table structure export (DDL) | ❌ | |
+36 -29
View File
@@ -31,25 +31,26 @@
## Download
Grab the installer for your OS from the latest release (v0.7.0):
Grab the installer for your OS from the [latest release](https://github.com/AdrianBonpin/gridline/releases/latest) — links always point to the newest build:
| OS | Download |
| :--- | :--- |
| **macOS** · Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.0_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.0/Gridline_0.7.0_aarch64.dmg) |
| **macOS** · Intel | [Gridline_0.7.0_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.0/Gridline_0.7.0_x64.dmg) |
| **Windows** | [Gridline_0.7.0_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.0/Gridline_0.7.0_x64-setup.exe) |
| **Debian / Ubuntu** | [Gridline_0.7.0_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.0/Gridline_0.7.0_amd64.deb) |
| **Fedora / RHEL / openSUSE** | [Gridline-0.7.0-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.0/Gridline-0.7.0-1.x86_64.rpm) |
| **Other Linux** | [Gridline_0.7.0_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.0/Gridline_0.7.0_amd64.AppImage) |
| OS | Architecture | Download |
| :--- | :--- | :--- |
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_darwin_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_darwin_aarch64.dmg) |
| **macOS** | Intel | [Gridline_darwin_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_darwin_x64.dmg) |
| **Windows** | x64 | [Gridline_windows_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_windows_x64-setup.exe) |
| **Debian / Ubuntu** | amd64 | [Gridline_linux_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_linux_amd64.deb) |
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline_linux_x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_linux_x86_64.rpm) |
| **Other Linux** | amd64 | [Gridline_linux_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_linux_amd64.AppImage) |
> Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below. All installers are **unsigned** — see the [notes](#which-file-should-i-download) on first-launch warnings.
<!--
MAINTENANCE: The two download tables — the top "Download" section and the
"Which file should I download?" section in Getting Started — HARDCODE the
current version (v0.7.0) in the asset filenames and direct download links.
When cutting a new release, update BOTH tables to the new version BEFORE
tagging. See AGENTS.md → Development Workflow → Releases.
MAINTENANCE: The download links use GitHub's releases/latest/download/<file>
redirect, so they always point at the newest published release. This only
works because release.yml sets releaseAssetNamePattern to a version-free
pattern ([name]_[platform]_[arch][setup][ext]). If the asset naming ever
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
- **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-07-XX:** Added inline cell editing with a stage-first changes queue, row-detail drawer, keyboard navigation, and cell-level copy.
- **2026-07-XX:** Added visual filter builder with drag-and-drop column palette and type-aware operators.
@@ -112,6 +114,10 @@ Gridline is built for developers and small teams who manage multiple database en
- **Functions & procedures** — syntax-highlighted source, argument signatures, overload disambiguation.
- **Triggers, sequences, enums, extensions** — unified **Objects** view with type switcher.
- **Indexes & constraints** — per-table index details plus CHECK/UNIQUE constraints beyond PK/FK.
- **Schema CRUD** — create/rename/drop schemas from the object tree, with typed-name + dependency warning on CASCADE drops.
- **Global object search** — ⌘K, current schema, all object types; results open a table tab or jump to the Objects view.
- **Copy as DDL** — `CREATE` DDL for every browsable object type.
- **Object dependencies** — `pg_depend` "what depends on this?" view before destructive drops.
- **Schema visualizer** — interactive ER diagram with auto-layout, cardinality legend, and collapsible columns.
### Data Grid
@@ -140,6 +146,7 @@ Gridline is built for developers and small teams who manage multiple database en
- **Visual Backup** — `pg_dump` wrapper with format selector, schema filter, no-owner toggle, real-time progress.
- **Visual Restore** — `pg_restore` wrapper with clean toggle and destructive confirmation.
- **DB-to-DB Sync** — pipe `pg_dump``pg_restore` between two connections.
- **Bundled client tools** — `pg_dump`/`pg_restore`/`psql` ship with the app; system tools are preferred when present, bundled tools are the fallback.
---
@@ -218,7 +225,7 @@ Capabilities below are fact-checked against each vendor's official docs and pric
| **Desktop shell** | [Tauri 2.0](https://tauri.app) | Native webview container (~40 MB baseline) |
| **Backend** | Rust + [tokio](https://tokio.rs) | Async runtime, connection pooling, CLI execution |
| **DB drivers** | [sqlx](https://github.com/launchbadge/sqlx) / [tokio-postgres](https://github.com/sfackler/rust-postgres) | PostgreSQL via tokio-postgres; MySQL via sqlx; SQLite via rusqlite |
| **CLI integration** | `std::process::Command` | Wraps system `pg_dump` / `pg_restore` |
| **CLI integration** | `std::process::Command` | Wraps bundled or system `pg_dump` / `pg_restore` |
| **Frontend** | [React 19](https://react.dev) + [TypeScript](https://www.typescriptlang.org) | Component-based UI |
| **Styling** | [Tailwind CSS](https://tailwindcss.com) | Utility-first, dark mode, glassmorphic design |
| **State** | [Zustand](https://zustand.docs.pmnd.rs) / Jotai | Domain stores |
@@ -248,16 +255,16 @@ Code signing **will be added in the future** (Apple Developer Program + a Window
#### Which file should I download?
Each release contains **one file per platform** — you only need the one that matches your computer. (If a newer version is available, swap `0.7.0` for the version shown in the release title.)
Each release contains **one file per platform** — you only need the one that matches your computer. The links below always point at the newest build (the asset names are version-free, so they never go stale):
| Your system | Download this | Notes |
| :--- | :--- | :--- |
| macOS **Apple Silicon** (M1/M2/M3/M4…) | `Gridline_0.7.0_aarch64.dmg` | `aarch64` = Apple's own chip |
| macOS **Intel** | `Gridline_0.7.0_x64.dmg` | `x64` = Intel/AMD |
| **Windows** (most PCs) | `Gridline_0.7.0_x64-setup.exe` | The `.msi` is an alternate installer (for enterprises/IT admins) |
| **Debian / Ubuntu** | `Gridline_0.7.0_amd64.deb` | Install: `sudo apt install ./Gridline_0.7.0_amd64.deb` |
| **Fedora / RHEL / openSUSE** | `Gridline-0.7.0-1.x86_64.rpm` | Install: `sudo dnf install Gridline-0.7.0-1.x86_64.rpm` |
| **Any other Linux** | `Gridline_0.7.0_amd64.AppImage` | Works on every distro: `chmod +x` the file, then double-click it |
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_darwin_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_darwin_aarch64.dmg) | `aarch64` = Apple's own chip |
| macOS **Intel** | [Gridline_darwin_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_darwin_x64.dmg) | `x64` = Intel/AMD |
| **Windows** (most PCs) | [Gridline_windows_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_windows_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
| **Debian / Ubuntu** | [Gridline_linux_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_linux_amd64.deb) | Install: `sudo apt install ./Gridline_linux_amd64.deb` |
| **Fedora / RHEL / openSUSE** | [Gridline_linux_x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_linux_x86_64.rpm) | Install: `sudo dnf install Gridline_linux_x86_64.rpm` |
| **Any other Linux** | [Gridline_linux_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_linux_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it |
**Not sure if your Mac is Intel or Apple Silicon?** Click the **Apple menu****About This Mac**. If it shows "Apple M1/M2/M3/M4…" download the `aarch64` file; if it shows an Intel chip, download `x64`. Downloading the wrong one won't run.
@@ -267,8 +274,8 @@ Cutting a release is one command — CI builds everything. **Releases are cut fr
```bash
git checkout prod && git pull
git tag v0.7.0
git push origin v0.7.0
git tag v0.7.5
git push origin v0.7.5
```
GitHub Actions (`.github/workflows/release.yml`) builds installers for **Apple Silicon, Intel Macs, Windows, and Linux**, then opens a **draft release** on the [Releases](https://github.com/adrianbonpin/gridline/releases) page — review it and hit **Publish release**.
@@ -298,7 +305,7 @@ bun run tauri build
- **Windows:** 10 or newer
- **Linux:** Ubuntu 22.04+ or equivalent modern distribution
- **RAM:** 8 GB recommended
- **PostgreSQL client tools:** `pg_dump` and `pg_restore` are required for admin features
- **PostgreSQL client tools:** `pg_dump`/`pg_restore`/`psql` are **bundled** with Gridline — no separate install required for backup/restore/sync. (System tools, if installed, are preferred.)
---
@@ -352,14 +359,14 @@ gridline/
## Roadmap
The full plan — in-development (v0.7.0), next-up, queue, and shipped history — lives in **[ROADMAP.md](./ROADMAP.md)**.
The full plan — in-development (v0.7.5), next-up, queue, and shipped history — lives in **[ROADMAP.md](./ROADMAP.md)**.
Highlights of what's next:
- **Full Object Management** — CRUD on functions, triggers, sequences, enums, extensions, and views without the Query tab, plus schema CRUD, global object search, and copy-as-DDL
- **Full Object Management** — CRUD on functions, triggers, sequences, enums, extensions, and views without the Query tab (schema CRUD, global object search, copy-as-DDL, and object dependencies shipped in v0.7.5)
- **Full Redis support** — key browser, type-aware value editors, TTL management
- **More database types** — MariaDB, TimescaleDB, and friends
- **Managed DB support** — PlanetScale, Turso (Supabase/Neon presets shipped in v0.7.0)
- **Managed DB support** — PlanetScale, Turso (Supabase/Neon presets shipped in v0.7.5)
- **AI integration (BYOK)** — natural-language → SQL, chat, summaries, charts
**[View the full roadmap →](./ROADMAP.md)**
+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)
- **New Connection screen revamp** — a single progressive flow: connection-string input + 2-column provider tab grid → expands into the full configuration form (label, tags/env/folder, General + SSH·SSL tabs). Removes the simple/detailed toggle.
@@ -36,18 +45,15 @@ Gridline can already **browse** every PostgreSQL object type (functions, trigger
- Staged through the changes queue (with confirmation) — never a surprise DDL
- **Out of scope this release:** the MySQL "Objects" view stays deferred (see In the queue) — this iteration is PostgreSQL-only
### Shipping alongside (companions)
- **Schema CRUD** — create/rename/drop schemas from the object tree
- **Global object search** — Cmd+K-style search across tables, functions, triggers, sequences, and extensions by name
- **Copy as DDL for any object** — `CREATE FUNCTION` / `CREATE TRIGGER` / … via the same DDL-generation used for edit/drop previews (also covers the "Table structure export" queue item)
- **Object dependencies** — `pg_depend`-based "what depends on this object?" view, shown before drops so nothing breaks silently
### Admin follow-up (after object management)
- **PostgreSQL users/roles + grants management** — create roles and set privileges from a UI (DB Pro has this at 0% on their roadmap — a differentiator to hold)
- **Maintenance actions** — right-click table → VACUUM / ANALYZE / REINDEX
### Connection & credentials
- **Wire up the "Enable Keychain" toggle** — currently a form-only placeholder: the flag is submitted and stored with the connection record, but the Rust backend never reads it and the frontend store unconditionally calls `saveConnectionPassword`. Decide the intended behavior (e.g. off = store the password with the connection record / don't persist at all, on = OS keychain as today) and implement the conditional path + migration for existing records.
## 📋 In the queue
### Full Redis Support
@@ -128,6 +134,11 @@ Deferred from 0.7.0, slated for this bucket:
## ✅ Shipped
- Bundled PG client tools — `pg_dump`/`pg_restore`/`psql` shipped with the app, system-first with bundled fallback (v0.7.5)
- Schema CRUD — create/rename/drop schemas with CASCADE + dependency warning (v0.7.5)
- Global object search — Cmd+K across all object types in the current schema (v0.7.5)
- Copy as DDL for any object (v0.7.5)
- Object dependencies — `pg_depend` view before destructive drops (v0.7.5)
- Tauri 2.0 + React 19 + TypeScript 5.8 project shell
- PostgreSQL and SQLite browse/query support
- Full MySQL DB viewer — connect, browse, query, edit + changes queue (v0.7.0)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "gridline",
"private": true,
"version": "0.7.0",
"version": "0.7.5",
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
"type": "module",
"scripts": {
+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]]
name = "gridline"
version = "0.7.0"
version = "0.7.5"
dependencies = [
"chrono",
"deadpool-postgres",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "gridline"
version = "0.7.0"
version = "0.7.5"
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
authors = ["you"]
edition = "2021"
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 tauri::{AppHandle, Emitter, State};
use tauri::{AppHandle, Emitter, Manager, State};
use crate::models::backup::*;
@@ -51,20 +51,63 @@ fn sanitize_error(s: &str) -> String {
crate::commands::test_connection::sanitize_error(s)
}
fn bundled_bin_name(tool: &str) -> String {
if cfg!(windows) { format!("{tool}.exe") } else { tool.to_string() }
}
/// Pure resolution decision (unit-testable): system > bundled > bare name.
fn pick_tool(system_ok: bool, bundled: Option<&str>, tool: &str) -> (String, Option<String>) {
if system_ok { return (tool.to_string(), Some("system".to_string())); }
if let Some(b) = bundled { return (b.to_string(), Some("bundled".to_string())); }
(tool.to_string(), None)
}
/// System-first, bundled-fallback resolver. Returns (command_to_invoke, source).
pub fn resolve_tool(app: &AppHandle, tool: &str) -> (String, Option<String>) {
let system_ok = Command::new(tool).arg("--version").output().is_ok();
let bundled = app.path().resource_dir().ok()
.map(|rd| rd.join("pg_tools").join(bundled_bin_name(tool)))
.filter(|p| p.exists())
.map(|p| p.to_string_lossy().to_string());
pick_tool(system_ok, bundled.as_deref(), tool)
}
pub fn resolve_tool_paths(app: &AppHandle) -> PgToolPaths {
let (d, _) = resolve_tool(app, "pg_dump");
let (r, _) = resolve_tool(app, "pg_restore");
let (p, _) = resolve_tool(app, "psql");
PgToolPaths { pg_dump: d, pg_restore: r, psql: p }
}
// ---------------------------------------------------------------------------
// detect_pg_tools
// ---------------------------------------------------------------------------
#[tauri::command]
pub fn detect_pg_tools() -> PgToolStatus {
/// Shapes the tool status from resolved tool paths + sources. Headless so the
/// Tauri command stays thin and the status logic stays unit-testable.
fn build_pg_tool_status(
dump: &str,
restore: &str,
dump_src: Option<String>,
restore_src: Option<String>,
) -> PgToolStatus {
PgToolStatus {
pg_dump_found: Command::new("pg_dump").arg("--version").output().is_ok(),
pg_restore_found: Command::new("pg_restore").arg("--version").output().is_ok(),
pg_dump_version: get_version("pg_dump"),
pg_restore_version: get_version("pg_restore"),
pg_dump_found: Command::new(dump).arg("--version").output().is_ok(),
pg_restore_found: Command::new(restore).arg("--version").output().is_ok(),
pg_dump_version: get_version(dump),
pg_restore_version: get_version(restore),
pg_dump_source: dump_src,
pg_restore_source: restore_src,
}
}
#[tauri::command]
pub fn detect_pg_tools(app_handle: AppHandle) -> PgToolStatus {
let (dump, dump_src) = resolve_tool(&app_handle, "pg_dump");
let (restore, restore_src) = resolve_tool(&app_handle, "pg_restore");
build_pg_tool_status(&dump, &restore, dump_src, restore_src)
}
// ---------------------------------------------------------------------------
// Core logic (headless-testable — no Tauri, no store, no keychain)
// ---------------------------------------------------------------------------
@@ -132,11 +175,11 @@ fn build_restore_args(conn: &PgConnParams, options: &RestoreOptions) -> Vec<Stri
/// Runs `pg_dump` against `conn`, writing to `options.file_path`.
/// Returns `Ok(())` on success or a sanitized error message.
pub fn run_pg_dump(conn: &PgConnParams, options: &BackupOptions) -> Result<(), String> {
pub fn run_pg_dump(conn: &PgConnParams, options: &BackupOptions, tools: &PgToolPaths) -> Result<(), String> {
let mut args = build_dump_args(conn, options);
args.push(format!("--file={}", options.file_path));
let result = Command::new("pg_dump")
let result = Command::new(&tools.pg_dump)
.env("PGPASSWORD", &conn.password)
.args(&args)
.output();
@@ -154,9 +197,9 @@ pub fn run_pg_dump(conn: &PgConnParams, options: &BackupOptions) -> Result<(), S
/// Plain-format dumps are SQL text and cannot be read by `pg_restore` — they
/// are executed with `psql` instead. The `clean` option is only honored for
/// archive formats (custom/tar/directory); the UI disables it for plain.
pub fn run_pg_restore(conn: &PgConnParams, options: &RestoreOptions) -> Result<(), String> {
pub fn run_pg_restore(conn: &PgConnParams, options: &RestoreOptions, tools: &PgToolPaths) -> Result<(), String> {
if options.format == "plain" {
let result = Command::new("psql")
let result = Command::new(&tools.psql)
.env("PGPASSWORD", &conn.password)
.args([
format!("--host={}", conn.host),
@@ -177,7 +220,7 @@ pub fn run_pg_restore(conn: &PgConnParams, options: &RestoreOptions) -> Result<(
let mut args = build_restore_args(conn, options);
args.push(options.file_path.clone());
let result = Command::new("pg_restore")
let result = Command::new(&tools.pg_restore)
.env("PGPASSWORD", &conn.password)
.args(&args)
.output();
@@ -196,6 +239,7 @@ pub fn run_db_sync(
target: &PgConnParams,
schema: Option<&str>,
tables: Option<&[String]>,
tools: &PgToolPaths,
) -> Result<(), String> {
// --- Build pg_dump args ---
let mut dump_args = base_conn_args(source);
@@ -221,7 +265,7 @@ pub fn run_db_sync(
restore_args.push("--if-exists".into());
// --- Spawn pg_dump with piped stdout ---
let mut dump_child = Command::new("pg_dump")
let mut dump_child = Command::new(&tools.pg_dump)
.env("PGPASSWORD", &source.password)
.args(&dump_args)
.stdout(Stdio::piped())
@@ -243,7 +287,7 @@ pub fn run_db_sync(
});
// --- Run pg_restore with pg_dump stdout as stdin ---
let restore_result = Command::new("pg_restore")
let restore_result = Command::new(&tools.pg_restore)
.env("PGPASSWORD", &target.password)
.args(&restore_args)
.stdin(dump_stdout)
@@ -332,9 +376,10 @@ pub async fn pg_dump(
let job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone();
let tools = resolve_tool_paths(&app_handle);
tokio::task::spawn_blocking(move || {
let result = run_pg_dump(&params, &options);
let result = run_pg_dump(&params, &options, &tools);
emit_result(&app_handle_clone, &job_id_clone, result);
});
@@ -374,9 +419,10 @@ pub async fn pg_restore(
let job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone();
let tools = resolve_tool_paths(&app_handle);
tokio::task::spawn_blocking(move || {
let result = run_pg_restore(&params, &options);
let result = run_pg_restore(&params, &options, &tools);
emit_result(&app_handle_clone, &job_id_clone, result);
});
@@ -464,9 +510,10 @@ pub async fn db_sync(
let tables = options.tables.clone();
let job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone();
let tools = resolve_tool_paths(&app_handle);
tokio::task::spawn_blocking(move || {
let result = run_db_sync(&source, &target, schema.as_deref(), tables.as_deref());
let result = run_db_sync(&source, &target, schema.as_deref(), tables.as_deref(), &tools);
emit_result(&app_handle_clone, &job_id_clone, result);
});
+32 -6
View File
@@ -133,7 +133,9 @@ fn build_pg_restore_args_with_schema() {
#[test]
fn detect_pg_tools_does_not_panic() {
let status = detect_pg_tools();
// detect_pg_tools needs a Tauri AppHandle; the headless core keeps the
// status-shaping logic testable without one.
let status = build_pg_tool_status("pg_dump", "pg_restore", None, None);
// May or may not find tools, but the call itself must not panic
let _ = status.pg_dump_found;
let _ = status.pg_restore_found;
@@ -148,6 +150,8 @@ fn pg_tool_status_serialization() {
pg_restore_found: false,
pg_dump_version: Some("pg_dump (PostgreSQL) 16.0".into()),
pg_restore_version: None,
pg_dump_source: None,
pg_restore_source: None,
};
let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("pg_dump_found"));
@@ -294,7 +298,8 @@ fn integration_dump_restore_sync() {
tables: None,
no_owner: true,
};
run_pg_dump(&src, &dump_opts).expect("pg_dump should succeed");
run_pg_dump(&src, &dump_opts, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
.expect("pg_dump should succeed");
// --- 2. Restore into target ---
let restore_opts = RestoreOptions {
@@ -303,7 +308,8 @@ fn integration_dump_restore_sync() {
clean: true,
schema: None,
};
run_pg_restore(&tgt, &restore_opts).expect("pg_restore should succeed");
run_pg_restore(&tgt, &restore_opts, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
.expect("pg_restore should succeed");
// --- 3. Verify data landed in target ---
assert_eq!(
@@ -320,7 +326,8 @@ fn integration_dump_restore_sync() {
// --- 4. Sync source -> target (target already has tables from the restore
// above — db_sync now passes --clean --if-exists, so it must succeed into a
// non-empty target). ---
run_db_sync(&src, &tgt, None, None).expect("db_sync should succeed");
run_db_sync(&src, &tgt, None, None, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
.expect("db_sync should succeed");
assert_eq!(
psql_count(&tgt, "SELECT count(*) FROM public.products;"),
3,
@@ -367,7 +374,8 @@ fn integration_plain_dump_restore() {
tables: None,
no_owner: true,
};
run_pg_dump(&src, &dump_opts).expect("pg_dump (plain) should succeed");
run_pg_dump(&src, &dump_opts, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
.expect("pg_dump (plain) should succeed");
// --- 2. Restore into target (plain -> psql path) ---
let restore_opts = RestoreOptions {
@@ -376,7 +384,8 @@ fn integration_plain_dump_restore() {
clean: false,
schema: None,
};
run_pg_restore(&tgt, &restore_opts).expect("pg_restore (plain/psql) should succeed");
run_pg_restore(&tgt, &restore_opts, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
.expect("pg_restore (plain/psql) should succeed");
// --- 3. Verify data landed in target ---
assert_eq!(
@@ -392,3 +401,20 @@ fn integration_plain_dump_restore() {
let _ = std::fs::remove_file(&dump_path);
}
// ------------------------------------------------------------------
// Tool resolution (Task 2.1: system-first, bundled-fallback)
// ------------------------------------------------------------------
#[test]
fn pick_tool_prefers_system_then_bundled_then_bare() {
assert_eq!(pick_tool(true, None, "pg_dump"), ("pg_dump".to_string(), Some("system".into())));
assert_eq!(pick_tool(false, Some("/r/pg_dump"), "pg_dump"), ("/r/pg_dump".to_string(), Some("bundled".into())));
assert_eq!(pick_tool(false, None, "pg_dump"), ("pg_dump".to_string(), None));
}
#[test]
fn bundled_bin_name_appends_exe_on_windows() {
let name = bundled_bin_name("pg_dump");
if cfg!(windows) { assert_eq!(name, "pg_dump.exe"); } else { assert_eq!(name, "pg_dump"); }
}
+24 -12
View File
@@ -149,19 +149,21 @@ pub fn build_pg_dump_ddl_args(schema: &str, table: &str) -> Vec<String> {
]
}
/// Check whether the system `pg_dump` binary is on PATH.
pub fn pg_dump_available() -> bool {
std::process::Command::new("pg_dump")
/// Check whether the `pg_dump` binary at the given path (bare name or
/// absolute path) is executable and reports a version.
pub fn pg_dump_available_at(path: &str) -> bool {
std::process::Command::new(path)
.arg("--version")
.output()
.is_ok()
}
/// Extract a single table's DDL from a PostgreSQL database by shelling out to
/// the system `pg_dump` with `--schema-only`. Credentials are supplied via the
/// `PGPASSWORD` environment variable only — never as argv — and are never
/// logged. Execution requires a reachable PostgreSQL server plus an installed
/// `pg_dump`; unit tests cover the argument construction instead.
/// `pg_dump` (system-first, bundled-fallback) with `--schema-only`.
/// Credentials are supplied via the `PGPASSWORD` environment variable only —
/// never as argv — and are never logged. Execution requires a reachable
/// PostgreSQL server plus an installed `pg_dump`; unit tests cover the
/// argument construction instead.
pub fn get_pg_ddl_via_dump(
schema: &str,
table: &str,
@@ -170,13 +172,14 @@ pub fn get_pg_ddl_via_dump(
user: &str,
db: &str,
password: &str,
pg_dump_path: &str,
) -> Result<String, String> {
if !pg_dump_available() {
if !pg_dump_available_at(pg_dump_path) {
return Err(
"pg_dump not found. Install PostgreSQL client tools to copy table schema.".into(),
"pg_dump not found. Install PostgreSQL client tools or use the bundled tools to copy table schema.".into(),
);
}
let mut cmd = std::process::Command::new("pg_dump");
let mut cmd = std::process::Command::new(pg_dump_path);
cmd.args([
format!("--host={host}"),
format!("--port={port}"),
@@ -2715,10 +2718,12 @@ pub async fn get_table_ddl(
};
// pg_dump is blocking I/O; run it off the async runtime. Credentials
// travel via PGPASSWORD, never argv.
// travel via PGPASSWORD, never argv. Resolve system-first,
// bundled-fallback, before moving into the closure.
let pg_dump_path = crate::commands::backup::resolve_tool(&app, "pg_dump").0;
let ddl = tokio::task::spawn_blocking(move || {
get_pg_ddl_via_dump(
&schema, &table, &dump_host, dump_port, &user, &db, &password,
&schema, &table, &dump_host, dump_port, &user, &db, &password, &pg_dump_path,
)
})
.await
@@ -3319,4 +3324,11 @@ mod tests {
.expect("columns");
assert_eq!(cols.len(), data.columns.len());
}
#[test]
fn pg_dump_available_at_checks_given_path() {
// A bare name that resolves on PATH passes; a bogus path fails.
assert!(pg_dump_available_at("pg_dump") || !pg_dump_available_at("pg_dump"));
assert!(!pg_dump_available_at("/nonexistent/pg_dump_999999"));
}
}
+1
View File
@@ -5,6 +5,7 @@ pub mod demo;
pub mod folders;
pub mod import_export;
pub mod keychain;
pub mod objects;
pub mod query;
pub mod schema_graph;
pub mod settings;
+151
View File
@@ -0,0 +1,151 @@
use tauri::State;
use crate::db::pool::{ConnectionPoolManager, DbHandle};
use crate::db::object_ddl::*;
use crate::models::db_viewer::{ObjectSearchHit, DependencyInfo};
fn sanitize(e: &str) -> String { crate::commands::db_viewer::sanitize_error(e) }
async fn exec_sql(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, sql: String) -> Result<(), String> {
let mut pm = pm.lock().await;
match pm.get(connection_id) {
Some(DbHandle::Postgresql(client, _)) => client.execute(&sql, &[]).await.map(|_| ()).map_err(|e| sanitize(&e.to_string())),
Some(_) => Err("Schema CRUD is PostgreSQL-only".into()),
None => Err("Connection not found".into()),
}
}
pub(crate) async fn create_schema_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, name: &str) -> Result<(), String> {
exec_sql(pm, connection_id, create_schema_sql(name)?).await
}
pub(crate) async fn rename_schema_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, old: &str, new: &str) -> Result<(), String> {
exec_sql(pm, connection_id, rename_schema_sql(old, new)?).await
}
pub(crate) async fn drop_schema_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, name: &str, cascade: bool) -> Result<(), String> {
exec_sql(pm, connection_id, drop_schema_sql(name, cascade)?).await
}
#[tauri::command]
pub async fn create_schema(connection_id: String, name: String, state: State<'_, crate::AppState>) -> Result<(), String> {
create_schema_inner(&state.pool_manager, &connection_id, &name).await
}
#[tauri::command]
pub async fn rename_schema(connection_id: String, old_name: String, new_name: String, state: State<'_, crate::AppState>) -> Result<(), String> {
rename_schema_inner(&state.pool_manager, &connection_id, &old_name, &new_name).await
}
#[tauri::command]
pub async fn drop_schema(connection_id: String, name: String, cascade: bool, state: State<'_, crate::AppState>) -> Result<(), String> {
drop_schema_inner(&state.pool_manager, &connection_id, &name, cascade).await
}
pub(crate) async fn search_objects_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, needle: &str) -> Result<Vec<ObjectSearchHit>, String> {
let sql = pg_object_search_query();
let mut pm = pm.lock().await;
match pm.get(connection_id) {
Some(DbHandle::Postgresql(client, _)) => {
let rows = client.query(&sql, &[&needle, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(rows.iter().map(|r| ObjectSearchHit {
name: r.get(0), schema: r.get(1), object_type: r.get(2),
}).collect())
}
Some(DbHandle::Sqlite(_)) | Some(DbHandle::MySql(_)) => Ok(vec![]),
None => Err("Connection not found".into()),
}
}
#[tauri::command]
pub async fn search_objects(connection_id: String, schema: String, query: String, state: State<'_, crate::AppState>) -> Result<Vec<ObjectSearchHit>, String> {
search_objects_inner(&state.pool_manager, &connection_id, &schema, &query).await
}
pub(crate) async fn get_object_ddl_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, object_type: &str, name: &str) -> Result<String, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c,
Some(_) => return Err("Copy-as-DDL is PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
match object_type {
"function" | "procedure" => {
let row = client.query_one("SELECT pg_get_functiondef(p.oid) FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE p.proname=$1 AND n.nspname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(row.get::<_, String>(0))
}
"trigger" => {
let row = client.query_one("SELECT pg_get_triggerdef(t.oid) FROM pg_trigger t JOIN pg_class c ON t.tgrelid=c.oid JOIN pg_namespace n ON c.relnamespace=n.oid WHERE t.tgname=$1 AND n.nspname=$2 AND NOT t.tgisinternal LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(row.get::<_, String>(0))
}
"index" => {
let row = client.query_one("SELECT pg_get_indexdef(ix.indexrelid) FROM pg_index ix JOIN pg_class i ON i.oid=ix.indexrelid JOIN pg_class t ON t.oid=ix.indrelid JOIN pg_namespace n ON t.relnamespace=n.oid WHERE i.relname=$1 AND n.nspname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(row.get::<_, String>(0))
}
"constraint" => {
let row = client.query_one("SELECT c.conname, ns.nspname, cl.relname, pg_get_constraintdef(c.oid) FROM pg_constraint c JOIN pg_class cl ON c.conrelid=cl.oid JOIN pg_namespace ns ON cl.relnamespace=ns.oid WHERE c.conname=$1 AND ns.nspname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(constraint_ddl(&crate::models::db_viewer::ConstraintInfo {
name: row.get(0), schema: row.get(1), table: row.get(2), contype: "CHECK".into(),
definition: row.get(3), deferrable: false, validated: true, columns: vec![] }))
}
"view" => {
let row = client.query_one("SELECT pg_get_viewdef(c.oid, true) FROM pg_class c JOIN pg_namespace n ON c.relnamespace=n.oid WHERE c.relname=$1 AND n.nspname=$2 AND c.relkind='v' LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(view_ddl(schema, name, &row.get::<_, String>(0)))
}
"materialized view" => {
let row = client.query_one("SELECT definition FROM pg_matviews WHERE matviewname=$1 AND schemaname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(matview_ddl(schema, name, &row.get::<_, String>(0)))
}
"sequence" => {
let row = client.query_one("SELECT sequence_name, sequence_schema, start_value::text, minimum_value::text, maximum_value::text, increment::text, COALESCE(pg_catalog.pg_sequence_last_value(sequence_name::regclass)::text,'0'), cycle_option::text FROM information_schema.sequences WHERE sequence_name=$1 AND sequence_schema=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(sequence_ddl(&crate::models::db_viewer::SequenceInfo { name: row.get(0), schema: row.get(1), start_value: row.get(2), min_value: row.get(3), max_value: row.get(4), increment: row.get(5), current_value: row.get(6), cycle: row.get::<_, String>(7).eq_ignore_ascii_case("YES") }))
}
"enum" => {
let row = client.query_one("SELECT t.typname, n.nspname, ARRAY(SELECT e.enumlabel FROM pg_enum e WHERE e.enumtypid=t.oid ORDER BY e.enumsortorder) FROM pg_type t JOIN pg_namespace n ON t.typnamespace=n.oid WHERE t.typname=$1 AND n.nspname=$2 AND t.typtype='e' LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(enum_ddl(&crate::models::db_viewer::EnumInfo { name: row.get(0), schema: row.get(1), labels: row.get::<_, Vec<String>>(2) }))
}
"extension" => {
let row = client.query_one("SELECT e.extname, n.nspname, e.extversion::text FROM pg_extension e JOIN pg_namespace n ON e.extnamespace=n.oid WHERE e.extname=$1 AND n.nspname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(extension_ddl(&crate::models::db_viewer::ExtensionInfo { name: row.get(0), schema: row.get(1), version: row.get(2), comment: None }))
}
"table" => {
// Tables reuse the pg_dump path (bundled-aware); the command wrapper resolves the tool path via AppHandle.
Err("table DDL uses get_table_ddl".into())
}
other => Err(format!("Unsupported object type for DDL: {other}")),
}
}
#[tauri::command]
pub async fn get_object_ddl(connection_id: String, schema: String, object_type: String, name: String, state: State<'_, crate::AppState>, app: tauri::AppHandle) -> Result<String, String> {
if object_type == "table" || object_type == "TABLE" {
return crate::commands::db_viewer::get_table_ddl(connection_id, schema, name, state, app).await;
}
get_object_ddl_inner(&state.pool_manager, &connection_id, &schema, &object_type.to_lowercase(), &name).await
}
pub(crate) async fn get_object_dependencies_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, object_type: &str, name: &str) -> Result<Vec<DependencyInfo>, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c,
Some(_) => return Err("Dependencies are PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
if object_type.eq_ignore_ascii_case("schema") {
let rows = client.query(&pg_schema_contents_query(), &[&name]).await.map_err(|e| sanitize(&e.to_string()))?;
return Ok(rows.iter().map(|r| DependencyInfo {
deptype: "n".into(), class: format!("pg_class: {}", r.get::<_, String>(1)), name: r.get(0),
}).collect());
}
let oid_sql = pg_object_oid_query(&object_type.to_lowercase());
if oid_sql.is_empty() { return Err(format!("Unsupported object type: {object_type}")); }
let oid: tokio_postgres::types::Oid = client.query_one(&oid_sql, &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?.get(0);
let rows = client.query(&pg_depend_query(), &[&oid]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(rows.iter().map(|r| DependencyInfo {
deptype: r.get(0), class: r.get(1), name: r.get::<_, String>(2),
}).collect())
}
#[tauri::command]
pub async fn get_object_dependencies(connection_id: String, schema: String, object_type: String, name: String, state: State<'_, crate::AppState>) -> Result<Vec<DependencyInfo>, String> {
get_object_dependencies_inner(&state.pool_manager, &connection_id, &schema, &object_type, &name).await
}
#[cfg(test)]
#[path = "objects.test.rs"]
mod tests;
+70
View File
@@ -0,0 +1,70 @@
use super::*;
use crate::db::pool::{ConnectionPoolManager, DbHandle};
/// Connect directly via tokio-postgres (no tunnel) so tests are Tauri-free.
async fn pool() -> (tokio::sync::Mutex<ConnectionPoolManager>, String) {
let h = std::env::var("GRIDLINE_TEST_PG_HOST").expect("set GRIDLINE_TEST_PG_HOST");
let p: u16 = std::env::var("GRIDLINE_TEST_PG_PORT").unwrap_or_else(|_| "5432".into()).parse().unwrap();
let u = std::env::var("GRIDLINE_TEST_PG_USER").expect("set GRIDLINE_TEST_PG_USER");
let d = std::env::var("GRIDLINE_TEST_PG_DB").expect("set GRIDLINE_TEST_PG_DB");
let pw = std::env::var("GRIDLINE_TEST_PG_PASSWORD").unwrap_or_default();
let (client, conn) = tokio_postgres::connect(
&format!("host={h} port={p} user={u} dbname={d} password={pw}"),
tokio_postgres::NoTls,
).await.expect("connect to test PG");
let handle = tokio::spawn(async move { let _ = conn.await; });
let mut pm = ConnectionPoolManager::new();
let id = "test-conn".to_string();
pm.register(&id, DbHandle::Postgresql(client, handle));
(tokio::sync::Mutex::new(pm), id)
}
#[tokio::test]
#[ignore]
async fn schema_crud_create_rename_drop() {
let (pm, id) = pool().await;
let name = "gridline_test_schema";
create_schema_inner(&pm, &id, name).await.unwrap();
assert!(create_schema_inner(&pm, &id, name).await.is_err(), "duplicate should error");
rename_schema_inner(&pm, &id, name, "gridline_test_schema2").await.unwrap();
drop_schema_inner(&pm, &id, "gridline_test_schema2", false).await.unwrap();
}
#[tokio::test]
#[ignore]
async fn search_objects_finds_table_and_function() {
let (pm, id) = pool().await;
let hits = search_objects_inner(&pm, &id, "public", "users").await.unwrap();
assert!(hits.iter().any(|h| h.name == "users" && h.object_type == "TABLE"), "demo has a users table");
let fns = search_objects_inner(&pm, &id, "public", "get").await.unwrap();
// substring match across types; assert it returns a Vec<ObjectSearchHit>
assert!(fns.iter().all(|h| h.object_type != ""));
// empty needle returns nothing matched by position('' in name) > 0 is always true — so empty returns all (capped at 100)
let all = search_objects_inner(&pm, &id, "public", "").await.unwrap();
assert!(all.len() <= 100);
}
#[tokio::test]
#[ignore]
async fn object_ddl_for_sequence_enum_function() {
let (pm, id) = pool().await;
// demo has users_id_seq, an enum, and a function
let seq = get_object_ddl_inner(&pm, &id, "public", "sequence", "users_id_seq").await.unwrap();
assert!(seq.starts_with("CREATE SEQUENCE"), "{seq}");
// function: pg_get_functiondef passthrough
let f = get_object_ddl_inner(&pm, &id, "public", "function", "audit_log").await; // name per demo
assert!(f.is_ok());
assert!(f.clone().unwrap().contains("CREATE FUNCTION") || f.unwrap().contains("CREATE OR REPLACE FUNCTION"));
}
#[tokio::test]
#[ignore]
async fn object_dependencies_for_table_includes_view() {
let (pm, id) = pool().await;
// demo has order_summary VIEW depending on orders — drop would break it
let deps = get_object_dependencies_inner(&pm, &id, "public", "table", "orders").await.unwrap();
assert!(deps.iter().any(|d| d.class.contains("pg_class") && d.name.contains("order_summary")), "view depending on orders should surface: {deps:?}");
// schema contents path
let contents = get_object_dependencies_inner(&pm, &id, "public", "schema", "public").await.unwrap();
assert!(!contents.is_empty(), "public schema should list contents");
}
+1
View File
@@ -1,5 +1,6 @@
pub mod introspection;
pub mod mysql;
pub mod object_ddl;
pub mod pool;
pub mod tls;
+261
View File
@@ -0,0 +1,261 @@
//! Pure builders for schema DDL, cross-object search, pg_depend lookups,
//! and synthesized object DDL. No DB I/O — deterministic string builders.
use crate::models::db_viewer::{SequenceInfo, EnumInfo, ExtensionInfo, ConstraintInfo};
/// Double-quote an identifier, doubling embedded quotes.
pub fn quote_ident(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
/// Reject empty / SQL-injection-prone names (mirrors schema_graph::validate_schema_name).
pub fn validate_object_name(name: &str) -> Result<(), String> {
if name.trim().is_empty() {
return Err("Name must not be empty".into());
}
for bad in [";", "--", "/*", "'", "\"", "\\"] {
if name.contains(bad) {
return Err(format!("Name contains forbidden character(s): {bad}"));
}
}
Ok(())
}
pub fn create_schema_sql(name: &str) -> Result<String, String> {
validate_object_name(name)?;
Ok(format!("CREATE SCHEMA {}", quote_ident(name)))
}
pub fn rename_schema_sql(old: &str, new: &str) -> Result<String, String> {
validate_object_name(old)?;
validate_object_name(new)?;
Ok(format!("ALTER SCHEMA {} RENAME TO {}", quote_ident(old), quote_ident(new)))
}
pub fn drop_schema_sql(name: &str, cascade: bool) -> Result<String, String> {
validate_object_name(name)?;
Ok(format!("DROP SCHEMA {}{}", quote_ident(name), if cascade { " CASCADE" } else { "" }))
}
/// UNION ALL of every browsable object type in `$2` (schema), substring-matching `$1` (needle)
/// via position() — case-insensitive, no wildcard-escaping pitfalls. LIMIT 100.
pub fn pg_object_search_query() -> String {
"SELECT name, schema, type FROM (
SELECT table_name AS name, table_schema AS schema, CASE WHEN table_type = 'VIEW' THEN 'VIEW' ELSE 'TABLE' END AS type FROM information_schema.tables WHERE table_schema=$2 AND position(lower($1) in lower(table_name))>0
UNION ALL
SELECT matviewname, schemaname, 'MATERIALIZED VIEW' FROM pg_matviews WHERE schemaname=$2 AND position(lower($1) in lower(matviewname))>0
UNION ALL
SELECT p.proname, n.nspname, CASE p.prokind WHEN 'f' THEN 'FUNCTION' WHEN 'p' THEN 'PROCEDURE' END FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE n.nspname=$2 AND position(lower($1) in lower(p.proname))>0
UNION ALL
SELECT t.tgname, cn.nspname, 'TRIGGER' FROM pg_trigger t JOIN pg_class c ON t.tgrelid=c.oid JOIN pg_namespace cn ON c.relnamespace=cn.oid WHERE cn.nspname=$2 AND NOT t.tgisinternal AND position(lower($1) in lower(t.tgname))>0
UNION ALL
SELECT sequence_name, sequence_schema, 'SEQUENCE' FROM information_schema.sequences WHERE sequence_schema=$2 AND position(lower($1) in lower(sequence_name))>0
UNION ALL
SELECT t.typname, n.nspname, 'ENUM' FROM pg_type t JOIN pg_namespace n ON t.typnamespace=n.oid WHERE t.typtype='e' AND n.nspname=$2 AND position(lower($1) in lower(t.typname))>0
UNION ALL
SELECT e.extname, n.nspname, 'EXTENSION' FROM pg_extension e JOIN pg_namespace n ON e.extnamespace=n.oid WHERE n.nspname=$2 AND position(lower($1) in lower(e.extname))>0
UNION ALL
SELECT i.relname, ns.nspname, 'INDEX' FROM pg_index ix JOIN pg_class i ON i.oid=ix.indexrelid JOIN pg_class t ON t.oid=ix.indrelid JOIN pg_namespace ns ON t.relnamespace=ns.oid WHERE ns.nspname=$2 AND position(lower($1) in lower(i.relname))>0
UNION ALL
SELECT c.conname, ns.nspname, 'CONSTRAINT' FROM pg_constraint c JOIN pg_class cl ON c.conrelid=cl.oid JOIN pg_namespace ns ON cl.relnamespace=ns.oid WHERE ns.nspname=$2 AND c.contype IN ('c','u','x') AND position(lower($1) in lower(c.conname))>0
) AS hits ORDER BY type, name LIMIT 100".to_string()
}
/// Resolve a single object oid by type. $1=name, $2=schema. (Overloads: first match — see spec open questions.)
pub fn pg_object_oid_query(object_type: &str) -> String {
match object_type {
"table" | "view" | "materialized view" | "sequence" | "index" =>
"SELECT c.oid FROM pg_class c JOIN pg_namespace n ON c.relnamespace=n.oid WHERE c.relname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
"function" | "procedure" =>
"SELECT p.oid FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE p.proname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
"trigger" =>
"SELECT t.oid FROM pg_trigger t JOIN pg_class c ON t.tgrelid=c.oid JOIN pg_namespace n ON c.relnamespace=n.oid WHERE t.tgname=$1 AND n.nspname=$2 AND NOT t.tgisinternal LIMIT 1".to_string(),
"enum" =>
"SELECT t.oid FROM pg_type t JOIN pg_namespace n ON t.typnamespace=n.oid WHERE t.typname=$1 AND n.nspname=$2 AND t.typtype='e' LIMIT 1".to_string(),
"extension" =>
"SELECT e.oid FROM pg_extension e JOIN pg_namespace n ON e.extnamespace=n.oid WHERE e.extname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
"constraint" =>
"SELECT c.oid FROM pg_constraint c JOIN pg_class cl ON c.conrelid=cl.oid JOIN pg_namespace n ON cl.relnamespace=n.oid WHERE c.conname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
_ => String::new(),
}
}
/// Objects that depend on `$1` (the target oid). Excludes internal/pinned deps; resolves readable name per classid.
pub fn pg_depend_query() -> String {
"SELECT d.deptype::text AS deptype,
CASE WHEN d.classid = 'pg_rewrite'::regclass THEN 'pg_class'
ELSE d.classid::regclass::text END AS class,
CASE
WHEN d.classid='pg_class'::regclass THEN (SELECT relname FROM pg_class WHERE oid=d.objid)
WHEN d.classid='pg_proc'::regclass THEN (SELECT proname FROM pg_proc WHERE oid=d.objid)
WHEN d.classid='pg_trigger'::regclass THEN (SELECT tgname FROM pg_trigger WHERE oid=d.objid)
WHEN d.classid='pg_type'::regclass THEN (SELECT typname FROM pg_type WHERE oid=d.objid)
WHEN d.classid='pg_constraint'::regclass THEN (SELECT conname FROM pg_constraint WHERE oid=d.objid)
WHEN d.classid='pg_rewrite'::regclass THEN (SELECT ev_class::regclass::text FROM pg_rewrite WHERE oid=d.objid)
ELSE ''
END AS name
FROM pg_depend d
WHERE d.refobjid = $1 AND d.deptype IN ('n', 'a')
ORDER BY d.deptype, name".to_string()
}
/// Objects contained in a schema (for the schema-drop dependency warning). $1=schema.
pub fn pg_schema_contents_query() -> String {
"SELECT c.relname, c.relkind::text FROM pg_class c JOIN pg_namespace n ON c.relnamespace=n.oid WHERE n.nspname=$1 AND c.relkind IN ('r','v','m','S','i','c') ORDER BY c.relkind, c.relname".to_string()
}
// --- Synthesized DDL ---
pub fn sequence_ddl(s: &SequenceInfo) -> String {
format!("CREATE SEQUENCE {}.{}\n INCREMENT BY {}\n MINVALUE {}\n MAXVALUE {}\n START WITH {}\n {}",
quote_ident(&s.schema), quote_ident(&s.name), s.increment, s.min_value, s.max_value, s.start_value,
if s.cycle { "CYCLE" } else { "NO CYCLE" })
}
pub fn enum_ddl(e: &EnumInfo) -> String {
let labels: Vec<String> = e.labels.iter().map(|l| format!("'{}'", l.replace('\'', "''"))).collect();
format!("CREATE TYPE {}.{} AS ENUM ({});", quote_ident(&e.schema), quote_ident(&e.name), labels.join(", "))
}
pub fn extension_ddl(x: &ExtensionInfo) -> String {
format!("CREATE EXTENSION IF NOT EXISTS {} WITH SCHEMA {} VERSION '{}';", quote_ident(&x.name), quote_ident(&x.schema), x.version.replace('\'', "''"))
}
pub fn view_ddl(schema: &str, name: &str, selectdef: &str) -> String {
format!("CREATE OR REPLACE VIEW {}.{} AS\n{}", quote_ident(schema), quote_ident(name), selectdef)
}
pub fn matview_ddl(schema: &str, name: &str, selectdef: &str) -> String {
format!("CREATE MATERIALIZED VIEW {}.{} AS\n{}", quote_ident(schema), quote_ident(name), selectdef)
}
pub fn constraint_ddl(c: &ConstraintInfo) -> String {
format!("ALTER TABLE {}.{} ADD CONSTRAINT {} {}",
quote_ident(&c.schema), quote_ident(&c.table), quote_ident(&c.name), c.definition)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quote_ident_doubles_embedded_quotes() {
assert_eq!(quote_ident("public"), "\"public\"");
assert_eq!(quote_ident("a\"b"), "\"a\"\"b\"");
}
#[test]
fn validate_object_name_rejects_dangerous_chars() {
assert!(validate_object_name("public").is_ok());
assert!(validate_object_name("").is_err());
assert!(validate_object_name("a; DROP").is_err());
assert!(validate_object_name("a--b").is_err());
assert!(validate_object_name("a'b").is_err());
assert!(validate_object_name("a\"b").is_err());
assert!(validate_object_name("a\\b").is_err());
assert!(validate_object_name("/*x*/").is_err());
}
#[test]
fn create_schema_sql_quotes_name() {
assert_eq!(create_schema_sql("my_schema").unwrap(), "CREATE SCHEMA \"my_schema\"");
assert!(create_schema_sql("bad; name").is_err());
}
#[test]
fn rename_schema_sql_quotes_both() {
assert_eq!(
rename_schema_sql("old", "new").unwrap(),
"ALTER SCHEMA \"old\" RENAME TO \"new\""
);
}
#[test]
fn drop_schema_sql_cascade_flag() {
assert_eq!(drop_schema_sql("s", false).unwrap(), "DROP SCHEMA \"s\"");
assert_eq!(drop_schema_sql("s", true).unwrap(), "DROP SCHEMA \"s\" CASCADE");
}
#[test]
fn object_search_query_unions_types_and_uses_position_match() {
let sql = pg_object_search_query();
assert!(sql.contains("position(lower($1) in lower("), "case-insensitive substring, no wildcard escaping: {sql}");
assert!(sql.contains("$2"), "schema is $2");
// covers every browsable type
for t in ["'TABLE'", "'VIEW'", "'MATERIALIZED VIEW'", "'FUNCTION'", "'PROCEDURE'", "'TRIGGER'", "'SEQUENCE'", "'ENUM'", "'EXTENSION'", "'INDEX'", "'CONSTRAINT'"] {
assert!(sql.contains(t), "search must cover {t}");
}
assert!(sql.contains("LIMIT 100"));
}
#[test]
fn object_oid_query_branches_per_type() {
assert!(pg_object_oid_query("table").contains("pg_class"));
assert!(pg_object_oid_query("function").contains("pg_proc"));
assert!(pg_object_oid_query("trigger").contains("pg_trigger"));
assert!(pg_object_oid_query("enum").contains("pg_type"));
assert!(pg_object_oid_query("extension").contains("pg_extension"));
assert!(pg_object_oid_query("constraint").contains("pg_constraint"));
}
#[test]
fn depend_query_filters_internal_and_resolves_names() {
let sql = pg_depend_query();
assert!(sql.contains("refobjid = $1"));
assert!(sql.contains("deptype IN ('n', 'a')"), "exclude internal 'i' / pinned 'p'");
assert!(sql.contains("pg_proc'::regclass"));
assert!(sql.contains("pg_trigger'::regclass"));
assert!(sql.contains("pg_constraint'::regclass"));
}
#[test]
fn depend_query_maps_rewrite_rules_to_the_view_class() {
// View dependencies surface in pg_depend as rewrite-rule rows
// (classid = pg_rewrite). The dependent object a user cares about is
// the VIEW itself, so the class must be reported as pg_class and the
// name resolved through ev_class (the view's relation).
let sql = pg_depend_query();
assert!(
sql.contains("WHEN d.classid = 'pg_rewrite'::regclass THEN 'pg_class'"),
"pg_rewrite rows must be reported as pg_class: {sql}"
);
assert!(
sql.contains("pg_rewrite'::regclass THEN (SELECT ev_class::regclass::text FROM pg_rewrite WHERE oid=d.objid)"),
"pg_rewrite name resolves through ev_class: {sql}"
);
}
#[test]
fn sequence_ddl_is_synthesized() {
let s = SequenceInfo { name: "users_id_seq".into(), schema: "public".into(),
start_value: "1".into(), min_value: "1".into(), max_value: "9".into(),
increment: "1".into(), current_value: "5".into(), cycle: false };
let ddl = sequence_ddl(&s);
assert!(ddl.starts_with("CREATE SEQUENCE \"public\".\"users_id_seq\""));
assert!(ddl.contains("INCREMENT BY 1"));
assert!(ddl.contains("NO CYCLE"));
}
#[test]
fn enum_ddl_lists_labels_quoted() {
let e = EnumInfo { name: "role".into(), schema: "public".into(), labels: vec!["admin".into(), "user's".into()] };
let ddl = enum_ddl(&e);
assert!(ddl.starts_with("CREATE TYPE \"public\".\"role\" AS ENUM ("));
assert!(ddl.contains("'admin'"));
assert!(ddl.contains("'user''s'"), "single quotes doubled");
}
#[test]
fn extension_ddl_is_synthesized() {
let x = ExtensionInfo { name: "pgcrypto".into(), schema: "public".into(), version: "1.3".into(), comment: None };
let ddl = extension_ddl(&x);
assert!(ddl.contains("CREATE EXTENSION IF NOT EXISTS \"pgcrypto\""));
assert!(ddl.contains("WITH SCHEMA \"public\""));
assert!(ddl.contains("VERSION '1.3'"));
}
#[test]
fn view_ddl_wraps_selectdef() {
assert_eq!(view_ddl("public", "v_users", "SELECT * FROM users"),
"CREATE OR REPLACE VIEW \"public\".\"v_users\" AS\nSELECT * FROM users");
}
#[test]
fn constraint_ddl_wraps_definition() {
let c = ConstraintInfo { name: "ck_pos".into(), schema: "public".into(), table: "orders".into(),
contype: "CHECK".into(), definition: "CHECK (amount > 0)".into(), deferrable: false, validated: true, columns: vec!["amount".into()] };
let ddl = constraint_ddl(&c);
assert_eq!(ddl, "ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"ck_pos\" CHECK (amount > 0)");
}
}
+8 -2
View File
@@ -20,8 +20,8 @@ pub struct AppState {
}
use commands::{
backup, connections, db_viewer, demo, folders, import_export, keychain, query, schema_graph,
settings, tags,
backup, connections, db_viewer, demo, folders, import_export, keychain, objects, query,
schema_graph, settings, tags,
};
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
@@ -116,6 +116,12 @@ pub fn run() {
db_viewer::get_extensions,
db_viewer::get_indexes,
db_viewer::get_constraints,
objects::create_schema,
objects::rename_schema,
objects::drop_schema,
objects::search_objects,
objects::get_object_ddl,
objects::get_object_dependencies,
keychain::save_connection_password,
keychain::get_connection_password,
keychain::delete_connection_password,
+20
View File
@@ -34,6 +34,16 @@ pub struct PgToolStatus {
pub pg_restore_found: bool,
pub pg_dump_version: Option<String>,
pub pg_restore_version: Option<String>,
pub pg_dump_source: Option<String>, // "system" | "bundled" | None
pub pg_restore_source: Option<String>,
}
/// Resolved on-disk paths for the three client tools (system-first, bundled-fallback).
#[derive(Debug, Clone)]
pub struct PgToolPaths {
pub pg_dump: String,
pub pg_restore: String,
pub psql: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -97,4 +107,14 @@ mod tests {
assert!(json.contains("dump"));
assert!(json.contains("completed"));
}
#[test]
fn pg_tool_status_reports_source() {
let s = PgToolStatus { pg_dump_found: true, pg_restore_found: true,
pg_dump_version: Some("pg_dump 16".into()), pg_restore_version: Some("pg_restore 16".into()),
pg_dump_source: Some("system".into()), pg_restore_source: Some("bundled".into()) };
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("\"pg_dump_source\":\"system\""));
assert!(json.contains("\"pg_restore_source\":\"bundled\""));
}
}
+31
View File
@@ -132,6 +132,20 @@ pub struct ExtensionInfo {
pub comment: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObjectSearchHit {
pub name: String,
pub schema: String,
pub object_type: String, // TABLE | VIEW | MATERIALIZED VIEW | FUNCTION | PROCEDURE | TRIGGER | SEQUENCE | ENUM | EXTENSION | INDEX | CONSTRAINT
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DependencyInfo {
pub deptype: String, // "n" (normal) | "a" (auto)
pub class: String, // pg_class | pg_proc | pg_trigger | pg_type | pg_constraint | pg_rewrite
pub name: String, // resolved dependent object name
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Change {
@@ -583,4 +597,21 @@ mod tests {
assert!(json.contains("users"), "should contain referenced table");
assert!(json.contains("id"), "should contain referenced column");
}
#[test]
fn object_search_hit_tagged_roundtrip() {
let hit = ObjectSearchHit { name: "users".into(), schema: "public".into(), object_type: "TABLE".into() };
let json = serde_json::to_string(&hit).unwrap();
assert!(json.contains("\"object_type\":\"TABLE\""));
let back: ObjectSearchHit = serde_json::from_str(&json).unwrap();
assert_eq!(back.name, "users");
}
#[test]
fn dependency_info_roundtrip() {
let d = DependencyInfo { deptype: "n".into(), class: "pg_class".into(), name: "v_users".into() };
let json = serde_json::to_string(&d).unwrap();
assert!(json.contains("\"deptype\":\"n\""));
assert!(json.contains("v_users"));
}
}
+3 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Gridline",
"version": "0.7.0",
"version": "0.7.5",
"identifier": "com.adrianbonpin.gridline",
"build": {
"beforeDevCommand": "bun run dev",
@@ -26,10 +26,12 @@
"bundle": {
"active": true,
"targets": "all",
"resources": ["resources/pg_tools/*"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.png",
"icons/icon.icns",
"icons/icon.ico"
]
+1 -1
View File
@@ -58,7 +58,7 @@ export function BackupDialog({ open, connectionId, onClose }: BackupDialogProps)
setCheckingTools(true);
detectPgTools()
.then((status) => setToolStatus(status))
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null }))
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null, pg_dump_source: null, pg_restore_source: null }))
.finally(() => setCheckingTools(false));
}, [open]);
@@ -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_dump_version: null,
pg_restore_version: null,
pg_dump_source: null,
pg_restore_source: null,
}),
)
.finally(() => setCheckingTools(false));
@@ -136,6 +138,7 @@ export function BackupPage({ connectionId }: BackupPageProps) {
}, [filePath, format, schema, noOwner, connectionId, startJob, notify]);
const toolsMissing = toolStatus && !toolStatus.pg_dump_found;
const toolsBundled = toolStatus?.pg_dump_source === "bundled";
return (
<div className="flex flex-col h-full">
@@ -160,7 +163,7 @@ export function BackupPage({ connectionId }: BackupPageProps) {
</div>
)}
{toolsMissing && (
{toolsMissing && !toolsBundled && (
<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">
pg_dump not found
@@ -30,6 +30,7 @@ import { ToolsPage } from "./ToolsPage";
import { SchemaVisualizerPage } from "./SchemaVisualizerPage";
import { QueriesPanel } from "../queries/QueriesPanel";
import { useQueryStore } from "../../stores/queryStore";
import { ObjectSearchPalette } from "./ObjectSearchPalette";
import * as cmd from "../../lib/commands";
import type { EnumInfo } from "../../lib/types";
import type { FkOption } from "../grid/CellEditor";
@@ -186,6 +187,8 @@ export function DbViewerScreen({
const toggleHiddenColumn = useDbViewerStore((s) => s.toggleHiddenColumn);
const setSmartSortApplied = useDbViewerStore((s) => s.setSmartSortApplied);
const setObjectSearchOpen = useDbViewerStore((s) => s.setObjectSearchOpen);
// Sync settings defaults to store
useEffect(() => {
if (settings?.table_page_size) {
@@ -423,6 +426,11 @@ export function DbViewerScreen({
onHome();
}
});
useShortcut("command_palette", () => {
if (capabilities.objects) {
setObjectSearchOpen(true);
}
});
useEffect(() => {
if (!activeTab) return;
if (activeTab.tabType !== "table") return;
@@ -1455,6 +1463,9 @@ const onQueriesPanelResizeStart = useCallback(
onSaved={() => {}}
/>
)}
{capabilities.objects && (
<ObjectSearchPalette connectionId={connectionId} />
)}
</div>
</TooltipProvider>
);
@@ -12,6 +12,7 @@ import { SelectDropdown } from "../ui/SelectDropdown";
import { Tooltip } from "../ui/Tooltip";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import * as cmd from "../../lib/commands";
import { SchemaMenu } from "./SchemaMenu";
export function DbViewerToolbar({
databases,
@@ -227,6 +228,11 @@ export function DbViewerToolbar({
disabled={schemaTreeLoading}
/>
)}
<SchemaMenu
connectionId={connectionId ?? ""}
schema={currentSchema ?? undefined}
onRefresh={handleRefresh}
/>
</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 { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ObjectExplorerPage } from "./ObjectExplorerPage";
import { useDbViewerStore } from "../../stores/dbViewerStore";
@@ -189,4 +189,57 @@ describe("ObjectExplorerPage", () => {
);
expect(screen.queryByText("calc")).not.toBeInTheDocument();
});
it("Copy DDL calls getObjectDdl and writes to clipboard", async () => {
vi.spyOn(commands, "getEnums").mockResolvedValue([
{ name: "role", schema: "public", labels: ["a"] },
]);
vi.spyOn(commands, "getObjectDdl").mockResolvedValue("CREATE TYPE ...");
const writeText = vi.fn();
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
render(<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,
ListChecks,
ListOrdered,
MoreVertical,
SquareFunction,
Tag,
Puzzle,
@@ -15,6 +16,7 @@ import {
} from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { SelectDropdown } from "../ui/SelectDropdown";
import { DependencyDialog } from "./DependencyDialog";
import * as cmd from "../../lib/commands";
import type {
FunctionInfo,
@@ -24,17 +26,11 @@ import type {
ExtensionInfo,
IndexInfo,
ConstraintInfo,
ObjectType,
DependencyInfo,
} from "../../lib/types";
export type ObjectType =
| "functions"
| "triggers"
| "sequences"
| "enums"
| "extensions"
| "indexes"
| "constraints"
| "procedures";
interface ObjectExplorerPageProps {
connectionId: string;
@@ -120,6 +116,28 @@ function itemLabel(item: AnyObject): string {
return name;
}
function schemaOf(item: AnyObject): string {
return item.schema;
}
function objectName(item: AnyObject): string {
return item.name;
}
function typeToDdlType(type: ObjectType): string {
const map: Record<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 ──────────────
const SQL_KEYWORDS = new Set([
@@ -1020,7 +1038,8 @@ function renderDetail(type: ObjectType, item: AnyObject) {
}
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 panelResizeRef = useRef<{ startX: number; startW: number } | null>(
null,
@@ -1061,6 +1080,9 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | 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 [searchQuery, setSearchQuery] = useState("");
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
// the loading state renders (no flash of the previous type's objects), and
// reset the last-fetched-schema marker so the fetch effect re-runs.
const handleTypeChange = (next: ObjectType) => {
const handleTypeChange = useCallback((next: ObjectType) => {
setType(next);
setSearchQuery("");
setSelectedItem(null);
setOpenKey(null);
setItems(null);
setLoading(true);
lastSchemaRef.current = undefined;
};
}, []);
// Consume any object-type preselection from the Cmd+K palette.
useEffect(() => {
if (selectedObjectType && selectedObjectType !== type) {
handleTypeChange(selectedObjectType);
}
if (selectedObjectType) {
useDbViewerStore.getState().setSelectedObjectType(null);
}
}, [selectedObjectType, type, handleTypeChange]);
return (
<div className="flex flex-1 min-h-0 overflow-hidden">
@@ -1326,6 +1359,34 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
const isSelected =
selectedItem !== null &&
itemKey(selectedItem) === itemKey(item);
const menuOpen = openKey === key;
const handleCopyDdl = async () => {
setOpenKey(null);
const ddl = await cmd.getObjectDdl(
connectionId,
schemaOf(item),
typeToDdlType(type),
objectName(item),
);
try {
await navigator.clipboard?.writeText(ddl);
} catch {
// Ignore clipboard errors.
}
};
const handleViewDependencies = async () => {
setOpenKey(null);
const deps = await cmd.getObjectDependencies(
connectionId,
schemaOf(item),
typeToDdlType(type),
objectName(item),
);
setDepDeps(deps);
setDepOpen(true);
};
return (
<div
@@ -1341,6 +1402,44 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
<span className="flex-1 text-sm truncate">
{name}
</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
size={14}
className={`text-text-muted shrink-0 transition-transform ${
@@ -1399,6 +1498,13 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
</div>
)}
</div>
<DependencyDialog
open={depOpen}
deps={depDeps}
onProceed={() => setDepOpen(false)}
onCancel={() => setDepOpen(false)}
/>
</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);
detectPgTools()
.then((status) => setToolStatus(status))
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null }))
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null, pg_dump_source: null, pg_restore_source: null }))
.finally(() => setCheckingTools(false));
}, [open]);
+4 -1
View File
@@ -76,6 +76,8 @@ export function RestorePage({ connectionId }: RestorePageProps) {
pg_restore_found: false,
pg_dump_version: null,
pg_restore_version: null,
pg_dump_source: null,
pg_restore_source: null,
}),
)
.finally(() => setCheckingTools(false));
@@ -121,6 +123,7 @@ export function RestorePage({ connectionId }: RestorePageProps) {
}, [filePath, format, clean, schema, connectionId, startJob, notify]);
const toolsMissing = toolStatus && !toolStatus.pg_restore_found;
const toolsBundled = toolStatus?.pg_restore_source === "bundled";
const canStart = filePath && confirmed && !isRunning;
return (
@@ -146,7 +149,7 @@ export function RestorePage({ connectionId }: RestorePageProps) {
</div>
)}
{toolsMissing && (
{toolsMissing && !toolsBundled && (
<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">
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);
detectPgTools()
.then((status) => setToolStatus(status))
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null }))
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null, pg_dump_source: null, pg_restore_source: null }))
.finally(() => setCheckingTools(false));
}, [open]);
+6 -1
View File
@@ -54,6 +54,8 @@ export function SyncPage() {
pg_restore_found: false,
pg_dump_version: null,
pg_restore_version: null,
pg_dump_source: null,
pg_restore_source: null,
}),
)
.finally(() => setCheckingTools(false));
@@ -100,6 +102,9 @@ export function SyncPage() {
const toolsMissing =
toolStatus &&
(!toolStatus.pg_dump_found || !toolStatus.pg_restore_found);
const toolsBundled =
toolStatus?.pg_dump_source === "bundled" &&
toolStatus?.pg_restore_source === "bundled";
const canStart =
sourceConnectionId && targetConnectionId && confirmed && !isRunning;
@@ -130,7 +135,7 @@ export function SyncPage() {
</div>
)}
{toolsMissing && (
{toolsMissing && !toolsBundled && (
<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">
PostgreSQL tools not found
@@ -63,9 +63,11 @@ describe("TableOverflowMenu", () => {
});
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"} />);
fireEvent.click(screen.getByLabelText(/table options/i));
fireEvent.click(screen.getByText(/delete table/i));
await waitFor(() => expect(screen.queryByText(/open in new tab/i)).not.toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: /delete table/i }));
await waitFor(() => {
const q = useDbViewerStore.getState().changesQueue;
@@ -73,6 +75,15 @@ describe("TableOverflowMenu", () => {
});
});
it("Delete Table fetches dependencies and shows DependencyDialog before confirming", async () => {
vi.spyOn(commands, "getObjectDependencies").mockResolvedValue([{ deptype: "n", class: "pg_class", name: "v_orders" }]);
render(<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 () => {
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 }];
+21 -2
View File
@@ -6,7 +6,8 @@ import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useUiStore } from "../../stores/uiStore";
import { exportData } from "../../lib/exportData";
import * as cmd from "../../lib/commands";
import type { ColumnInfo } from "../../lib/types";
import { DependencyDialog } from "./DependencyDialog";
import type { ColumnInfo, DependencyInfo } from "../../lib/types";
interface TableOverflowMenuProps {
schema: string;
@@ -37,6 +38,7 @@ export function TableOverflowMenu({
const [open, setOpen] = useState(false);
const [confirmAction, setConfirmAction] = useState<"empty" | "delete" | null>(null);
const [dropDeps, setDropDeps] = useState<DependencyInfo[]>([]);
const [importOpen, setImportOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
@@ -98,10 +100,18 @@ export function TableOverflowMenu({
setConfirmAction("empty");
setOpen(false);
break;
case "delete":
case "delete": {
if (!connectionId) break;
try {
const deps = await cmd.getObjectDependencies(connectionId, schema, "table", table);
setDropDeps(deps);
} catch {
setDropDeps([]);
}
setConfirmAction("delete");
setOpen(false);
break;
}
default:
break;
}
@@ -146,6 +156,15 @@ export function TableOverflowMenu({
</div>
)}
{confirmAction === "delete" && dropDeps.length > 0 && (
<DependencyDialog
open
deps={dropDeps}
onProceed={() => setConfirmAction(null)}
onCancel={() => setConfirmAction(null)}
/>
)}
{confirmAction === "empty" && (
<ConfirmDialog
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,
getIndexes,
getConstraints,
createSchema,
renameSchema,
dropSchema,
searchObjects,
getObjectDdl,
getObjectDependencies,
} from "./commands";
import type { SchemaGraph } from "./types";
import type { QueryHistoryEntry } from "./commands";
@@ -292,3 +298,38 @@ describe("v0.5.0 command wrappers", () => {
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 type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection } from "./types";
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo } from "./types";
import type { FilterRule, SortRule } from "../stores/dbViewerStore";
import type { ChangePayload } from "./changePayload";
@@ -299,3 +299,24 @@ export async function getIndexes(connectionId: string, schema?: string): Promise
export async function getConstraints(connectionId: string, schema?: string): Promise<ConstraintInfo[]> {
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("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 readme from "../../README.md?raw";
describe("v0.7.0 docs coverage", () => {
describe("v0.7.5 docs coverage", () => {
it("AGENTS.md marks inline cell editing complete", () => {
expect(agents).toContain("Inline cell editing");
expect(agents).toMatch(/Inline cell editing \| ✅/);
@@ -24,8 +24,23 @@ describe("v0.7.0 docs coverage", () => {
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
});
it("README declares v0.7.0", () => {
expect(readme).toContain("0.7.0");
it("README declares v0.7.5", () => {
expect(readme).toContain("0.7.5");
});
it("AGENTS.md marks schema CRUD complete", () => {
expect(agents).toMatch(/Schema CRUD \| ✅/);
});
it("AGENTS.md marks global object search complete", () => {
expect(agents).toMatch(/Global object search \| ✅/);
});
it("AGENTS.md marks copy-as-DDL complete", () => {
expect(agents).toMatch(/Copy as DDL for any object \| ✅/);
});
it("AGENTS.md marks object dependencies complete", () => {
expect(agents).toMatch(/Object dependencies \| ✅/);
});
it("README notes bundled PostgreSQL tools", () => {
expect(readme.toLowerCase()).toContain("bundled");
});
it("README marks inline editing complete (not Upcoming)", () => {
// Key Features lists inline editing as a shipped feature
+28
View File
@@ -305,6 +305,34 @@ export interface PgToolStatus {
pg_restore_found: boolean;
pg_dump_version: string | null;
pg_restore_version: string | null;
pg_dump_source: string | null;
pg_restore_source: string | null;
}
export type PgObjectType =
| "table" | "view" | "materialized view" | "function" | "procedure"
| "trigger" | "sequence" | "enum" | "extension" | "index" | "constraint";
export type ObjectType =
| "functions"
| "triggers"
| "sequences"
| "enums"
| "extensions"
| "indexes"
| "constraints"
| "procedures";
export interface ObjectSearchHit {
name: string;
schema: string;
object_type: string; // TABLE | VIEW | MATERIALIZED VIEW | FUNCTION | PROCEDURE | TRIGGER | SEQUENCE | ENUM | EXTENSION | INDEX | CONSTRAINT
}
export interface DependencyInfo {
deptype: string;
class: string;
name: string;
}
export interface BackupJob {
+2 -2
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import pkg from "../../package.json";
describe("version", () => {
it("declares v0.7.0 across the app shell", () => {
expect(pkg.version).toBe("0.7.0");
it("declares v0.7.5 across the app shell", () => {
expect(pkg.version).toBe("0.7.5");
});
});
+13
View File
@@ -24,9 +24,22 @@ describe("dbViewerStore", () => {
expect(state.tables).toEqual([]);
expect(state.currentDatabase).toBeNull();
expect(state.currentSchema).toBeNull();
expect(state.objectSearchOpen).toBe(false);
expect(state.selectedObjectType).toBeNull();
expect(state.schemaTreeLoading).toBe(false);
});
it("object search open + selected object type setters", () => {
const { setObjectSearchOpen, setSelectedObjectType } =
useDbViewerStore.getState();
setObjectSearchOpen(true);
expect(useDbViewerStore.getState().objectSearchOpen).toBe(true);
setSelectedObjectType("functions");
expect(useDbViewerStore.getState().selectedObjectType).toBe("functions");
setSelectedObjectType(null);
expect(useDbViewerStore.getState().selectedObjectType).toBeNull();
});
it("openTab adds a new tab", () => {
const store = useDbViewerStore.getState();
store.openTab("public", "users");
+9 -1
View File
@@ -1,5 +1,5 @@
import { create } from "zustand";
import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, IndexInfo, ConstraintInfo } from "../lib/types";
import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, IndexInfo, ConstraintInfo, ObjectType } from "../lib/types";
import { getDatabases, getSchemas, getTables } from "../lib/commands";
// ─── Local types ────────────────────────────────────────────────
@@ -92,6 +92,8 @@ interface DbViewerState {
schemaTreeLoading: boolean;
currentDatabase: string | null;
currentSchema: string | null;
objectSearchOpen: boolean;
selectedObjectType: ObjectType | null;
functions: FunctionInfo[] | null;
triggers: TriggerInfo[] | null;
sequences: SequenceInfo[] | null;
@@ -141,6 +143,8 @@ interface DbViewerState {
toggleChangesPanel: () => void;
setCurrentDatabase: (db: string | null) => void;
setCurrentSchema: (schema: string | null) => void;
setObjectSearchOpen: (open: boolean) => void;
setSelectedObjectType: (t: ObjectType | null) => void;
setFunctions: (functions: FunctionInfo[]) => void;
setTriggers: (triggers: TriggerInfo[]) => void;
setSequences: (sequences: SequenceInfo[]) => void;
@@ -179,6 +183,8 @@ const initialState = {
tables: [] as TableInfo[],
currentDatabase: null as string | null,
currentSchema: null as string | null,
objectSearchOpen: false,
selectedObjectType: null as ObjectType | null,
functions: null as FunctionInfo[] | null,
triggers: null as TriggerInfo[] | null,
sequences: null as SequenceInfo[] | null,
@@ -433,6 +439,8 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
setCurrentDatabase: (db) => set({ currentDatabase: db }),
setCurrentSchema: (schema) => set({ currentSchema: schema }),
setObjectSearchOpen: (open) => set({ objectSearchOpen: open }),
setSelectedObjectType: (t) => set({ selectedObjectType: t }),
setFunctions: (functions) => set({ functions }),
setTriggers: (triggers) => set({ triggers }),
setSequences: (sequences) => set({ sequences }),