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.
@@ -63,6 +63,53 @@ jobs:
|
||||
- name: Install frontend dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Build PostgreSQL client tools (bundled)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PG_VER="16.4"
|
||||
OUT="src-tauri/resources/pg_tools"
|
||||
mkdir -p "$OUT"
|
||||
case "${{ matrix.platform }}" in
|
||||
ubuntu-22.04)
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y build-essential libreadline-dev zlib1g-dev flex bison
|
||||
curl -fsSL "https://ftp.postgresql.org/pub/source/v${PG_VER}/postgresql-${PG_VER}.tar.bz2" -o /tmp/pg.tar.bz2
|
||||
tar -xf /tmp/pg.tar.bz2 -C /tmp
|
||||
cd /tmp/postgresql-${PG_VER}
|
||||
./configure --prefix=/tmp/pgbuild --without-readline --without-icu --disable-shared CFLAGS="-O2"
|
||||
make -j"$(nproc)" -C src/bin/pg_dump all
|
||||
make -j"$(nproc)" -C src/bin/psql all
|
||||
cp src/bin/pg_dump/pg_dump src/bin/pg_dump/pg_restore "$OUT"/
|
||||
cp src/bin/psql/psql "$OUT"/
|
||||
;;
|
||||
macos-latest|macos-15-intel)
|
||||
curl -fsSL "https://ftp.postgresql.org/pub/source/v${PG_VER}/postgresql-${PG_VER}.tar.bz2" -o /tmp/pg.tar.bz2
|
||||
tar -xf /tmp/pg.tar.bz2 -C /tmp
|
||||
cd /tmp/postgresql-${PG_VER}
|
||||
./configure --prefix=/tmp/pgbuild --without-readline --without-icu --disable-shared CFLAGS="-O2"
|
||||
make -j"$(sysctl -n hw.ncpu)" -C src/bin/pg_dump all
|
||||
make -j"$(sysctl -n hw.ncpu)" -C src/bin/psql all
|
||||
cp src/bin/pg_dump/pg_dump src/bin/pg_dump/pg_restore "$OUT"/
|
||||
cp src/bin/psql/psql "$OUT"/
|
||||
;;
|
||||
windows-latest)
|
||||
URL="https://get.enterprisedb.com/postgresql/postgresql-${PG_VER}-1-windows-x64-binaries.zip"
|
||||
curl -fsSL "$URL" -o /tmp/pg.zip
|
||||
EXPECTED="3508d8f085bc3980f38211a82e3f31e5fcae9952105d3dc2f8be67b64a822baa"
|
||||
echo "$EXPECTED /tmp/pg.zip" | sha256sum -c -
|
||||
sha256sum /tmp/pg.zip
|
||||
unzip -o /tmp/pg.zip -d /tmp/pg
|
||||
cp /tmp/pg/pgsql/bin/pg_dump.exe /tmp/pg/pgsql/bin/pg_restore.exe /tmp/pg/pgsql/bin/psql.exe "$OUT"/
|
||||
cp /tmp/pg/pgsql/bin/libpq.dll "$OUT"/
|
||||
;;
|
||||
esac
|
||||
(cd "$OUT" && sha256sum * | tee checksums.txt)
|
||||
for b in pg_dump pg_restore psql; do
|
||||
f="$OUT/${b}$( [ "${{ matrix.platform }}" = windows-latest ] && echo .exe )"
|
||||
test -f "$f" || { echo "missing $f"; exit 1; }
|
||||
done
|
||||
|
||||
- name: Build and upload to GitHub Release
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
@@ -71,4 +118,10 @@ jobs:
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseName: 'Gridline ${{ github.ref_name }}'
|
||||
releaseDraft: true
|
||||
args: ${{ matrix.args }}
|
||||
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]'
|
||||
@@ -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) | ❌ | |
|
||||
|
||||
|
||||
@@ -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.0 — revamped the New Connection screen into a two-stage flow with a 6-provider grid (PostgreSQL, MySQL, SQLite, Redis, Supabase, NeonDB; managed presets ship with setup guides + SSL hints) and added full MySQL DB viewer support (connect, browse, query, inline cell editing + changes queue, DDL copy).
|
||||
- **2026-08-05:** v0.7.5 — bundled `pg_dump`/`pg_restore`/`psql` (system-first, bundled fallback) so admin features work with no separate install; schema CRUD (create/rename/drop with CASCADE + dependency warning); Cmd+K object search (current schema, all object types); copy-as-DDL for every browsable object type; `pg_depend` object-dependency view shown before destructive drops.
|
||||
- **2026-08-04:** v0.7.5 — revamped the New Connection screen into a two-stage flow with a 6-provider grid (PostgreSQL, MySQL, SQLite, Redis, Supabase, NeonDB; managed presets ship with setup guides + SSL hints) and added full MySQL DB viewer support (connect, browse, query, inline cell editing + changes queue, DDL copy).
|
||||
- **2026-08-04:** Revamped the built-in SQLite demo database with realistic e-commerce data (20 users, 24 products, 50 orders, 100 page views, 500 audit rows) and renamed it to **Gridline Demo (SQLite)**.
|
||||
- **2026-07-XX:** Added inline cell editing with a stage-first changes queue, row-detail drawer, keyboard navigation, and cell-level copy.
|
||||
- **2026-07-XX:** Added visual filter builder with drag-and-drop column palette and type-aware operators.
|
||||
@@ -112,6 +114,10 @@ Gridline is built for developers and small teams who manage multiple database en
|
||||
- **Functions & procedures** — syntax-highlighted source, argument signatures, overload disambiguation.
|
||||
- **Triggers, sequences, enums, extensions** — unified **Objects** view with type switcher.
|
||||
- **Indexes & constraints** — per-table index details plus CHECK/UNIQUE constraints beyond PK/FK.
|
||||
- **Schema CRUD** — create/rename/drop schemas from the object tree, with typed-name + dependency warning on CASCADE drops.
|
||||
- **Global object search** — ⌘K, current schema, all object types; results open a table tab or jump to the Objects view.
|
||||
- **Copy as DDL** — `CREATE` DDL for every browsable object type.
|
||||
- **Object dependencies** — `pg_depend` "what depends on this?" view before destructive drops.
|
||||
- **Schema visualizer** — interactive ER diagram with auto-layout, cardinality legend, and collapsible columns.
|
||||
|
||||
### Data Grid
|
||||
@@ -140,6 +146,7 @@ Gridline is built for developers and small teams who manage multiple database en
|
||||
- **Visual Backup** — `pg_dump` wrapper with format selector, schema filter, no-owner toggle, real-time progress.
|
||||
- **Visual Restore** — `pg_restore` wrapper with clean toggle and destructive confirmation.
|
||||
- **DB-to-DB Sync** — pipe `pg_dump` → `pg_restore` between two connections.
|
||||
- **Bundled client tools** — `pg_dump`/`pg_restore`/`psql` ship with the app; system tools are preferred when present, bundled tools are the fallback.
|
||||
|
||||
---
|
||||
|
||||
@@ -218,7 +225,7 @@ Capabilities below are fact-checked against each vendor's official docs and pric
|
||||
| **Desktop shell** | [Tauri 2.0](https://tauri.app) | Native webview container (~40 MB baseline) |
|
||||
| **Backend** | Rust + [tokio](https://tokio.rs) | Async runtime, connection pooling, CLI execution |
|
||||
| **DB drivers** | [sqlx](https://github.com/launchbadge/sqlx) / [tokio-postgres](https://github.com/sfackler/rust-postgres) | PostgreSQL via tokio-postgres; MySQL via sqlx; SQLite via rusqlite |
|
||||
| **CLI integration** | `std::process::Command` | Wraps system `pg_dump` / `pg_restore` |
|
||||
| **CLI integration** | `std::process::Command` | Wraps bundled or system `pg_dump` / `pg_restore` |
|
||||
| **Frontend** | [React 19](https://react.dev) + [TypeScript](https://www.typescriptlang.org) | Component-based UI |
|
||||
| **Styling** | [Tailwind CSS](https://tailwindcss.com) | Utility-first, dark mode, glassmorphic design |
|
||||
| **State** | [Zustand](https://zustand.docs.pmnd.rs) / Jotai | Domain stores |
|
||||
@@ -248,16 +255,16 @@ Code signing **will be added in the future** (Apple Developer Program + a Window
|
||||
|
||||
#### Which file should I download?
|
||||
|
||||
Each release contains **one file per platform** — you only need the one that matches your computer. (If a newer version is available, swap `0.7.0` for the version shown in the release title.)
|
||||
Each release contains **one file per platform** — you only need the one that matches your computer. The links below always point at the newest build (the asset names are version-free, so they never go stale):
|
||||
|
||||
| Your system | Download this | Notes |
|
||||
| :--- | :--- | :--- |
|
||||
| macOS **Apple Silicon** (M1/M2/M3/M4…) | `Gridline_0.7.0_aarch64.dmg` | `aarch64` = Apple's own chip |
|
||||
| macOS **Intel** | `Gridline_0.7.0_x64.dmg` | `x64` = Intel/AMD |
|
||||
| **Windows** (most PCs) | `Gridline_0.7.0_x64-setup.exe` | The `.msi` is an alternate installer (for enterprises/IT admins) |
|
||||
| **Debian / Ubuntu** | `Gridline_0.7.0_amd64.deb` | Install: `sudo apt install ./Gridline_0.7.0_amd64.deb` |
|
||||
| **Fedora / RHEL / openSUSE** | `Gridline-0.7.0-1.x86_64.rpm` | Install: `sudo dnf install Gridline-0.7.0-1.x86_64.rpm` |
|
||||
| **Any other Linux** | `Gridline_0.7.0_amd64.AppImage` | Works on every distro: `chmod +x` the file, then double-click it |
|
||||
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_darwin_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_darwin_aarch64.dmg) | `aarch64` = Apple's own chip |
|
||||
| macOS **Intel** | [Gridline_darwin_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_darwin_x64.dmg) | `x64` = Intel/AMD |
|
||||
| **Windows** (most PCs) | [Gridline_windows_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_windows_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
|
||||
| **Debian / Ubuntu** | [Gridline_linux_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_linux_amd64.deb) | Install: `sudo apt install ./Gridline_linux_amd64.deb` |
|
||||
| **Fedora / RHEL / openSUSE** | [Gridline_linux_x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_linux_x86_64.rpm) | Install: `sudo dnf install Gridline_linux_x86_64.rpm` |
|
||||
| **Any other Linux** | [Gridline_linux_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_linux_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it |
|
||||
|
||||
**Not sure if your Mac is Intel or Apple Silicon?** Click the **Apple menu** → **About This Mac**. If it shows "Apple M1/M2/M3/M4…" download the `aarch64` file; if it shows an Intel chip, download `x64`. Downloading the wrong one won't run.
|
||||
|
||||
@@ -267,8 +274,8 @@ Cutting a release is one command — CI builds everything. **Releases are cut fr
|
||||
|
||||
```bash
|
||||
git checkout prod && git pull
|
||||
git tag v0.7.0
|
||||
git push origin v0.7.0
|
||||
git tag v0.7.5
|
||||
git push origin v0.7.5
|
||||
```
|
||||
|
||||
GitHub Actions (`.github/workflows/release.yml`) builds installers for **Apple Silicon, Intel Macs, Windows, and Linux**, then opens a **draft release** on the [Releases](https://github.com/adrianbonpin/gridline/releases) page — review it and hit **Publish release**.
|
||||
@@ -298,7 +305,7 @@ bun run tauri build
|
||||
- **Windows:** 10 or newer
|
||||
- **Linux:** Ubuntu 22.04+ or equivalent modern distribution
|
||||
- **RAM:** 8 GB recommended
|
||||
- **PostgreSQL client tools:** `pg_dump` and `pg_restore` are required for admin features
|
||||
- **PostgreSQL client tools:** `pg_dump`/`pg_restore`/`psql` are **bundled** with Gridline — no separate install required for backup/restore/sync. (System tools, if installed, are preferred.)
|
||||
|
||||
---
|
||||
|
||||
@@ -352,14 +359,14 @@ gridline/
|
||||
|
||||
## Roadmap
|
||||
|
||||
The full plan — in-development (v0.7.0), next-up, queue, and shipped history — lives in **[ROADMAP.md](./ROADMAP.md)**.
|
||||
The full plan — in-development (v0.7.5), next-up, queue, and shipped history — lives in **[ROADMAP.md](./ROADMAP.md)**.
|
||||
|
||||
Highlights of what's next:
|
||||
|
||||
- **Full Object Management** — CRUD on functions, triggers, sequences, enums, extensions, and views without the Query tab, plus schema CRUD, global object search, and copy-as-DDL
|
||||
- **Full Object Management** — CRUD on functions, triggers, sequences, enums, extensions, and views without the Query tab (schema CRUD, global object search, copy-as-DDL, and object dependencies shipped in v0.7.5)
|
||||
- **Full Redis support** — key browser, type-aware value editors, TTL management
|
||||
- **More database types** — MariaDB, TimescaleDB, and friends
|
||||
- **Managed DB support** — PlanetScale, Turso (Supabase/Neon presets shipped in v0.7.0)
|
||||
- **Managed DB support** — PlanetScale, Turso (Supabase/Neon presets shipped in v0.7.5)
|
||||
- **AI integration (BYOK)** — natural-language → SQL, chat, summaries, charts
|
||||
|
||||
✅ **[View the full roadmap →](./ROADMAP.md)**
|
||||
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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';
|
||||
@@ -1783,7 +1783,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridline"
|
||||
version = "0.7.0"
|
||||
version = "0.7.5"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deadpool-postgres",
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 1002 B After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 74 KiB |
@@ -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.
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::process::{Command, Stdio};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
|
||||
use crate::models::backup::*;
|
||||
|
||||
@@ -51,20 +51,63 @@ fn sanitize_error(s: &str) -> String {
|
||||
crate::commands::test_connection::sanitize_error(s)
|
||||
}
|
||||
|
||||
fn bundled_bin_name(tool: &str) -> String {
|
||||
if cfg!(windows) { format!("{tool}.exe") } else { tool.to_string() }
|
||||
}
|
||||
|
||||
/// Pure resolution decision (unit-testable): system > bundled > bare name.
|
||||
fn pick_tool(system_ok: bool, bundled: Option<&str>, tool: &str) -> (String, Option<String>) {
|
||||
if system_ok { return (tool.to_string(), Some("system".to_string())); }
|
||||
if let Some(b) = bundled { return (b.to_string(), Some("bundled".to_string())); }
|
||||
(tool.to_string(), None)
|
||||
}
|
||||
|
||||
/// System-first, bundled-fallback resolver. Returns (command_to_invoke, source).
|
||||
pub fn resolve_tool(app: &AppHandle, tool: &str) -> (String, Option<String>) {
|
||||
let system_ok = Command::new(tool).arg("--version").output().is_ok();
|
||||
let bundled = app.path().resource_dir().ok()
|
||||
.map(|rd| rd.join("pg_tools").join(bundled_bin_name(tool)))
|
||||
.filter(|p| p.exists())
|
||||
.map(|p| p.to_string_lossy().to_string());
|
||||
pick_tool(system_ok, bundled.as_deref(), tool)
|
||||
}
|
||||
|
||||
pub fn resolve_tool_paths(app: &AppHandle) -> PgToolPaths {
|
||||
let (d, _) = resolve_tool(app, "pg_dump");
|
||||
let (r, _) = resolve_tool(app, "pg_restore");
|
||||
let (p, _) = resolve_tool(app, "psql");
|
||||
PgToolPaths { pg_dump: d, pg_restore: r, psql: p }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detect_pg_tools
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub fn detect_pg_tools() -> PgToolStatus {
|
||||
/// Shapes the tool status from resolved tool paths + sources. Headless so the
|
||||
/// Tauri command stays thin and the status logic stays unit-testable.
|
||||
fn build_pg_tool_status(
|
||||
dump: &str,
|
||||
restore: &str,
|
||||
dump_src: Option<String>,
|
||||
restore_src: Option<String>,
|
||||
) -> PgToolStatus {
|
||||
PgToolStatus {
|
||||
pg_dump_found: Command::new("pg_dump").arg("--version").output().is_ok(),
|
||||
pg_restore_found: Command::new("pg_restore").arg("--version").output().is_ok(),
|
||||
pg_dump_version: get_version("pg_dump"),
|
||||
pg_restore_version: get_version("pg_restore"),
|
||||
pg_dump_found: Command::new(dump).arg("--version").output().is_ok(),
|
||||
pg_restore_found: Command::new(restore).arg("--version").output().is_ok(),
|
||||
pg_dump_version: get_version(dump),
|
||||
pg_restore_version: get_version(restore),
|
||||
pg_dump_source: dump_src,
|
||||
pg_restore_source: restore_src,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn detect_pg_tools(app_handle: AppHandle) -> PgToolStatus {
|
||||
let (dump, dump_src) = resolve_tool(&app_handle, "pg_dump");
|
||||
let (restore, restore_src) = resolve_tool(&app_handle, "pg_restore");
|
||||
build_pg_tool_status(&dump, &restore, dump_src, restore_src)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core logic (headless-testable — no Tauri, no store, no keychain)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -132,11 +175,11 @@ fn build_restore_args(conn: &PgConnParams, options: &RestoreOptions) -> Vec<Stri
|
||||
|
||||
/// Runs `pg_dump` against `conn`, writing to `options.file_path`.
|
||||
/// Returns `Ok(())` on success or a sanitized error message.
|
||||
pub fn run_pg_dump(conn: &PgConnParams, options: &BackupOptions) -> Result<(), String> {
|
||||
pub fn run_pg_dump(conn: &PgConnParams, options: &BackupOptions, tools: &PgToolPaths) -> Result<(), String> {
|
||||
let mut args = build_dump_args(conn, options);
|
||||
args.push(format!("--file={}", options.file_path));
|
||||
|
||||
let result = Command::new("pg_dump")
|
||||
let result = Command::new(&tools.pg_dump)
|
||||
.env("PGPASSWORD", &conn.password)
|
||||
.args(&args)
|
||||
.output();
|
||||
@@ -154,9 +197,9 @@ pub fn run_pg_dump(conn: &PgConnParams, options: &BackupOptions) -> Result<(), S
|
||||
/// Plain-format dumps are SQL text and cannot be read by `pg_restore` — they
|
||||
/// are executed with `psql` instead. The `clean` option is only honored for
|
||||
/// archive formats (custom/tar/directory); the UI disables it for plain.
|
||||
pub fn run_pg_restore(conn: &PgConnParams, options: &RestoreOptions) -> Result<(), String> {
|
||||
pub fn run_pg_restore(conn: &PgConnParams, options: &RestoreOptions, tools: &PgToolPaths) -> Result<(), String> {
|
||||
if options.format == "plain" {
|
||||
let result = Command::new("psql")
|
||||
let result = Command::new(&tools.psql)
|
||||
.env("PGPASSWORD", &conn.password)
|
||||
.args([
|
||||
format!("--host={}", conn.host),
|
||||
@@ -177,7 +220,7 @@ pub fn run_pg_restore(conn: &PgConnParams, options: &RestoreOptions) -> Result<(
|
||||
let mut args = build_restore_args(conn, options);
|
||||
args.push(options.file_path.clone());
|
||||
|
||||
let result = Command::new("pg_restore")
|
||||
let result = Command::new(&tools.pg_restore)
|
||||
.env("PGPASSWORD", &conn.password)
|
||||
.args(&args)
|
||||
.output();
|
||||
@@ -196,6 +239,7 @@ pub fn run_db_sync(
|
||||
target: &PgConnParams,
|
||||
schema: Option<&str>,
|
||||
tables: Option<&[String]>,
|
||||
tools: &PgToolPaths,
|
||||
) -> Result<(), String> {
|
||||
// --- Build pg_dump args ---
|
||||
let mut dump_args = base_conn_args(source);
|
||||
@@ -221,7 +265,7 @@ pub fn run_db_sync(
|
||||
restore_args.push("--if-exists".into());
|
||||
|
||||
// --- Spawn pg_dump with piped stdout ---
|
||||
let mut dump_child = Command::new("pg_dump")
|
||||
let mut dump_child = Command::new(&tools.pg_dump)
|
||||
.env("PGPASSWORD", &source.password)
|
||||
.args(&dump_args)
|
||||
.stdout(Stdio::piped())
|
||||
@@ -243,7 +287,7 @@ pub fn run_db_sync(
|
||||
});
|
||||
|
||||
// --- Run pg_restore with pg_dump stdout as stdin ---
|
||||
let restore_result = Command::new("pg_restore")
|
||||
let restore_result = Command::new(&tools.pg_restore)
|
||||
.env("PGPASSWORD", &target.password)
|
||||
.args(&restore_args)
|
||||
.stdin(dump_stdout)
|
||||
@@ -332,9 +376,10 @@ pub async fn pg_dump(
|
||||
|
||||
let job_id_clone = job_id.clone();
|
||||
let app_handle_clone = app_handle.clone();
|
||||
let tools = resolve_tool_paths(&app_handle);
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = run_pg_dump(¶ms, &options);
|
||||
let result = run_pg_dump(¶ms, &options, &tools);
|
||||
emit_result(&app_handle_clone, &job_id_clone, result);
|
||||
});
|
||||
|
||||
@@ -374,9 +419,10 @@ pub async fn pg_restore(
|
||||
|
||||
let job_id_clone = job_id.clone();
|
||||
let app_handle_clone = app_handle.clone();
|
||||
let tools = resolve_tool_paths(&app_handle);
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = run_pg_restore(¶ms, &options);
|
||||
let result = run_pg_restore(¶ms, &options, &tools);
|
||||
emit_result(&app_handle_clone, &job_id_clone, result);
|
||||
});
|
||||
|
||||
@@ -464,9 +510,10 @@ pub async fn db_sync(
|
||||
let tables = options.tables.clone();
|
||||
let job_id_clone = job_id.clone();
|
||||
let app_handle_clone = app_handle.clone();
|
||||
let tools = resolve_tool_paths(&app_handle);
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = run_db_sync(&source, &target, schema.as_deref(), tables.as_deref());
|
||||
let result = run_db_sync(&source, &target, schema.as_deref(), tables.as_deref(), &tools);
|
||||
emit_result(&app_handle_clone, &job_id_clone, result);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"); }
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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,5 +1,6 @@
|
||||
pub mod introspection;
|
||||
pub mod mysql;
|
||||
pub mod object_ddl;
|
||||
pub mod pool;
|
||||
pub mod tls;
|
||||
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 }];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,12 @@ import {
|
||||
clearRecentConnections,
|
||||
getIndexes,
|
||||
getConstraints,
|
||||
createSchema,
|
||||
renameSchema,
|
||||
dropSchema,
|
||||
searchObjects,
|
||||
getObjectDdl,
|
||||
getObjectDependencies,
|
||||
} from "./commands";
|
||||
import type { SchemaGraph } from "./types";
|
||||
import type { QueryHistoryEntry } from "./commands";
|
||||
@@ -291,4 +297,39 @@ describe("v0.5.0 command wrappers", () => {
|
||||
await getConstraints("c1", "public");
|
||||
expect(mockInvoke).toHaveBeenCalledWith("get_constraints", { connectionId: "c1", schema: "public" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("object management commands (v0.7.5)", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("createSchema calls invoke with name", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce(undefined);
|
||||
await createSchema("c1", "my_schema");
|
||||
expect(invoke).toHaveBeenCalledWith("create_schema", { connectionId: "c1", name: "my_schema" });
|
||||
});
|
||||
it("renameSchema maps old/new names", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce(undefined);
|
||||
await renameSchema("c1", "old", "new");
|
||||
expect(invoke).toHaveBeenCalledWith("rename_schema", { connectionId: "c1", oldName: "old", newName: "new" });
|
||||
});
|
||||
it("dropSchema passes cascade", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce(undefined);
|
||||
await dropSchema("c1", "s", true);
|
||||
expect(invoke).toHaveBeenCalledWith("drop_schema", { connectionId: "c1", name: "s", cascade: true });
|
||||
});
|
||||
it("searchObjects maps query+schema", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce([]);
|
||||
await searchObjects("c1", "public", "user");
|
||||
expect(invoke).toHaveBeenCalledWith("search_objects", { connectionId: "c1", schema: "public", query: "user" });
|
||||
});
|
||||
it("getObjectDdl maps objectType+name", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce("CREATE SEQUENCE ...");
|
||||
await getObjectDdl("c1", "public", "sequence", "users_id_seq");
|
||||
expect(invoke).toHaveBeenCalledWith("get_object_ddl", { connectionId: "c1", schema: "public", objectType: "sequence", name: "users_id_seq" });
|
||||
});
|
||||
it("getObjectDependencies maps objectType+name", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce([]);
|
||||
await getObjectDependencies("c1", "public", "table", "orders");
|
||||
expect(invoke).toHaveBeenCalledWith("get_object_dependencies", { connectionId: "c1", schema: "public", objectType: "table", name: "orders" });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection } from "./types";
|
||||
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo } from "./types";
|
||||
import type { FilterRule, SortRule } from "../stores/dbViewerStore";
|
||||
import type { ChangePayload } from "./changePayload";
|
||||
|
||||
@@ -298,4 +298,25 @@ export async function getIndexes(connectionId: string, schema?: string): Promise
|
||||
|
||||
export async function getConstraints(connectionId: string, schema?: string): Promise<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 });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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 }),
|
||||
|
||||