11 Commits
Author SHA1 Message Date
adrianbonpin 0c74d77e75 v0.7.6: PostgreSQL object management, keychain toggle, tabbed Objects workspace (#12)
Release / publish (, ubuntu-22.04) (push) Failing after 14m42s
Release / publish (, windows-latest) (push) Canceled after 0s
Release / publish (--target aarch64-apple-darwin, macos-latest) (push) Canceled after 0s
Release / publish (--target x86_64-apple-darwin, macos-15-intel) (push) Canceled after 0s
* [P1-T1] Change::Ddl Rust variant + execute_change arm

* [P1-T2] Frontend ddl change type + objectCrud capability

* [P1-T3] use_keychain data model + migration v8

* [P2-T1] object_crud skeleton + validators + build_ddl dispatch

* [P2-T2] Sequence builders

* [P2-T3] Enum builders (no value removal)

* [P2-T4] View / matview / extension builders

* [P2-T5] Index + constraint builders

* [P2-T6] Function / procedure + trigger builders

* [P2-T7] build_object_ddl + get_available_extensions commands + wrappers

* [P3-T1] conditional keychain + session passwords

* [P3-T2] keychain-off password prompt on connect

* [P3-T3] default-ON keychain opt-out + tooltip + modal conditional

* [P3-T4] ddl queue card + after-commit refetch

* [P4-T1] ObjectCrudDialog shell

* [P4-T2] SequenceForm

* [P4-T3] EnumForm with no-removal note

* [P4-T4] ExtensionForm with available-extensions picker

* [P4-T5] ViewForm (view + materialized view)

* [P5-T1] IndexForm with column picker

* [P5-T2] ConstraintForm (check/unique/pk/fk) + ColumnPicker

* [P5-T3] FunctionForm (function + procedure)

* [P5-T4] TriggerForm with trigger-function picker

* [P5-T5] ObjectContextMenu + Explorer/TableOverflowMenu CRUD wiring

* [P6-T1] object tab type + openObjectTab dedup

* [P6-T2] extract ObjectDetail for object tabs

* [P6-T3] Objects view two-pane sidebar + workspace

* [P6-T4] object-tab content + per-type tab icons

* [P7-T1] Version bump 0.7.5 -> 0.7.6

* [P7-T2] docs sync README/ROADMAP/AGENTS for v0.7.6

* [P7-T3] chore: Cargo.lock version sync 0.7.5 -> 0.7.6

* fix(ui): object tab icon stacks above name (preflight svg block)

* fix(ui): optically center object tab icon with name

* fix(ui): object tab icon matches query/table icon handling

* [UI-POLISH-1] objectForm tab type + openFormTab store action

* [UI-POLISH-2] ObjectFormTab + KindForm with Visual/SQL toggle

* [UI-POLISH-3] route create/edit through form tabs; remove modal

* docs: create/edit now open as form tabs (AGENTS sync)

* [FB-1] follow app styling patterns + schema dropdown in forms

* [FB-2] Monaco editor for function body + view definition

* [FB-3] form tabs styled like viewers + in-cell editing

* [FB-5] focus outline scoped to input area (label excluded)

* [FB-6] no amber focus outline on Monaco body/definition rows

* [FB-7] header dedupe + schema default + full edit prefill

* [FB-8] SQL view in read-only Monaco editor

* docs: roadmap — table create/edit + relationships (next)

* docs: roadmap — Admin follow-up is 0.7.7 (next after 0.7.6)
2026-08-06 22:56:49 +08:00
adrianbonpin 8d8ed78202 docs(release): static versioned README download links
The releaseAssetNamePattern input was never honored by tauri-action —
the draft release got default versioned asset names (Gridline_0.7.5_*.dmg,
Gridline-0.7.5-1.x86_64.rpm, ...), so the version-free releases/latest
links in the README would 404. Per user decision, drop the dynamic
scheme: both README download tables now link statically to the v0.7.5
assets, with a MAINTENANCE comment and an AGENTS.md checklist entry
reminding to bump both tables on each release. Remove the dead
releaseAssetNamePattern config from release.yml.
2026-08-05 22:13:41 +08:00
adrianbonpin 0c8c240384 fix(release): serial shared-deps build, macOS strchrnul fallback, copy all Windows DLLs
Release / publish (, ubuntu-22.04) (push) Failing after 15m5s
Release / publish (--target aarch64-apple-darwin, macos-latest) (push) Canceled after 0s
Release / publish (--target x86_64-apple-darwin, macos-15-intel) (push) Canceled after 0s
Release / publish (, windows-latest) (push) Canceled after 0s
Three independent pg-tools step failures on the 5th release run:

- Linux: 'ar: libpgcommon_srv.a: file format not recognized' — parallel
  `make -C` tool builds race each other's recursion into src/common
  (rm -f / ar crs on the same archive). Pre-build generated-headers +
  libpq (which pulls in common/port) serially so the -j tool builds find
  the archives up to date.
- macOS x86_64: strchrnul marked 'introduced in macOS 15.4' by Xcode 16.4
  fails -Werror=unguarded-availability-new and would crash at runtime on
  macOS < 15.4. Force ac_cv_func_strchrnul=no and rename PG16's inline
  fallback (pg_strchrnul) so it compiles on any SDK without touching the
  system symbol.
- Windows: EDB pg_dump.exe needs libcrypto/libssl/libiconv/libintl/
  libwinpthread/liblz4/libzstd/ICU DLLs; copy every bin/*.dll, not just
  libpq.dll.

All three verified locally (macOS: full build + tools run from resource
dir; YAML + bash -n for all platform variants).
2026-08-05 21:52:45 +08:00
adrianbonpin 0b8b11cef3 fix(release): set -e abort on command-substitution exit code
The per-platform binary-suffix check used
f="$OUT/${b}$( [ platform = windows-latest ] && echo .exe )".
The [ ] test exits 1 when false (linux/macos), and under 'set -euo
pipefail' the failing command substitution aborts the whole step before
any check output — exit 1 right after checksums with no error message.
Replace with an if/else BIN_EXT variable set before the loops.
Verified locally: full script block (checksums -> file check -> sanity)
passes with real pg_dump/pg_restore/psql + libpq, exit 0.
2026-08-05 21:31:04 +08:00
adrianbonpin 0d6c9a039a fix(release): correct $ORIGIN rpath and awk field ref in pg-tools step
- patchelf was passed '\$ORIGIN' (backslash-dollar inside single quotes),
  so the ELF rpath was a literal \$ORIGIN path — the loader never expanded
  it and the tools couldn't find libpq at runtime. Use '$ORIGIN'.
- macOS awk program had \$1, which awk rejects as a syntax error; use $1
  (verified locally: otool -> install_name_tool -> all 3 tools run).
2026-08-05 21:22:28 +08:00
adrianbonpin 1d72a174dd fix(release): use absolute OUT path for pg-tools step
cp ran from /tmp/postgresql-16.4 after 'cd', so the relative
src-tauri/resources/pg_tools target didn't exist. Use
${GITHUB_WORKSPACE}/src-tauri/resources/pg_tools so copies land in the repo.
2026-08-05 21:15:52 +08:00
adrianbonpin cebd548568 fix(release): source-build pg tools + Cmd+K palette select/navigation
CI (release.yml) — pg-tools bundle step failed on Linux/macOS with
'catalog/pg_*_d.h not found': building src/bin/pg_dump directly raced its
generated-headers prerequisite under -j. Fix: run 'make -C src/backend
generated-headers' serially first. Also drop the unrecognized --disable-shared
flag (PG16 client tools link shared libpq) and bundle libpq alongside the
tools — @loader_path rewrite via install_name_tool on macOS, $ORIGIN rpath
via patchelf on Linux — plus a 'tools run' sanity check so a broken bundle
fails the step before release. Windows (EDB download + libpq.dll) already
passed. Verified locally: all 3 tools run from the bundled dir.

Cmd+K palette — selecting a result did nothing visible:
- non-table results set selectedObjectType but never switched the view (the
  Objects page only mounts when DbViewerScreen's local currentView ===
  'objects'), so nothing happened. Added a store 'requestedView' field that
  DbViewerScreen consumes and clears; the palette now requests the right view
  ('objects' for functions/triggers/sequences/enums/etc., 'db-viewer' for
  tables/views/matviews before openTab) so the tab or Objects list actually
  appears.
- Added keyboard navigation: ↑/↓ move the highlight (wrapping), Enter picks,
  Esc closes. scrollIntoView guarded with optional call so jsdom tests don't
  crash on the ref callback.
- Tests: palette select now asserts requestedView; new ArrowDown/Enter,
  ArrowUp-wrap, empty-Enter cases; store test for requestedView setter.
2026-08-05 21:11:10 +08:00
adrianbonpin d84c70a1be docs(roadmap): log Cmd+K palette fixes for 0.7.6 (click-does-nothing + arrow-key nav) 2026-08-05 20:53:30 +08:00
adrianbonpin 04e8ed3300 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.
2026-08-05 20:46:36 +08:00
adrianbonpin cbc54119ed docs: note to keep README download tables + version in sync on releases 2026-08-04 22:22:51 +08:00
adrianbonpin 89fd35f697 docs: add per-OS download table to top of README 2026-08-04 22:19:06 +08:00
128 changed files with 9306 additions and 1169 deletions
+109 -1
View File
@@ -63,6 +63,110 @@ 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="${GITHUB_WORKSPACE}/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 patchelf
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 CFLAGS="-O2"
# Build the shared prerequisites (catalog headers + libpq/common/
# port archives) SERIALLY FIRST. Parallel `make -C` runs recurse
# into src/common concurrently and race `rm -f` / `ar crs` on the
# same .a — intermittently failing with 'ar: libpgcommon_srv.a:
# file format not recognized'. Once the archives exist, the tools'
# -j builds find them up to date and those recursions are no-ops.
make -C src/backend generated-headers
make -C src/interfaces/libpq all
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"/
# pg_dump links shared libpq (PG16 has no --disable-shared for the
# client tools); bundle libpq.so alongside and point the loader at
# the app's resource dir via $ORIGIN rpath.
cp src/interfaces/libpq/libpq.so.5 "$OUT"/libpq.so.5
for b in pg_dump pg_restore psql; do
patchelf --set-rpath '$ORIGIN' "$OUT/$b"
done
;;
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}
# ac_cv_func_strchrnul=no: Xcode 16.4's SDK marks strchrnul as
# introduced in macOS 15.4, so configure detects it and PG's
# snprintf.c calls the system symbol — which fails to compile
# (-Werror=unguarded-availability-new) and would crash at runtime
# on macOS < 15.4. Forcing the check off makes PG use its own
# inline fallback instead.
ac_cv_func_strchrnul=no ./configure --prefix=/tmp/pgbuild --without-readline --without-icu CFLAGS="-O2"
# macOS SDKs always declare strchrnul in <string.h> (Xcode 16.4
# marks it 'introduced in macOS 15.4'), so even with HAVE_STRCHRNUL
# disabled PG16's static-inline fallback would collide with the
# header declaration on newer SDKs. Rename PG's fallback so the
# tools compile on any SDK and never touch the (15.4+ only)
# system symbol.
sed -i '' 's/strchrnul/pg_strchrnul/g' src/port/snprintf.c
# Serial shared-prerequisite build first (see the Linux block: the
# archives must exist before the -j tool builds recurse into
# src/common).
make -C src/backend generated-headers
make -C src/interfaces/libpq all
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"/
cp src/interfaces/libpq/libpq.5.dylib "$OUT"/libpq.5.dylib
# Rewrite the absolute /tmp/pgbuild libpq install_name to a
# relative @loader_path so the tools find libpq next to themselves.
for b in pg_dump pg_restore psql; do
libpq=$(otool -L "$OUT/$b" | awk '/libpq/ {print $1; exit}')
install_name_tool -change "$libpq" "@loader_path/libpq.5.dylib" "$OUT/$b"
done
;;
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"/
# pg_dump.exe needs far more than libpq.dll (libcrypto-3-x64.dll,
# libssl-3-x64.dll, libiconv-2.dll, libintl-9.dll,
# libwinpthread-1.dll, liblz4.dll, libzstd.dll, ICU DLLs, ...);
# copy every DLL next to the tools.
cp /tmp/pg/pgsql/bin/*.dll "$OUT"/
;;
esac
(cd "$OUT" && sha256sum * | tee checksums.txt)
# Resolve the per-platform binary suffix WITHOUT a command
# substitution: `$( [ ... ] && echo .exe )` returns exit 1 when the
# test is false, and under `set -e` that aborts the whole step.
if [ "${{ matrix.platform }}" = windows-latest ]; then
BIN_EXT=".exe"
else
BIN_EXT=""
fi
for b in pg_dump pg_restore psql; do
f="$OUT/${b}${BIN_EXT}"
test -f "$f" || { echo "missing $f"; exit 1; }
done
# Sanity: every tool must run (loader path is correct) — this catches
# a wrong @loader_path / rpath before we ship a broken bundle.
for b in pg_dump pg_restore psql; do
"$OUT/${b}${BIN_EXT}" --version >/dev/null 2>&1 || { echo "$b failed to run from resource dir"; exit 1; }
done
- name: Build and upload to GitHub Release
uses: tauri-apps/tauri-action@v0
env:
@@ -71,4 +175,8 @@ jobs:
tagName: ${{ github.ref_name }}
releaseName: 'Gridline ${{ github.ref_name }}'
releaseDraft: true
args: ${{ matrix.args }}
args: ${{ matrix.args }}
# Assets use tauri-action's default versioned naming (e.g.
# Gridline_0.7.5_aarch64.dmg, Gridline-0.7.5-1.x86_64.rpm) and the
# README download tables link to them statically per release —
# remember to update both README tables when cutting a new version.
+19 -1
View File
@@ -149,6 +149,16 @@ cargo build # Rust backend only (from src-tauri/)
cargo test # Rust tests
```
### Releases
Cut a release from the **`prod`** branch (never feature branches) by tagging it — `git tag vN.M.N && git push origin vN.M.N`. GitHub Actions (`release.yml`) builds installers for macOS (Apple Silicon + Intel), Windows, and Linux and opens a **draft** release (review + publish on GitHub).
**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
- **README download links are static (versioned)** — both download tables (top **Download** section + **Which file should I download?**) link directly to the release-tag assets (`releases/download/v0.7.6/<file>`). tauri-action uses default versioned asset names (`Gridline_<ver>_aarch64.dmg`, `Gridline-<ver>-1.x86_64.rpm`, etc.) — update BOTH tables to the new names on every release (see the MAINTENANCE comment in README.md).
- **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
1. Define the command function in the appropriate `src-tauri/src/commands/` module
@@ -171,7 +181,7 @@ cargo test # Rust tests
## 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
@@ -200,6 +210,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| DB Viewer: MySQL browse + query + edit | ✅ | Full viewer: connect (SSL + SSH tunnel), databases/tables/columns/FKs, query + pagination, inline cell editing + changes queue, DDL copy (`SHOW CREATE TABLE`), CSV/JSON import — added in v0.7.0. PK-only editing (no ctid equivalent); VARBINARY `information_schema` columns decoded correctly |
| DB Viewer: Redis browse | ❌ | Connection + test only; browsing gated off with a clean "not supported" state (v0.7.0) |
| Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager |
| Enable keychain toggle | ✅ | Default ON (opt-out); OFF = don't persist the DB password (session-only, re-prompt on connect) + purge existing keychain entry; SSH secrets stay keychain-only (v0.7.6) |
| SSH tunnel config UI | ✅ | Host, port, user, auth method, key path, passphrase fields |
| SSH tunnel runtime | ✅ | Real ssh2 tunnel (password + key auth), binds 127.0.0.1 only, secrets in OS keychain (`ssh_password:<id>` / `ssh_passphrase:<id>`), closed on pool eviction / app exit; TLS downgraded to `require` through the tunnel |
| SSL/TLS config UI | ✅ | Mode (disable/require/verify-ca/verify-full), cert paths |
@@ -276,6 +287,12 @@ 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) |
| Object management CRUD | ✅ | Right-click / ⋮ create/edit/drop for every PG object type; create/edit open as **workspace tabs** with a Visual ⇄ SQL toggle, staged through the changes queue with generated-SQL preview (v0.7.6). Enum value removal unsupported (PG has no DROP VALUE) |
| Objects view tabbed workspace | ✅ | Objects view uses the shared tabbed workspace: object details open as tabs (per-type icons), inline manual query tab, and the changes queue reachable from the tab bar (v0.7.6) |
| 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
@@ -301,6 +318,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) | ❌ | |
+51 -15
View File
@@ -29,6 +29,33 @@
---
## Download
Grab the installer for your OS from the [latest release](https://github.com/AdrianBonpin/gridline/releases/latest) — the links below point at the current release (**v0.7.6**):
| OS | Architecture | Download |
| :--- | :--- | :--- |
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.6_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_aarch64.dmg) |
| **macOS** | Intel | [Gridline_0.7.6_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_x64.dmg) |
| **Windows** | x64 | [Gridline_0.7.6_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_x64-setup.exe) |
| **Debian / Ubuntu** | amd64 | [Gridline_0.7.6_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_amd64.deb) |
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.6-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline-0.7.6-1.x86_64.rpm) |
| **Other Linux** | amd64 | [Gridline_0.7.6_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_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: These links are STATIC (versioned) — they point at the v0.7.6
release assets, not at a moving "latest" target. On every new release,
update BOTH tables here (Download + Which file should I download?) to the
new version's asset names, which are tauri-action's default naming:
Gridline_<version>_aarch64.dmg / _x64.dmg / _x64-setup.exe /
_amd64.deb / _amd64.AppImage and Gridline-<version>-1.x86_64.rpm
The .msi (Gridline_<version>_x64_en-US.msi) is not linked below.
-->
---
## What is Gridline?
Gridline is a modern, open-source database GUI client built with [Tauri 2.0](https://tauri.app), Rust, and React. It is lightweight by design (~40 MB baseline), powerful by default, and free from the paywalls that limit commercial alternatives.
@@ -56,7 +83,10 @@ 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).
- **v0.7.6** — Full PostgreSQL object management (create/edit/drop for enums, functions, procedures, triggers, sequences, extensions, views, materialized views, indexes, constraints) staged through the changes queue with generated-SQL previews; Enable Keychain toggle wired (default ON, opt-out; OFF = session-only); Objects view upgraded to the shared tabbed workspace (object detail tabs with per-type icons, inline manual query + changes queue); ⌘K object search fixes; version bump.
- **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.
@@ -87,6 +117,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
@@ -115,6 +149,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.
---
@@ -193,7 +228,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 |
@@ -223,16 +258,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 point at the current release (**v0.7.6**):
| 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_0.7.6_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_aarch64.dmg) | `aarch64` = Apple's own chip |
| macOS **Intel** | [Gridline_0.7.6_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_x64.dmg) | `x64` = Intel/AMD |
| **Windows** (most PCs) | [Gridline_0.7.6_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
| **Debian / Ubuntu** | [Gridline_0.7.6_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.6_amd64.deb` |
| **Fedora / RHEL / openSUSE** | [Gridline-0.7.6-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline-0.7.6-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.6-1.x86_64.rpm` |
| **Any other Linux** | [Gridline_0.7.6_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_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.
@@ -242,13 +277,13 @@ 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.6
git push origin v0.7.6
```
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**.
Before tagging, make sure the version number is in sync across `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json`.
Before tagging, make sure the version number is in sync across `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json`, and update the **README download tables** (Download + Which file should I download?) to the new version's asset names.
### Build from source
@@ -273,7 +308,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.)
---
@@ -327,11 +362,11 @@ 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.6), 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
- **Admin follow-up** — PostgreSQL users/roles + grants management, and table maintenance actions (VACUUM / ANALYZE / REINDEX)
- **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)
@@ -343,6 +378,7 @@ Highlights of what's next:
## Table of Contents
- [Download](#download)
- [What is Gridline?](#what-is-gridline)
- [Who is it for?](#who-is-it-for)
- [Recent Changes](#recent-changes)
+33 -23
View File
@@ -6,6 +6,23 @@ This file is the **source of truth** for what Gridline is building. [AGENTS.md](
---
## ✅ Shipped (0.7.6)
- **Full Object Management (PostgreSQL)** — create/edit/drop for every PostgreSQL object type (enums, functions, procedures, triggers, sequences, extensions, views, materialized views, indexes, constraints), staged through the changes queue with generated-SQL previews.
- **Enable Keychain toggle** — wired end-to-end (default ON, opt-out): OFF = don't persist the DB password (session-only, re-prompt on connect) + purge the existing keychain entry; SSH secrets stay keychain-only.
- **Objects view tabbed workspace** — object details open as tabs in the shared tabbed workspace (per-type icons), with an inline manual query tab and the changes queue reachable from the tab bar.
- **⌘K object-search fixes** — clicking a result now switches to the Objects view / opens the table tab; arrow-key navigation + Enter to pick.
- **Version bump** 0.7.5 → **0.7.6**.
## ✅ 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.
@@ -21,33 +38,18 @@ This file is the **source of truth** for what Gridline is building. [AGENTS.md](
## 🎯 Next up
### Full Object Management (PostgreSQL)
Gridline can already **browse** every PostgreSQL object type (functions, triggers, sequences, enums, extensions, views, materialized views, procedures, indexes, constraints). Next up: full **CRUD** on those objects without ever touching the Query tab.
- Right-click any object → create / edit / drop with a generated-SQL preview before applying
- Enums: add/remove values, rename types
- Functions & procedures: edit signature + body, drop overloads by signature
- Triggers: create/edit/disable/enable, timing + event pickers
- Sequences: alter increment/start/min/max/cycle, restart
- Extensions: install/uninstall, schema reassignment
- Views & materialized views: edit definition, refresh matviews
- Indexes & constraints: create/drop per table with column pickers
- 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)
### Admin follow-up (0.7.7)
- **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
### Table & relationship management (next)
- **Create table** — a "Create Table…" tab in the workspace using the same Visual ⇄ SQL flow as object create/edit: a columns grid (name / type / nullable / default / PK per row, add/remove rows) with a live SQL preview
- **Edit table (robust column diff)** — open an existing table's columns in the same grid; on Stage, diff old vs new columns and emit the right statements, one per queue item: `ADD COLUMN`, `DROP COLUMN`, `RENAME COLUMN`, `ALTER COLUMN … TYPE`, `ALTER COLUMN … SET|DROP DEFAULT`, `SET|DROP NOT NULL`
- **Relationships between tables & schemas** — FK create/edit/drop from a table editor (including cross-schema FKs) and schema-level relationship maintenance
- **Related niceties** — column reordering (PG requires a table rebuild — decide semantics), table options (tablespace, row-level security), multi-column PRIMARY KEY on create
## 📋 In the queue
### Full Redis Support
@@ -128,6 +130,14 @@ Deferred from 0.7.0, slated for this bucket:
## ✅ Shipped
- Full PostgreSQL object management — create/edit/drop staged through the changes queue (v0.7.6)
- Enable Keychain toggle — opt-out password persistence (v0.7.6)
- Objects view tabbed workspace (v0.7.6)
- Bundled PG client tools — `pg_dump`/`pg_restore`/`psql` shipped with the app, system-first with bundled fallback (v0.7.5)
- Schema CRUD — create/rename/drop schemas with CASCADE + dependency warning (v0.7.5)
- Global object search — Cmd+K across all object types in the current schema (v0.7.5)
- Copy as DDL for any object (v0.7.5)
- Object dependencies — `pg_depend` view before destructive drops (v0.7.5)
- Tauri 2.0 + React 19 + TypeScript 5.8 project shell
- PostgreSQL and SQLite browse/query support
- Full MySQL DB viewer — connect, browse, query, edit + changes queue (v0.7.0)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "gridline",
"private": true,
"version": "0.7.0",
"version": "0.7.6",
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
"type": "module",
"scripts": {
+75
View File
@@ -0,0 +1,75 @@
-- Gridline v0.7.5 integration-test seed (idempotent).
-- Target: the FIRST PG test DB (GRIDLINE_TEST_SRC @ :7501), db=postgres.
-- Objects are chosen to satisfy the assertions in src-tauri/src/commands/objects.test.rs:
-- search_objects: 'users' TABLE hit, 'get*' function hit, empty-needle <= 100
-- get_object_ddl: users_id_seq -> CREATE SEQUENCE; audit_log -> CREATE FUNCTION
-- get_object_dependencies: orders -> order_summary VIEW; schema contents non-empty
-- Run with: psql ... -f scripts/seed-pg-test.sql
BEGIN;
-- 1. users table with serial -> creates users_id_seq in public
DROP VIEW IF EXISTS order_summary CASCADE;
DROP TABLE IF EXISTS orders CASCADE;
DROP TABLE IF EXISTS products CASCADE;
DROP TABLE IF EXISTS users CASCADE;
DROP TYPE IF EXISTS user_role CASCADE;
DROP FUNCTION IF EXISTS audit_log(text) CASCADE;
DROP FUNCTION IF EXISTS get_user(bigint) CASCADE;
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
role TEXT NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO users (email, role) VALUES ('a@example.com', 'admin'), ('b@example.com', 'member');
-- 2. enum type (for enum search + enum DDL)
CREATE TYPE user_role AS ENUM ('admin', 'member', 'guest');
-- 3. products (backup tests assert 3 rows)
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
price NUMERIC(10,2) NOT NULL
);
INSERT INTO products (name, price) VALUES ('Widget', 9.99), ('Gadget', 19.99), ('Gizmo', 29.99);
-- 4. orders + a dependent VIEW (what-depends-on-this test: dropping orders must surface order_summary)
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
total NUMERIC(10,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO orders (user_id, total) VALUES (1, 99.50), (2, 12.00), (1, 45.25);
CREATE VIEW order_summary AS
SELECT u.email, count(o.id) AS order_count, sum(o.total) AS total
FROM users u LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.email;
-- 4. functions: audit_log (DDL test) + get_user (search 'get' hit)
CREATE FUNCTION audit_log(msg text) RETURNS void AS $$
SELECT pg_sleep(0); -- placeholder body so the function is real
$$ LANGUAGE sql;
CREATE FUNCTION get_user(uid bigint) RETURNS TABLE(email text, role text) AS $$
SELECT u.email, u.role FROM users u WHERE u.id = uid;
$$ LANGUAGE sql STABLE;
COMMIT;
-- Verify
\echo '--- seeded objects ---'
SELECT c.relkind::text || ' ' || c.relname
FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE n.nspname = 'public' AND c.relkind IN ('r','v','S')
ORDER BY 1;
\echo '--- public functions ---'
SELECT p.proname FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid
WHERE n.nspname = 'public' ORDER BY 1;
\echo '--- enums ---'
SELECT t.typname FROM pg_type t JOIN pg_namespace n ON t.typnamespace = n.oid
WHERE n.nspname = 'public' AND t.typtype = 'e';
+1 -1
View File
@@ -1783,7 +1783,7 @@ dependencies = [
[[package]]
name = "gridline"
version = "0.7.0"
version = "0.7.6"
dependencies = [
"chrono",
"deadpool-postgres",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "gridline"
version = "0.7.0"
version = "0.7.6"
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
authors = ["you"]
edition = "2021"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1002 B

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 74 KiB

+8
View File
@@ -0,0 +1,8 @@
# Bundled PostgreSQL client tools
This directory is populated **at build time by CI** (`.github/workflows/release.yml`)
with statically-linked `pg_dump`, `pg_restore`, and `psql` for the current target.
Binaries are NOT committed to the repo.
In `tauri dev`, this dir is usually empty — the app falls back to any `pg_dump`/`pg_restore`
on `PATH` (system-first resolution). Admin features degrade with a clear error if neither is present.
+64 -17
View File
@@ -1,5 +1,5 @@
use std::process::{Command, Stdio};
use tauri::{AppHandle, Emitter, State};
use tauri::{AppHandle, Emitter, Manager, State};
use crate::models::backup::*;
@@ -51,20 +51,63 @@ fn sanitize_error(s: &str) -> String {
crate::commands::test_connection::sanitize_error(s)
}
fn bundled_bin_name(tool: &str) -> String {
if cfg!(windows) { format!("{tool}.exe") } else { tool.to_string() }
}
/// Pure resolution decision (unit-testable): system > bundled > bare name.
fn pick_tool(system_ok: bool, bundled: Option<&str>, tool: &str) -> (String, Option<String>) {
if system_ok { return (tool.to_string(), Some("system".to_string())); }
if let Some(b) = bundled { return (b.to_string(), Some("bundled".to_string())); }
(tool.to_string(), None)
}
/// System-first, bundled-fallback resolver. Returns (command_to_invoke, source).
pub fn resolve_tool(app: &AppHandle, tool: &str) -> (String, Option<String>) {
let system_ok = Command::new(tool).arg("--version").output().is_ok();
let bundled = app.path().resource_dir().ok()
.map(|rd| rd.join("pg_tools").join(bundled_bin_name(tool)))
.filter(|p| p.exists())
.map(|p| p.to_string_lossy().to_string());
pick_tool(system_ok, bundled.as_deref(), tool)
}
pub fn resolve_tool_paths(app: &AppHandle) -> PgToolPaths {
let (d, _) = resolve_tool(app, "pg_dump");
let (r, _) = resolve_tool(app, "pg_restore");
let (p, _) = resolve_tool(app, "psql");
PgToolPaths { pg_dump: d, pg_restore: r, psql: p }
}
// ---------------------------------------------------------------------------
// detect_pg_tools
// ---------------------------------------------------------------------------
#[tauri::command]
pub fn detect_pg_tools() -> PgToolStatus {
/// Shapes the tool status from resolved tool paths + sources. Headless so the
/// Tauri command stays thin and the status logic stays unit-testable.
fn build_pg_tool_status(
dump: &str,
restore: &str,
dump_src: Option<String>,
restore_src: Option<String>,
) -> PgToolStatus {
PgToolStatus {
pg_dump_found: Command::new("pg_dump").arg("--version").output().is_ok(),
pg_restore_found: Command::new("pg_restore").arg("--version").output().is_ok(),
pg_dump_version: get_version("pg_dump"),
pg_restore_version: get_version("pg_restore"),
pg_dump_found: Command::new(dump).arg("--version").output().is_ok(),
pg_restore_found: Command::new(restore).arg("--version").output().is_ok(),
pg_dump_version: get_version(dump),
pg_restore_version: get_version(restore),
pg_dump_source: dump_src,
pg_restore_source: restore_src,
}
}
#[tauri::command]
pub fn detect_pg_tools(app_handle: AppHandle) -> PgToolStatus {
let (dump, dump_src) = resolve_tool(&app_handle, "pg_dump");
let (restore, restore_src) = resolve_tool(&app_handle, "pg_restore");
build_pg_tool_status(&dump, &restore, dump_src, restore_src)
}
// ---------------------------------------------------------------------------
// Core logic (headless-testable — no Tauri, no store, no keychain)
// ---------------------------------------------------------------------------
@@ -132,11 +175,11 @@ fn build_restore_args(conn: &PgConnParams, options: &RestoreOptions) -> Vec<Stri
/// Runs `pg_dump` against `conn`, writing to `options.file_path`.
/// Returns `Ok(())` on success or a sanitized error message.
pub fn run_pg_dump(conn: &PgConnParams, options: &BackupOptions) -> Result<(), String> {
pub fn run_pg_dump(conn: &PgConnParams, options: &BackupOptions, tools: &PgToolPaths) -> Result<(), String> {
let mut args = build_dump_args(conn, options);
args.push(format!("--file={}", options.file_path));
let result = Command::new("pg_dump")
let result = Command::new(&tools.pg_dump)
.env("PGPASSWORD", &conn.password)
.args(&args)
.output();
@@ -154,9 +197,9 @@ pub fn run_pg_dump(conn: &PgConnParams, options: &BackupOptions) -> Result<(), S
/// Plain-format dumps are SQL text and cannot be read by `pg_restore` — they
/// are executed with `psql` instead. The `clean` option is only honored for
/// archive formats (custom/tar/directory); the UI disables it for plain.
pub fn run_pg_restore(conn: &PgConnParams, options: &RestoreOptions) -> Result<(), String> {
pub fn run_pg_restore(conn: &PgConnParams, options: &RestoreOptions, tools: &PgToolPaths) -> Result<(), String> {
if options.format == "plain" {
let result = Command::new("psql")
let result = Command::new(&tools.psql)
.env("PGPASSWORD", &conn.password)
.args([
format!("--host={}", conn.host),
@@ -177,7 +220,7 @@ pub fn run_pg_restore(conn: &PgConnParams, options: &RestoreOptions) -> Result<(
let mut args = build_restore_args(conn, options);
args.push(options.file_path.clone());
let result = Command::new("pg_restore")
let result = Command::new(&tools.pg_restore)
.env("PGPASSWORD", &conn.password)
.args(&args)
.output();
@@ -196,6 +239,7 @@ pub fn run_db_sync(
target: &PgConnParams,
schema: Option<&str>,
tables: Option<&[String]>,
tools: &PgToolPaths,
) -> Result<(), String> {
// --- Build pg_dump args ---
let mut dump_args = base_conn_args(source);
@@ -221,7 +265,7 @@ pub fn run_db_sync(
restore_args.push("--if-exists".into());
// --- Spawn pg_dump with piped stdout ---
let mut dump_child = Command::new("pg_dump")
let mut dump_child = Command::new(&tools.pg_dump)
.env("PGPASSWORD", &source.password)
.args(&dump_args)
.stdout(Stdio::piped())
@@ -243,7 +287,7 @@ pub fn run_db_sync(
});
// --- Run pg_restore with pg_dump stdout as stdin ---
let restore_result = Command::new("pg_restore")
let restore_result = Command::new(&tools.pg_restore)
.env("PGPASSWORD", &target.password)
.args(&restore_args)
.stdin(dump_stdout)
@@ -332,9 +376,10 @@ pub async fn pg_dump(
let job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone();
let tools = resolve_tool_paths(&app_handle);
tokio::task::spawn_blocking(move || {
let result = run_pg_dump(&params, &options);
let result = run_pg_dump(&params, &options, &tools);
emit_result(&app_handle_clone, &job_id_clone, result);
});
@@ -374,9 +419,10 @@ pub async fn pg_restore(
let job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone();
let tools = resolve_tool_paths(&app_handle);
tokio::task::spawn_blocking(move || {
let result = run_pg_restore(&params, &options);
let result = run_pg_restore(&params, &options, &tools);
emit_result(&app_handle_clone, &job_id_clone, result);
});
@@ -464,9 +510,10 @@ pub async fn db_sync(
let tables = options.tables.clone();
let job_id_clone = job_id.clone();
let app_handle_clone = app_handle.clone();
let tools = resolve_tool_paths(&app_handle);
tokio::task::spawn_blocking(move || {
let result = run_db_sync(&source, &target, schema.as_deref(), tables.as_deref());
let result = run_db_sync(&source, &target, schema.as_deref(), tables.as_deref(), &tools);
emit_result(&app_handle_clone, &job_id_clone, result);
});
+32 -6
View File
@@ -133,7 +133,9 @@ fn build_pg_restore_args_with_schema() {
#[test]
fn detect_pg_tools_does_not_panic() {
let status = detect_pg_tools();
// detect_pg_tools needs a Tauri AppHandle; the headless core keeps the
// status-shaping logic testable without one.
let status = build_pg_tool_status("pg_dump", "pg_restore", None, None);
// May or may not find tools, but the call itself must not panic
let _ = status.pg_dump_found;
let _ = status.pg_restore_found;
@@ -148,6 +150,8 @@ fn pg_tool_status_serialization() {
pg_restore_found: false,
pg_dump_version: Some("pg_dump (PostgreSQL) 16.0".into()),
pg_restore_version: None,
pg_dump_source: None,
pg_restore_source: None,
};
let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("pg_dump_found"));
@@ -294,7 +298,8 @@ fn integration_dump_restore_sync() {
tables: None,
no_owner: true,
};
run_pg_dump(&src, &dump_opts).expect("pg_dump should succeed");
run_pg_dump(&src, &dump_opts, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
.expect("pg_dump should succeed");
// --- 2. Restore into target ---
let restore_opts = RestoreOptions {
@@ -303,7 +308,8 @@ fn integration_dump_restore_sync() {
clean: true,
schema: None,
};
run_pg_restore(&tgt, &restore_opts).expect("pg_restore should succeed");
run_pg_restore(&tgt, &restore_opts, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
.expect("pg_restore should succeed");
// --- 3. Verify data landed in target ---
assert_eq!(
@@ -320,7 +326,8 @@ fn integration_dump_restore_sync() {
// --- 4. Sync source -> target (target already has tables from the restore
// above — db_sync now passes --clean --if-exists, so it must succeed into a
// non-empty target). ---
run_db_sync(&src, &tgt, None, None).expect("db_sync should succeed");
run_db_sync(&src, &tgt, None, None, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
.expect("db_sync should succeed");
assert_eq!(
psql_count(&tgt, "SELECT count(*) FROM public.products;"),
3,
@@ -367,7 +374,8 @@ fn integration_plain_dump_restore() {
tables: None,
no_owner: true,
};
run_pg_dump(&src, &dump_opts).expect("pg_dump (plain) should succeed");
run_pg_dump(&src, &dump_opts, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
.expect("pg_dump (plain) should succeed");
// --- 2. Restore into target (plain -> psql path) ---
let restore_opts = RestoreOptions {
@@ -376,7 +384,8 @@ fn integration_plain_dump_restore() {
clean: false,
schema: None,
};
run_pg_restore(&tgt, &restore_opts).expect("pg_restore (plain/psql) should succeed");
run_pg_restore(&tgt, &restore_opts, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
.expect("pg_restore (plain/psql) should succeed");
// --- 3. Verify data landed in target ---
assert_eq!(
@@ -392,3 +401,20 @@ fn integration_plain_dump_restore() {
let _ = std::fs::remove_file(&dump_path);
}
// ------------------------------------------------------------------
// Tool resolution (Task 2.1: system-first, bundled-fallback)
// ------------------------------------------------------------------
#[test]
fn pick_tool_prefers_system_then_bundled_then_bare() {
assert_eq!(pick_tool(true, None, "pg_dump"), ("pg_dump".to_string(), Some("system".into())));
assert_eq!(pick_tool(false, Some("/r/pg_dump"), "pg_dump"), ("/r/pg_dump".to_string(), Some("bundled".into())));
assert_eq!(pick_tool(false, None, "pg_dump"), ("pg_dump".to_string(), None));
}
#[test]
fn bundled_bin_name_appends_exe_on_windows() {
let name = bundled_bin_name("pg_dump");
if cfg!(windows) { assert_eq!(name, "pg_dump.exe"); } else { assert_eq!(name, "pg_dump"); }
}
+5
View File
@@ -223,6 +223,7 @@ mod tests {
ssl_cert_path: None,
ssl_key_path: None,
tag_ids: vec![],
use_keychain: true,
};
let result = create_connection_inner(&st, input.clone()).unwrap();
assert_eq!(result.name, "Prod");
@@ -254,6 +255,7 @@ mod tests {
ssl_cert_path: None,
ssl_key_path: None,
tag_ids: vec![],
use_keychain: true,
};
assert!(create_connection_inner(&st, input).is_err());
}
@@ -283,6 +285,7 @@ mod tests {
ssl_cert_path: None,
ssl_key_path: None,
tag_ids: vec![],
use_keychain: true,
};
let conn = create_connection_inner(&st, input).unwrap();
delete_connection_inner(&st, &conn.id).unwrap();
@@ -300,6 +303,7 @@ mod tests {
username: None,
folder_id: None,
tag_ids: vec![],
use_keychain: true,
password: None,
database: None,
environment: None,
@@ -331,6 +335,7 @@ mod tests {
username: None,
folder_id: None,
tag_ids: vec![],
use_keychain: true,
password: None,
database: None,
environment: None,
+61 -12
View File
@@ -149,19 +149,21 @@ pub fn build_pg_dump_ddl_args(schema: &str, table: &str) -> Vec<String> {
]
}
/// Check whether the system `pg_dump` binary is on PATH.
pub fn pg_dump_available() -> bool {
std::process::Command::new("pg_dump")
/// Check whether the `pg_dump` binary at the given path (bare name or
/// absolute path) is executable and reports a version.
pub fn pg_dump_available_at(path: &str) -> bool {
std::process::Command::new(path)
.arg("--version")
.output()
.is_ok()
}
/// Extract a single table's DDL from a PostgreSQL database by shelling out to
/// the system `pg_dump` with `--schema-only`. Credentials are supplied via the
/// `PGPASSWORD` environment variable only — never as argv — and are never
/// logged. Execution requires a reachable PostgreSQL server plus an installed
/// `pg_dump`; unit tests cover the argument construction instead.
/// `pg_dump` (system-first, bundled-fallback) with `--schema-only`.
/// Credentials are supplied via the `PGPASSWORD` environment variable only —
/// never as argv — and are never logged. Execution requires a reachable
/// PostgreSQL server plus an installed `pg_dump`; unit tests cover the
/// argument construction instead.
pub fn get_pg_ddl_via_dump(
schema: &str,
table: &str,
@@ -170,13 +172,14 @@ pub fn get_pg_ddl_via_dump(
user: &str,
db: &str,
password: &str,
pg_dump_path: &str,
) -> Result<String, String> {
if !pg_dump_available() {
if !pg_dump_available_at(pg_dump_path) {
return Err(
"pg_dump not found. Install PostgreSQL client tools to copy table schema.".into(),
"pg_dump not found. Install PostgreSQL client tools or use the bundled tools to copy table schema.".into(),
);
}
let mut cmd = std::process::Command::new("pg_dump");
let mut cmd = std::process::Command::new(pg_dump_path);
cmd.args([
format!("--host={host}"),
format!("--port={port}"),
@@ -2152,6 +2155,10 @@ pub async fn execute_change(
client.execute(sql, &[]).await.map_err(|e| e.to_string())?;
return Ok(());
}
Change::Ddl { sql, .. } => {
client.execute(sql, &[]).await.map_err(|e| e.to_string())?;
return Ok(());
}
Change::BulkInsert {
schema,
table,
@@ -2244,6 +2251,9 @@ pub async fn execute_change(
conn.execute(sql, []).map_err(|e| e.to_string())?;
return Ok(());
}
Change::Ddl { .. } => {
return Err("Object management is PostgreSQL-only".to_string());
}
Change::BulkInsert {
table,
columns,
@@ -2316,6 +2326,9 @@ pub async fn execute_change(
.map_err(|e| sanitize_error(&format!("{e}")))?;
return Ok(());
}
Change::Ddl { .. } => {
return Err("Object management is PostgreSQL-only".to_string());
}
Change::BulkInsert {
schema,
table,
@@ -2715,10 +2728,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
@@ -2815,6 +2830,33 @@ mod tests {
);
}
/// Verify that a `Change::Ddl` serializes with the snake_case `ddl` tag.
#[test]
fn change_ddl_serialization_uses_snake_case_tag() {
let change = Change::Ddl {
id: "chg-ddl-1".to_string(),
sql: "CREATE TYPE public.role AS ENUM ('admin')".to_string(),
};
let json = serde_json::to_string(&change).unwrap();
assert!(
json.contains(r#""type":"ddl""#),
"serialized Change::Ddl should use snake_case tag 'ddl'; got: {}",
json
);
assert!(json.contains(r#""id":"chg-ddl-1""#));
assert!(json.contains(r#""sql":"CREATE TYPE public.role AS ENUM ('admin')""#));
}
/// Verify that `Change::id()` returns the identifier of a `Change::Ddl`.
#[test]
fn change_ddl_id_is_accessible() {
let change = Change::Ddl {
id: "chg-ddl-42".to_string(),
sql: "DROP INDEX public.i".to_string(),
};
assert_eq!(change.id(), "chg-ddl-42");
}
/// Verify that `build_update_sql` produces valid SQL with all required
/// clauses.
#[test]
@@ -3319,4 +3361,11 @@ mod tests {
.expect("columns");
assert_eq!(cols.len(), data.columns.len());
}
#[test]
fn pg_dump_available_at_checks_given_path() {
// A bare name that resolves on PATH passes; a bogus path fails.
assert!(pg_dump_available_at("pg_dump") || !pg_dump_available_at("pg_dump"));
assert!(!pg_dump_available_at("/nonexistent/pg_dump_999999"));
}
}
+1
View File
@@ -53,6 +53,7 @@ fn demo_connection_input(db_path: &Path) -> ConnectionInput {
database: None,
folder_id: None,
tag_ids: vec![],
use_keychain: true,
environment: Some("development".to_string()),
ssh_host: None,
ssh_port: None,
+2
View File
@@ -102,6 +102,7 @@ pub fn import_connections_inner(
ssl_key_path: None,
environment: None,
tag_ids: rec.tag_ids.clone().unwrap_or_default(),
use_keychain: true,
};
match store.create_connection(input) {
Ok(_) => imported += 1,
@@ -214,6 +215,7 @@ mod tests {
ssl_key_path: None,
environment: None,
tag_ids: vec![],
use_keychain: true,
});
let json = export_connections_inner(&st).unwrap();
assert!(json.contains("\"name\""));
+1
View File
@@ -5,6 +5,7 @@ pub mod demo;
pub mod folders;
pub mod import_export;
pub mod keychain;
pub mod objects;
pub mod query;
pub mod schema_graph;
pub mod settings;
+193
View File
@@ -0,0 +1,193 @@
use tauri::State;
use crate::db::pool::{ConnectionPoolManager, DbHandle};
use crate::db::object_crud::build_ddl;
use crate::db::object_ddl::*;
use crate::models::db_viewer::{ObjectSearchHit, DependencyInfo, ExtensionInfo};
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
}
/// Build SQL for an object CRUD operation. The pool is resolved only to enforce
/// PostgreSQL-only / present-connection; the SQL itself is built by the pure
/// `crate::db::object_crud::build_ddl` dispatcher (one statement per String).
pub(crate) async fn build_object_ddl_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, kind: &str, params: serde_json::Value) -> Result<Vec<String>, String> {
let mut pm = pm.lock().await;
match pm.get(connection_id) {
Some(DbHandle::Postgresql(_, _)) => build_ddl(kind, params),
Some(_) => Err("Object management is PostgreSQL-only".into()),
None => Err("Connection not found".into()),
}
}
#[tauri::command]
pub async fn build_object_ddl(connection_id: String, kind: String, params: serde_json::Value, state: State<'_, crate::AppState>) -> Result<Vec<String>, String> {
build_object_ddl_inner(&state.pool_manager, &connection_id, &kind, params).await
}
/// List extensions installable on this server (`pg_available_extensions`):
/// name + default version + comment. The picker uses name/version only;
/// `schema` is left empty (available extensions are schema-wide).
pub(crate) async fn get_available_extensions_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str) -> Result<Vec<ExtensionInfo>, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c,
Some(_) => return Err("Extensions are PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
let rows = client.query(&crate::db::introspection::pg_available_extensions_query(), &[]).await.map_err(|e| sanitize(&e.to_string()))?;
Ok(rows.iter().map(|r| ExtensionInfo {
name: r.get(0),
schema: String::new(),
version: r.get(1),
comment: r.get(2),
}).collect())
}
#[tauri::command]
pub async fn get_available_extensions(connection_id: String, state: State<'_, crate::AppState>) -> Result<Vec<ExtensionInfo>, String> {
get_available_extensions_inner(&state.pool_manager, &connection_id).await
}
#[cfg(test)]
#[path = "objects.test.rs"]
mod tests;
+117
View File
@@ -0,0 +1,117 @@
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]
async fn build_object_ddl_inner_guards_postgresql_only() {
let pm = tokio::sync::Mutex::new(ConnectionPoolManager::new());
// Missing connection -> Connection not found
let err = build_object_ddl_inner(&pm, "missing", "sequence", serde_json::json!({
"schema": "public", "name": "s", "action": { "op": "drop" }
})).await.unwrap_err();
assert!(err.contains("Connection not found"), "{err}");
// Non-PostgreSQL handle -> PostgreSQL-only error
pm.lock().await.register("sqlite", DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()));
let err = build_object_ddl_inner(&pm, "sqlite", "sequence", serde_json::json!({
"schema": "public", "name": "s", "action": { "op": "drop" }
})).await.unwrap_err();
assert!(err.contains("PostgreSQL-only"), "{err}");
}
#[tokio::test]
#[ignore]
async fn build_object_ddl_inner_on_postgresql_pool() {
let (pm, id) = pool().await;
let sql = build_object_ddl_inner(&pm, &id, "sequence", serde_json::json!({
"schema": "public", "name": "s", "action": { "op": "drop" }
})).await.unwrap();
assert_eq!(sql, vec!["DROP SEQUENCE \"public\".\"s\""]);
}
#[tokio::test]
async fn get_available_extensions_inner_guards_postgresql_only() {
let pm = tokio::sync::Mutex::new(ConnectionPoolManager::new());
// Missing connection -> Connection not found
let err = get_available_extensions_inner(&pm, "missing").await.unwrap_err();
assert!(err.contains("Connection not found"), "{err}");
// Non-PostgreSQL handle -> PostgreSQL-only error
pm.lock().await.register("sqlite", DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()));
let err = get_available_extensions_inner(&pm, "sqlite").await.unwrap_err();
assert!(err.contains("PostgreSQL-only"), "{err}");
}
#[tokio::test]
#[ignore]
async fn get_available_extensions_on_postgresql_pool() {
let (pm, id) = pool().await;
let exts = get_available_extensions_inner(&pm, &id).await.unwrap();
assert!(!exts.is_empty(), "pg_available_extensions should list built-ins");
assert!(exts.iter().all(|e| !e.name.is_empty() && !e.version.is_empty()), "every extension needs name + default version: {exts:?}");
}
#[tokio::test]
#[ignore]
async fn object_dependencies_for_table_includes_view() {
let (pm, id) = pool().await;
// demo has order_summary VIEW depending on orders — drop would break it
let deps = get_object_dependencies_inner(&pm, &id, "public", "table", "orders").await.unwrap();
assert!(deps.iter().any(|d| d.class.contains("pg_class") && d.name.contains("order_summary")), "view depending on orders should surface: {deps:?}");
// schema contents path
let contents = get_object_dependencies_inner(&pm, &id, "public", "schema", "public").await.unwrap();
assert!(!contents.is_empty(), "public schema should list contents");
}
+1
View File
@@ -1060,6 +1060,7 @@ mod tests {
username: None,
folder_id: None,
tag_ids: vec![],
use_keychain: true,
password: None,
database: None,
environment: None,
+17
View File
@@ -292,6 +292,14 @@ pub fn pg_extensions_query() -> String {
.to_string()
}
/// Query available (installable) extensions with default version + comment.
/// Schema-wide: `pg_available_extensions` is not schema-scoped.
pub fn pg_available_extensions_query() -> String {
"SELECT name, default_version::text AS version, comment \
FROM pg_available_extensions ORDER BY name"
.to_string()
}
/// Query indexes in a schema.
///
/// Returns index name, schema, table, definition (`pg_get_indexdef`),
@@ -624,4 +632,13 @@ mod tests {
let sql = pg_extensions_query();
assert!(sql.contains("pg_extension"));
}
#[test]
fn pg_available_extensions_query_has_name_default_version_comment() {
let sql = pg_available_extensions_query();
assert!(sql.contains("pg_available_extensions"));
assert!(sql.contains("default_version"), "should select default version; got: {sql}");
assert!(sql.contains("comment"), "should select comment; got: {sql}");
assert!(sql.contains("ORDER BY name"), "should order by name; got: {sql}");
}
}
+2
View File
@@ -1,5 +1,7 @@
pub mod introspection;
pub mod mysql;
pub mod object_crud;
pub mod object_ddl;
pub mod pool;
pub mod tls;
+727
View File
@@ -0,0 +1,727 @@
//! Pure SQL builders for PostgreSQL object CRUD. No DB I/O.
use crate::db::object_ddl::{quote_ident, validate_object_name};
use serde::Deserialize;
#[derive(Deserialize)]
pub struct SequenceParams {
pub schema: String,
pub name: String,
pub action: SequenceAction,
}
#[derive(Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum SequenceAction {
Create { increment: Option<String>, min_value: Option<String>, max_value: Option<String>, start: Option<String>, cycle: bool },
Alter { increment: Option<String>, min_value: Option<String>, max_value: Option<String>, cycle: Option<bool> },
Restart { with: Option<String> },
Drop,
}
fn qual(schema: &str, name: &str) -> Result<String, String> {
validate_object_name(schema)?;
validate_object_name(name)?;
Ok(format!("{}.{}", quote_ident(schema), quote_ident(name)))
}
pub fn sequence_ddl(p: &SequenceParams) -> Result<Vec<String>, String> {
let q = qual(&p.schema, &p.name)?;
Ok(vec![match &p.action {
SequenceAction::Create { increment, min_value, max_value, start, cycle } => {
let mut s = format!("CREATE SEQUENCE {}", q);
if let Some(v) = increment { s.push_str(&format!("\n INCREMENT BY {}", v)); }
if let Some(v) = min_value { s.push_str(&format!("\n MINVALUE {}", v)); }
if let Some(v) = max_value { s.push_str(&format!("\n MAXVALUE {}", v)); }
if let Some(v) = start { s.push_str(&format!("\n START WITH {}", v)); }
s.push_str(if *cycle { "\n CYCLE" } else { "\n NO CYCLE" });
s
}
SequenceAction::Alter { increment, min_value, max_value, cycle } => {
let mut s = format!("ALTER SEQUENCE {}", q);
if let Some(v) = increment { s.push_str(&format!("\n INCREMENT BY {}", v)); }
if let Some(v) = min_value { s.push_str(&format!("\n MINVALUE {}", v)); }
if let Some(v) = max_value { s.push_str(&format!("\n MAXVALUE {}", v)); }
if let Some(c) = cycle { s.push_str(if *c { "\n CYCLE" } else { "\n NO CYCLE" }); }
s
}
SequenceAction::Restart { with } => match with {
Some(v) => format!("ALTER SEQUENCE {} RESTART WITH {}", q, v),
None => format!("ALTER SEQUENCE {} RESTART", q),
},
SequenceAction::Drop => format!("DROP SEQUENCE {}", q),
}])
}
/// Escape an enum label as a single-quoted SQL literal (empty rejected).
pub fn validate_enum_label(label: &str) -> Result<String, String> {
let t = label.trim();
if t.is_empty() {
return Err("Enum label must not be empty".into());
}
Ok(format!("'{}'", t.replace('\'', "''")))
}
#[derive(Deserialize)]
pub struct EnumParams {
pub schema: String,
pub name: String,
pub action: EnumAction,
}
#[derive(Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum EnumAction {
Create { labels: Vec<String> },
RenameType { new_name: String },
AddValue { value: String, if_not_exists: bool, before: Option<String>, after: Option<String> },
RenameValue { from: String, to: String },
Drop,
}
pub fn enum_ddl(p: &EnumParams) -> Result<Vec<String>, String> {
let q = qual(&p.schema, &p.name)?;
Ok(vec![match &p.action {
EnumAction::Create { labels } => {
let mut out = String::new();
for l in labels {
if !out.is_empty() { out.push_str(", "); }
out.push_str(&validate_enum_label(l)?);
}
format!("CREATE TYPE {} AS ENUM ({})", q, out)
}
EnumAction::RenameType { new_name } => {
validate_object_name(new_name)?;
format!("ALTER TYPE {} RENAME TO {}", q, quote_ident(new_name))
}
EnumAction::AddValue { value, if_not_exists, before, after } => {
let v = format!(" {}", validate_enum_label(value)?);
let ine = if *if_not_exists { " IF NOT EXISTS" } else { "" };
let pos = match (before, after) {
(Some(b), None) => format!(" BEFORE {}", validate_enum_label(b)?),
(None, Some(a)) => format!(" AFTER {}", validate_enum_label(a)?),
_ => String::new(),
};
format!("ALTER TYPE {} ADD VALUE{}{}{}", q, ine, v, pos)
}
EnumAction::RenameValue { from, to } => {
format!("ALTER TYPE {} RENAME VALUE {} TO {}", q, validate_enum_label(from)?, validate_enum_label(to)?)
}
EnumAction::Drop => format!("DROP TYPE {}", q),
}])
}
/// Validate a SQL expression body: non-empty, no trailing semicolon.
pub fn validate_expression(expr: &str) -> Result<String, String> {
let t = expr.trim();
if t.is_empty() {
return Err("Expression must not be empty".into());
}
if t.ends_with(';') {
return Err("Expression must not end with ';'".into());
}
Ok(t.to_string())
}
#[derive(Deserialize)]
pub struct ViewParams {
pub schema: String,
pub name: String,
pub materialized: bool,
pub action: ViewAction,
}
#[derive(Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum ViewAction {
Create { definition: String },
Replace { definition: String },
Refresh,
Drop,
}
pub fn view_ddl(p: &ViewParams) -> Result<Vec<String>, String> {
let q = qual(&p.schema, &p.name)?;
Ok(match &p.action {
ViewAction::Create { definition } if !p.materialized =>
vec![format!("CREATE OR REPLACE VIEW {} AS\n{}", q, definition)],
ViewAction::Create { definition } =>
vec![format!("CREATE MATERIALIZED VIEW {} AS\n{}", q, definition)],
ViewAction::Replace { definition } if !p.materialized =>
vec![format!("CREATE OR REPLACE VIEW {} AS\n{}", q, definition)],
ViewAction::Replace { definition } => vec![
format!("DROP MATERIALIZED VIEW {}", q),
format!("CREATE MATERIALIZED VIEW {} AS\n{}", q, definition),
],
ViewAction::Refresh if p.materialized => vec![format!("REFRESH MATERIALIZED VIEW {}", q)],
ViewAction::Refresh => return Err("Cannot REFRESH a non-materialized view".into()),
ViewAction::Drop if p.materialized => vec![format!("DROP MATERIALIZED VIEW {}", q)],
ViewAction::Drop => vec![format!("DROP VIEW {}", q)],
})
}
#[derive(Deserialize)]
pub struct ExtensionParams {
pub schema: String,
pub name: String,
pub action: ExtensionAction,
}
#[derive(Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum ExtensionAction {
Create { version: Option<String> },
SetSchema { new_schema: String },
Drop,
}
pub fn extension_ddl(p: &ExtensionParams) -> Result<Vec<String>, String> {
validate_object_name(&p.schema)?;
validate_object_name(&p.name)?;
let name = quote_ident(&p.name);
let schema = quote_ident(&p.schema);
Ok(vec![match &p.action {
ExtensionAction::Create { version: None } =>
format!("CREATE EXTENSION IF NOT EXISTS {} WITH SCHEMA {}", name, schema),
ExtensionAction::Create { version: Some(v) } =>
format!("CREATE EXTENSION IF NOT EXISTS {} WITH SCHEMA {} VERSION '{}'", name, schema, v.replace('\'', "''")),
ExtensionAction::SetSchema { new_schema } => {
validate_object_name(new_schema)?;
format!("ALTER EXTENSION {} SET SCHEMA {}", name, quote_ident(new_schema))
}
ExtensionAction::Drop => format!("DROP EXTENSION {}", name),
}])
}
/// Quote a list of identifiers, joined with ", ".
fn quoted_cols(v: &[String]) -> String {
v.iter().map(|c| quote_ident(c)).collect::<Vec<_>>().join(", ")
}
#[derive(Deserialize)]
pub struct IndexParams {
pub schema: String,
pub table: String,
pub name: String,
pub action: IndexAction,
}
#[derive(Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum IndexAction {
Create { unique: bool, method: String, columns: Vec<String>, predicate: Option<String> },
Drop,
}
pub fn index_ddl(p: &IndexParams) -> Result<Vec<String>, String> {
validate_object_name(&p.schema)?;
validate_object_name(&p.table)?;
validate_object_name(&p.name)?;
let table = format!("{}.{}", quote_ident(&p.schema), quote_ident(&p.table));
let name = quote_ident(&p.name);
Ok(vec![match &p.action {
IndexAction::Create { unique, method, columns, predicate } => {
if columns.is_empty() { return Err("Index requires at least one column".into()); }
let cols = quoted_cols(columns);
let unique = if *unique { "UNIQUE " } else { "" };
let method = if method.trim().is_empty() { String::new() } else { format!(" USING {}", method.trim()) };
let pred = match predicate {
Some(p) if !p.trim().is_empty() => format!(" WHERE {}", validate_expression(p)?),
_ => String::new(),
};
format!("CREATE {}INDEX {} ON {}{} ({}){}", unique, name, table, method, cols, pred)
}
IndexAction::Drop => format!("DROP INDEX {}.{}", quote_ident(&p.schema), name),
}])
}
#[derive(Deserialize)]
pub struct ConstraintParams {
pub schema: String,
pub table: String,
pub name: String,
pub action: ConstraintAction,
}
#[derive(Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum ConstraintAction {
Check { expression: String },
Unique { columns: Vec<String> },
PrimaryKey { columns: Vec<String> },
ForeignKey { columns: Vec<String>, ref_schema: String, ref_table: String, ref_columns: Vec<String> },
Drop,
}
pub fn constraint_ddl(p: &ConstraintParams) -> Result<Vec<String>, String> {
validate_object_name(&p.schema)?;
validate_object_name(&p.table)?;
validate_object_name(&p.name)?;
let table = format!("{}.{}", quote_ident(&p.schema), quote_ident(&p.table));
let name = quote_ident(&p.name);
Ok(vec![match &p.action {
ConstraintAction::Check { expression } =>
format!("ALTER TABLE {} ADD CONSTRAINT {} CHECK ({})", table, name, validate_expression(expression)?),
ConstraintAction::Unique { columns } => {
if columns.is_empty() { return Err("UNIQUE constraint requires a column".into()); }
format!("ALTER TABLE {} ADD CONSTRAINT {} UNIQUE ({})", table, name, quoted_cols(columns))
}
ConstraintAction::PrimaryKey { columns } => {
if columns.is_empty() { return Err("PRIMARY KEY requires a column".into()); }
format!("ALTER TABLE {} ADD CONSTRAINT {} PRIMARY KEY ({})", table, name, quoted_cols(columns))
}
ConstraintAction::ForeignKey { columns, ref_schema, ref_table, ref_columns } => {
if columns.is_empty() || ref_columns.is_empty() { return Err("FOREIGN KEY requires source and referenced columns".into()); }
validate_object_name(ref_schema)?;
validate_object_name(ref_table)?;
let refq = format!("{}.{}", quote_ident(ref_schema), quote_ident(ref_table));
format!("ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({})", table, name, quoted_cols(columns), refq, quoted_cols(ref_columns))
}
ConstraintAction::Drop => format!("ALTER TABLE {} DROP CONSTRAINT {}", table, name),
}])
}
#[derive(Deserialize)]
pub struct FunctionArg { pub mode: String, pub name: String, #[serde(rename = "type")] pub type_: String }
#[derive(Deserialize)]
pub struct FunctionParams {
pub schema: String,
pub name: String,
pub is_procedure: bool,
pub action: FunctionAction,
}
#[derive(Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum FunctionAction {
CreateOrReplace { args: Vec<FunctionArg>, return_type: Option<String>, language: String, body: String, volatility: Option<String>, strict: bool },
Drop { arg_types: Vec<String> },
}
fn arg_sql(a: &FunctionArg) -> String {
let mode = match a.mode.trim().to_lowercase().as_str() {
"in" | "" => String::new(),
m => format!("{} ", m.to_uppercase()),
};
format!("{}{} {}", mode, a.name, a.type_)
}
pub fn function_ddl(p: &FunctionParams) -> Result<Vec<String>, String> {
let q = qual(&p.schema, &p.name)?;
let kind = if p.is_procedure { "PROCEDURE" } else { "FUNCTION" };
Ok(vec![match &p.action {
FunctionAction::CreateOrReplace { args, return_type, language, body, volatility, strict } => {
let arglist: Vec<String> = args.iter().map(arg_sql).collect();
let ret = match (p.is_procedure, return_type) {
(false, Some(r)) => format!(" RETURNS {}", r),
_ => String::new(),
};
let vol = match volatility.as_deref() {
Some("IMMUTABLE") => " IMMUTABLE", Some("STABLE") => " STABLE", Some("VOLATILE") => " VOLATILE", _ => "",
};
let strict = if *strict { " STRICT" } else { "" };
format!("CREATE OR REPLACE {} {}({}){} LANGUAGE {}{}{} AS $${}$$", kind, q, arglist.join(", "), ret, language, vol, strict, body)
}
FunctionAction::Drop { arg_types } => format!("DROP {} {}({})", kind, q, arg_types.join(", ")),
}])
}
#[derive(Deserialize)]
pub struct TriggerParams {
pub schema: String,
pub name: String,
pub action: TriggerAction,
}
#[derive(Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum TriggerAction {
Create { table: String, timing: String, events: Vec<String>, orientation: String, function_schema: String, function_name: String, function_args: Vec<String>, when: Option<String> },
Enable { table: String },
Disable { table: String },
Drop { table: String },
}
pub fn trigger_ddl(p: &TriggerParams) -> Result<Vec<String>, String> {
validate_object_name(&p.schema)?;
validate_object_name(&p.name)?;
let schema = quote_ident(&p.schema);
let name = quote_ident(&p.name);
Ok(vec![match &p.action {
TriggerAction::Create { table, timing, events, orientation, function_schema, function_name, function_args, when } => {
validate_object_name(table)?;
validate_object_name(function_schema)?;
validate_object_name(function_name)?;
let evs = events.join(" OR ");
let orient = match orientation.trim().to_uppercase().as_str() { "STATEMENT" => "FOR EACH STATEMENT", _ => "FOR EACH ROW" };
let when_clause = match when {
Some(w) if !w.trim().is_empty() => format!(" WHEN ({})", w),
_ => String::new(),
};
let fq = format!("{}.{}", quote_ident(function_schema), quote_ident(function_name));
format!("CREATE TRIGGER {} {} {} ON {}.{} {}{} EXECUTE FUNCTION {}({})",
name, timing, evs, schema, quote_ident(table), orient, when_clause, fq, function_args.join(", "))
}
TriggerAction::Enable { table } => format!("ALTER TABLE {}.{} ENABLE TRIGGER {}", schema, quote_ident(table), name),
TriggerAction::Disable { table } => format!("ALTER TABLE {}.{} DISABLE TRIGGER {}", schema, quote_ident(table), name),
TriggerAction::Drop { table } => format!("DROP TRIGGER {} ON {}.{}", name, schema, quote_ident(table)),
}])
}
/// Dispatch a DDL build by kind. `params` is the JSON payload from the frontend.
/// Returns one or more single SQL statements.
pub fn build_ddl(kind: &str, params: serde_json::Value) -> Result<Vec<String>, String> {
match kind {
"sequence" => {
let p: SequenceParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
sequence_ddl(&p)
}
"enum" => {
let p: EnumParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
enum_ddl(&p)
}
"view" => {
let p: ViewParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
view_ddl(&p)
}
"extension" => {
let p: ExtensionParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
extension_ddl(&p)
}
"index" => {
let p: IndexParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
index_ddl(&p)
}
"constraint" => {
let p: ConstraintParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
constraint_ddl(&p)
}
"function" | "procedure" => {
let mut p: FunctionParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
p.is_procedure = kind == "procedure";
function_ddl(&p)
}
"trigger" => {
let p: TriggerParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
trigger_ddl(&p)
}
other => Err(format!("Unsupported object kind: {other}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_enum_label_quotes_and_doubles() {
assert_eq!(validate_enum_label("admin").unwrap(), "'admin'");
assert_eq!(validate_enum_label("user's").unwrap(), "'user''s'");
assert!(validate_enum_label("").is_err());
assert!(validate_enum_label(" ").is_err());
}
#[test]
fn validate_expression_rejects_empty_and_trailing_semicolon() {
assert_eq!(validate_expression("amount > 0").unwrap(), "amount > 0");
assert!(validate_expression("").is_err());
assert!(validate_expression("amount > 0;").is_err());
}
#[test]
fn build_ddl_rejects_unknown_kind() {
let res = build_ddl("bogus", serde_json::json!({}));
assert!(res.is_err());
assert!(res.unwrap_err().contains("Unsupported object kind: bogus"));
}
#[test]
fn sequence_create_full() {
let p = serde_json::json!({
"schema": "public", "name": "users_id_seq",
"action": { "op": "create", "increment": "1", "min_value": "1", "max_value": "9223372036854775807", "start": "1", "cycle": false }
});
let sql = build_ddl("sequence", p).unwrap();
assert_eq!(sql, vec!["CREATE SEQUENCE \"public\".\"users_id_seq\"\n INCREMENT BY 1\n MINVALUE 1\n MAXVALUE 9223372036854775807\n START WITH 1\n NO CYCLE"]);
}
#[test]
fn sequence_alter_partial_and_cycle_option() {
let p = serde_json::json!({
"schema": "public", "name": "s",
"action": { "op": "alter", "increment": "2", "cycle": true }
});
let sql = build_ddl("sequence", p).unwrap();
assert_eq!(sql, vec!["ALTER SEQUENCE \"public\".\"s\"\n INCREMENT BY 2\n CYCLE"]);
}
#[test]
fn sequence_restart_with_value() {
let p = serde_json::json!({ "schema": "public", "name": "s", "action": { "op": "restart", "with": "100" } });
assert_eq!(build_ddl("sequence", p).unwrap(), vec!["ALTER SEQUENCE \"public\".\"s\" RESTART WITH 100"]);
}
#[test]
fn sequence_drop() {
let p = serde_json::json!({ "schema": "public", "name": "s", "action": { "op": "drop" } });
assert_eq!(build_ddl("sequence", p).unwrap(), vec!["DROP SEQUENCE \"public\".\"s\""]);
}
#[test]
fn sequence_rejects_bad_name() {
let p = serde_json::json!({ "schema": "public", "name": "a; DROP", "action": { "op": "drop" } });
assert!(build_ddl("sequence", p).is_err());
}
#[test]
fn enum_create_quotes_labels() {
let p = serde_json::json!({ "schema": "public", "name": "role", "action": { "op": "create", "labels": ["admin", "user's"] } });
assert_eq!(build_ddl("enum", p).unwrap(), vec!["CREATE TYPE \"public\".\"role\" AS ENUM ('admin', 'user''s')"]);
}
#[test]
fn enum_rename_type() {
let p = serde_json::json!({ "schema": "public", "name": "role", "action": { "op": "rename_type", "new_name": "user_role" } });
assert_eq!(build_ddl("enum", p).unwrap(), vec!["ALTER TYPE \"public\".\"role\" RENAME TO \"user_role\""]);
}
#[test]
fn enum_add_value_with_position() {
let p = serde_json::json!({ "schema": "public", "name": "color", "action": { "op": "add_value", "value": "orange", "if_not_exists": true, "before": "red", "after": null } });
assert_eq!(build_ddl("enum", p).unwrap(), vec!["ALTER TYPE \"public\".\"color\" ADD VALUE IF NOT EXISTS 'orange' BEFORE 'red'"]);
}
#[test]
fn enum_add_value_plain() {
let p = serde_json::json!({ "schema": "public", "name": "color", "action": { "op": "add_value", "value": "green", "if_not_exists": false, "before": null, "after": null } });
assert_eq!(build_ddl("enum", p).unwrap(), vec!["ALTER TYPE \"public\".\"color\" ADD VALUE 'green'"]);
}
#[test]
fn enum_rename_value() {
let p = serde_json::json!({ "schema": "public", "name": "color", "action": { "op": "rename_value", "from": "purple", "to": "mauve" } });
assert_eq!(build_ddl("enum", p).unwrap(), vec!["ALTER TYPE \"public\".\"color\" RENAME VALUE 'purple' TO 'mauve'"]);
}
#[test]
fn enum_drop() {
let p = serde_json::json!({ "schema": "public", "name": "role", "action": { "op": "drop" } });
assert_eq!(build_ddl("enum", p).unwrap(), vec!["DROP TYPE \"public\".\"role\""]);
}
#[test]
fn enum_add_value_rejects_empty_label() {
let p = serde_json::json!({ "schema": "public", "name": "color", "action": { "op": "add_value", "value": "", "if_not_exists": false, "before": null, "after": null } });
assert!(build_ddl("enum", p).is_err());
}
#[test]
fn view_create_or_replace() {
let p = serde_json::json!({ "schema": "public", "name": "v_users", "materialized": false, "action": { "op": "create", "definition": "SELECT * FROM users" } });
assert_eq!(build_ddl("view", p).unwrap(), vec!["CREATE OR REPLACE VIEW \"public\".\"v_users\" AS\nSELECT * FROM users"]);
}
#[test]
fn matview_create() {
let p = serde_json::json!({ "schema": "public", "name": "mv_sales", "materialized": true, "action": { "op": "create", "definition": "SELECT count(*) FROM sales" } });
assert_eq!(build_ddl("view", p).unwrap(), vec!["CREATE MATERIALIZED VIEW \"public\".\"mv_sales\" AS\nSELECT count(*) FROM sales"]);
}
#[test]
fn matview_replace_is_drop_then_create() {
let p = serde_json::json!({ "schema": "public", "name": "mv_sales", "materialized": true, "action": { "op": "replace", "definition": "SELECT count(*) FROM sales" } });
assert_eq!(build_ddl("view", p).unwrap(), vec![
"DROP MATERIALIZED VIEW \"public\".\"mv_sales\"",
"CREATE MATERIALIZED VIEW \"public\".\"mv_sales\" AS\nSELECT count(*) FROM sales",
]);
}
#[test]
fn matview_refresh() {
let p = serde_json::json!({ "schema": "public", "name": "mv_sales", "materialized": true, "action": { "op": "refresh" } });
assert_eq!(build_ddl("view", p).unwrap(), vec!["REFRESH MATERIALIZED VIEW \"public\".\"mv_sales\""]);
}
#[test]
fn view_refresh_errors() {
let p = serde_json::json!({ "schema": "public", "name": "v_users", "materialized": false, "action": { "op": "refresh" } });
assert!(build_ddl("view", p).is_err());
}
#[test]
fn view_drop() {
let p = serde_json::json!({ "schema": "public", "name": "v_users", "materialized": false, "action": { "op": "drop" } });
assert_eq!(build_ddl("view", p).unwrap(), vec!["DROP VIEW \"public\".\"v_users\""]);
}
#[test]
fn extension_create_with_version() {
let p = serde_json::json!({ "schema": "public", "name": "pgcrypto", "action": { "op": "create", "version": "1.3" } });
assert_eq!(build_ddl("extension", p).unwrap(), vec!["CREATE EXTENSION IF NOT EXISTS \"pgcrypto\" WITH SCHEMA \"public\" VERSION '1.3'"]);
}
#[test]
fn extension_create_without_version() {
let p = serde_json::json!({ "schema": "public", "name": "pgcrypto", "action": { "op": "create", "version": null } });
assert_eq!(build_ddl("extension", p).unwrap(), vec!["CREATE EXTENSION IF NOT EXISTS \"pgcrypto\" WITH SCHEMA \"public\""]);
}
#[test]
fn extension_set_schema() {
let p = serde_json::json!({ "schema": "public", "name": "pgcrypto", "action": { "op": "set_schema", "new_schema": "utils" } });
assert_eq!(build_ddl("extension", p).unwrap(), vec!["ALTER EXTENSION \"pgcrypto\" SET SCHEMA \"utils\""]);
}
#[test]
fn extension_drop() {
let p = serde_json::json!({ "schema": "public", "name": "pgcrypto", "action": { "op": "drop" } });
assert_eq!(build_ddl("extension", p).unwrap(), vec!["DROP EXTENSION \"pgcrypto\""]);
}
#[test]
fn index_create_unique_btree_with_predicate() {
let p = serde_json::json!({
"schema": "public", "table": "users", "name": "users_email_key",
"action": { "op": "create", "unique": true, "method": "btree", "columns": ["email"], "predicate": "email IS NOT NULL" }
});
assert_eq!(build_ddl("index", p).unwrap(),
vec!["CREATE UNIQUE INDEX \"users_email_key\" ON \"public\".\"users\" USING btree (\"email\") WHERE email IS NOT NULL"]);
}
#[test]
fn index_create_no_method_no_predicate() {
let p = serde_json::json!({
"schema": "public", "table": "users", "name": "i_name",
"action": { "op": "create", "unique": false, "method": "", "columns": ["a", "b"], "predicate": null }
});
assert_eq!(build_ddl("index", p).unwrap(),
vec!["CREATE INDEX \"i_name\" ON \"public\".\"users\" (\"a\", \"b\")"]);
}
#[test]
fn index_drop() {
let p = serde_json::json!({ "schema": "public", "table": "users", "name": "i_name", "action": { "op": "drop" } });
assert_eq!(build_ddl("index", p).unwrap(), vec!["DROP INDEX \"public\".\"i_name\""]);
}
#[test]
fn index_create_rejects_empty_columns() {
let p = serde_json::json!({ "schema": "public", "table": "users", "name": "i", "action": { "op": "create", "unique": false, "method": "", "columns": [], "predicate": null } });
assert!(build_ddl("index", p).is_err());
}
#[test]
fn constraint_check() {
let p = serde_json::json!({ "schema": "public", "table": "orders", "name": "ck_pos", "action": { "op": "check", "expression": "amount > 0" } });
assert_eq!(build_ddl("constraint", p).unwrap(),
vec!["ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"ck_pos\" CHECK (amount > 0)"]);
}
#[test]
fn constraint_unique() {
let p = serde_json::json!({ "schema": "public", "table": "users", "name": "u_email", "action": { "op": "unique", "columns": ["email"] } });
assert_eq!(build_ddl("constraint", p).unwrap(),
vec!["ALTER TABLE \"public\".\"users\" ADD CONSTRAINT \"u_email\" UNIQUE (\"email\")"]);
}
#[test]
fn constraint_primary_key() {
let p = serde_json::json!({ "schema": "public", "table": "users", "name": "pk_users", "action": { "op": "primary_key", "columns": ["id"] } });
assert_eq!(build_ddl("constraint", p).unwrap(),
vec!["ALTER TABLE \"public\".\"users\" ADD CONSTRAINT \"pk_users\" PRIMARY KEY (\"id\")"]);
}
#[test]
fn constraint_foreign_key() {
let p = serde_json::json!({
"schema": "public", "table": "orders", "name": "fk_user",
"action": { "op": "foreign_key", "columns": ["user_id"], "ref_schema": "public", "ref_table": "users", "ref_columns": ["id"] }
});
assert_eq!(build_ddl("constraint", p).unwrap(),
vec!["ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"fk_user\" FOREIGN KEY (\"user_id\") REFERENCES \"public\".\"users\" (\"id\")"]);
}
#[test]
fn constraint_drop() {
let p = serde_json::json!({ "schema": "public", "table": "orders", "name": "ck_pos", "action": { "op": "drop" } });
assert_eq!(build_ddl("constraint", p).unwrap(),
vec!["ALTER TABLE \"public\".\"orders\" DROP CONSTRAINT \"ck_pos\""]);
}
#[test]
fn function_create_or_replace_basic() {
let p = serde_json::json!({
"schema": "public", "name": "add", "is_procedure": false,
"action": { "op": "create_or_replace",
"args": [ { "mode": "in", "name": "a", "type": "int" }, { "mode": "in", "name": "b", "type": "int" } ],
"return_type": "int", "language": "plpgsql", "body": "BEGIN RETURN a+b; END",
"volatility": "IMMUTABLE", "strict": true }
});
assert_eq!(build_ddl("function", p).unwrap(), vec![
"CREATE OR REPLACE FUNCTION \"public\".\"add\"(a int, b int) RETURNS int LANGUAGE plpgsql IMMUTABLE STRICT AS $$BEGIN RETURN a+b; END$$"
]);
}
#[test]
fn procedure_create_or_replace_no_returns() {
let p = serde_json::json!({
"schema": "public", "name": "do_thing", "is_procedure": true,
"action": { "op": "create_or_replace",
"args": [ { "mode": "in", "name": "x", "type": "int" } ],
"return_type": null, "language": "plpgsql", "body": "BEGIN PERFORM x; END",
"volatility": null, "strict": false }
});
assert_eq!(build_ddl("procedure", p).unwrap(), vec![
"CREATE OR REPLACE PROCEDURE \"public\".\"do_thing\"(x int) LANGUAGE plpgsql AS $$BEGIN PERFORM x; END$$"
]);
}
#[test]
fn function_drop_by_signature() {
let p = serde_json::json!({ "schema": "public", "name": "add", "is_procedure": false, "action": { "op": "drop", "arg_types": ["int", "int"] } });
assert_eq!(build_ddl("function", p).unwrap(), vec!["DROP FUNCTION \"public\".\"add\"(int, int)"]);
}
#[test]
fn procedure_drop_by_signature() {
let p = serde_json::json!({ "schema": "public", "name": "do_thing", "is_procedure": true, "action": { "op": "drop", "arg_types": ["int"] } });
assert_eq!(build_ddl("procedure", p).unwrap(), vec!["DROP PROCEDURE \"public\".\"do_thing\"(int)"]);
}
#[test]
fn trigger_create_row() {
let p = serde_json::json!({
"schema": "public", "name": "tr_audit",
"action": { "op": "create", "table": "orders", "timing": "BEFORE",
"events": ["INSERT", "UPDATE"], "orientation": "ROW",
"function_schema": "public", "function_name": "audit_fn",
"function_args": [], "when": null }
});
assert_eq!(build_ddl("trigger", p).unwrap(), vec![
"CREATE TRIGGER \"tr_audit\" BEFORE INSERT OR UPDATE ON \"public\".\"orders\" FOR EACH ROW EXECUTE FUNCTION \"public\".\"audit_fn\"()"
]);
}
#[test]
fn trigger_create_with_when_and_args() {
let p = serde_json::json!({
"schema": "public", "name": "tr_audit",
"action": { "op": "create", "table": "orders", "timing": "AFTER",
"events": ["UPDATE"], "orientation": "STATEMENT",
"function_schema": "public", "function_name": "audit_fn",
"function_args": ["'log'"], "when": "OLD.amount IS DISTINCT FROM NEW.amount" }
});
assert_eq!(build_ddl("trigger", p).unwrap(), vec![
"CREATE TRIGGER \"tr_audit\" AFTER UPDATE ON \"public\".\"orders\" FOR EACH STATEMENT WHEN (OLD.amount IS DISTINCT FROM NEW.amount) EXECUTE FUNCTION \"public\".\"audit_fn\"('log')"
]);
}
#[test]
fn trigger_enable_disable_drop() {
let base = |op: &str| serde_json::json!({ "schema": "public", "name": "tr_audit", "action": { "op": op, "table": "orders" } });
assert_eq!(build_ddl("trigger", base("enable")).unwrap(), vec!["ALTER TABLE \"public\".\"orders\" ENABLE TRIGGER \"tr_audit\""]);
assert_eq!(build_ddl("trigger", base("disable")).unwrap(), vec!["ALTER TABLE \"public\".\"orders\" DISABLE TRIGGER \"tr_audit\""]);
assert_eq!(build_ddl("trigger", base("drop")).unwrap(), vec!["DROP TRIGGER \"tr_audit\" ON \"public\".\"orders\""]);
}
}
+261
View File
@@ -0,0 +1,261 @@
//! Pure builders for schema DDL, cross-object search, pg_depend lookups,
//! and synthesized object DDL. No DB I/O — deterministic string builders.
use crate::models::db_viewer::{SequenceInfo, EnumInfo, ExtensionInfo, ConstraintInfo};
/// Double-quote an identifier, doubling embedded quotes.
pub fn quote_ident(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
/// Reject empty / SQL-injection-prone names (mirrors schema_graph::validate_schema_name).
pub fn validate_object_name(name: &str) -> Result<(), String> {
if name.trim().is_empty() {
return Err("Name must not be empty".into());
}
for bad in [";", "--", "/*", "'", "\"", "\\"] {
if name.contains(bad) {
return Err(format!("Name contains forbidden character(s): {bad}"));
}
}
Ok(())
}
pub fn create_schema_sql(name: &str) -> Result<String, String> {
validate_object_name(name)?;
Ok(format!("CREATE SCHEMA {}", quote_ident(name)))
}
pub fn rename_schema_sql(old: &str, new: &str) -> Result<String, String> {
validate_object_name(old)?;
validate_object_name(new)?;
Ok(format!("ALTER SCHEMA {} RENAME TO {}", quote_ident(old), quote_ident(new)))
}
pub fn drop_schema_sql(name: &str, cascade: bool) -> Result<String, String> {
validate_object_name(name)?;
Ok(format!("DROP SCHEMA {}{}", quote_ident(name), if cascade { " CASCADE" } else { "" }))
}
/// UNION ALL of every browsable object type in `$2` (schema), substring-matching `$1` (needle)
/// via position() — case-insensitive, no wildcard-escaping pitfalls. LIMIT 100.
pub fn pg_object_search_query() -> String {
"SELECT name, schema, type FROM (
SELECT table_name AS name, table_schema AS schema, CASE WHEN table_type = 'VIEW' THEN 'VIEW' ELSE 'TABLE' END AS type FROM information_schema.tables WHERE table_schema=$2 AND position(lower($1) in lower(table_name))>0
UNION ALL
SELECT matviewname, schemaname, 'MATERIALIZED VIEW' FROM pg_matviews WHERE schemaname=$2 AND position(lower($1) in lower(matviewname))>0
UNION ALL
SELECT p.proname, n.nspname, CASE p.prokind WHEN 'f' THEN 'FUNCTION' WHEN 'p' THEN 'PROCEDURE' END FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE n.nspname=$2 AND position(lower($1) in lower(p.proname))>0
UNION ALL
SELECT t.tgname, cn.nspname, 'TRIGGER' FROM pg_trigger t JOIN pg_class c ON t.tgrelid=c.oid JOIN pg_namespace cn ON c.relnamespace=cn.oid WHERE cn.nspname=$2 AND NOT t.tgisinternal AND position(lower($1) in lower(t.tgname))>0
UNION ALL
SELECT sequence_name, sequence_schema, 'SEQUENCE' FROM information_schema.sequences WHERE sequence_schema=$2 AND position(lower($1) in lower(sequence_name))>0
UNION ALL
SELECT t.typname, n.nspname, 'ENUM' FROM pg_type t JOIN pg_namespace n ON t.typnamespace=n.oid WHERE t.typtype='e' AND n.nspname=$2 AND position(lower($1) in lower(t.typname))>0
UNION ALL
SELECT e.extname, n.nspname, 'EXTENSION' FROM pg_extension e JOIN pg_namespace n ON e.extnamespace=n.oid WHERE n.nspname=$2 AND position(lower($1) in lower(e.extname))>0
UNION ALL
SELECT i.relname, ns.nspname, 'INDEX' FROM pg_index ix JOIN pg_class i ON i.oid=ix.indexrelid JOIN pg_class t ON t.oid=ix.indrelid JOIN pg_namespace ns ON t.relnamespace=ns.oid WHERE ns.nspname=$2 AND position(lower($1) in lower(i.relname))>0
UNION ALL
SELECT c.conname, ns.nspname, 'CONSTRAINT' FROM pg_constraint c JOIN pg_class cl ON c.conrelid=cl.oid JOIN pg_namespace ns ON cl.relnamespace=ns.oid WHERE ns.nspname=$2 AND c.contype IN ('c','u','x') AND position(lower($1) in lower(c.conname))>0
) AS hits ORDER BY type, name LIMIT 100".to_string()
}
/// Resolve a single object oid by type. $1=name, $2=schema. (Overloads: first match — see spec open questions.)
pub fn pg_object_oid_query(object_type: &str) -> String {
match object_type {
"table" | "view" | "materialized view" | "sequence" | "index" =>
"SELECT c.oid FROM pg_class c JOIN pg_namespace n ON c.relnamespace=n.oid WHERE c.relname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
"function" | "procedure" =>
"SELECT p.oid FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE p.proname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
"trigger" =>
"SELECT t.oid FROM pg_trigger t JOIN pg_class c ON t.tgrelid=c.oid JOIN pg_namespace n ON c.relnamespace=n.oid WHERE t.tgname=$1 AND n.nspname=$2 AND NOT t.tgisinternal LIMIT 1".to_string(),
"enum" =>
"SELECT t.oid FROM pg_type t JOIN pg_namespace n ON t.typnamespace=n.oid WHERE t.typname=$1 AND n.nspname=$2 AND t.typtype='e' LIMIT 1".to_string(),
"extension" =>
"SELECT e.oid FROM pg_extension e JOIN pg_namespace n ON e.extnamespace=n.oid WHERE e.extname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
"constraint" =>
"SELECT c.oid FROM pg_constraint c JOIN pg_class cl ON c.conrelid=cl.oid JOIN pg_namespace n ON cl.relnamespace=n.oid WHERE c.conname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
_ => String::new(),
}
}
/// Objects that depend on `$1` (the target oid). Excludes internal/pinned deps; resolves readable name per classid.
pub fn pg_depend_query() -> String {
"SELECT d.deptype::text AS deptype,
CASE WHEN d.classid = 'pg_rewrite'::regclass THEN 'pg_class'
ELSE d.classid::regclass::text END AS class,
CASE
WHEN d.classid='pg_class'::regclass THEN (SELECT relname FROM pg_class WHERE oid=d.objid)
WHEN d.classid='pg_proc'::regclass THEN (SELECT proname FROM pg_proc WHERE oid=d.objid)
WHEN d.classid='pg_trigger'::regclass THEN (SELECT tgname FROM pg_trigger WHERE oid=d.objid)
WHEN d.classid='pg_type'::regclass THEN (SELECT typname FROM pg_type WHERE oid=d.objid)
WHEN d.classid='pg_constraint'::regclass THEN (SELECT conname FROM pg_constraint WHERE oid=d.objid)
WHEN d.classid='pg_rewrite'::regclass THEN (SELECT ev_class::regclass::text FROM pg_rewrite WHERE oid=d.objid)
ELSE ''
END AS name
FROM pg_depend d
WHERE d.refobjid = $1 AND d.deptype IN ('n', 'a')
ORDER BY d.deptype, name".to_string()
}
/// Objects contained in a schema (for the schema-drop dependency warning). $1=schema.
pub fn pg_schema_contents_query() -> String {
"SELECT c.relname, c.relkind::text FROM pg_class c JOIN pg_namespace n ON c.relnamespace=n.oid WHERE n.nspname=$1 AND c.relkind IN ('r','v','m','S','i','c') ORDER BY c.relkind, c.relname".to_string()
}
// --- Synthesized DDL ---
pub fn sequence_ddl(s: &SequenceInfo) -> String {
format!("CREATE SEQUENCE {}.{}\n INCREMENT BY {}\n MINVALUE {}\n MAXVALUE {}\n START WITH {}\n {}",
quote_ident(&s.schema), quote_ident(&s.name), s.increment, s.min_value, s.max_value, s.start_value,
if s.cycle { "CYCLE" } else { "NO CYCLE" })
}
pub fn enum_ddl(e: &EnumInfo) -> String {
let labels: Vec<String> = e.labels.iter().map(|l| format!("'{}'", l.replace('\'', "''"))).collect();
format!("CREATE TYPE {}.{} AS ENUM ({});", quote_ident(&e.schema), quote_ident(&e.name), labels.join(", "))
}
pub fn extension_ddl(x: &ExtensionInfo) -> String {
format!("CREATE EXTENSION IF NOT EXISTS {} WITH SCHEMA {} VERSION '{}';", quote_ident(&x.name), quote_ident(&x.schema), x.version.replace('\'', "''"))
}
pub fn view_ddl(schema: &str, name: &str, selectdef: &str) -> String {
format!("CREATE OR REPLACE VIEW {}.{} AS\n{}", quote_ident(schema), quote_ident(name), selectdef)
}
pub fn matview_ddl(schema: &str, name: &str, selectdef: &str) -> String {
format!("CREATE MATERIALIZED VIEW {}.{} AS\n{}", quote_ident(schema), quote_ident(name), selectdef)
}
pub fn constraint_ddl(c: &ConstraintInfo) -> String {
format!("ALTER TABLE {}.{} ADD CONSTRAINT {} {}",
quote_ident(&c.schema), quote_ident(&c.table), quote_ident(&c.name), c.definition)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quote_ident_doubles_embedded_quotes() {
assert_eq!(quote_ident("public"), "\"public\"");
assert_eq!(quote_ident("a\"b"), "\"a\"\"b\"");
}
#[test]
fn validate_object_name_rejects_dangerous_chars() {
assert!(validate_object_name("public").is_ok());
assert!(validate_object_name("").is_err());
assert!(validate_object_name("a; DROP").is_err());
assert!(validate_object_name("a--b").is_err());
assert!(validate_object_name("a'b").is_err());
assert!(validate_object_name("a\"b").is_err());
assert!(validate_object_name("a\\b").is_err());
assert!(validate_object_name("/*x*/").is_err());
}
#[test]
fn create_schema_sql_quotes_name() {
assert_eq!(create_schema_sql("my_schema").unwrap(), "CREATE SCHEMA \"my_schema\"");
assert!(create_schema_sql("bad; name").is_err());
}
#[test]
fn rename_schema_sql_quotes_both() {
assert_eq!(
rename_schema_sql("old", "new").unwrap(),
"ALTER SCHEMA \"old\" RENAME TO \"new\""
);
}
#[test]
fn drop_schema_sql_cascade_flag() {
assert_eq!(drop_schema_sql("s", false).unwrap(), "DROP SCHEMA \"s\"");
assert_eq!(drop_schema_sql("s", true).unwrap(), "DROP SCHEMA \"s\" CASCADE");
}
#[test]
fn object_search_query_unions_types_and_uses_position_match() {
let sql = pg_object_search_query();
assert!(sql.contains("position(lower($1) in lower("), "case-insensitive substring, no wildcard escaping: {sql}");
assert!(sql.contains("$2"), "schema is $2");
// covers every browsable type
for t in ["'TABLE'", "'VIEW'", "'MATERIALIZED VIEW'", "'FUNCTION'", "'PROCEDURE'", "'TRIGGER'", "'SEQUENCE'", "'ENUM'", "'EXTENSION'", "'INDEX'", "'CONSTRAINT'"] {
assert!(sql.contains(t), "search must cover {t}");
}
assert!(sql.contains("LIMIT 100"));
}
#[test]
fn object_oid_query_branches_per_type() {
assert!(pg_object_oid_query("table").contains("pg_class"));
assert!(pg_object_oid_query("function").contains("pg_proc"));
assert!(pg_object_oid_query("trigger").contains("pg_trigger"));
assert!(pg_object_oid_query("enum").contains("pg_type"));
assert!(pg_object_oid_query("extension").contains("pg_extension"));
assert!(pg_object_oid_query("constraint").contains("pg_constraint"));
}
#[test]
fn depend_query_filters_internal_and_resolves_names() {
let sql = pg_depend_query();
assert!(sql.contains("refobjid = $1"));
assert!(sql.contains("deptype IN ('n', 'a')"), "exclude internal 'i' / pinned 'p'");
assert!(sql.contains("pg_proc'::regclass"));
assert!(sql.contains("pg_trigger'::regclass"));
assert!(sql.contains("pg_constraint'::regclass"));
}
#[test]
fn depend_query_maps_rewrite_rules_to_the_view_class() {
// View dependencies surface in pg_depend as rewrite-rule rows
// (classid = pg_rewrite). The dependent object a user cares about is
// the VIEW itself, so the class must be reported as pg_class and the
// name resolved through ev_class (the view's relation).
let sql = pg_depend_query();
assert!(
sql.contains("WHEN d.classid = 'pg_rewrite'::regclass THEN 'pg_class'"),
"pg_rewrite rows must be reported as pg_class: {sql}"
);
assert!(
sql.contains("pg_rewrite'::regclass THEN (SELECT ev_class::regclass::text FROM pg_rewrite WHERE oid=d.objid)"),
"pg_rewrite name resolves through ev_class: {sql}"
);
}
#[test]
fn sequence_ddl_is_synthesized() {
let s = SequenceInfo { name: "users_id_seq".into(), schema: "public".into(),
start_value: "1".into(), min_value: "1".into(), max_value: "9".into(),
increment: "1".into(), current_value: "5".into(), cycle: false };
let ddl = sequence_ddl(&s);
assert!(ddl.starts_with("CREATE SEQUENCE \"public\".\"users_id_seq\""));
assert!(ddl.contains("INCREMENT BY 1"));
assert!(ddl.contains("NO CYCLE"));
}
#[test]
fn enum_ddl_lists_labels_quoted() {
let e = EnumInfo { name: "role".into(), schema: "public".into(), labels: vec!["admin".into(), "user's".into()] };
let ddl = enum_ddl(&e);
assert!(ddl.starts_with("CREATE TYPE \"public\".\"role\" AS ENUM ("));
assert!(ddl.contains("'admin'"));
assert!(ddl.contains("'user''s'"), "single quotes doubled");
}
#[test]
fn extension_ddl_is_synthesized() {
let x = ExtensionInfo { name: "pgcrypto".into(), schema: "public".into(), version: "1.3".into(), comment: None };
let ddl = extension_ddl(&x);
assert!(ddl.contains("CREATE EXTENSION IF NOT EXISTS \"pgcrypto\""));
assert!(ddl.contains("WITH SCHEMA \"public\""));
assert!(ddl.contains("VERSION '1.3'"));
}
#[test]
fn view_ddl_wraps_selectdef() {
assert_eq!(view_ddl("public", "v_users", "SELECT * FROM users"),
"CREATE OR REPLACE VIEW \"public\".\"v_users\" AS\nSELECT * FROM users");
}
#[test]
fn constraint_ddl_wraps_definition() {
let c = ConstraintInfo { name: "ck_pos".into(), schema: "public".into(), table: "orders".into(),
contype: "CHECK".into(), definition: "CHECK (amount > 0)".into(), deferrable: false, validated: true, columns: vec!["amount".into()] };
let ddl = constraint_ddl(&c);
assert_eq!(ddl, "ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"ck_pos\" CHECK (amount > 0)");
}
}
+10 -2
View File
@@ -20,8 +20,8 @@ pub struct AppState {
}
use commands::{
backup, connections, db_viewer, demo, folders, import_export, keychain, query, schema_graph,
settings, tags,
backup, connections, db_viewer, demo, folders, import_export, keychain, objects, query,
schema_graph, settings, tags,
};
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
@@ -116,6 +116,14 @@ 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,
objects::build_object_ddl,
objects::get_available_extensions,
keychain::save_connection_password,
keychain::get_connection_password,
keychain::delete_connection_password,
+20
View File
@@ -34,6 +34,16 @@ pub struct PgToolStatus {
pub pg_restore_found: bool,
pub pg_dump_version: Option<String>,
pub pg_restore_version: Option<String>,
pub pg_dump_source: Option<String>, // "system" | "bundled" | None
pub pg_restore_source: Option<String>,
}
/// Resolved on-disk paths for the three client tools (system-first, bundled-fallback).
#[derive(Debug, Clone)]
pub struct PgToolPaths {
pub pg_dump: String,
pub pg_restore: String,
pub psql: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -97,4 +107,14 @@ mod tests {
assert!(json.contains("dump"));
assert!(json.contains("completed"));
}
#[test]
fn pg_tool_status_reports_source() {
let s = PgToolStatus { pg_dump_found: true, pg_restore_found: true,
pg_dump_version: Some("pg_dump 16".into()), pg_restore_version: Some("pg_restore 16".into()),
pg_dump_source: Some("system".into()), pg_restore_source: Some("bundled".into()) };
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("\"pg_dump_source\":\"system\""));
assert!(json.contains("\"pg_restore_source\":\"bundled\""));
}
}
+24
View File
@@ -23,6 +23,7 @@ pub struct Connection {
pub ssl_cert_path: Option<String>,
pub ssl_key_path: Option<String>,
pub tag_ids: Vec<String>,
pub use_keychain: bool,
pub created_at: String,
pub updated_at: String,
}
@@ -50,6 +51,12 @@ pub struct ConnectionInput {
pub ssl_ca_path: Option<String>,
pub ssl_cert_path: Option<String>,
pub ssl_key_path: Option<String>,
#[serde(default = "default_true")]
pub use_keychain: bool,
}
fn default_true() -> bool {
true
}
#[cfg(test)]
@@ -66,6 +73,7 @@ mod tests {
username: Some("admin".to_string()),
folder_id: Some("folder1".to_string()),
tag_ids: vec!["tag1".to_string(), "tag2".to_string()],
use_keychain: true,
password: Some("secret123".to_string()),
database: Some("mydb".to_string()),
ssh_host: Some("jumphost.example.com".to_string()),
@@ -133,6 +141,7 @@ mod tests {
environment: None,
favorite: false,
tag_ids: vec![],
use_keychain: true,
created_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
database: Some("mydb".to_string()),
@@ -177,6 +186,7 @@ mod tests {
ssl_cert_path: None,
ssl_key_path: None,
tag_ids: vec![],
use_keychain: true,
favorite: true,
created_at: "2024-01-01T00:00:00Z".into(),
updated_at: "2024-01-01T00:00:00Z".into(),
@@ -184,4 +194,18 @@ mod tests {
let json = serde_json::to_string(&conn).unwrap();
assert!(json.contains("\"favorite\":true"));
}
#[test]
fn connection_input_use_keychain_defaults_true_when_absent() {
let json = r#"{"name":"n","db_type":"postgresql","host":"h","port":5432,"tag_ids":[]}"#;
let input: ConnectionInput = serde_json::from_str(json).unwrap();
assert!(input.use_keychain, "absent use_keychain defaults to true (opt-out)");
}
#[test]
fn connection_input_use_keychain_preserves_false() {
let json = r#"{"name":"n","db_type":"postgresql","host":"h","port":5432,"tag_ids":[],"use_keychain":false}"#;
let input: ConnectionInput = serde_json::from_str(json).unwrap();
assert!(!input.use_keychain);
}
}
+37 -1
View File
@@ -132,6 +132,20 @@ pub struct ExtensionInfo {
pub comment: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObjectSearchHit {
pub name: String,
pub schema: String,
pub object_type: String, // TABLE | VIEW | MATERIALIZED VIEW | FUNCTION | PROCEDURE | TRIGGER | SEQUENCE | ENUM | EXTENSION | INDEX | CONSTRAINT
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DependencyInfo {
pub deptype: String, // "n" (normal) | "a" (auto)
pub class: String, // pg_class | pg_proc | pg_trigger | pg_type | pg_constraint | pg_rewrite
pub name: String, // resolved dependent object name
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Change {
@@ -179,6 +193,10 @@ pub enum Change {
schema: String,
table: String,
},
Ddl {
id: String,
sql: String,
},
}
impl Change {
@@ -190,7 +208,8 @@ impl Change {
| Change::AlterTable { id, .. }
| Change::BulkInsert { id, .. }
| Change::DropTable { id, .. }
| Change::EmptyTable { id, .. } => id,
| Change::EmptyTable { id, .. }
| Change::Ddl { id, .. } => id,
}
}
}
@@ -583,4 +602,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"));
}
}
+69 -3
View File
@@ -242,6 +242,30 @@ pub fn run_migrations(conn: &Connection) -> Result<(), String> {
.map_err(|e| e.to_string())?;
}
// v8: use_keychain opt-out flag on connections
if current_ver < 8 {
let existing: Vec<String> = {
let mut stmt = conn
.prepare("PRAGMA table_info(connections)")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| row.get::<_, String>(1))
.map_err(|e| e.to_string())?;
rows.filter_map(|r| r.ok()).collect()
};
if !existing.iter().any(|c| c == "use_keychain") {
conn.execute(
"ALTER TABLE connections ADD COLUMN use_keychain INTEGER NOT NULL DEFAULT 1",
[],
)
.map_err(|e| e.to_string())?;
}
conn.execute("INSERT INTO schema_version (version) VALUES (8)", [])
.map_err(|e| e.to_string())?;
}
Ok(())
}
@@ -292,7 +316,49 @@ mod tests {
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 6);
assert_eq!(count, 7);
}
#[test]
fn migration_v8_adds_use_keychain_column_default_1() {
let conn = Connection::open_in_memory().unwrap();
// v7 baseline
run_migrations(&conn).unwrap();
// simulate an existing connection row (pre-v8 shape had no use_keychain)
conn.execute(
"INSERT INTO connections (id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, created_at, updated_at) VALUES ('c1','n','postgresql','h',5432,'u','d',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'t','t','t')",
[],
)
.unwrap();
run_migrations(&conn).unwrap();
let uses: i64 = conn
.query_row("SELECT use_keychain FROM connections WHERE id='c1'", [], |r| r.get(0))
.unwrap();
assert_eq!(uses, 1, "existing connections default to use_keychain=1 (ON)");
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM schema_version WHERE version=8", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 1, "schema_version row 8 inserted exactly once");
}
#[test]
fn migration_v8_is_idempotent() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
run_migrations(&conn).unwrap();
let v8: i64 = conn
.query_row("SELECT COUNT(*) FROM schema_version WHERE version=8", [], |r| r.get(0))
.unwrap();
assert_eq!(v8, 1);
// PRAGMA confirms exactly one use_keychain column
let cols: Vec<String> = conn
.prepare("PRAGMA table_info(connections)")
.unwrap()
.query_map([], |r| r.get::<_, String>(1))
.unwrap()
.filter_map(Result::ok)
.collect();
assert_eq!(cols.iter().filter(|c| c == &"use_keychain").count(), 1);
}
#[test]
@@ -541,12 +607,12 @@ mod tests {
}
#[test]
fn v7_bumps_schema_version_to_7() {
fn v8_bumps_schema_version_to_8() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
let ver: i64 = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(ver, 7);
assert_eq!(ver, 8);
}
}
+18 -5
View File
@@ -257,7 +257,7 @@ impl Store {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, favorite, created_at, updated_at FROM connections ORDER BY name",
"SELECT id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, favorite, created_at, updated_at, use_keychain FROM connections ORDER BY name",
)
.map_err(|e| e.to_string())?;
let rows = stmt
@@ -286,6 +286,7 @@ impl Store {
tag_ids: vec![],
created_at: row.get(20)?,
updated_at: row.get(21)?,
use_keychain: row.get(22)?,
})
})
.map_err(|e| e.to_string())?;
@@ -308,8 +309,8 @@ impl Store {
let id = uuid::Uuid::new_v4().to_string();
let now = Self::now();
conn.execute(
"INSERT INTO connections (id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
params![id, input.name, input.db_type, input.host, input.port, input.username, input.database, input.folder_id, input.ssh_host, input.ssh_port, input.ssh_user, input.ssh_auth_method, input.ssh_private_key_path, input.ssl_mode, input.ssl_ca_path, input.ssl_cert_path, input.ssl_key_path, input.environment, now, now],
"INSERT INTO connections (id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, created_at, updated_at, use_keychain) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21)",
params![id, input.name, input.db_type, input.host, input.port, input.username, input.database, input.folder_id, input.ssh_host, input.ssh_port, input.ssh_user, input.ssh_auth_method, input.ssh_private_key_path, input.ssl_mode, input.ssl_ca_path, input.ssl_cert_path, input.ssl_key_path, input.environment, now, now, input.use_keychain],
)
.map_err(|e| e.to_string())?;
for tag_id in &input.tag_ids {
@@ -341,6 +342,7 @@ impl Store {
ssl_cert_path: input.ssl_cert_path,
ssl_key_path: input.ssl_key_path,
tag_ids: input.tag_ids,
use_keychain: input.use_keychain,
created_at: now.clone(),
updated_at: now,
})
@@ -419,13 +421,13 @@ impl Store {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let now = Self::now();
conn.execute(
"UPDATE connections SET name=?1, db_type=?2, host=?3, port=?4, username=?5, database=?6, folder_id=?7, ssh_host=?8, ssh_port=?9, ssh_user=?10, ssh_auth_method=?11, ssh_private_key_path=?12, ssl_mode=?13, ssl_ca_path=?14, ssl_cert_path=?15, ssl_key_path=?16, environment=?17, updated_at=?18 WHERE id=?19",
"UPDATE connections SET name=?1, db_type=?2, host=?3, port=?4, username=?5, database=?6, folder_id=?7, ssh_host=?8, ssh_port=?9, ssh_user=?10, ssh_auth_method=?11, ssh_private_key_path=?12, ssl_mode=?13, ssl_ca_path=?14, ssl_cert_path=?15, ssl_key_path=?16, environment=?17, use_keychain=?18, updated_at=?19 WHERE id=?20",
params![
input.name, input.db_type, input.host, input.port, input.username,
input.database, input.folder_id, input.ssh_host, input.ssh_port,
input.ssh_user, input.ssh_auth_method, input.ssh_private_key_path,
input.ssl_mode, input.ssl_ca_path, input.ssl_cert_path, input.ssl_key_path,
input.environment, now, id
input.environment, input.use_keychain, now, id
],
).map_err(|e| e.to_string())?;
// Update tags
@@ -463,6 +465,7 @@ impl Store {
ssl_cert_path: input.ssl_cert_path,
ssl_key_path: input.ssl_key_path,
tag_ids: input.tag_ids.clone(),
use_keychain: input.use_keychain,
created_at: String::new(), // not updated
updated_at: now,
})
@@ -954,6 +957,7 @@ mod tests {
ssl_key_path: None,
environment: None,
tag_ids: vec![],
use_keychain: true,
})
.unwrap();
let got = store.get_connections().unwrap();
@@ -1002,6 +1006,7 @@ mod tests {
ssl_key_path: None,
environment: None,
tag_ids: vec![t1.id.clone(), t2.id.clone()],
use_keychain: true,
})
.unwrap();
let got = store.get_connections().unwrap();
@@ -1043,6 +1048,7 @@ mod tests {
ssl_key_path: None,
environment: None,
tag_ids: vec![],
use_keychain: true,
})
.unwrap();
store.delete_folder(&folder.id).unwrap();
@@ -1082,6 +1088,7 @@ mod tests {
ssl_key_path: None,
environment: None,
tag_ids: vec![tag.id.clone()],
use_keychain: true,
})
.unwrap();
store.delete_tag(&tag.id).unwrap();
@@ -1142,6 +1149,7 @@ mod tests {
ssl_key_path: Some("/etc/ssl/private/client-key.pem".into()),
environment: None,
tag_ids: vec![],
use_keychain: true,
})
.unwrap();
let got = store.get_connections().unwrap();
@@ -1194,6 +1202,7 @@ mod tests {
ssl_key_path: None,
environment: None,
tag_ids: vec![],
use_keychain: true,
})
.unwrap();
@@ -1278,6 +1287,7 @@ mod tests {
ssl_key_path: None,
environment: None,
tag_ids: vec![],
use_keychain: true,
})
.unwrap();
// Insert 510 rows — should trigger pruning beyond 500
@@ -1334,6 +1344,7 @@ mod tests {
ssl_key_path: None,
environment: None,
tag_ids: vec![],
use_keychain: true,
})
.unwrap();
store
@@ -1378,6 +1389,7 @@ mod tests {
ssl_key_path: None,
environment: None,
tag_ids: vec![],
use_keychain: true,
})
.unwrap();
store
@@ -1480,6 +1492,7 @@ mod tests {
ssl_key_path: None,
environment: None,
tag_ids: vec![],
use_keychain: true,
})
.unwrap()
}
+3 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Gridline",
"version": "0.7.0",
"version": "0.7.6",
"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"
]
@@ -8,7 +8,7 @@ import type { ConnectionFormData } from "./connectionFormData";
const BASE_FORM: ConnectionFormData = {
name: "", environment: null, folder_id: null, tag_ids: [],
connection_string: "", db_type: "postgresql", host: "", port: 5432,
username: null, password: null, database: null, use_keychain: false, ssh_password: null,
username: null, password: null, database: null, use_keychain: true, ssh_password: null,
};
describe("ConnectionMetadataRow", () => {
@@ -19,7 +19,7 @@ const BASE_FORM: ConnectionFormData = {
username: null,
password: null,
database: null,
use_keychain: false,
use_keychain: true,
ssh_password: null,
};
+12 -1
View File
@@ -7,7 +7,7 @@ const BASE_FORM: ConnectionFormData = {
name: "My DB", environment: null, folder_id: null, tag_ids: [],
connection_string: "postgresql://u:p@localhost:5432/db", db_type: "postgresql",
host: "localhost", port: 5432, username: "u", password: "p", database: "db",
use_keychain: false, ssh_password: null,
use_keychain: true, ssh_password: null,
};
describe("GeneralTab", () => {
@@ -54,4 +54,15 @@ describe("GeneralTab", () => {
render(<GeneralTab form={{ ...BASE_FORM, db_type: "postgresql" }} managedPreset="supabase" onChange={() => {}} />);
expect(screen.getByText(/requires ssl/i)).toBeInTheDocument();
});
it("defaults the keychain toggle to ON (opt-out)", () => {
render(<GeneralTab form={{ ...BASE_FORM, use_keychain: true }} onChange={() => {}} />);
const cb = screen.getByLabelText("Enable keychain") as HTMLInputElement;
expect(cb.checked).toBe(true);
});
it("shows the DB-password-only tooltip text", () => {
render(<GeneralTab form={{ ...BASE_FORM, use_keychain: true }} onChange={() => {}} />);
expect(screen.getByText(/DB password only/i)).toBeInTheDocument();
});
});
@@ -84,6 +84,9 @@ export function GeneralTab({ form, onChange, managedPreset }: GeneralTabProps) {
<input type="checkbox" checked={form.use_keychain} onChange={(e) => onChange({ use_keychain: e.target.checked })} aria-label="Enable keychain" className="rounded border-border bg-surface text-accent focus:ring-accent" />
Enable keychain
</label>
<p className="text-xs text-text-muted -mt-1 mb-2">
Saves the DB password to the OS keychain (DB password only; SSH secrets always use the keychain). Uncheck to never persist the password — you'll re-enter it each session.
</p>
</div>
);
}
@@ -50,7 +50,7 @@ function createEmptyForm(
username: null,
password: null,
database: null,
use_keychain: false,
use_keychain: true,
ssh_password: null,
};
}
+1 -1
View File
@@ -58,7 +58,7 @@ export function BackupDialog({ open, connectionId, onClose }: BackupDialogProps)
setCheckingTools(true);
detectPgTools()
.then((status) => setToolStatus(status))
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null }))
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null, pg_dump_source: null, pg_restore_source: null }))
.finally(() => setCheckingTools(false));
}, [open]);
@@ -0,0 +1,32 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { BackupPage } from "./BackupPage";
import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore";
import * as commands from "../../lib/commands";
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn().mockReturnValue(Promise.resolve(() => {})) }));
vi.mock("@tauri-apps/plugin-dialog", () => ({ save: vi.fn().mockResolvedValue("/tmp/backup.dump") }));
describe("BackupPage", () => {
beforeEach(() => {
vi.restoreAllMocks();
useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 });
useNotificationStore.setState({ notifications: [] });
vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
});
it("hides install instructions when tools are bundled", async () => {
vi.spyOn(commands, "detectPgTools").mockResolvedValue({
pg_dump_found: false,
pg_restore_found: false,
pg_dump_version: null,
pg_restore_version: null,
pg_dump_source: "bundled",
pg_restore_source: "bundled",
});
render(<BackupPage connectionId="c1" />);
await waitFor(() => expect(screen.queryByText(/checking for pg_dump/i)).not.toBeInTheDocument());
expect(screen.queryByText(/brew install|apt install/i)).toBeNull();
});
});
+4 -1
View File
@@ -79,6 +79,8 @@ export function BackupPage({ connectionId }: BackupPageProps) {
pg_restore_found: false,
pg_dump_version: null,
pg_restore_version: null,
pg_dump_source: null,
pg_restore_source: null,
}),
)
.finally(() => setCheckingTools(false));
@@ -136,6 +138,7 @@ export function BackupPage({ connectionId }: BackupPageProps) {
}, [filePath, format, schema, noOwner, connectionId, startJob, notify]);
const toolsMissing = toolStatus && !toolStatus.pg_dump_found;
const toolsBundled = toolStatus?.pg_dump_source === "bundled";
return (
<div className="flex flex-col h-full">
@@ -160,7 +163,7 @@ export function BackupPage({ connectionId }: BackupPageProps) {
</div>
)}
{toolsMissing && (
{toolsMissing && !toolsBundled && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
<p className="text-amber-300 text-sm font-semibold">
pg_dump not found
@@ -93,6 +93,20 @@ describe("ChangesQueuePanel", () => {
expect(screen.getByText(/drop table: public.t/i)).toBeInTheDocument();
});
it("renders a ddl change with a DDL badge + description (visual) and SQL preview (sql view)", async () => {
const user = userEvent.setup();
useDbViewerStore.getState().addChange({
type: "ddl",
sql: "DROP INDEX public.i",
description: "Drop index i",
} as any);
render(<ChangesQueuePanel />);
expect(screen.getByText("DDL")).toBeInTheDocument();
expect(screen.getByText("Drop index i")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /sql/i }));
expect(screen.getByText(/DROP INDEX public\.i/)).toBeInTheDocument();
});
it("commit calls executeChange with buildChangePayload output for insert", async () => {
const exec = vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
useDbViewerStore.getState().addChange({
@@ -25,6 +25,8 @@ function formatChangeLabel(change: QueueItem): string {
return `Empty Table: ${fullName}`;
case "drop_table":
return `Drop Table: ${fullName}`;
case "ddl":
return change.description ?? "DDL";
default:
return change.table ?? "-";
}
@@ -50,6 +52,11 @@ function capitalizeType(type: string) {
return type.charAt(0).toUpperCase() + type.slice(1);
}
/** Badge label for a queue-item type — ddl renders uppercase to match its acronym. */
function badgeLabel(type: string): string {
return type === "ddl" ? "DDL" : capitalizeType(type);
}
function tableRef(change: QueueItem): string {
if (change.schema && change.table) return `${change.schema}.${change.table}`;
return change.table ?? "-";
@@ -171,7 +178,7 @@ export function ChangesQueuePanel({ onCommitted }: { onCommitted?: () => void }
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="rounded bg-surface-raised px-1.5 py-0.5 text-xs font-medium text-text-muted">
{capitalizeType(change.type)}
{badgeLabel(change.type)}
</span>
<span className="text-sm text-text truncate">
{tableRef(change)}
@@ -16,6 +16,9 @@ vi.mock("../../hooks/useDbConnection", () => ({
useDbConnection: (_connectionId: string) => ({
connectionError: null,
connect: vi.fn(),
passwordPromptOpen: false,
submitPassword: vi.fn(),
cancelPassword: vi.fn(),
}),
}));
@@ -896,7 +899,66 @@ describe("DbViewerScreen", () => {
).toBeInTheDocument();
});
it("guards the Objects view for MySQL (capability false)", () => {
it("objects view renders the sidebar + the tabbed workspace (New query + Changes)", () => {
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /objects/i }));
expect(
screen.getByRole("button", { name: /new query/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Changes queue" }),
).toBeInTheDocument();
});
it("objects view renders ObjectDetail for an open object tab", () => {
useDbViewerStore.getState().openObjectTab("enums", "public", "role", { name: "role", schema: "public", labels: ["admin"] });
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /objects/i }));
expect(screen.getByText("admin")).toBeInTheDocument();
});
it("renders an objectForm tab with the Visual/SQL toggle in the objects view", async () => {
vi.spyOn(commands, "executeQuery").mockResolvedValue({ columns: [], rows: [], total_rows: 0, page: 1, page_size: 50 } as any);
vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
vi.spyOn(commands, "getDatabases").mockResolvedValue(["mydb"]);
vi.spyOn(commands, "getTables").mockResolvedValue([] as any);
useDbViewerStore.getState().openFormTab({
kind: "sequence",
schema: "public",
name: "",
title: "Create sequence",
description: "Create sequence",
mode: "create",
params: { schema: "public", name: "", action: { op: "create" } },
});
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /objects/i }));
await waitFor(() => {
expect(screen.getByPlaceholderText("Sequence name")).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: "Visual" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "SQL" })).toBeInTheDocument();
});
it("guards the Objects view for MySQL (capability false)", () => {
useConnectionStore.setState({
connections: [
{
+62 -3
View File
@@ -15,11 +15,14 @@ const DestructiveQueryDialog = lazy(() =>
);
import { TableTree } from "./TableTree";
import { ObjectExplorerPage } from "./ObjectExplorerPage";
import { ObjectDetail, type AnyObject } from "./objects/ObjectDetail";
import { ObjectFormTab } from "./objects/ObjectFormTab";
import { TabBar } from "./TabBar";
import { VirtualDataGrid } from "../grid/VirtualDataGrid";
import { RowDetailDrawer } from "../grid/RowDetailDrawer";
import { TableControls } from "./TableControls";
import { EditConnectionModal } from "./EditConnectionModal";
import { PasswordPromptDialog } from "./PasswordPromptDialog";
import { useDbConnection } from "../../hooks/useDbConnection";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useConnectionStore } from "../../stores/connectionStore";
@@ -30,6 +33,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";
@@ -161,7 +165,8 @@ export function DbViewerScreen({
onHome,
onSettings,
}: DbViewerScreenProps) {
const { connectionError, connect } = useDbConnection(connectionId);
const { connectionError, connect, passwordPromptOpen, submitPassword, cancelPassword } =
useDbConnection(connectionId);
const [dismissedError, setDismissedError] = useState<string | null>(null);
const [currentView, setCurrentView] = useState<string>("db-viewer");
const [tablePanelWidth, setTablePanelWidth] = useState(280);
@@ -186,6 +191,18 @@ export function DbViewerScreen({
const toggleHiddenColumn = useDbViewerStore((s) => s.toggleHiddenColumn);
const setSmartSortApplied = useDbViewerStore((s) => s.setSmartSortApplied);
const setObjectSearchOpen = useDbViewerStore((s) => s.setObjectSearchOpen);
const requestedView = useDbViewerStore((s) => s.requestedView);
// Consume the search palette's navigation request: switch the local
// currentView state to the requested view, then clear it so a second
// request for the same view still fires.
useEffect(() => {
if (!requestedView) return;
setCurrentView(requestedView);
useDbViewerStore.getState().setRequestedView(null);
}, [requestedView]);
// Sync settings defaults to store
useEffect(() => {
if (settings?.table_page_size) {
@@ -423,6 +440,11 @@ export function DbViewerScreen({
onHome();
}
});
useShortcut("command_palette", () => {
if (capabilities.objects) {
setObjectSearchOpen(true);
}
});
useEffect(() => {
if (!activeTab) return;
if (activeTab.tabType !== "table") return;
@@ -966,15 +988,30 @@ const onQueriesPanelResizeStart = useCallback(
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-text-muted">
{currentView === "queries" ? (
<Terminal size={32} />
) : currentView === "objects" ? (
<Database size={32} />
) : (
<Table2 size={32} />
)}
<span>
{currentView === "queries"
? "Open a new query tab or run a query from the history"
: "Select a table from the tree to browse its data, or open a new query tab"}
: currentView === "objects"
? "Open an object from the list, or open a new query tab"
: "Select a table from the tree to browse its data, or open a new query tab"}
</span>
</div>
) : activeTab?.tabType === "objectForm" ? (
<ObjectFormTab
connectionId={connectionId}
tab={activeTab}
/>
) : activeTab?.tabType === "object" ? (
<ObjectDetail
connectionId={connectionId}
type={activeTab.objectType!}
item={activeTab.objectItem as AnyObject}
/>
) : activeTab?.tabType === "query" ? (
<Suspense
fallback={
@@ -1421,7 +1458,20 @@ const onQueriesPanelResizeStart = useCallback(
{renderQueryWorkspace()}
</div>
) : currentView === "objects" ? (
<ObjectExplorerPage connectionId={connectionId} />
<div className="flex flex-1 min-h-0 overflow-hidden">
<div
className="border-r border-border flex flex-col shrink-0"
style={{ width: tablePanelWidth }}
>
<ObjectExplorerPage connectionId={connectionId} sidebarMode />
</div>
<div
className="w-1 cursor-col-resize bg-border/20 hover:bg-accent/30 active:bg-accent/50 shrink-0 border-r border-border"
onMouseDown={onPanelResizeStart}
onDoubleClick={() => setTablePanelWidth(280)}
/>
{renderQueryWorkspace()}
</div>
) : currentView === "tools" ? (
<ToolsPage connectionId={connectionId} />
) : currentView === "queries" ? (
@@ -1455,6 +1505,15 @@ const onQueriesPanelResizeStart = useCallback(
onSaved={() => {}}
/>
)}
<PasswordPromptDialog
open={passwordPromptOpen}
connectionName={currentConnection?.name ?? ""}
onConnect={submitPassword}
onCancel={cancelPassword}
/>
{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,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { EditConnectionModal } from "./EditConnectionModal";
import * as commands from "../../lib/commands";
import type { Connection } from "../../lib/types";
const { updateConnection, loadAll } = vi.hoisted(() => ({
@@ -12,13 +13,18 @@ vi.mock("../../stores/connectionStore", () => ({
useConnectionStore: (sel: (s: any) => any) =>
sel({ updateConnection, loadAll, folders: [], tags: [] }),
}));
vi.mock("../../lib/commands", () => ({
updateConnection: vi.fn(),
testConnection: vi.fn(),
saveConnectionPassword: vi.fn(),
saveConnectionSshPassword: vi.fn(),
saveConnectionSshPassphrase: vi.fn(),
}));
vi.mock("../../lib/commands", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/commands")>();
return {
...actual,
updateConnection: vi.fn(),
testConnection: vi.fn(),
saveConnectionPassword: vi.fn(),
saveConnectionSshPassword: vi.fn(),
saveConnectionSshPassphrase: vi.fn(),
deleteConnectionPassword: vi.fn(),
};
});
vi.mock("../../stores/notificationStore", () => ({
useNotificationStore: (sel: (s: any) => any) => sel({ notify: vi.fn() }),
}));
@@ -68,4 +74,51 @@ describe("EditConnectionModal", () => {
);
expect(screen.getByText(/requires ssl/i)).toBeInTheDocument();
});
it("prefills the keychain toggle from the connection (use_keychain=true)", () => {
render(
<EditConnectionModal
connection={{ ...baseConn, use_keychain: true }}
open={true}
onClose={() => {}}
onSaved={() => {}}
/>
);
const cb = screen.getByLabelText("Enable keychain") as HTMLInputElement;
expect(cb.checked).toBe(true);
});
it("saves to keychain when use_keychain=true", async () => {
render(
<EditConnectionModal
connection={{ ...baseConn, use_keychain: true }}
open={true}
onClose={() => {}}
onSaved={() => {}}
/>
);
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "secret" } });
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() =>
expect(vi.mocked(commands.saveConnectionPassword)).toHaveBeenCalledWith("c1", "secret")
);
expect(vi.mocked(commands.deleteConnectionPassword)).not.toHaveBeenCalled();
});
it("purges keychain when use_keychain=false", async () => {
render(
<EditConnectionModal
connection={{ ...baseConn, use_keychain: false }}
open={true}
onClose={() => {}}
onSaved={() => {}}
/>
);
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "secret" } });
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() =>
expect(vi.mocked(commands.deleteConnectionPassword)).toHaveBeenCalledWith("c1")
);
expect(vi.mocked(commands.saveConnectionPassword)).not.toHaveBeenCalled();
});
});
@@ -4,7 +4,8 @@ import { Button } from "../ui/Button";
import { DetailedConnectionForm } from "../connections/DetailedConnectionForm";
import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { updateConnection, testConnection, saveConnectionPassword, saveConnectionSshPassword, saveConnectionSshPassphrase } from "../../lib/commands";
import { updateConnection, testConnection, saveConnectionSshPassword, saveConnectionSshPassphrase } from "../../lib/commands";
import { persistDbPassword } from "../../lib/keychain";
import { detectProviderFromHost } from "../../lib/connectionString";
import type { Connection, ConnectionInput } from "../../lib/types";
import type { ConnectionFormData } from "../connections/connectionFormData";
@@ -36,7 +37,7 @@ export function EditConnectionModal({
username: connection.username,
password: null,
database: connection.database ?? null,
use_keychain: false,
use_keychain: connection.use_keychain ?? true,
ssh_host: connection.ssh_host ?? null,
ssh_port: connection.ssh_port ?? null,
ssh_user: connection.ssh_user ?? null,
@@ -76,9 +77,7 @@ export function EditConnectionModal({
ssh_passphrase: form.ssh_passphrase ?? null,
};
const updated = await updateConnection(connection.id, input);
if (form.password) {
await saveConnectionPassword(connection.id, form.password).catch(() => {});
}
await persistDbPassword(connection.id, form.use_keychain, form.password).catch(() => {});
// Persist SSH secrets to the OS keychain (not SQLite)
if (form.ssh_host && (form.ssh_auth_method ?? "password") === "password" && form.ssh_password) {
await saveConnectionSshPassword(connection.id, form.ssh_password).catch(() => {});
@@ -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,171 @@ 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(/actions/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(/actions/i)[0]);
fireEvent.click(screen.getByText(/dependencies/i));
await waitFor(() => expect(commands.getObjectDependencies).toHaveBeenCalled());
await waitFor(() => expect(screen.getByText("v")).toBeTruthy());
});
it("per-item menu offers Create…/Edit…/Drop… and Edit opens an objectForm tab", async () => {
vi.spyOn(commands, "getEnums").mockResolvedValue([
{ name: "role", schema: "public", labels: ["admin"] },
]);
render(<ObjectExplorerPage connectionId="c1" />);
fireEvent.click(screen.getByLabelText("Object type"));
fireEvent.click(screen.getByText("Enums"));
await waitFor(() => screen.getByText("role"));
fireEvent.click(screen.getAllByLabelText(/actions/i)[0]);
expect(screen.getByText("Create…")).toBeInTheDocument();
expect(screen.getByText("Edit…")).toBeInTheDocument();
expect(screen.getByText("Drop…")).toBeInTheDocument();
fireEvent.click(screen.getByText("Edit…"));
const st = useDbViewerStore.getState();
expect(st.tabs).toHaveLength(1);
expect(st.tabs[0].tabType).toBe("objectForm");
expect(st.tabs[0].form?.mode).toBe("edit");
});
it("right-click on a list row opens the context menu", async () => {
vi.spyOn(commands, "getEnums").mockResolvedValue([
{ name: "role", schema: "public", labels: ["admin"] },
]);
render(<ObjectExplorerPage connectionId="c1" />);
fireEvent.click(screen.getByLabelText("Object type"));
fireEvent.click(screen.getByText("Enums"));
const row = await screen.findByText("role");
fireEvent.contextMenu(row);
expect(screen.getByText("Create…")).toBeInTheDocument();
expect(screen.getByText("Edit…")).toBeInTheDocument();
});
it("header + create button opens an objectForm create tab for the current type", async () => {
vi.spyOn(commands, "getEnums").mockResolvedValue([
{ name: "role", schema: "public", labels: ["admin"] },
]);
render(<ObjectExplorerPage connectionId="c1" />);
fireEvent.click(screen.getByLabelText("Object type"));
fireEvent.click(screen.getByText("Enums"));
await waitFor(() => screen.getByText("role"));
fireEvent.click(screen.getByRole("button", { name: /create enum/i }));
const st = useDbViewerStore.getState();
expect(st.tabs).toHaveLength(1);
expect(st.tabs[0].tabType).toBe("objectForm");
expect(st.tabs[0].form?.mode).toBe("create");
expect(st.tabs[0].form?.kind).toBe("enum");
expect(st.tabs[0].form?.params?.schema).toBe("public");
});
it("refetches the current object list after a ddl commit succeeds", async () => {
const getFunctions = vi
.spyOn(commands, "getFunctions")
.mockResolvedValue([]);
render(<ObjectExplorerPage connectionId="c1" />);
await waitFor(() => expect(getFunctions).toHaveBeenCalledTimes(1));
useDbViewerStore.getState().addChange({
type: "ddl",
sql: "DROP INDEX public.i",
description: "Drop index i",
} as any);
useDbViewerStore.getState().markChangeCommitted("ch-1");
await waitFor(() => expect(getFunctions).toHaveBeenCalledTimes(2));
});
it("sidebarMode: clicking a list row opens an object tab instead of an inline detail", async () => {
vi.spyOn(commands, "getFunctions").mockResolvedValue([
{
name: "add",
schema: "public",
return_type: "int",
argument_types: [],
argument_names: [],
argument_modes: [],
language: "plpgsql",
source: "BEGIN RETURN 1; END",
kind: "f",
},
]);
render(<ObjectExplorerPage connectionId="c1" sidebarMode />);
await waitFor(() =>
expect(screen.getByText("add")).toBeInTheDocument(),
);
fireEvent.click(screen.getByText("add"));
const st = useDbViewerStore.getState();
expect(
st.tabs.some(
(t) => t.tabType === "object" && t.table === "add",
),
).toBe(true);
});
it("sidebarMode: does not render the inline detail pane", async () => {
vi.spyOn(commands, "getEnums").mockResolvedValue([
{ name: "role", schema: "public", labels: ["admin"] },
]);
render(<ObjectExplorerPage connectionId="c1" sidebarMode />);
fireEvent.click(screen.getByLabelText("Object type"));
fireEvent.click(screen.getByText("Enums"));
await waitFor(() =>
expect(screen.getByText("role")).toBeInTheDocument(),
);
fireEvent.click(screen.getByText("role"));
// clicking opened an object tab (no inline detail selected)
expect(
useDbViewerStore
.getState()
.tabs.some((t) => t.tabType === "object"),
).toBe(true);
// detail pane is gone; only the list remains
expect(
screen.queryByText("Select an enum to view details"),
).not.toBeInTheDocument();
});
it("preselects type from store on mount", () => {
useDbViewerStore.setState({ selectedObjectType: "sequences" });
render(<ObjectExplorerPage connectionId="c1" />);
expect(screen.getByText("Sequences")).toBeTruthy();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,264 @@
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 mockSetRequestedView = vi.fn();
const mockOpenTab = vi.fn();
const baseMockState = {
objectSearchOpen: true,
setObjectSearchOpen: mockSetObjectSearchOpen,
currentSchema: "public" as string | null,
setCurrentSchema: mockSetCurrentSchema,
setSelectedObjectType: mockSetSelectedObjectType,
setRequestedView: mockSetRequestedView,
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(mockSetRequestedView).toHaveBeenCalledWith("db-viewer");
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);
expect(mockSetRequestedView).toHaveBeenCalledWith("db-viewer");
});
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(mockSetRequestedView).toHaveBeenCalledWith("objects");
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("");
});
it("ArrowDown then Enter selects the second hit", async () => {
vi.mocked(cmd.searchObjects).mockResolvedValue([
{ name: "first", schema: "public", object_type: "FUNCTION" },
{ name: "second", schema: "public", object_type: "ENUM" },
]);
render(<ObjectSearchPalette connectionId="c1" />);
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
target: { value: "s" },
});
await waitFor(() => screen.getByText("second"));
fireEvent.keyDown(window, { key: "ArrowDown" });
fireEvent.keyDown(window, { key: "Enter" });
// highlight started on the first hit; one ArrowDown moved to the second
expect(mockSetSelectedObjectType).toHaveBeenCalledWith("enums");
expect(mockSetCurrentSchema).toHaveBeenCalledWith("public");
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
});
it("ArrowUp wraps from the first hit to the last", async () => {
vi.mocked(cmd.searchObjects).mockResolvedValue([
{ name: "first", schema: "public", object_type: "FUNCTION" },
{ name: "last", schema: "public", object_type: "SEQUENCE" },
]);
render(<ObjectSearchPalette connectionId="c1" />);
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
target: { value: "s" },
});
await waitFor(() => screen.getByText("last"));
fireEvent.keyDown(window, { key: "ArrowUp" });
fireEvent.keyDown(window, { key: "Enter" });
expect(mockSetSelectedObjectType).toHaveBeenCalledWith("sequences");
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
});
it("Enter with no results does nothing", async () => {
vi.mocked(cmd.searchObjects).mockResolvedValue([]);
render(<ObjectSearchPalette connectionId="c1" />);
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
target: { value: "nope" },
});
await waitFor(() => screen.getByText(/no matches/i));
fireEvent.keyDown(window, { key: "Enter" });
expect(mockOpenTab).not.toHaveBeenCalled();
expect(mockSetSelectedObjectType).not.toHaveBeenCalled();
expect(mockSetObjectSearchOpen).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,221 @@
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 setRequestedView = useDbViewerStore((s) => s.setRequestedView);
const [query, setQuery] = useState("");
const [hits, setHits] = useState<ObjectSearchHit[]>([]);
const [loading, setLoading] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// Clear the transient state whenever the palette is closed so it reopens
// with an empty search, no stale results, and the highlight reset.
useEffect(() => {
if (!open) {
setQuery("");
setHits([]);
setHighlightedIndex(0);
}
}, [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);
setHighlightedIndex(0);
} catch {
setHits([]);
} finally {
setLoading(false);
}
}, 150);
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, [query, connectionId, currentSchema]);
// Select: open a table/view tab (switching to the DB viewer view so the
// tab is actually visible), or jump to the Objects view with the type
// preselected. The view switch goes through the store's requestedView
// mechanism — currentView is local state in DbViewerScreen, which watches
// requestedView and clears it after navigating.
const handleSelect = (hit: ObjectSearchHit) => {
if (
hit.object_type === "TABLE" ||
hit.object_type === "VIEW" ||
hit.object_type === "MATERIALIZED VIEW"
) {
setRequestedView("db-viewer");
openTab(hit.schema, hit.name);
} else {
setCurrentSchema(hit.schema);
setSelectedObjectType(TYPE_TO_OBJECTS[hit.object_type] ?? "functions");
setRequestedView("objects");
}
setOpen(false);
};
// Keyboard navigation: ↓/↑ move the highlight (wrapping), Enter picks,
// Esc closes. Registered on window so it works even after the input blurs.
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (!open) return;
if (e.key === "Escape") {
e.preventDefault();
setOpen(false);
return;
}
if (hits.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlightedIndex((i) => (i + 1) % hits.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlightedIndex((i) => (i - 1 + hits.length) % hits.length);
} else if (e.key === "Enter") {
e.preventDefault();
const hit = hits[highlightedIndex];
if (hit) handleSelect(hit);
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [open, hits, highlightedIndex]);
// Scroll the highlighted row into view inside the results list.
// `?.()` guards environments without scrollIntoView (jsdom) so the ref
// callback never throws during commit.
const onHighlightedRef = (el: HTMLButtonElement | null) => {
el?.scrollIntoView?.({ block: "nearest" });
};
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;
}, {});
// Flattened index → hit, so keyboard navigation matches the visible order.
// (Highlight index is tracked against the flat result list; grouped output
// below increments it in render order.)
let flatIndex = -1;
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
ref={inputRef}
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) => {
flatIndex += 1;
const index = flatIndex;
const highlighted = index === highlightedIndex;
return (
<button
key={`${hit.object_type}:${hit.schema}:${hit.name}`}
type="button"
ref={highlighted ? onHighlightedRef : undefined}
onClick={() => handleSelect(hit)}
onMouseEnter={() => setHighlightedIndex(index)}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm ${
highlighted
? "bg-surface-raised text-text"
: "text-text-muted hover:text-text"
}`}
>
<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>
);
}
@@ -0,0 +1,61 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { PasswordPromptDialog } from "./PasswordPromptDialog";
describe("PasswordPromptDialog", () => {
it("renders nothing when closed", () => {
const { container } = render(
<PasswordPromptDialog
open={false}
connectionName="n"
onConnect={() => {}}
onCancel={() => {}}
/>,
);
expect(container).toBeEmptyDOMElement();
});
it("shows the connection name and a password field when open", () => {
render(
<PasswordPromptDialog
open={true}
connectionName="Prod DB"
onConnect={() => {}}
onCancel={() => {}}
/>,
);
expect(screen.getByText(/Prod DB/)).toBeInTheDocument();
expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument();
});
it("calls onConnect with the typed value", () => {
const onConnect = vi.fn();
render(
<PasswordPromptDialog
open={true}
connectionName="X"
onConnect={onConnect}
onCancel={() => {}}
/>,
);
fireEvent.change(screen.getByPlaceholderText(/password/i), {
target: { value: "p@ss" },
});
fireEvent.click(screen.getByRole("button", { name: /connect/i }));
expect(onConnect).toHaveBeenCalledWith("p@ss");
});
it("calls onCancel on cancel", () => {
const onCancel = vi.fn();
render(
<PasswordPromptDialog
open={true}
connectionName="X"
onConnect={() => {}}
onCancel={onCancel}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
expect(onCancel).toHaveBeenCalled();
});
});
@@ -0,0 +1,58 @@
import { useState } from "react";
import { AnimatedModal } from "../ui/AnimatedModal";
interface Props {
open: boolean;
connectionName: string;
onConnect: (password: string) => void;
onCancel: () => void;
}
export function PasswordPromptDialog({
open,
connectionName,
onConnect,
onCancel,
}: Props) {
const [pw, setPw] = useState("");
if (!open) return null;
return (
<AnimatedModal open={open} onClose={onCancel}>
<div className="p-5 w-80">
<h2 className="text-sm font-medium text-text mb-1">Enter password</h2>
<p className="text-xs text-text-muted mb-3">
“{connectionName}” has keychain disabled. Enter the password for this
session (it will not be saved).
</p>
<input
type="password"
autoFocus
aria-label="Password"
placeholder="Password"
value={pw}
onChange={(e) => setPw(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && pw) onConnect(pw);
}}
className="w-full rounded-lg border-border bg-surface px-3 py-2 text-sm text-text mb-3"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="text-xs px-3 py-1.5 rounded-lg text-text-muted hover:bg-surface"
>
Cancel
</button>
<button
type="button"
onClick={() => pw && onConnect(pw)}
className="text-xs px-3 py-1.5 rounded-lg bg-accent text-white"
>
Connect
</button>
</div>
</div>
</AnimatedModal>
);
}
+1 -1
View File
@@ -50,7 +50,7 @@ export function RestoreDialog({ open, connectionId, onClose }: RestoreDialogProp
setConfirmed(false);
detectPgTools()
.then((status) => setToolStatus(status))
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null }))
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null, pg_dump_source: null, pg_restore_source: null }))
.finally(() => setCheckingTools(false));
}, [open]);
+4 -1
View File
@@ -76,6 +76,8 @@ export function RestorePage({ connectionId }: RestorePageProps) {
pg_restore_found: false,
pg_dump_version: null,
pg_restore_version: null,
pg_dump_source: null,
pg_restore_source: null,
}),
)
.finally(() => setCheckingTools(false));
@@ -121,6 +123,7 @@ export function RestorePage({ connectionId }: RestorePageProps) {
}, [filePath, format, clean, schema, connectionId, startJob, notify]);
const toolsMissing = toolStatus && !toolStatus.pg_restore_found;
const toolsBundled = toolStatus?.pg_restore_source === "bundled";
const canStart = filePath && confirmed && !isRunning;
return (
@@ -146,7 +149,7 @@ export function RestorePage({ connectionId }: RestorePageProps) {
</div>
)}
{toolsMissing && (
{toolsMissing && !toolsBundled && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
<p className="text-amber-300 text-sm font-semibold">
pg_restore not found
@@ -0,0 +1,42 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import * as cmd from "../../lib/commands";
import { SchemaMenu } from "./SchemaMenu";
vi.mock("../../lib/commands");
describe("SchemaMenu", () => {
beforeEach(() => {
vi.mocked(cmd.createSchema).mockReset();
vi.mocked(cmd.renameSchema).mockReset();
vi.mocked(cmd.dropSchema).mockReset();
vi.mocked(cmd.getObjectDependencies).mockReset();
});
it("create happy path calls createSchema then refreshTree", async () => {
vi.mocked(cmd.createSchema).mockResolvedValue(undefined);
const refresh = vi.fn();
render(<SchemaMenu connectionId="c1" onRefresh={refresh} />);
fireEvent.click(screen.getByLabelText(/new schema/i));
fireEvent.change(screen.getByPlaceholderText(/schema name/i), { target: { value: "myschema" } });
fireEvent.click(screen.getByRole("button", { name: /create/i }));
await waitFor(() => expect(cmd.createSchema).toHaveBeenCalledWith("c1", "myschema"));
await waitFor(() => expect(refresh).toHaveBeenCalled());
});
it("drop non-empty requires cascade checkbox + typed confirm", async () => {
vi.mocked(cmd.getObjectDependencies).mockResolvedValue([{ deptype: "n", class: "pg_class", name: "users" }]);
vi.mocked(cmd.dropSchema).mockResolvedValue(undefined);
const refresh = vi.fn();
render(<SchemaMenu connectionId="c1" schema="s" onRefresh={refresh} />);
fireEvent.click(screen.getByLabelText(/schema menu/i));
fireEvent.click(screen.getByText(/drop/i));
await waitFor(() => expect(cmd.getObjectDependencies).toHaveBeenCalledWith("c1", "s", "schema", "s"));
await waitFor(() => expect(screen.getByText("users")).toBeTruthy());
fireEvent.click(screen.getByRole("checkbox"));
fireEvent.change(screen.getByPlaceholderText(/type the schema name/i), { target: { value: "s" } });
fireEvent.click(screen.getByRole("button", { name: /drop schema/i }));
await waitFor(() => expect(cmd.dropSchema).toHaveBeenCalledWith("c1", "s", true));
await waitFor(() => expect(refresh).toHaveBeenCalled());
});
});
+295
View File
@@ -0,0 +1,295 @@
import { useState, useRef, useEffect } from "react";
import { Plus, MoreVertical } from "lucide-react";
import * as cmd from "../../lib/commands";
import type { DependencyInfo } from "../../lib/types";
import { AnimatedModal } from "../ui/AnimatedModal";
import { Button } from "../ui/Button";
import { DependencyDialog } from "./DependencyDialog";
interface SchemaMenuProps {
connectionId: string;
schema?: string;
onRefresh: () => void;
}
export function SchemaMenu({ connectionId, schema, onRefresh }: SchemaMenuProps) {
const [menuOpen, setMenuOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [name, setName] = useState("");
const [renaming, setRenaming] = useState(false);
const [newName, setNewName] = useState("");
const [dropOpen, setDropOpen] = useState(false);
const [deps, setDeps] = useState<DependencyInfo[]>([]);
const [typed, setTyped] = useState("");
const [err, setErr] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!menuOpen) return;
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [menuOpen]);
const resetErrors = () => setErr(null);
const create = async () => {
resetErrors();
try {
await cmd.createSchema(connectionId, name);
setCreating(false);
setName("");
onRefresh();
} catch (e) {
setErr((e as Error)?.message ?? String(e));
}
};
const rename = async () => {
resetErrors();
if (!schema) return;
try {
await cmd.renameSchema(connectionId, schema, newName);
setRenaming(false);
setNewName("");
onRefresh();
} catch (e) {
setErr((e as Error)?.message ?? String(e));
}
};
const startDrop = async () => {
setMenuOpen(false);
if (!schema) return;
try {
const d = await cmd.getObjectDependencies(connectionId, schema, "schema", schema);
setDeps(d);
setDropOpen(true);
setTyped("");
setErr(null);
} catch (e) {
setErr((e as Error)?.message ?? String(e));
}
};
const confirmDrop = async (cascade: boolean) => {
if (!schema) return;
try {
await cmd.dropSchema(connectionId, schema, cascade);
setDropOpen(false);
setTyped("");
setDeps([]);
onRefresh();
} catch (e) {
setErr((e as Error)?.message ?? String(e));
}
};
const hasDeps = deps.length > 0;
const inputClass =
"w-full bg-surface border border-border rounded-md px-3 py-2 text-sm text-text placeholder:text-text-muted outline-none focus:border-accent/50 transition-colors";
return (
<div className="flex items-center gap-1" ref={menuRef}>
<button
aria-label="New schema"
onClick={() => {
setCreating(true);
setName("");
setErr(null);
}}
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
>
<Plus size={14} />
</button>
{schema && (
<button
aria-label="Schema menu"
onClick={() => setMenuOpen((v) => !v)}
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
>
<MoreVertical size={14} />
</button>
)}
{menuOpen && (
<div className="absolute right-0 top-8 mt-1 rounded-xl bg-surface border border-border py-1 z-20 min-w-[160px] shadow-lg">
<button
type="button"
onClick={() => {
setMenuOpen(false);
setRenaming(true);
setNewName("");
setErr(null);
}}
className="flex items-center px-3 py-2 text-sm w-full text-left transition-colors cursor-pointer text-text-muted hover:text-text hover:bg-surface-raised"
>
Rename
</button>
<button
type="button"
onClick={startDrop}
className="flex items-center px-3 py-2 text-sm w-full text-left transition-colors cursor-pointer text-red-400 hover:bg-red-500/10 hover:text-red-300"
>
Drop
</button>
</div>
)}
{/* New Schema */}
<AnimatedModal open={creating} onClose={() => setCreating(false)}>
<div className="w-80">
<h3 className="font-heading text-text text-lg mb-3">New Schema</h3>
<p className="text-sm text-text-muted mb-3">Create a new schema in the current database.</p>
<input
placeholder="Schema name"
value={name}
onChange={(e) => setName(e.target.value)}
className={inputClass}
/>
{err && <p className="text-red-400 text-xs mt-2">{err}</p>}
<div className="flex justify-end gap-2 mt-5">
<Button variant="ghost" onClick={() => setCreating(false)}>
Cancel
</Button>
<Button variant="primary" onClick={create}>
Create
</Button>
</div>
</div>
</AnimatedModal>
{/* Rename Schema */}
<AnimatedModal open={renaming} onClose={() => setRenaming(false)}>
<div className="w-80">
<h3 className="font-heading text-text text-lg mb-3">Rename {schema}</h3>
<p className="text-sm text-text-muted mb-3">Enter the new name for this schema.</p>
<input
placeholder="New name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className={inputClass}
/>
{err && <p className="text-red-400 text-xs mt-2">{err}</p>}
<div className="flex justify-end gap-2 mt-5">
<Button variant="ghost" onClick={() => setRenaming(false)}>
Cancel
</Button>
<Button variant="primary" onClick={rename}>
Rename
</Button>
</div>
</div>
</AnimatedModal>
{/* Dependency warning */}
{dropOpen && (
<DependencyDialog
open={dropOpen}
deps={deps}
onCancel={() => {
setDropOpen(false);
setTyped("");
setDeps([]);
setErr(null);
}}
onProceed={() => {}}
/>
)}
{/* Typed-name confirmation for CASCADE drop */}
{dropOpen && hasDeps && (
<AnimatedModal
open={dropOpen}
onClose={() => {
setDropOpen(false);
setTyped("");
setErr(null);
}}
>
<div className="w-80">
<h3 className="font-heading text-text text-lg mb-3">Drop Schema: {schema}</h3>
<p className="text-sm text-text-muted mb-3">
Type the schema name to confirm the CASCADE drop.
</p>
<input
placeholder="Type the schema name"
value={typed}
onChange={(e) => {
setTyped(e.target.value);
if (err) setErr(null);
}}
className={inputClass}
/>
{err && <p className="text-red-400 text-xs mt-2">{err}</p>}
<div className="flex justify-end gap-2 mt-5">
<Button
variant="ghost"
onClick={() => {
setDropOpen(false);
setTyped("");
setErr(null);
}}
>
Cancel
</Button>
<Button
variant="primary"
onClick={() => {
if (typed !== schema) {
setErr("Name does not match");
return;
}
void confirmDrop(true);
}}
>
Drop Schema
</Button>
</div>
</div>
</AnimatedModal>
)}
{/* Empty-schema confirmation (no deps) */}
{dropOpen && !hasDeps && (
<AnimatedModal
open={dropOpen}
onClose={() => {
setDropOpen(false);
setErr(null);
}}
>
<div className="w-80">
<h3 className="font-heading text-text text-lg mb-3">Drop Schema: {schema}</h3>
<p className="text-sm text-text-muted mb-3">No dependencies — drop this empty schema?</p>
{err && <p className="text-red-400 text-xs mt-2">{err}</p>}
<div className="flex justify-end gap-2 mt-5">
<Button
variant="ghost"
onClick={() => {
setDropOpen(false);
setErr(null);
}}
>
Cancel
</Button>
<Button variant="primary" onClick={() => void confirmDrop(false)}>
Drop Schema
</Button>
</div>
</div>
</AnimatedModal>
)}
</div>
);
}
+1 -1
View File
@@ -36,7 +36,7 @@ export function SyncDialog({ open, onClose }: SyncDialogProps) {
setConfirmed(false);
detectPgTools()
.then((status) => setToolStatus(status))
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null }))
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null, pg_dump_source: null, pg_restore_source: null }))
.finally(() => setCheckingTools(false));
}, [open]);
+6 -1
View File
@@ -54,6 +54,8 @@ export function SyncPage() {
pg_restore_found: false,
pg_dump_version: null,
pg_restore_version: null,
pg_dump_source: null,
pg_restore_source: null,
}),
)
.finally(() => setCheckingTools(false));
@@ -100,6 +102,9 @@ export function SyncPage() {
const toolsMissing =
toolStatus &&
(!toolStatus.pg_dump_found || !toolStatus.pg_restore_found);
const toolsBundled =
toolStatus?.pg_dump_source === "bundled" &&
toolStatus?.pg_restore_source === "bundled";
const canStart =
sourceConnectionId && targetConnectionId && confirmed && !isRunning;
@@ -130,7 +135,7 @@ export function SyncPage() {
</div>
)}
{toolsMissing && (
{toolsMissing && !toolsBundled && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
<p className="text-amber-300 text-sm font-semibold">
PostgreSQL tools not found
+45
View File
@@ -83,6 +83,22 @@ describe("TabBar", () => {
expect(screen.queryByTestId("tab-icon-table")).not.toBeInTheDocument();
});
it("renders the per-type icon on an object tab", () => {
useDbViewerStore.getState().openObjectTab("functions", "public", "add", { name: "add", schema: "public" });
render(<TabBar />);
const icon = screen.getByLabelText(/object icon: functions/i);
const svg = icon.querySelector("svg");
expect(svg).toBeTruthy();
// Regression: the icon must use the SAME handling as the query/table icons —
// the svg itself is display:inline with the shared optical-centering classes.
// That defeats preflight svg{display:block} (no stacking) and lets
// vertical-align:middle center it with the tab name.
const cls = svg!.getAttribute("class") ?? "";
expect(cls).toContain("inline");
expect(cls).toContain("-mt-0.5");
expect(screen.getByText("add")).toBeInTheDocument();
});
it("renders a view icon on view tabs", () => {
useDbViewerStore.getState().openTab("main", "order_summary");
useDbViewerStore.setState({
@@ -95,6 +111,35 @@ describe("TabBar", () => {
expect(screen.queryByTestId("tab-icon-table")).not.toBeInTheDocument();
});
it("renders a create icon and title on an objectForm create tab", () => {
useDbViewerStore.getState().openFormTab({
kind: "sequence",
schema: "public",
name: "",
title: "Create sequence",
description: "Create sequence",
mode: "create",
params: { schema: "public", name: "", action: { op: "create" } },
});
render(<TabBar />);
expect(screen.getByTestId("tab-icon-form-create")).toBeInTheDocument();
expect(screen.getByText("Create sequence")).toBeInTheDocument();
});
it("renders an edit icon on an objectForm edit tab", () => {
useDbViewerStore.getState().openFormTab({
kind: "sequence",
schema: "public",
name: "s",
title: "Edit sequence",
description: "Edit sequence",
mode: "edit",
params: { schema: "public", name: "s", action: { op: "alter" } },
});
render(<TabBar />);
expect(screen.getByTestId("tab-icon-form-edit")).toBeInTheDocument();
});
it("renders a layers icon on materialized view tabs", () => {
useDbViewerStore.getState().openTab("public", "mv_products");
useDbViewerStore.setState({
+36 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, type ReactNode } from "react";
import { cloneElement, useEffect, useRef, type ReactElement, type ReactNode } from "react";
import {
DndContext,
closestCenter,
@@ -16,9 +16,10 @@ import {
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { ListChecks, Play, Table2, Layers, Eye, Terminal, X } from "lucide-react";
import { ListChecks, Play, Table2, Layers, Eye, Terminal, X, Plus, Pencil } from "lucide-react";
import { useDbViewerStore, type ViewerTab } from "../../stores/dbViewerStore";
import { ChangesQueuePanel } from "./ChangesQueuePanel";
import { OBJECT_ICONS } from "./objects/ObjectDetail";
function SortableTab({
tab,
@@ -181,6 +182,39 @@ export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) {
data-testid="tab-icon-query"
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
/>
) : tab.tabType === "object" ? (
<span
aria-label={`object icon: ${tab.objectType}`}
className="contents"
>
{cloneElement(
OBJECT_ICONS[tab.objectType!] as ReactElement<{
className?: string;
}>,
{
// Same handling as the query/table icons: the svg
// itself is display:inline (preflight vertical-align:
// middle centers it with the text) with the same
// optical-centering nudge. `display: contents` on the
// labelled span renders no box, so the geometry is
// identical to the bare Terminal/Table2 icons.
className:
"mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current",
},
)}
</span>
) : tab.tabType === "objectForm" ? (
tab.form?.mode === "create" ? (
<Plus
data-testid="tab-icon-form-create"
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
/>
) : (
<Pencil
data-testid="tab-icon-form-edit"
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
/>
)
) : objectType === "VIEW" ? (
<Eye
data-testid="tab-icon-view"
@@ -19,6 +19,31 @@ describe("TableOverflowMenu", () => {
vi.resetAllMocks();
});
it("offers Create Index… and Create Constraint…", () => {
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} connectionId="c1" />);
fireEvent.click(screen.getByLabelText(/table options/i));
expect(screen.getByText("Create Index…")).toBeInTheDocument();
expect(screen.getByText("Create Constraint…")).toBeInTheDocument();
});
it("opens objectForm create tabs for Create Index… and Create Constraint…", async () => {
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} connectionId="c1" />);
fireEvent.click(screen.getByLabelText(/table options/i));
fireEvent.click(screen.getByText("Create Index…"));
const st = useDbViewerStore.getState();
expect(st.tabs).toHaveLength(1);
expect(st.tabs[0].tabType).toBe("objectForm");
expect(st.tabs[0].form?.kind).toBe("index");
expect(st.tabs[0].form?.mode).toBe("create");
useDbViewerStore.getState().reset();
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} connectionId="c1" />);
fireEvent.click(screen.getAllByLabelText(/table options/i)[1]);
fireEvent.click(screen.getByText("Create Constraint…"));
expect(useDbViewerStore.getState().tabs[0].tabType).toBe("objectForm");
expect(useDbViewerStore.getState().tabs[0].form?.kind).toBe("constraint");
});
it("renders menu trigger button", () => {
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} />);
expect(screen.getByLabelText(/table options/i)).toBeInTheDocument();
@@ -63,9 +88,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 +100,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 }];
+50 -2
View File
@@ -6,7 +6,9 @@ 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 { initialCrudParams } from "../../lib/objectCrud";
import type { ColumnInfo, DependencyInfo } from "../../lib/types";
interface TableOverflowMenuProps {
schema: string;
@@ -37,6 +39,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);
@@ -94,14 +97,48 @@ export function TableOverflowMenu({
setImportOpen(true);
setOpen(false);
break;
case "create_index":
if (!connectionId) break;
useDbViewerStore.getState().openFormTab({
kind: "index",
schema,
name: "",
title: "Create Index",
description: `Create index on ${schema}.${table}`,
mode: "create",
params: initialCrudParams("index", { schema, table, name: "" }, "create"),
});
setOpen(false);
break;
case "create_constraint":
if (!connectionId) break;
useDbViewerStore.getState().openFormTab({
kind: "constraint",
schema,
name: "",
title: "Create Constraint",
description: `Create constraint on ${schema}.${table}`,
mode: "create",
params: initialCrudParams("constraint", { schema, table, name: "" }, "create"),
});
setOpen(false);
break;
case "empty":
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;
}
@@ -115,6 +152,8 @@ export function TableOverflowMenu({
{ id: "export-sql", label: "Export data (SQL)" },
{ id: "export-md", label: "Export data (Markdown)" },
{ id: "import", label: "Import data (CSV/JSON)" },
{ id: "create_index", label: "Create Index…" },
{ id: "create_constraint", label: "Create Constraint…" },
{ id: "empty", label: "Empty Table", danger: true },
{ id: "delete", label: "Delete Table", danger: true },
];
@@ -146,6 +185,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,26 @@
interface Props {
cols: string[];
selected: string[];
onToggle: (col: string) => void;
}
export function ColumnPicker({ cols, selected, onToggle }: Props) {
return (
<div className="flex flex-wrap gap-1" data-testid="column-picker">
{cols.map((c) => (
<button
key={c}
type="button"
onClick={() => onToggle(c)}
className={`text-xs px-2 py-1 rounded border transition-colors ${
selected.includes(c)
? "bg-accent text-white border-accent"
: "border-border text-text hover:border-text-muted"
}`}
>
{c}
</button>
))}
</div>
);
}
@@ -0,0 +1,111 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { ConstraintForm } from "./ConstraintForm";
import * as cmd from "../../../lib/commands";
vi.mock("../../../lib/commands", () => ({ getSchemaGraph: vi.fn() }));
const graph = {
tables: [
{
name: "orders",
schema: "public",
table_type: "BASE TABLE",
columns: [
{
name: "user_id",
data_type: "int",
is_pk: false,
is_fk: true,
is_unique: false,
is_nullable: true,
fk_ref: ["public", "users", "id"],
},
],
},
{
name: "users",
schema: "public",
table_type: "BASE TABLE",
columns: [
{
name: "id",
data_type: "int",
is_pk: true,
is_fk: false,
is_unique: true,
is_nullable: false,
fk_ref: null,
},
],
},
],
relationships: [],
};
describe("ConstraintForm", () => {
afterEach(() => {
vi.mocked(cmd.getSchemaGraph).mockReset();
});
it("check: emits the expression", () => {
vi.mocked(cmd.getSchemaGraph).mockResolvedValue(graph as any);
const onChange = vi.fn();
render(
<ConstraintForm
connectionId="c1"
params={{
schema: "public",
table: "orders",
name: "ck",
action: { op: "check", expression: "" },
}}
onChange={onChange}
/>,
);
fireEvent.change(screen.getByPlaceholderText("CHECK expression"), {
target: { value: "amount > 0" },
});
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
action: expect.objectContaining({ expression: "amount > 0" }),
}),
);
});
it("foreign_key: picks referenced table + column", async () => {
vi.mocked(cmd.getSchemaGraph).mockResolvedValue(graph as any);
const onChange = vi.fn();
render(
<ConstraintForm
connectionId="c1"
params={{
schema: "public",
table: "orders",
name: "fk",
action: {
op: "foreign_key",
columns: ["user_id"],
ref_schema: "",
ref_table: "",
ref_columns: [],
},
}}
onChange={onChange}
/>,
);
const kindSelect = screen.getByLabelText("Kind");
fireEvent.change(kindSelect, { target: { value: "foreign_key" } });
await screen.findByText("user_id");
const refTable = await screen.findByPlaceholderText("Referenced table");
fireEvent.change(refTable, { target: { value: "users" } });
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
action: expect.objectContaining({
ref_schema: "public",
ref_table: "users",
}),
}),
);
});
});
@@ -0,0 +1,178 @@
import { useEffect, useState } from "react";
import type { DdlParams } from "../../../lib/objectCrud";
import * as cmd from "../../../lib/commands";
import type { SchemaGraph } from "../../../lib/types";
import { ColumnPicker } from "./ColumnPicker";
import { FormRow, inputClass, controlClass, monoInputClass } from "./formRow";
interface Props {
connectionId: string;
params: DdlParams;
schemas?: string[];
onChange: (p: DdlParams) => void;
}
const KINDS = ["check", "unique", "primary_key", "foreign_key"];
function patchAction(params: DdlParams, patch: Record<string, unknown>): DdlParams {
const action = (params.action ?? {}) as Record<string, unknown>;
return { ...params, action: { ...action, ...patch } };
}
export function ConstraintForm({ connectionId, params, schemas, onChange }: Props) {
const p = params as Record<string, unknown>;
const action = (p.action ?? {}) as Record<string, unknown>;
const kind = (action.op as string) ?? "check";
const [graph, setGraph] = useState<SchemaGraph>({ tables: [], relationships: [] });
useEffect(() => {
cmd
.getSchemaGraph(connectionId, (p.schema as string) || undefined)
.then((g: SchemaGraph) => setGraph(g))
.catch(() => setGraph({ tables: [], relationships: [] }));
}, [connectionId, p.schema]);
const setAction = (patch: Record<string, unknown>) => onChange(patchAction(params, patch));
const tableCols =
graph.tables
.find((t) => t.name === (p.table as string) && t.schema === (p.schema as string))
?.columns.map((c) => c.name) ?? [];
const refCols =
graph.tables
.find(
(t) =>
t.name === (action.ref_table as string) &&
t.schema === (action.ref_schema as string),
)
?.columns.map((c) => c.name) ?? [];
const selected: string[] = (action.columns as string[]) ?? [];
const refSelected: string[] = (action.ref_columns as string[]) ?? [];
const pick = (list: string[], c: string) =>
list.includes(c) ? list.filter((x) => x !== c) : [...list, c];
return (
<div>
<FormRow label="Schema">
{schemas && schemas.length > 0 ? (
<select
value={(p.schema as string) ?? ""}
onChange={(e) => onChange({ ...p, schema: e.target.value })}
aria-label="Schema"
className={controlClass}
>
<option value="" disabled>Schema</option>
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
) : (
<input
type="text"
placeholder="Schema"
value={(p.schema as string) ?? ""}
onChange={(e) => onChange({ ...p, schema: e.target.value })}
className={inputClass}
/>
)}
</FormRow>
<FormRow label="Table">
<input
type="text"
placeholder="Table"
value={(p.table as string) ?? ""}
onChange={(e) => onChange({ ...p, table: e.target.value })}
className={inputClass}
/>
</FormRow>
<FormRow label="Name">
<input
type="text"
placeholder="Constraint name"
value={(p.name as string) ?? ""}
onChange={(e) => onChange({ ...p, name: e.target.value })}
className={inputClass}
/>
</FormRow>
<FormRow label="Kind">
<select
aria-label="Kind"
value={kind}
onChange={(e) => onChange({ ...p, action: { op: e.target.value } })}
className={controlClass}
>
{KINDS.map((k) => (
<option key={k} value={k}>
{k.replace(/_/g, " ")}
</option>
))}
</select>
</FormRow>
{(kind === "unique" || kind === "primary_key") && (
<FormRow label="Columns" className="items-stretch">
<div className="min-w-0 flex-1 flex flex-col gap-1 px-4 py-2">
<ColumnPicker
cols={tableCols}
selected={selected}
onToggle={(c) => setAction({ columns: pick(selected, c) })}
/>
</div>
</FormRow>
)}
{kind === "check" && (
<FormRow label="Expression">
<input
type="text"
placeholder="CHECK expression"
value={(action.expression as string) ?? ""}
onChange={(e) => setAction({ expression: e.target.value })}
className={monoInputClass}
/>
</FormRow>
)}
{kind === "foreign_key" && (
<>
<FormRow label="Columns" className="items-stretch">
<div className="min-w-0 flex-1 flex flex-col gap-1 px-4 py-2">
<ColumnPicker
cols={tableCols}
selected={selected}
onToggle={(c) => setAction({ columns: pick(selected, c) })}
/>
</div>
</FormRow>
<FormRow label="Referenced table">
<input
type="text"
placeholder="Referenced table"
value={(action.ref_table as string) ?? ""}
onChange={(e) => {
const t = graph.tables.find((t) => t.name === e.target.value);
setAction({
ref_table: e.target.value,
ref_schema: t?.schema ?? "",
ref_columns: [],
});
}}
className={inputClass}
/>
</FormRow>
<FormRow label="Referenced columns" className="items-stretch">
<div className="min-w-0 flex-1 flex flex-col gap-1 px-4 py-2">
<ColumnPicker
cols={refCols}
selected={refSelected}
onToggle={(c) => setAction({ ref_columns: pick(refSelected, c) })}
/>
</div>
</FormRow>
</>
)}
</div>
);
}
@@ -0,0 +1,54 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { EnumForm } from "./EnumForm";
describe("EnumForm", () => {
it("create: add/remove labels", () => {
const onChange = vi.fn();
render(
<EnumForm
params={{ schema: "public", name: "role", action: { op: "create", labels: ["admin"] } }}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /add value/i }));
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
action: expect.objectContaining({ labels: ["admin", ""] }),
}),
);
});
it("add_value op shows value + position + the no-removal note", () => {
render(
<EnumForm
params={{
schema: "public",
name: "color",
action: { op: "add_value", value: "orange", if_not_exists: false, before: null, after: null },
}}
onChange={() => {}}
/>,
);
expect(screen.getByPlaceholderText("New value")).toHaveValue("orange");
expect(screen.getByText(/no ALTER TYPE … DROP VALUE/i)).toBeInTheDocument();
});
it("rename_value op shows from + to", () => {
render(
<EnumForm
params={{
schema: "public",
name: "color",
action: { op: "rename_value", from: "purple", to: "mauve" },
}}
onChange={() => {}}
/>,
);
expect(screen.getByPlaceholderText("From")).toHaveValue("purple");
expect(screen.getByPlaceholderText("To")).toHaveValue("mauve");
});
});
@@ -0,0 +1,226 @@
import type { DdlParams } from "../../../lib/objectCrud";
import { FormRow, inputClass, controlClass, monoInputClass } from "./formRow";
interface Props {
params: DdlParams;
schemas?: string[];
onChange: (p: DdlParams) => void;
}
type EnumOp = "create" | "rename_type" | "add_value" | "rename_value";
const OP_LABELS: Record<EnumOp, string> = {
create: "Create",
rename_type: "Rename type",
add_value: "Add value",
rename_value: "Rename value",
};
function getOp(params: DdlParams): EnumOp {
const action = (params.action ?? {}) as Record<string, unknown>;
const op = action.op;
if (op === "rename_type" || op === "add_value" || op === "rename_value") return op;
return "create";
}
function patchAction(params: DdlParams, patch: Record<string, unknown>): DdlParams {
const action = (params.action ?? {}) as Record<string, unknown>;
return { ...params, action: { ...action, ...patch } };
}
function patchActionResetOp(params: DdlParams, op: EnumOp): DdlParams {
const action = (params.action ?? {}) as Record<string, unknown>;
const labels = (action.labels as string[]) ?? [];
return { ...params, action: { op, labels } };
}
function NoRemovalNote() {
return (
<p className="text-xs text-text-muted">
PostgreSQL has no ALTER TYPE … DROP VALUE. To remove a value, drop and recreate the type.
</p>
);
}
export function EnumForm({ params, schemas, onChange }: Props) {
const op = getOp(params);
const action = (params.action ?? {}) as Record<string, unknown>;
const labels = (action.labels as string[]) ?? [];
return (
<div>
<FormRow label="Schema">
{schemas && schemas.length > 0 ? (
<select
value={(params.schema as string) ?? ""}
onChange={(e) => onChange({ ...params, schema: e.target.value })}
aria-label="Schema"
className={controlClass}
>
<option value="" disabled>Schema</option>
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
) : (
<input
type="text"
placeholder="Schema"
value={(params.schema as string) ?? ""}
onChange={(e) => onChange({ ...params, schema: e.target.value })}
className={inputClass}
/>
)}
</FormRow>
<FormRow label="Name">
<input
type="text"
placeholder="Enum name"
value={(params.name as string) ?? ""}
onChange={(e) => onChange({ ...params, name: e.target.value })}
className={inputClass}
/>
</FormRow>
<FormRow label="Operation">
<select
aria-label="Operation"
value={op}
onChange={(e) => onChange(patchActionResetOp(params, e.target.value as EnumOp))}
className={controlClass}
>
{(Object.keys(OP_LABELS) as EnumOp[]).map((key) => (
<option key={key} value={key}>
{OP_LABELS[key]}
</option>
))}
</select>
</FormRow>
{op === "create" && (
<FormRow label="Values" className="items-stretch">
<div className="min-w-0 flex-1 flex flex-col gap-1 px-4 py-2">
{labels.map((l, i) => (
<div key={i} className="flex gap-1 items-center">
<input
type="text"
placeholder={`Value ${i + 1}`}
value={l}
onChange={(e) =>
onChange(
patchAction(params, {
labels: labels.map((x, j) => (j === i ? e.target.value : x)),
}),
)
}
className={monoInputClass}
/>
<button
type="button"
onClick={() =>
onChange(patchAction(params, { labels: labels.filter((_, j) => j !== i) }))
}
className="text-text-muted hover:text-red-400 px-2"
aria-label={`Remove value ${i + 1}`}
>
×
</button>
</div>
))}
<button
type="button"
onClick={() => onChange(patchAction(params, { labels: [...labels, ""] }))}
className="self-start text-xs text-accent hover:text-accent/80"
>
+ Add value
</button>
<NoRemovalNote />
</div>
</FormRow>
)}
{op === "rename_type" && (
<FormRow label="New name">
<input
type="text"
placeholder="New name"
value={(action.new_name as string) ?? ""}
onChange={(e) => onChange(patchAction(params, { new_name: e.target.value }))}
className={inputClass}
/>
</FormRow>
)}
{op === "add_value" && (
<>
<FormRow label="New value">
<input
type="text"
placeholder="New value"
value={(action.value as string) ?? ""}
onChange={(e) => onChange(patchAction(params, { value: e.target.value }))}
className={monoInputClass}
/>
</FormRow>
<FormRow label="If not exists">
<label className="min-w-0 flex-1 flex items-center gap-2 px-3 font-heading text-xs text-text cursor-pointer">
<input
type="checkbox"
checked={!!action.if_not_exists}
onChange={(e) => onChange(patchAction(params, { if_not_exists: e.target.checked }))}
aria-label="IF NOT EXISTS"
className="rounded border-border bg-surface text-accent focus:ring-accent"
/>
<span>IF NOT EXISTS</span>
</label>
</FormRow>
<FormRow label="Before">
<input
type="text"
placeholder="BEFORE (optional)"
value={(action.before as string) ?? ""}
onChange={(e) =>
onChange(patchAction(params, { before: e.target.value || null, after: null }))
}
className={monoInputClass}
/>
</FormRow>
<FormRow label="After">
<input
type="text"
placeholder="AFTER (optional)"
value={(action.after as string) ?? ""}
onChange={(e) =>
onChange(patchAction(params, { after: e.target.value || null, before: null }))
}
className={monoInputClass}
/>
</FormRow>
<div className="border-b border-border px-4 py-2">
<NoRemovalNote />
</div>
</>
)}
{op === "rename_value" && (
<>
<FormRow label="From">
<input
type="text"
placeholder="From"
value={(action.from as string) ?? ""}
onChange={(e) => onChange(patchAction(params, { from: e.target.value }))}
className={monoInputClass}
/>
</FormRow>
<FormRow label="To">
<input
type="text"
placeholder="To"
value={(action.to as string) ?? ""}
onChange={(e) => onChange(patchAction(params, { to: e.target.value }))}
className={monoInputClass}
/>
</FormRow>
</>
)}
</div>
);
}
@@ -0,0 +1,65 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { ExtensionForm } from "./ExtensionForm";
import * as objectCrud from "../../../lib/objectCrud";
vi.mock("../../../lib/objectCrud", () => ({
getAvailableExtensions: vi.fn(),
buildObjectDdl: vi.fn(),
}));
describe("ExtensionForm", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
it("create: lists available extensions and emits name on pick", async () => {
vi.mocked(objectCrud.getAvailableExtensions).mockResolvedValue([
{ name: "pgcrypto", version: "1.3", comment: null },
]);
const onChange = vi.fn();
render(
<ExtensionForm
connectionId="c1"
params={{
schema: "public",
name: "",
action: { op: "create", version: null },
}}
onChange={onChange}
/>,
);
expect(await screen.findByText("pgcrypto")).toBeInTheDocument();
fireEvent.click(screen.getByText("pgcrypto"));
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
name: "pgcrypto",
action: expect.objectContaining({ version: "1.3" }),
}),
);
});
it("set_schema op emits new_schema", () => {
const onChange = vi.fn();
render(
<ExtensionForm
connectionId="c1"
params={{
schema: "public",
name: "pgcrypto",
action: { op: "set_schema", new_schema: "" },
}}
onChange={onChange}
/>,
);
fireEvent.change(screen.getByPlaceholderText("New schema"), {
target: { value: "utils" },
});
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
action: expect.objectContaining({ new_schema: "utils" }),
}),
);
});
});
@@ -0,0 +1,146 @@
import { useEffect, useState } from "react";
import type { DdlParams } from "../../../lib/objectCrud";
import {
getAvailableExtensions,
type AvailableExtension,
} from "../../../lib/objectCrud";
import { FormRow, inputClass, controlClass } from "./formRow";
interface Props {
connectionId: string;
params: DdlParams;
schemas?: string[];
onChange: (p: DdlParams) => void;
}
type ExtensionOp = "create" | "set_schema";
const OP_LABELS: Record<ExtensionOp, string> = {
create: "Install",
set_schema: "Set schema",
};
export function ExtensionForm({ connectionId, params, schemas, onChange }: Props) {
const p = params as Record<string, unknown>;
const action = (p.action ?? {}) as Record<string, unknown>;
const op = (action.op as ExtensionOp) ?? "create";
const [available, setAvailable] = useState<AvailableExtension[]>([]);
useEffect(() => {
getAvailableExtensions(connectionId)
.then(setAvailable)
.catch(() => setAvailable([]));
}, [connectionId]);
const setAction = (patch: Record<string, unknown>) => {
onChange({ ...p, action: { ...action, ...patch } });
};
const pickExtension = (ext: AvailableExtension) => {
onChange({
...p,
name: ext.name,
action: { op: "create", version: ext.version || null },
});
};
return (
<div>
<FormRow label="Schema">
{schemas && schemas.length > 0 ? (
<select
value={(p.schema as string) ?? ""}
onChange={(e) => onChange({ ...p, schema: e.target.value })}
aria-label="Schema"
className={controlClass}
>
<option value="" disabled>Schema</option>
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
) : (
<input
type="text"
placeholder="Schema"
value={(p.schema as string) ?? ""}
onChange={(e) => onChange({ ...p, schema: e.target.value })}
className={inputClass}
/>
)}
</FormRow>
<FormRow label="Operation">
<select
aria-label="Operation"
value={op}
onChange={(e) =>
onChange({ ...p, action: { op: e.target.value as ExtensionOp } })
}
className={controlClass}
>
{(Object.keys(OP_LABELS) as ExtensionOp[]).map((key) => (
<option key={key} value={key}>
{OP_LABELS[key]}
</option>
))}
</select>
</FormRow>
{op === "create" && (
<>
<FormRow label="Extension">
<input
type="text"
placeholder="Extension name"
value={(p.name as string) ?? ""}
onChange={(e) => onChange({ ...p, name: e.target.value })}
className={inputClass}
/>
</FormRow>
{available.length > 0 && (
<FormRow label="Available" className="items-stretch">
<div className="min-w-0 flex-1 flex flex-col gap-0.5 px-4 py-2">
{available.map((ext) => (
<button
key={ext.name}
type="button"
onClick={() => pickExtension(ext)}
className="text-left text-xs font-medium text-accent hover:text-accent-hover hover:underline"
>
<span>{ext.name}</span>
{ext.version ? (
<span className="text-text-muted"> ({ext.version})</span>
) : null}
</button>
))}
</div>
</FormRow>
)}
<FormRow label="Version">
<input
type="text"
placeholder="Version (optional)"
value={(action.version as string) ?? ""}
onChange={(e) => setAction({ version: e.target.value || null })}
className={inputClass}
/>
</FormRow>
</>
)}
{op === "set_schema" && (
<FormRow label="New schema">
<input
type="text"
placeholder="New schema"
value={(action.new_schema as string) ?? ""}
onChange={(e) => setAction({ new_schema: e.target.value })}
className={inputClass}
/>
</FormRow>
)}
</div>
);
}
@@ -0,0 +1,143 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { FunctionForm } from "./FunctionForm";
import type { DdlParams } from "../../../lib/objectCrud";
vi.mock("../../editor/SqlEditorField", () => ({
SqlEditorField: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
<textarea data-testid="sql-editor" value={value} onChange={(e) => onChange(e.target.value)} />
),
}));
describe("FunctionForm", () => {
it("regression: Body row opts out of focus-within outline; normal rows keep it", async () => {
const params: DdlParams = {
schema: "public",
name: "add",
is_procedure: false,
action: {
op: "create_or_replace",
args: [],
return_type: "int",
language: "plpgsql",
body: "",
volatility: null,
strict: false,
},
};
render(<FunctionForm kind="function" params={params} onChange={() => {}} />);
// Monaco rows must NOT get the amber in-cell-editing outline.
const editor = await screen.findByTestId("sql-editor");
const bodyRow = editor.closest("div.flex.flex-row");
expect(bodyRow?.className ?? "").not.toContain("focus-within:outline");
// Normal rows still carry the outline — the opt-out must be scoped.
const operationRow = screen
.getByLabelText("Operation")
.closest("div.flex.flex-row");
expect(operationRow?.className ?? "").toContain("focus-within:outline");
});
it("function: renders args grid + return type; emits body", async () => {
const onChange = vi.fn();
const params: DdlParams = {
schema: "public",
name: "add",
is_procedure: false,
action: {
op: "create_or_replace",
args: [{ mode: "in", name: "a", type: "int" }],
return_type: "int",
language: "plpgsql",
body: "",
volatility: null,
strict: false,
},
};
render(<FunctionForm kind="function" params={params} onChange={onChange} />);
fireEvent.change(await screen.findByTestId("sql-editor"), {
target: { value: "BEGIN RETURN a; END" },
});
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
action: expect.objectContaining({ body: "BEGIN RETURN a; END" }),
}),
);
});
it("renders a schema dropdown when schemas are provided", () => {
const onChange = vi.fn();
const params: DdlParams = {
schema: "public",
name: "add",
is_procedure: false,
action: {
op: "create_or_replace",
args: [],
return_type: "int",
language: "plpgsql",
body: "",
volatility: null,
strict: false,
},
};
render(
<FunctionForm
kind="function"
params={params}
schemas={["public", "utils"]}
onChange={onChange}
/>,
);
const select = screen.getByLabelText("Schema");
expect(select).toBeInTheDocument();
expect(screen.getByRole("option", { name: "utils" })).toBeInTheDocument();
fireEvent.change(select, { target: { value: "utils" } });
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ schema: "utils" }),
);
});
it("procedure: hides return type", () => {
const params: DdlParams = {
schema: "public",
name: "p",
is_procedure: true,
action: {
op: "create_or_replace",
args: [],
return_type: null,
language: "plpgsql",
body: "",
volatility: null,
strict: false,
},
};
render(
<FunctionForm kind="procedure" params={params} onChange={() => {}} />,
);
expect(screen.queryByPlaceholderText("Return type")).not.toBeInTheDocument();
});
it("drop op: renders arg_types list", () => {
const onChange = vi.fn();
const params: DdlParams = {
schema: "public",
name: "add",
is_procedure: false,
action: { op: "drop", arg_types: ["int"] },
};
render(<FunctionForm kind="function" params={params} onChange={onChange} />);
fireEvent.change(screen.getByPlaceholderText("Arg types (comma-separated)"), {
target: { value: "int, int" },
});
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
action: expect.objectContaining({ arg_types: ["int", "int"] }),
}),
);
});
});
@@ -0,0 +1,275 @@
import { lazy, Suspense } from "react";
import type { DdlParams } from "../../../lib/objectCrud";
import { FormRow, FormSectionHeader, inputClass, controlClass, monoInputClass } from "./formRow";
const SqlEditorField = lazy(() =>
import("../../editor/SqlEditorField").then((m) => ({ default: m.SqlEditorField })),
);
interface Props {
kind: "function" | "procedure";
params: DdlParams;
schemas?: string[];
onChange: (p: DdlParams) => void;
}
interface Arg {
mode: string;
name: string;
type: string;
}
type FunctionOp = "create_or_replace" | "drop";
const MODES = ["in", "out", "inout", "variadic"];
const LANGS = ["plpgsql", "sql", "c"];
const VOL = ["", "IMMUTABLE", "STABLE", "VOLATILE"];
function getOp(params: DdlParams): FunctionOp {
const action = (params.action ?? {}) as Record<string, unknown>;
return action.op === "drop" ? "drop" : "create_or_replace";
}
function patchAction(
params: DdlParams,
patch: Record<string, unknown>,
): DdlParams {
const action = (params.action ?? {}) as Record<string, unknown>;
return { ...params, action: { ...action, ...patch } };
}
function patchTopLevel(
params: DdlParams,
kind: "function" | "procedure",
patch: Record<string, unknown>,
): DdlParams {
return {
...params,
...patch,
is_procedure: kind === "procedure" ? true : params.is_procedure,
};
}
export function FunctionForm({ kind, params, schemas, onChange }: Props) {
const op = getOp(params);
const action = (params.action ?? {}) as Record<string, unknown>;
const args = (action.args as Arg[]) ?? [];
const setAction = (patch: Record<string, unknown>) =>
onChange(patchAction(params, patch));
const setArg = (i: number, patch: Partial<Arg>) =>
setAction({
args: args.map((a, j) => (j === i ? { ...a, ...patch } : a)),
});
const placeholder = kind === "procedure" ? "Procedure name" : "Function name";
return (
<div>
<FormRow label="Schema">
{schemas && schemas.length > 0 ? (
<select
value={(params.schema as string) ?? ""}
onChange={(e) =>
onChange(patchTopLevel(params, kind, { schema: e.target.value }))
}
aria-label="Schema"
className={controlClass}
>
<option value="" disabled>Schema</option>
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
) : (
<input
type="text"
placeholder="Schema"
value={(params.schema as string) ?? ""}
onChange={(e) =>
onChange(patchTopLevel(params, kind, { schema: e.target.value }))
}
className={inputClass}
/>
)}
</FormRow>
<FormRow label="Name">
<input
type="text"
placeholder={placeholder}
value={(params.name as string) ?? ""}
onChange={(e) =>
onChange(patchTopLevel(params, kind, { name: e.target.value }))
}
className={inputClass}
/>
</FormRow>
<FormRow label="Operation">
<select
aria-label="Operation"
value={op}
onChange={(e) => onChange({ ...params, action: { op: e.target.value } })}
className={controlClass}
>
<option value="create_or_replace">Create / replace</option>
<option value="drop">Drop by signature</option>
</select>
</FormRow>
{op === "create_or_replace" && (
<>
<FormSectionHeader label="Arguments" count={args.length} />
{args.map((a, i) => (
<div
key={i}
className="border-b border-border px-4 py-2 flex items-center gap-2"
>
<select
value={a.mode}
onChange={(e) => setArg(i, { mode: e.target.value })}
aria-label={`Argument ${i + 1} mode`}
className="w-24 shrink-0 rounded bg-surface px-2 py-1 font-heading text-xs text-text outline-none"
>
{MODES.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
<span className="text-xs text-text-muted w-8 shrink-0 font-mono">
#{i + 1}
</span>
<input
type="text"
placeholder="name"
value={a.name}
onChange={(e) => setArg(i, { name: e.target.value })}
className={`${monoInputClass} flex-1`}
/>
<span className="text-border">:</span>
<input
type="text"
placeholder="type"
value={a.type}
onChange={(e) => setArg(i, { type: e.target.value })}
className={`${monoInputClass} flex-1`}
/>
<button
type="button"
onClick={() => setAction({ args: args.filter((_, j) => j !== i) })}
className="text-text-muted px-2"
>
×
</button>
</div>
))}
<div className="border-b border-border px-4 py-2">
<button
type="button"
onClick={() =>
setAction({
args: [...args, { mode: "in", name: "", type: "" }],
})
}
className="text-xs text-accent hover:text-accent-hover"
>
+ Add argument
</button>
</div>
{kind === "function" && (
<FormRow label="Return type">
<input
type="text"
placeholder="Return type"
value={(action.return_type as string | null) ?? ""}
onChange={(e) => setAction({ return_type: e.target.value })}
className={monoInputClass}
/>
</FormRow>
)}
<FormRow label="Language">
<select
aria-label="Language"
value={(action.language as string) ?? "plpgsql"}
onChange={(e) => setAction({ language: e.target.value })}
className={controlClass}
>
{LANGS.map((l) => (
<option key={l} value={l}>
{l}
</option>
))}
</select>
</FormRow>
<FormRow label="Volatility">
<select
aria-label="Volatility"
value={(action.volatility as string) ?? ""}
onChange={(e) => setAction({ volatility: e.target.value || null })}
className={controlClass}
>
{VOL.map((v) => (
<option key={v} value={v}>
{v || "(default VOLATILE)"}
</option>
))}
</select>
</FormRow>
<FormRow label="Strict">
<label className="min-w-0 flex-1 flex items-center gap-2 px-3 font-heading text-xs text-text cursor-pointer">
<input
type="checkbox"
checked={!!action.strict}
onChange={(e) => setAction({ strict: e.target.checked })}
aria-label="STRICT"
className="rounded border-border bg-surface text-accent focus:ring-accent"
/>
<span>STRICT (RETURNS NULL ON NULL INPUT)</span>
</label>
</FormRow>
<FormRow label="Body" className="items-stretch" outline={false}>
<div className="min-w-0 flex-1 py-2" style={{ minHeight: 140 }}>
<Suspense
fallback={
<textarea
rows={6}
value={(action.body as string) ?? ""}
onChange={(e) => setAction({ body: e.target.value })}
className="w-full h-full bg-transparent px-3 font-mono text-xs text-text outline-none resize-none"
/>
}
>
<div className="h-full w-full font-mono">
<SqlEditorField
value={(action.body as string) ?? ""}
onChange={(v) => setAction({ body: v })}
height={140}
/>
</div>
</Suspense>
</div>
</FormRow>
</>
)}
{op === "drop" && (
<FormRow label="Arg types">
<input
type="text"
placeholder="Arg types (comma-separated)"
value={(action.arg_types as string[] | undefined)?.join(", ") ?? ""}
onChange={(e) =>
setAction({
arg_types: e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
})
}
className={monoInputClass}
/>
</FormRow>
)}
</div>
);
}
@@ -0,0 +1,89 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { IndexForm } from "./IndexForm";
import * as cmd from "../../../lib/commands";
vi.mock("../../../lib/commands", () => ({ getSchemaGraph: vi.fn() }));
const graph = {
tables: [
{
name: "users",
schema: "public",
table_type: "BASE TABLE",
columns: [
{
name: "id",
data_type: "int",
is_pk: true,
is_fk: false,
is_unique: false,
is_nullable: false,
fk_ref: null,
},
{
name: "email",
data_type: "text",
is_pk: false,
is_fk: false,
is_unique: false,
is_nullable: true,
fk_ref: null,
},
],
},
],
relationships: [],
};
describe("IndexForm", () => {
afterEach(() => {
vi.mocked(cmd.getSchemaGraph).mockReset();
});
it("loads table columns and toggles a column into the index", async () => {
vi.mocked(cmd.getSchemaGraph).mockResolvedValue(graph as any);
const onChange = vi.fn();
render(
<IndexForm
connectionId="c1"
params={{
schema: "public",
table: "users",
name: "i",
action: { op: "create", unique: false, method: "btree", columns: [], predicate: null },
}}
onChange={onChange}
/>,
);
expect(await screen.findByText("email")).toBeInTheDocument();
fireEvent.click(screen.getByText("email"));
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({ action: expect.objectContaining({ columns: ["email"] }) }),
);
});
it("toggles unique", () => {
vi.mocked(cmd.getSchemaGraph).mockResolvedValue({
tables: [{ name: "users", schema: "public", table_type: "BASE TABLE", columns: [] }],
relationships: [],
} as any);
const onChange = vi.fn();
render(
<IndexForm
connectionId="c1"
params={{
schema: "public",
table: "users",
name: "i",
action: { op: "create", unique: false, method: "btree", columns: [], predicate: null },
}}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByLabelText("Unique"));
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({ action: expect.objectContaining({ unique: true }) }),
);
});
});
@@ -0,0 +1,135 @@
import { useEffect, useState } from "react";
import type { DdlParams } from "../../../lib/objectCrud";
import * as cmd from "../../../lib/commands";
import type { SchemaGraph } from "../../../lib/types";
import { ColumnPicker } from "./ColumnPicker";
import { FormRow, inputClass, controlClass } from "./formRow";
interface Props {
connectionId: string;
params: DdlParams;
schemas?: string[];
onChange: (p: DdlParams) => void;
}
const METHODS = ["", "btree", "hash", "gist", "gin", "brin"];
function patchAction(params: DdlParams, patch: Record<string, unknown>): DdlParams {
const action = (params.action ?? {}) as Record<string, unknown>;
return { ...params, action: { ...action, ...patch } };
}
export function IndexForm({ connectionId, params, schemas, onChange }: Props) {
const p = params as Record<string, unknown>;
const action = (p.action ?? {}) as Record<string, unknown>;
const [cols, setCols] = useState<string[]>([]);
useEffect(() => {
cmd
.getSchemaGraph(connectionId, (p.schema as string) || undefined)
.then((g: SchemaGraph) => {
const t = g.tables.find(
(t) => t.name === (p.table as string) && t.schema === (p.schema as string),
);
setCols(t ? t.columns.map((c) => c.name) : []);
})
.catch(() => setCols([]));
}, [connectionId, p.schema, p.table]);
const selected: string[] = (action.columns as string[]) ?? [];
const setAction = (patch: Record<string, unknown>) => onChange(patchAction(params, patch));
return (
<div>
<FormRow label="Schema">
{schemas && schemas.length > 0 ? (
<select
value={(p.schema as string) ?? ""}
onChange={(e) => onChange({ ...p, schema: e.target.value })}
aria-label="Schema"
className={controlClass}
>
<option value="" disabled>Schema</option>
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
) : (
<input
type="text"
placeholder="Schema"
value={(p.schema as string) ?? ""}
onChange={(e) => onChange({ ...p, schema: e.target.value })}
className={inputClass}
/>
)}
</FormRow>
<FormRow label="Table">
<input
type="text"
placeholder="Table"
value={(p.table as string) ?? ""}
onChange={(e) => onChange({ ...p, table: e.target.value })}
className={inputClass}
/>
</FormRow>
<FormRow label="Name">
<input
type="text"
placeholder="Index name"
value={(p.name as string) ?? ""}
onChange={(e) => onChange({ ...p, name: e.target.value })}
className={inputClass}
/>
</FormRow>
<FormRow label="Unique">
<label className="min-w-0 flex-1 flex items-center gap-2 px-3 font-heading text-xs text-text cursor-pointer">
<input
type="checkbox"
checked={!!action.unique}
onChange={(e) => setAction({ unique: e.target.checked })}
aria-label="Unique"
className="rounded border-border bg-surface text-accent focus:ring-accent"
/>
<span>Unique</span>
</label>
</FormRow>
<FormRow label="Method">
<select
aria-label="Method"
value={(action.method as string) ?? ""}
onChange={(e) => setAction({ method: e.target.value })}
className={controlClass}
>
{METHODS.map((m) => (
<option key={m} value={m}>
{m || "(default btree)"}
</option>
))}
</select>
</FormRow>
<FormRow label="Columns" className="items-stretch">
<div className="min-w-0 flex-1 flex flex-col gap-1 px-4 py-2">
<ColumnPicker
cols={cols}
selected={selected}
onToggle={(c) =>
setAction({
columns: selected.includes(c)
? selected.filter((x) => x !== c)
: [...selected, c],
})
}
/>
</div>
</FormRow>
<FormRow label="Predicate">
<input
type="text"
placeholder="WHERE predicate (optional)"
value={(action.predicate as string) ?? ""}
onChange={(e) => setAction({ predicate: e.target.value || null })}
className={inputClass}
/>
</FormRow>
</div>
);
}
@@ -0,0 +1,68 @@
import { SequenceForm } from "./SequenceForm";
import { EnumForm } from "./EnumForm";
import { ExtensionForm } from "./ExtensionForm";
import { ViewForm } from "./ViewForm";
import { IndexForm } from "./IndexForm";
import { ConstraintForm } from "./ConstraintForm";
import { FunctionForm } from "./FunctionForm";
import { TriggerForm } from "./TriggerForm";
import type { ObjectKind, DdlParams } from "../../../lib/objectCrud";
interface Props {
connectionId: string;
kind: ObjectKind;
params: DdlParams;
schemas?: string[];
onChange: (p: DdlParams) => void;
}
/** Renders the matching CRUD form for a kind (shared by the context menu and the form tab). */
export function KindForm({ connectionId, kind, params, schemas, onChange }: Props) {
switch (kind) {
case "sequence":
return <SequenceForm params={params} schemas={schemas} onChange={onChange} />;
case "enum":
return <EnumForm params={params} schemas={schemas} onChange={onChange} />;
case "extension":
return (
<ExtensionForm
connectionId={connectionId}
params={params}
schemas={schemas}
onChange={onChange}
/>
);
case "view":
return <ViewForm params={params} schemas={schemas} onChange={onChange} />;
case "index":
return (
<IndexForm
connectionId={connectionId}
params={params}
schemas={schemas}
onChange={onChange}
/>
);
case "constraint":
return (
<ConstraintForm
connectionId={connectionId}
params={params}
schemas={schemas}
onChange={onChange}
/>
);
case "function":
case "procedure":
return <FunctionForm kind={kind} params={params} schemas={schemas} onChange={onChange} />;
case "trigger":
return (
<TriggerForm
connectionId={connectionId}
params={params}
schemas={schemas}
onChange={onChange}
/>
);
}
}
@@ -0,0 +1,90 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { ObjectContextMenu } from "./ObjectContextMenu";
import * as objectCrud from "../../../lib/objectCrud";
import { useDbViewerStore } from "../../../stores/dbViewerStore";
vi.mock("../../../lib/objectCrud", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../lib/objectCrud")>();
return {
...actual,
buildObjectDdl: vi.fn(),
getAvailableExtensions: vi.fn(),
};
});
describe("ObjectContextMenu", () => {
beforeEach(() => {
vi.clearAllMocks();
useDbViewerStore.getState().reset();
});
it("Edit on a sequence opens an objectForm tab with prefilled edit params", async () => {
render(
<ObjectContextMenu
connectionId="c1"
objectType="sequence"
item={{ schema: "public", name: "s" }}
onRefresh={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /actions/i }));
fireEvent.click(screen.getByText("Edit…"));
const st = useDbViewerStore.getState();
expect(st.tabs).toHaveLength(1);
expect(st.tabs[0].tabType).toBe("objectForm");
expect(st.tabs[0].form?.mode).toBe("edit");
expect(st.tabs[0].form?.kind).toBe("sequence");
expect(st.tabs[0].form?.params?.name).toBe("s");
expect(st.tabs[0].form?.params?.schema).toBe("public");
});
it("Create on a sequence opens an objectForm tab in create mode", async () => {
render(
<ObjectContextMenu
connectionId="c1"
objectType="sequence"
item={{ schema: "public", name: "s" }}
onRefresh={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /actions/i }));
fireEvent.click(screen.getByText("Create…"));
const st = useDbViewerStore.getState();
expect(st.tabs).toHaveLength(1);
expect(st.tabs[0].tabType).toBe("objectForm");
expect(st.tabs[0].form?.mode).toBe("create");
expect(st.tabs[0].form?.kind).toBe("sequence");
expect(st.tabs[0].form?.params?.name).toBe("");
});
it("Drop fetches dependencies then stages the drop", async () => {
vi.mocked(objectCrud.buildObjectDdl).mockResolvedValue([
'DROP SEQUENCE "public"."s"',
]);
const addChange = vi.spyOn(useDbViewerStore.getState(), "addChange");
render(
<ObjectContextMenu
connectionId="c1"
objectType="sequence"
item={{ schema: "public", name: "s" }}
onRefresh={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /actions/i }));
fireEvent.click(screen.getByText("Drop…"));
// DependencyDialog with no deps → Proceed is enabled
expect(
await screen.findByRole("button", { name: /proceed/i }),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /proceed/i }));
await waitFor(() =>
expect(addChange).toHaveBeenCalledWith(
expect.objectContaining({
type: "ddl",
sql: 'DROP SEQUENCE "public"."s"',
}),
),
);
});
});
@@ -0,0 +1,188 @@
import { useEffect, useRef, useState } from "react";
import { DependencyDialog } from "../DependencyDialog";
import {
buildObjectDdl,
dropCrudParams,
initialCrudParams,
type CrudItem,
type ObjectKind,
} from "../../../lib/objectCrud";
import { getObjectDependencies } from "../../../lib/commands";
import type { DependencyInfo } from "../../../lib/types";
import { useDbViewerStore } from "../../../stores/dbViewerStore";
interface Props {
connectionId: string;
objectType: ObjectKind;
item: CrudItem;
onRefresh: () => void;
/** Controlled open state (e.g. driven by a row's right-click). */
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** Extra non-CRUD actions appended below the divider (Copy DDL, Dependencies…). */
extraItems?: { id: string; label: string; danger?: boolean; onClick: () => void }[];
}
const DROP_TITLE: Record<ObjectKind, string> = {
sequence: "sequence",
enum: "type",
view: "view",
extension: "extension",
index: "index",
constraint: "constraint",
function: "function",
procedure: "procedure",
trigger: "trigger",
};
export function ObjectContextMenu({
connectionId,
objectType,
item,
onRefresh,
open,
onOpenChange,
extraItems,
}: Props) {
const [internalOpen, setInternalOpen] = useState(false);
const [deps, setDeps] = useState<DependencyInfo[] | null>(null);
const addChange = useDbViewerStore((s) => s.addChange);
const menuRef = useRef<HTMLDivElement>(null);
const isOpen = open ?? internalOpen;
const setOpen = (v: boolean) => {
if (onOpenChange) onOpenChange(v);
else setInternalOpen(v);
};
// Track the current open state in a ref so the document-level outside-click
// handler (registered once) only closes a menu that is actually open. Without
// this, closed instances would fire onOpenChange(false) on every mousedown
// and clobber the shared open key in the controlled (row right-click) case.
const openRef = useRef(isOpen);
openRef.current = isOpen;
useEffect(() => {
const h = (e: MouseEvent) => {
if (
openRef.current &&
menuRef.current &&
!menuRef.current.contains(e.target as Node)
) {
setOpen(false);
}
};
document.addEventListener("mousedown", h);
return () => document.removeEventListener("mousedown", h);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const kind = objectType;
const openFormTab = (mode: "create" | "edit") => {
const title = `${mode === "create" ? "Create" : "Edit"} ${kind}`;
const description = `${mode === "create" ? "Create" : "Edit"} ${
item.name || kind
}`;
useDbViewerStore.getState().openFormTab({
kind,
schema:
mode === "create"
? useDbViewerStore.getState().currentSchema ?? item.schema ?? "public"
: item.schema,
name: mode === "edit" ? item.name : "",
title,
description,
mode,
params: initialCrudParams(kind, item, mode),
});
setOpen(false);
};
const startDrop = async () => {
setOpen(false);
let d: DependencyInfo[] = [];
try {
d = await getObjectDependencies(connectionId, item.schema, kind, item.name);
} catch {
d = [];
}
setDeps(d);
};
const confirmDrop = async () => {
const sqls = await buildObjectDdl(
connectionId,
kind,
dropCrudParams(kind, item),
);
sqls.forEach((sql) =>
addChange({
type: "ddl",
sql,
description: `Drop ${DROP_TITLE[kind]} ${item.name}`,
}),
);
setDeps(null);
onRefresh();
};
return (
<div ref={menuRef} className="relative">
<button
onClick={() => setOpen(!isOpen)}
aria-label="actions"
className="text-text-muted hover:text-text cursor-pointer"
>
⋮
</button>
{isOpen && (
<div className="absolute right-0 top-6 z-20 w-40 rounded-lg border border-border bg-surface py-1 text-sm text-text shadow-lg">
<button
onClick={() => openFormTab("create")}
className="block w-full text-left px-3 py-1.5 hover:bg-border/30 cursor-pointer"
>
Create…
</button>
<button
onClick={() => openFormTab("edit")}
className="block w-full text-left px-3 py-1.5 hover:bg-border/30 cursor-pointer"
>
Edit…
</button>
<button
onClick={startDrop}
className="block w-full text-left px-3 py-1.5 text-red-400 hover:bg-border/30 cursor-pointer"
>
Drop…
</button>
{extraItems && extraItems.length > 0 && (
<>
<div className="my-1 border-t border-border" />
{extraItems.map((it) => (
<button
key={it.id}
type="button"
onClick={() => {
setOpen(false);
it.onClick();
}}
className={`block w-full text-left px-3 py-1.5 hover:bg-border/30 cursor-pointer ${
it.danger ? "text-red-400" : ""
}`}
>
{it.label}
</button>
))}
</>
)}
</div>
)}
<DependencyDialog
open={!!deps}
deps={deps ?? []}
onProceed={confirmDrop}
onCancel={() => setDeps(null)}
/>
</div>
);
}
@@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { ObjectDetail } from "./ObjectDetail";
describe("ObjectDetail", () => {
it("renders an enum's labels as list items", () => {
render(
<ObjectDetail
connectionId="c1"
type="enums"
item={{ name: "role", schema: "public", labels: ["admin", "user"] }}
/>,
);
expect(screen.getByText("admin")).toBeInTheDocument();
expect(screen.getByText("user")).toBeInTheDocument();
});
it("renders a function's return type and language", () => {
render(
<ObjectDetail
connectionId="c1"
type="functions"
item={{
name: "add",
schema: "public",
return_type: "int",
argument_types: ["integer", "text"],
argument_names: ["a", "b"],
argument_modes: ["IN", "IN"],
language: "plpgsql",
source: "BEGIN RETURN a+b; END",
kind: "f",
}}
/>,
);
expect(screen.getByText("int")).toBeInTheDocument();
expect(screen.getAllByText(/plpgsql/i).length).toBeGreaterThanOrEqual(1);
});
});
@@ -0,0 +1,974 @@
import { useMemo, useState } from "react";
import {
BookMarked,
FunctionSquare,
GitBranch,
ListChecks,
ListOrdered,
Puzzle,
SquareFunction,
Tag,
} from "lucide-react";
import type {
ObjectType,
FunctionInfo,
TriggerInfo,
SequenceInfo,
EnumInfo,
ExtensionInfo,
IndexInfo,
ConstraintInfo,
} from "../../../lib/types";
export const TYPE_LABELS: Record<ObjectType, string> = {
functions: "Functions",
triggers: "Triggers",
sequences: "Sequences",
enums: "Enums",
extensions: "Extensions",
indexes: "Indexes",
constraints: "Constraints",
procedures: "Procedures",
};
export const SINGULAR_LABELS: Record<ObjectType, string> = {
functions: "function",
triggers: "trigger",
sequences: "sequence",
enums: "enum",
extensions: "extension",
indexes: "index",
constraints: "constraint",
procedures: "procedure",
};
export const OBJECT_ICONS: Record<ObjectType, React.ReactNode> = {
functions: (
<FunctionSquare size={14} className="text-text-muted shrink-0" />
),
triggers: <GitBranch size={14} className="text-text-muted shrink-0" />,
sequences: <ListOrdered size={14} className="text-text-muted shrink-0" />,
enums: <Tag size={14} className="text-text-muted shrink-0" />,
extensions: <Puzzle size={14} className="text-text-muted shrink-0" />,
indexes: <BookMarked size={14} className="text-text-muted shrink-0" />,
constraints: <ListChecks size={14} className="text-text-muted shrink-0" />,
procedures: <SquareFunction size={14} className="text-text-muted shrink-0" />,
};
export type AnyObject =
| FunctionInfo
| TriggerInfo
| SequenceInfo
| EnumInfo
| ExtensionInfo
| IndexInfo
| ConstraintInfo;
// ─── syntax highlighting for PL/pgSQL / SQL ──────────────
const SQL_KEYWORDS = new Set([
"ADD",
"ALL",
"ALTER",
"AND",
"ANY",
"AS",
"ASC",
"BEGIN",
"BETWEEN",
"BY",
"CALL",
"CASCADE",
"CASE",
"CAST",
"CHECK",
"CLOSE",
"COLLATE",
"COLUMN",
"COMMIT",
"CONSTRAINT",
"CONTINUE",
"CREATE",
"CROSS",
"CURRENT",
"CURSOR",
"DECLARE",
"DEFAULT",
"DELETE",
"DESC",
"DISTINCT",
"DO",
"DROP",
"ELSE",
"ELSIF",
"END",
"EXCEPTION",
"EXECUTE",
"EXISTS",
"EXIT",
"FETCH",
"FOR",
"FOREIGN",
"FROM",
"FULL",
"FUNCTION",
"GRANT",
"GROUP",
"HAVING",
"IF",
"IN",
"INDEX",
"INNER",
"INSERT",
"INTO",
"IS",
"JOIN",
"KEY",
"LANGUAGE",
"LEFT",
"LIMIT",
"LOOP",
"NOT",
"NULL",
"OF",
"OFFSET",
"ON",
"OPEN",
"OR",
"ORDER",
"OUTER",
"OVER",
"PERFORM",
"PLPGSQL",
"PRIMARY",
"PROCEDURE",
"QUERY",
"RAISE",
"REFERENCES",
"REPLACE",
"RETURN",
"RETURNS",
"REVOKE",
"RIGHT",
"ROLLBACK",
"ROW",
"ROWS",
"SCHEMA",
"SELECT",
"SET",
"STRICT",
"TABLE",
"THEN",
"TO",
"TRIGGER",
"UNION",
"UPDATE",
"USING",
"VALUES",
"VIEW",
"WHEN",
"WHERE",
"WHILE",
"WITH",
]);
const SQL_TYPES = new Set([
"BIGINT",
"BIGSERIAL",
"BIT",
"BOOL",
"BOOLEAN",
"BPCHAR",
"BYTEA",
"CHAR",
"CHARACTER",
"DATE",
"DECIMAL",
"DOUBLE",
"FLOAT",
"FLOAT4",
"FLOAT8",
"INT",
"INT2",
"INT4",
"INT8",
"INTEGER",
"INTERVAL",
"JSON",
"JSONB",
"MONEY",
"NAME",
"NUMERIC",
"OID",
"REAL",
"SERIAL",
"SMALLINT",
"TEXT",
"TIME",
"TIMESTAMP",
"TIMESTAMPTZ",
"UUID",
"VARBIT",
"VARCHAR",
"VOID",
"XML",
]);
interface Token {
text: string;
kind:
| "keyword"
| "type"
| "string"
| "comment"
| "number"
| "operator"
| "plain";
}
function tokenizeLine(line: string): Token[] {
const tokens: Token[] = [];
let i = 0;
while (i < line.length) {
if (/\s/.test(line[i])) {
let ws = "";
while (i < line.length && /\s/.test(line[i])) {
ws += line[i];
i++;
}
tokens.push({ text: ws, kind: "plain" });
continue;
}
if (line[i] === "-" && line[i + 1] === "-") {
tokens.push({ text: line.slice(i), kind: "comment" });
return tokens;
}
if (line[i] === "/" && line[i + 1] === "*") {
const end = line.indexOf("*/", i + 2);
if (end !== -1) {
tokens.push({ text: line.slice(i, end + 2), kind: "comment" });
i = end + 2;
} else {
tokens.push({ text: line.slice(i), kind: "comment" });
return tokens;
}
continue;
}
if (line[i] === "$") {
let dollar = "";
const start = i;
while (i < line.length && line[i] === "$") {
dollar += "$";
i++;
}
let tag = "";
if (dollar.length === 1 && i < line.length && line[i] !== "$") {
while (i < line.length && line[i] !== "$") {
tag += line[i];
i++;
}
if (line[i] === "$") {
i++;
dollar = `$${tag}$`;
}
}
const endTag = dollar;
const endIdx = line.indexOf(endTag, i);
if (endIdx !== -1) {
tokens.push({
text: line.slice(start, endIdx + endTag.length),
kind: "string",
});
i = endIdx + endTag.length;
} else {
tokens.push({ text: line.slice(start), kind: "string" });
return tokens;
}
continue;
}
if (line[i] === "'") {
let str = "'";
i++;
while (i < line.length) {
if (line[i] === "'" && line[i + 1] === "'") {
str += "''";
i += 2;
continue;
}
if (line[i] === "'") {
str += "'";
i++;
break;
}
str += line[i];
i++;
}
tokens.push({ text: str, kind: "string" });
continue;
}
if (/[0-9]/.test(line[i])) {
let num = "";
while (i < line.length && /[0-9.]/.test(line[i])) {
num += line[i];
i++;
}
tokens.push({ text: num, kind: "number" });
continue;
}
if (/[=<>!+\-*/%&|^~@#;,.[\](){}]/.test(line[i])) {
let op = line[i];
i++;
if (i < line.length) {
const pair = op + line[i];
if ([":=", "=>", "<=", ">=", "<>", "||", "::"].includes(pair)) {
op = pair;
i++;
}
}
tokens.push({ text: op, kind: "operator" });
continue;
}
let word = "";
while (i < line.length && /[a-zA-Z_]/.test(line[i])) {
word += line[i];
i++;
}
if (word) {
const upper = word.toUpperCase();
if (SQL_KEYWORDS.has(upper)) {
tokens.push({ text: word, kind: "keyword" });
} else if (SQL_TYPES.has(upper)) {
tokens.push({ text: word, kind: "type" });
} else {
tokens.push({ text: word, kind: "plain" });
}
} else {
// Catch-all for any character not matched above (non-ASCII, symbols, etc.)
tokens.push({ text: line[i], kind: "plain" });
i++;
}
}
return tokens;
}
function SyntaxCode({
source,
language: _language,
}: {
source: string;
language?: string;
}) {
const [expanded, setExpanded] = useState(false);
const maxLines = 60;
// Memoize the tokenized output — source doesn't change while viewing
const { displayLines, maxLineNum, truncated, totalLines } = useMemo(() => {
const lines: string[] = source.split("\n");
const total: number = lines.length;
const isTruncated: boolean = !expanded && total > maxLines;
const display: string[] = isTruncated
? lines.slice(0, maxLines)
: lines;
const maxNum: number = String(display.length).length;
const tokenized = display.map((line: string) => ({
tokens: tokenizeLine(line),
}));
return {
displayLines: tokenized,
maxLineNum: maxNum,
truncated: isTruncated,
totalLines: total,
};
}, [source, expanded]);
const TOKEN_COLORS: Record<string, string> = {
keyword: "text-blue-400",
type: "text-emerald-400",
string: "text-amber-300",
comment: "text-text-subtle italic",
number: "text-purple-400",
operator: "text-text-muted",
plain: "text-text",
};
return (
<div>
<div className="overflow-x-auto overscroll-x-none">
<pre className="text-xs leading-6 font-mono whitespace-pre w-max min-w-full">
{displayLines.map(
(entry: { tokens: Token[] }, i: number) => {
const { tokens } = entry;
const num = String(i + 1).padStart(maxLineNum, " ");
return (
<div
key={i}
className="flex hover:bg-surface/30"
>
<span
className="inline-block text-right select-none text-text-subtle border-r border-border pr-3 mx-3 shrink-0"
style={{
minWidth: `${maxLineNum + 2}ch`,
}}
>
{num}
</span>
<span className="flex-1 whitespace-pre">
{tokens.length === 1 &&
tokens[0].text.trim() === ""
? "\u00A0"
: tokens.map((t, j) => (
<span
key={j}
className={
TOKEN_COLORS[t.kind]
}
>
{t.text}
</span>
))}
</span>
</div>
);
},
)}
</pre>
</div>
{truncated && (
<div className="flex items-center justify-center py-1.5 border-t border-border">
<button
type="button"
onClick={() => setExpanded(true)}
className="text-xs text-accent hover:underline"
>
Show all {totalLines} lines…
</button>
</div>
)}
{expanded && totalLines > maxLines && (
<div className="flex items-center justify-center py-1.5 border-t border-border">
<button
type="button"
onClick={() => setExpanded(false)}
className="text-xs text-accent hover:underline"
>
Collapse
</button>
</div>
)}
</div>
);
}
function renderFunctionDetail(f: FunctionInfo) {
return (
<div>
<div className="border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Signature
</span>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Returns
</span>
<span className="text-sm text-accent font-mono">
{f.return_type || "void"}
</span>
</div>
<div className="px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Language
</span>
<span className="text-sm text-text">
{f.language}
</span>
</div>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Kind
</span>
<span className="text-sm text-text">
{f.kind === "f" ? "Function" : "Procedure"}
</span>
</div>
<div className="px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Schema
</span>
<span className="text-sm text-text font-mono">
{f.schema}
</span>
</div>
</div>
{f.argument_names.length > 0 && (
<>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Arguments
</span>
<span className="text-[10px] text-text-subtle">
{f.argument_names.length} total
</span>
</div>
{f.argument_names.map((name, i) => (
<div
key={i}
className="border-b border-border px-4 py-2 flex items-center"
>
<div className="w-24 shrink-0">
<span className="text-xs text-text-muted">
{f.argument_modes?.[i] &&
f.argument_modes[i] !==
"IN" && (
<span className="text-amber-400 font-medium mr-1">
{f.argument_modes[i]}
</span>
)}
#{i + 1}
</span>
</div>
<span className="text-sm text-accent font-mono">
{name}
</span>
<span className="mx-2 text-border">:</span>
<span className="text-sm text-text-muted font-mono">
{f.argument_types?.[i] || "unknown"}
</span>
</div>
))}
</>
)}
{f.source && (
<>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Source
</span>
<span className="text-[10px] text-text-subtle">
{f.language}
</span>
</div>
<SyntaxCode
source={f.source}
language={f.language}
/>
</>
)}
</div>
);
}
function renderDetail(type: ObjectType, item: AnyObject) {
switch (type) {
case "functions":
return renderFunctionDetail(item as FunctionInfo);
case "procedures":
return renderFunctionDetail(item as FunctionInfo);
case "indexes": {
const idx = item as IndexInfo;
return (
<div>
<div className="border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Index
</span>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Table
</span>
<span className="text-sm text-text font-mono">
{idx.table}
</span>
</div>
<div className="px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Method
</span>
<span className="text-sm text-accent font-mono">
{idx.method}
</span>
</div>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Unique
</span>
<span
className={`text-sm ${idx.is_unique ? "text-emerald-400" : "text-text-muted"}`}
>
{idx.is_unique ? "Yes" : "No"}
</span>
</div>
<div className="px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Size
</span>
<span className="text-sm text-text font-mono">
{idx.size_bytes ?? "-"}
</span>
</div>
</div>
{idx.columns.length > 0 && (
<>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Columns
</span>
<span className="text-[10px] text-text-subtle">
{idx.columns.length}
</span>
</div>
{idx.columns.map((col, i) => (
<div
key={i}
className="border-b border-border px-4 py-2 flex items-center"
>
<span className="text-xs text-text-muted w-12 shrink-0 font-mono">
#{i + 1}
</span>
<span className="text-sm text-accent font-mono">
{col}
</span>
</div>
))}
</>
)}
{idx.definition && (
<>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Definition
</span>
<span className="text-[10px] text-text-subtle">
SQL
</span>
</div>
<SyntaxCode source={idx.definition} />
</>
)}
</div>
);
}
case "constraints": {
const c = item as ConstraintInfo;
return (
<div>
<div className="border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Constraint
</span>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Type
</span>
<span className="text-sm text-accent font-mono">
{c.contype}
</span>
</div>
<div className="px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Table
</span>
<span className="text-sm text-text font-mono">
{c.table}
</span>
</div>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Deferrable
</span>
<span
className={`text-sm ${c.deferrable ? "text-amber-400" : "text-text-muted"}`}
>
{c.deferrable ? "Yes" : "No"}
</span>
</div>
<div className="px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Validated
</span>
<span
className={`text-sm ${c.validated ? "text-emerald-400" : "text-text-muted"}`}
>
{c.validated ? "Yes" : "No"}
</span>
</div>
</div>
{c.columns.length > 0 && (
<>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Columns
</span>
<span className="text-[10px] text-text-subtle">
{c.columns.length}
</span>
</div>
{c.columns.map((col, i) => (
<div
key={i}
className="border-b border-border px-4 py-2 flex items-center"
>
<span className="text-xs text-text-muted w-12 shrink-0 font-mono">
#{i + 1}
</span>
<span className="text-sm text-accent font-mono">
{col}
</span>
</div>
))}
</>
)}
{c.definition && (
<>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Definition
</span>
<span className="text-[10px] text-text-subtle">
SQL
</span>
</div>
<SyntaxCode source={c.definition} />
</>
)}
</div>
);
}
case "triggers": {
const t = item as TriggerInfo;
return (
<div>
<div className="border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Details
</span>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border flex-2 px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-24 shrink-0">
Table
</span>
<span className="text-sm text-text font-mono">
{t.table_schema}.{t.table_name}
</span>
</div>
<div className="flex-2 px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-24 shrink-0">
Event
</span>
<span className="text-sm text-text">
{t.event_manipulation}
</span>
</div>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border flex-2 px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-24 shrink-0">
Timing
</span>
<span className="text-sm text-text">
{t.action_timing} {t.action_orientation}
</span>
</div>
<div className="flex-2 px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-24 shrink-0">
Status
</span>
<span
className={`text-sm ${t.enabled === "O" ? "text-emerald-400" : "text-red-400"}`}
>
{t.enabled === "O"
? "Enabled"
: t.enabled === "D"
? "Disabled"
: t.enabled}
</span>
</div>
</div>
<div className="border-b border-border px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-24 shrink-0">
Schema
</span>
<span className="text-sm text-text font-mono">
{t.schema}
</span>
</div>
{t.action_statement && (
<>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Definition
</span>
<span className="text-[10px] text-text-subtle">
SQL
</span>
</div>
<SyntaxCode source={t.action_statement} />
</>
)}
</div>
);
}
case "sequences": {
const s = item as SequenceInfo;
return (
<div>
<div className="border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Sequence Values
</span>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-28 shrink-0">
Current Value
</span>
<span className="text-sm text-accent font-mono">
{s.current_value}
</span>
</div>
<div className="flex-2 px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-28 shrink-0">
Increment
</span>
<span className="text-sm text-text font-mono">
{s.increment}
</span>
</div>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-28 shrink-0">
Start
</span>
<span className="text-sm text-text font-mono">
{s.start_value}
</span>
</div>
<div className="flex-2 px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-28 shrink-0">
Min / Max
</span>
<span className="text-sm text-text font-mono">
{s.min_value} / {s.max_value}
</span>
</div>
</div>
<div className="border-b border-border px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-28 shrink-0">
Cycle
</span>
<span
className={`text-sm ${s.cycle ? "text-amber-400" : "text-text-muted"}`}
>
{s.cycle ? "Yes" : "No"}
</span>
</div>
</div>
);
}
case "enums": {
const e = item as EnumInfo;
return (
<div>
<div className="border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Details
</span>
</div>
<div className="border-b border-border px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-24 shrink-0">
Schema
</span>
<span className="text-sm text-text font-mono">
{e.schema}
</span>
</div>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Values
</span>
<span className="text-[10px] text-text-subtle">
{e.labels.length} labels
</span>
</div>
{e.labels.map((label, i) => (
<div
key={label}
className="border-b border-border px-4 py-2 flex items-center"
>
<span className="text-xs text-text-muted w-12 shrink-0 font-mono">
#{i + 1}
</span>
<span className="text-sm text-accent font-mono">
{label}
</span>
</div>
))}
</div>
);
}
case "extensions": {
const e = item as ExtensionInfo;
return (
<div>
<div className="border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Extension
</span>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Version
</span>
<span className="text-sm text-text font-mono">
{e.version}
</span>
</div>
<div className="flex-2 px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-24 shrink-0">
Schema
</span>
<span className="text-sm text-text font-mono">
{e.schema}
</span>
</div>
</div>
{e.comment && (
<div className="border-b border-border px-4 py-2.5">
<span className="text-xs text-text-muted block mb-1">
Comment
</span>
<p className="text-sm text-text leading-relaxed">
{e.comment}
</p>
</div>
)}
</div>
);
}
}
}
interface ObjectDetailProps {
connectionId: string;
type: ObjectType;
item: AnyObject;
}
export function ObjectDetail({ connectionId: _connectionId, type, item }: ObjectDetailProps) {
return <>{renderDetail(type, item)}</>;
}
@@ -0,0 +1,159 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { ObjectFormTab } from "./ObjectFormTab";
import * as objectCrud from "../../../lib/objectCrud";
import {
useDbViewerStore,
type ViewerTab,
} from "../../../stores/dbViewerStore";
vi.mock("../../../lib/objectCrud", () => ({
buildObjectDdl: vi.fn(),
}));
vi.mock("../../editor/SqlEditorField", () => ({
SqlEditorField: ({ value, readOnly }: { value: string; readOnly?: boolean }) => (
<textarea data-testid="sql-editor" readOnly={readOnly} value={value} onChange={() => {}} />
),
}));
describe("ObjectFormTab", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
useDbViewerStore.getState().reset();
});
const connectionId = "c1";
const baseTab: ViewerTab = {
id: "form-tab-1",
schema: "public",
table: "Create sequence",
page: 1,
pageSize: 50,
loading: false,
error: null,
data: null,
filterRules: [],
sortRules: [],
hiddenColumns: [],
smartSortApplied: false,
tabType: "objectForm",
objectType: null,
form: {
kind: "sequence",
title: "Create sequence",
description: "Create my_seq",
mode: "create",
params: { schema: "public", name: "my_seq", action: { op: "create" } },
},
};
it("renders the form for a sequence kind in Visual view", () => {
vi.mocked(objectCrud.buildObjectDdl).mockResolvedValue([
"CREATE SEQUENCE \"public\".\"my_seq\" START WITH 1;",
]);
render(<ObjectFormTab connectionId={connectionId} tab={baseTab} />);
expect(screen.getByPlaceholderText("Sequence name")).toBeInTheDocument();
});
it("toggles to SQL and shows the generated SQL, then back to Visual", async () => {
vi.mocked(objectCrud.buildObjectDdl).mockResolvedValue([
"CREATE SEQUENCE \"public\".\"my_seq\" START WITH 1;",
]);
render(<ObjectFormTab connectionId={connectionId} tab={baseTab} />);
fireEvent.click(screen.getByRole("button", { name: "SQL" }));
const ed = await screen.findByTestId("sql-editor");
expect(ed).toHaveValue('CREATE SEQUENCE "public"."my_seq" START WITH 1;');
expect(ed).toHaveProperty("readOnly", true);
fireEvent.click(screen.getByRole("button", { name: "Visual" }));
expect(screen.getByPlaceholderText("Sequence name")).toBeInTheDocument();
});
it("stages one ddl change and closes the tab", async () => {
vi.mocked(objectCrud.buildObjectDdl).mockResolvedValue([
"CREATE SEQUENCE \"public\".\"my_seq\" START WITH 1;",
]);
useDbViewerStore.setState({
tabs: [baseTab],
activeTabId: baseTab.id,
});
const addChange = vi.spyOn(useDbViewerStore.getState(), "addChange");
render(<ObjectFormTab connectionId={connectionId} tab={baseTab} />);
fireEvent.click(screen.getByRole("button", { name: /stage/i }));
await waitFor(() =>
expect(addChange).toHaveBeenCalledWith(
expect.objectContaining({
type: "ddl",
sql: 'CREATE SEQUENCE "public"."my_seq" START WITH 1;',
description: "Create my_seq",
}),
),
);
expect(useDbViewerStore.getState().tabs).toHaveLength(0);
});
it("stages multiple statements with (n/total) descriptions", async () => {
vi.mocked(objectCrud.buildObjectDdl).mockResolvedValue([
"DROP SEQUENCE \"public\".\"my_seq\";",
"CREATE SEQUENCE \"public\".\"my_seq\" START WITH 1;",
]);
useDbViewerStore.setState({
tabs: [baseTab],
activeTabId: baseTab.id,
});
const addChange = vi.spyOn(useDbViewerStore.getState(), "addChange");
render(<ObjectFormTab connectionId={connectionId} tab={baseTab} />);
fireEvent.click(screen.getByRole("button", { name: /stage/i }));
await waitFor(() => {
expect(addChange).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ description: "Create my_seq (1/2)" }),
);
expect(addChange).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ description: "Create my_seq (2/2)" }),
);
});
expect(useDbViewerStore.getState().tabs).toHaveLength(0);
});
it("shows a builder error and disables Stage", async () => {
vi.mocked(objectCrud.buildObjectDdl).mockRejectedValueOnce(
"Name is required",
);
render(<ObjectFormTab connectionId={connectionId} tab={baseTab} />);
await waitFor(() =>
expect(screen.getByText(/Name is required/)).toBeInTheDocument(),
);
expect(screen.getByRole("button", { name: /stage/i })).toBeDisabled();
});
it("renders only the uppercase kicker, not a duplicate title heading", () => {
vi.mocked(objectCrud.buildObjectDdl).mockResolvedValue([]);
render(
<ObjectFormTab
connectionId={connectionId}
tab={{
...baseTab,
table: "Create Function",
form: {
...baseTab.form!,
kind: "function",
title: "Create Function",
},
}}
/>,
);
// Kicker: lowercase DOM text uppercased by CSS
expect(screen.getByText("create function")).toBeInTheDocument();
expect(screen.queryByText("Create Function")).toBeNull();
});
});
@@ -0,0 +1,155 @@
import { lazy, Suspense, useEffect, useState } from "react";
import { KindForm } from "./KindForm";
import {
buildObjectDdl,
type DdlParams,
} from "../../../lib/objectCrud";
import { useDbViewerStore, type ViewerTab } from "../../../stores/dbViewerStore";
const SqlEditorField = lazy(() =>
import("../../editor/SqlEditorField").then((m) => ({ default: m.SqlEditorField })),
);
interface Props {
connectionId: string;
tab: ViewerTab;
}
export function ObjectFormTab({ connectionId, tab }: Props) {
const form = tab.form;
if (!form) return null;
const { kind, params, description, mode } = form;
const [view, setView] = useState<"visual" | "sql">("visual");
const [preview, setPreview] = useState("");
const [error, setError] = useState<string | null>(null);
const open = true;
useEffect(() => {
if (!open) return;
let active = true;
buildObjectDdl(connectionId, kind, params)
.then((sqls) => {
if (active) {
setPreview(sqls.join("\n;\n"));
setError(null);
}
})
.catch((e) => {
if (active) {
setPreview("");
setError(e instanceof Error ? e.message : String(e));
}
});
return () => {
active = false;
};
}, [open, connectionId, kind, params]);
const stage = async () => {
const sqls = await buildObjectDdl(connectionId, kind, params);
sqls.forEach((sql, i) =>
useDbViewerStore.getState().addChange({
type: "ddl",
sql,
description:
sqls.length > 1
? `${description} (${i + 1}/${sqls.length})`
: description,
}),
);
useDbViewerStore.getState().closeTab(tab.id);
};
const schemas = useDbViewerStore((s) => s.schemas);
const handleChange = (next: DdlParams) => {
useDbViewerStore.getState().updateFormTabParams(tab.id, next);
};
return (
<div className="flex h-full flex-col bg-transparent">
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
{mode} {kind}
</span>
<div className="flex items-center gap-2">
<div className="flex rounded-md border border-border overflow-hidden">
<button
type="button"
aria-label="Visual"
onClick={() => setView("visual")}
className={[
"px-2 py-0.5 text-xs transition-colors cursor-pointer",
view === "visual"
? "bg-surface-raised text-text"
: "text-text-muted hover:text-text",
].join(" ")}
>
Visual
</button>
<button
type="button"
aria-label="SQL"
onClick={() => setView("sql")}
className={[
"px-2 py-0.5 text-xs transition-colors cursor-pointer",
view === "sql"
? "bg-surface-raised text-text"
: "text-text-muted hover:text-text",
].join(" ")}
>
SQL
</button>
</div>
<button
type="button"
onClick={stage}
disabled={!!error}
className="rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
>
Stage
</button>
</div>
</div>
<div className="flex-1 overflow-auto">
{view === "visual" ? (
<KindForm
connectionId={connectionId}
kind={kind}
params={params}
schemas={schemas}
onChange={handleChange}
/>
) : (
<div className="px-4 py-3">
<Suspense
fallback={
<pre className="text-xs leading-6 font-mono whitespace-pre-wrap text-text">
{preview}
</pre>
}
>
<SqlEditorField
value={preview}
onChange={() => {}}
readOnly
height={420}
/>
</Suspense>
</div>
)}
</div>
{error && (
<div className="border-t border-border px-4 py-2">
<p className="text-xs text-red-400">{error}</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,113 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { useState } from "react";
import { SequenceForm } from "./SequenceForm";
import type { DdlParams } from "../../../lib/objectCrud";
function StatefulSequenceForm({ initialParams }: { initialParams: DdlParams }) {
const [params, setParams] = useState(initialParams);
return <SequenceForm params={params} onChange={setParams} />;
}
describe("SequenceForm", () => {
it("renders create fields and emits params on change", () => {
const onChange = vi.fn();
render(
<SequenceForm
params={{
schema: "public",
name: "s",
action: {
op: "create",
increment: "1",
min_value: "1",
max_value: "9",
start: "1",
cycle: false,
},
}}
onChange={onChange}
/>,
);
expect(screen.getByPlaceholderText("Sequence name")).toHaveValue("s");
fireEvent.change(screen.getByPlaceholderText("Sequence name"), {
target: { value: "s2" },
});
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ name: "s2" }),
);
});
it("renders a schema dropdown when schemas are provided", () => {
const onChange = vi.fn();
render(
<SequenceForm
params={{ schema: "public", name: "s", action: { op: "create" } }}
schemas={["public", "utils"]}
onChange={onChange}
/>,
);
const select = screen.getByLabelText("Schema");
expect(select).toBeInTheDocument();
expect(screen.getByRole("option", { name: "public" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "utils" })).toBeInTheDocument();
fireEvent.change(select, { target: { value: "utils" } });
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ schema: "utils" }),
);
});
it("the editing outline wraps only the value area, not the label cell", () => {
const onChange = vi.fn();
render(
<SequenceForm
params={{ schema: "public", name: "s", action: { op: "create" } }}
onChange={onChange}
/>,
);
// The children wrapper (direct parent of the input) carries the amber
// focus-within editing outline, exactly like a grid editing cell.
const input = screen.getByPlaceholderText("Sequence name");
const valueArea = input.parentElement;
expect(valueArea).not.toBeNull();
expect(valueArea!.className).toContain("focus-within:outline");
expect(valueArea!.className).toContain("focus-within:outline-amber-400");
expect(valueArea!.className).toContain("focus-within:outline-offset-[-2px]");
// The label cell must stay clean: no ancestor of the label may carry
// the editing outline (regression: the old row-level outline lit up the
// whole row, label cell included).
const label = screen.getByText("Name");
expect(label.closest('[class*="focus-within:outline"]')).toBeNull();
});
it("switching to restart shows only the with-field", () => {
render(
<StatefulSequenceForm
initialParams={{
schema: "public",
name: "s",
action: {
op: "create",
increment: "1",
min_value: "1",
max_value: "9",
start: "1",
cycle: false,
},
}}
/>,
);
const opSelect = screen.getByLabelText("Operation");
fireEvent.change(opSelect, { target: { value: "restart" } });
expect(screen.getByPlaceholderText("Restart with")).toBeInTheDocument();
expect(screen.queryByPlaceholderText("Increment")).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText("Start")).not.toBeInTheDocument();
expect(screen.queryByLabelText("CYCLE")).not.toBeInTheDocument();
});
});

Some files were not shown because too many files have changed in this diff Show More