Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
751746f784 | ||
|
|
32a7b852ec | ||
|
|
9fb3222956 | ||
|
|
76cd1ded16 | ||
|
|
a424e31de8 | ||
|
|
7401e91a12 | ||
|
|
0c74d77e75 | ||
|
|
8d8ed78202 |
@@ -167,6 +167,143 @@ jobs:
|
||||
"$OUT/${b}${BIN_EXT}" --version >/dev/null 2>&1 || { echo "$b failed to run from resource dir"; exit 1; }
|
||||
done
|
||||
|
||||
- name: Build MariaDB client tools (bundled)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MARIADB_VER="11.4.5"
|
||||
OUT="${GITHUB_WORKSPACE}/src-tauri/resources/mysql_tools"
|
||||
mkdir -p "$OUT"
|
||||
case "${{ matrix.platform }}" in
|
||||
ubuntu-22.04)
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y cmake build-essential libssl-dev libzstd-dev pkg-config libgnutls28-dev patchelf
|
||||
git clone --depth 1 --branch "mariadb-${MARIADB_VER}" https://github.com/MariaDB/server.git /tmp/mariadb-server
|
||||
cd /tmp/mariadb-server
|
||||
# Client-only build: wolfSSL + zlib are vendored. The in-tree
|
||||
# Connector C still probes GnuTLS on Unix regardless of WITH_SSL,
|
||||
# so libgnutls28-dev is required at build time.
|
||||
cmake -DCMAKE_BUILD_TYPE=Release \
|
||||
-DWITHOUT_SERVER=ON \
|
||||
-DWITHOUT_TOKUDB=1 \
|
||||
-DWITHOUT_ROCKSDB=1 \
|
||||
-DWITHOUT_MROONGA=1 \
|
||||
-DWITHOUT_SPIDER=1 \
|
||||
-DWITHOUT_SEQUENCE=1 \
|
||||
-DWITH_UNIT_TESTS=OFF \
|
||||
-DWITH_SSL=bundled \
|
||||
-DWITH_ZLIB=bundled .
|
||||
make -j"$(nproc)" mariadb-dump mariadb
|
||||
# Clients build into client/ (stable across MariaDB 10.x/11.x);
|
||||
# fall back to a broad search if a future version moves them.
|
||||
for b in mariadb-dump mariadb; do
|
||||
src=$(find client -maxdepth 1 -type f -name "$b" -print -quit 2>/dev/null || true)
|
||||
test -n "$src" || src=$(find . -type f -name "$b" -print -quit || true)
|
||||
test -n "$src" || { echo "build produced no $b binary"; exit 1; }
|
||||
cp "$src" "$OUT"/
|
||||
done
|
||||
# Bundle every non-core shared dependency (GnuTLS + its chain,
|
||||
# libzstd) next to the clients and point the loader at $ORIGIN —
|
||||
# the same pattern the pg_tools step uses for libpq.
|
||||
for b in mariadb-dump mariadb; do
|
||||
ldd "$OUT/$b" | awk '/=> \// {print $3}' | sort -u | while read -r lib; do
|
||||
case "$(basename "$lib")" in
|
||||
ld-linux*|libc.so*|libm.so*|libpthread*|libdl.so*|librt.so*|libgcc_s*|libstdc++*|libcrypt.so*|libresolv*|libutil*)
|
||||
;; # core runtime — present on every glibc system
|
||||
*)
|
||||
cp -n "$lib" "$OUT/$(basename "$lib")" 2>/dev/null || true
|
||||
;;
|
||||
esac
|
||||
done
|
||||
patchelf --set-rpath '$ORIGIN' "$OUT/$b"
|
||||
done
|
||||
;;
|
||||
macos-latest|macos-15-intel)
|
||||
git clone --depth 1 --branch "mariadb-${MARIADB_VER}" https://github.com/MariaDB/server.git /tmp/mariadb-server
|
||||
cd /tmp/mariadb-server
|
||||
# macOS connects the in-tree Connector C against brew OpenSSL
|
||||
# (proven to build+run on the runners); the Linux-only GnuTLS
|
||||
# probe does not fire here. Absolute brew paths are rewritten to
|
||||
# @loader_path below so user machines resolve them from the
|
||||
# resource dir.
|
||||
if [ "$(uname -m)" = arm64 ]; then
|
||||
SSL_DIR="/opt/homebrew/opt/openssl"
|
||||
else
|
||||
SSL_DIR="/usr/local/opt/openssl"
|
||||
fi
|
||||
[ -d "$SSL_DIR" ] || brew install openssl
|
||||
cmake -DCMAKE_BUILD_TYPE=Release \
|
||||
-DWITHOUT_SERVER=ON \
|
||||
-DWITHOUT_TOKUDB=1 \
|
||||
-DWITHOUT_ROCKSDB=1 \
|
||||
-DWITHOUT_MROONGA=1 \
|
||||
-DWITHOUT_SPIDER=1 \
|
||||
-DWITHOUT_SEQUENCE=1 \
|
||||
-DWITH_UNIT_TESTS=OFF \
|
||||
-DWITH_SSL="$SSL_DIR" \
|
||||
-DWITH_ZLIB=bundled .
|
||||
make -j"$(sysctl -n hw.ncpu)" mariadb-dump mariadb
|
||||
for b in mariadb-dump mariadb; do
|
||||
src=$(find client -maxdepth 1 -type f -name "$b" -print -quit 2>/dev/null || true)
|
||||
test -n "$src" || src=$(find . -type f -name "$b" -print -quit || true)
|
||||
test -n "$src" || { echo "build produced no $b binary"; exit 1; }
|
||||
cp "$src" "$OUT"/
|
||||
done
|
||||
# Rewrite absolute Homebrew dylib deps to @loader_path so the
|
||||
# bundled clients (and OpenSSL + their own deps) resolve from the
|
||||
# resource dir on user machines. Fixed-point loop: each pass
|
||||
# copies newly-seen dylibs and rewrites their parents' refs.
|
||||
# Ad-hoc re-sign after each edit — arm64 dyld refuses to load a
|
||||
# dylib whose (Homebrew) code signature was invalidated.
|
||||
for pass in 1 2 3 4 5 6; do
|
||||
for dylib in "$OUT"/*.dylib "$OUT"/mariadb-dump "$OUT"/mariadb; do
|
||||
[ -f "$dylib" ] || continue
|
||||
# `|| true` — with pipefail, grep exits 1 on zero matches.
|
||||
otool -L "$dylib" | awk 'NR>1 {print $1}' | grep -E '^/(usr/local|opt/homebrew)' | while read -r lib; do
|
||||
base=$(basename "$lib")
|
||||
if [ ! -f "$OUT/$base" ]; then cp "$lib" "$OUT/$base" 2>/dev/null || true; fi
|
||||
install_name_tool -change "$lib" "@loader_path/$base" "$dylib" 2>/dev/null || true
|
||||
done || true
|
||||
codesign --force --sign - "$dylib" 2>/dev/null || true
|
||||
done
|
||||
done
|
||||
;;
|
||||
windows-latest)
|
||||
URL="https://archive.mariadb.org/mariadb-${MARIADB_VER}/winx64-packages/mariadb-${MARIADB_VER}-winx64.zip"
|
||||
curl -fsSL "$URL" -o /tmp/mariadb.zip
|
||||
# TODO(RELEASE): pin the sha256 printed on the first CI run, then
|
||||
# uncomment the check so later releases verify the download.
|
||||
# EXPECTED="<sha256 of mariadb-${MARIADB_VER}-winx64.zip>"
|
||||
# echo "$EXPECTED /tmp/mariadb.zip" | sha256sum -c -
|
||||
sha256sum /tmp/mariadb.zip
|
||||
unzip -o /tmp/mariadb.zip -d /tmp/mariadb
|
||||
WINROOT="/tmp/mariadb/mariadb-${MARIADB_VER}-winx64"
|
||||
cp "$WINROOT"/bin/mariadb-dump.exe "$WINROOT"/bin/mariadb.exe "$OUT"/
|
||||
# The clients' only runtime dependency is the client library
|
||||
# (lib/libmariadb.dll); the winx64 package statically links
|
||||
# wolfSSL, so there are no OpenSSL DLLs to bundle. bin/*.dll is
|
||||
# just the embedded server.dll, which the client tools don't need.
|
||||
cp "$WINROOT"/lib/libmariadb.dll "$OUT"/
|
||||
;;
|
||||
esac
|
||||
(cd "$OUT" && sha256sum * | tee checksums.txt)
|
||||
# Resolve the per-platform binary suffix WITHOUT a command
|
||||
# substitution (see the pg_tools step for why).
|
||||
if [ "${{ matrix.platform }}" = windows-latest ]; then
|
||||
BIN_EXT=".exe"
|
||||
else
|
||||
BIN_EXT=""
|
||||
fi
|
||||
for b in mariadb-dump mariadb; 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 missing shared library before we ship a broken bundle.
|
||||
for b in mariadb-dump mariadb; 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:
|
||||
@@ -176,9 +313,7 @@ jobs:
|
||||
releaseName: 'Gridline ${{ github.ref_name }}'
|
||||
releaseDraft: true
|
||||
args: ${{ matrix.args }}
|
||||
# Version-free asset names (see README Download section): the README
|
||||
# links via GitHub's releases/latest/download/<file> redirect, which
|
||||
# only works if filenames are identical across releases. Omitting
|
||||
# [version] gives stable names: Gridline_darwin_aarch64.dmg,
|
||||
# Gridline_windows_x64-setup.exe, Gridline_linux_amd64.deb, etc.
|
||||
releaseAssetNamePattern: '[name]_[platform]_[arch][setup][ext]'
|
||||
# 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.
|
||||
@@ -149,14 +149,24 @@ cargo build # Rust backend only (from src-tauri/)
|
||||
cargo test # Rust tests
|
||||
```
|
||||
|
||||
### Branch & PR workflow
|
||||
|
||||
**Never commit or push directly to `prod`** — `prod` is protected (branch rules require a pull request). Always:
|
||||
|
||||
1. Branch out first — `git checkout -b feat/<short-name>` (or `fix/…`, `docs/…`) from `prod`
|
||||
2. Commit and push the feature branch
|
||||
3. Open a PR into `prod` and merge it there
|
||||
|
||||
Direct pushes to `prod` will be bypassed only in emergencies; prefer the PR path so changes land through the PR gate.
|
||||
|
||||
### 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).
|
||||
Cut a release by tagging the **`prod`** branch once the PR is merged — `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 stay version-free** — both download tables (top **Download** section + **Which file should I download?**) link via GitHub's `releases/latest/download/<file>` redirect, which only works because `release.yml` sets `releaseAssetNamePattern` to a version-free pattern (`[name]_[platform]_[arch][setup][ext]`). Do NOT re-add the version to these filenames on release.
|
||||
- **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.8/<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
|
||||
@@ -210,10 +220,12 @@ 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 |
|
||||
| SSL/TLS runtime | ✅ | PostgreSQL all modes via rustls (disable/require/verify-ca/verify-full; **v1: `verify-ca` behaves as `verify-full`** — documented refinement), client certs PKCS#1/PKCS#8/EC, encrypted client keys rejected; MySQL test path maps modes (verify-full → VerifyIdentity) |
|
||||
| Roles & grants (PG) | ✅ | Create/edit/drop PostgreSQL roles and GRANT/REVOKE privileges per object class from a UI (v0.7.7) |
|
||||
|
||||
### Home Screen & Organization
|
||||
| Feature | Status | Details |
|
||||
@@ -258,10 +270,11 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
||||
| Column show/hide | ✅ | Toggle visibility per column |
|
||||
| Column resize (drag handle) | ✅ | Double-click to auto-fit |
|
||||
| Row selection (checkboxes + select all) | ✅ | Bulk copy (JSON/CSV/SQL) and delete |
|
||||
| Export toolbar (JSON, CSV, SQL, Markdown) | ✅ | Client-side Blob download of visible rows |
|
||||
| Export toolbar (JSON, CSV, SQL, Markdown, Excel (.xlsx)) | ✅ | Client-side Blob download of visible rows; Excel (.xlsx) via client-side workbook generation (v0.7.8) |
|
||||
| Excel (.xlsx) export | ✅ | Client-side .xlsx export of visible rows alongside JSON/CSV/SQL/Markdown (v0.7.8) |
|
||||
| Auto-refresh timer | ✅ | Configurable interval in settings |
|
||||
| Changes queue (INSERT, UPDATE, DELETE, bulk_insert, empty_table, drop_table) | ✅ | Stage → **Commit All**. Tab bar **Changes** button (amber border + count badge when pending) toggles a **popover** anchored to it: header with **Visual/SQL** toggle (cards showing op badge + table + description + per-change **Revert**, or a generated-SQL preview via `buildChangeSql`), footer **Clear All** + **Commit All (N)** with **⌘S/Ctrl+S** shortcut. Committed cards show a green ✓ (failed ✗); committing `drop_table` auto-closes open tabs of that table |
|
||||
| Auto schema-tree refresh | ✅ | Tree auto-refreshes after a successful schema-modifying query run (`CREATE`/`DROP`/`ALTER`/`TRUNCATE` via `isSchemaModifyingQuery`) and after committing `drop_table` via the queue — no manual refresh needed |
|
||||
| Auto schema-tree refresh | ✅ | Tree auto-refreshes after a successful schema-modifying query run (`CREATE`/`DROP`/`ALTER`/`TRUNCATE` via `isSchemaModifyingQuery`) and after committing schema-modifying queue changes — `drop_table`, schema-modifying `ddl` (e.g. `CREATE TABLE`), and `rebuild_table` — no manual refresh needed |
|
||||
| Data import (CSV/JSON) | ✅ | Table overflow menu → ImportDialog: file pick, parse, preview (first 100 rows), header→column mapping, caps 100k rows / 100 MB; stages a bulk_insert change through the queue → Commit All |
|
||||
| Table menu actions | ✅ | Copy table schema (DDL via pg_dump / sqlite_master), Empty Table (DELETE) / Delete Table (DROP) through the queue with confirm, export stubs wired (JSON/CSV/SQL/Markdown) |
|
||||
| Edit connection modal (from DB viewer) | ✅ | AnimatedModal with keychain password fetch on test |
|
||||
@@ -272,6 +285,19 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
||||
| Keyboard cell navigation (arrow keys, Tab) | ✅ | Arrow keys + Tab/Shift+Tab wrap (`keyboardNav`); Enter opens the editor; **Esc cancels editing even after the editor lost focus** (document-level listener) and closes the context menu |
|
||||
| Cell-level copy (right-click or Ctrl+C) | ✅ | CellContextMenu: Copy / Copy JSON (jsonb) / Edit / Set NULL / Open FK reference + **View Row** and **Select Row** items; right-click or Ctrl/Cmd+C on the focused cell; menu closes on outside click/Esc |
|
||||
| FK reference | ✅ | Small ↗ icon at the start of FK cells opens the FK preview popover (also via context menu Open FK reference); plain click on the cell selects/edits and does not open it |
|
||||
| Maintenance actions (VACUUM/ANALYZE/REINDEX) | ✅ | Right-click table → VACUUM / ANALYZE / REINDEX (v0.7.7) |
|
||||
|
||||
### Table Editor & Roles (v0.7.7)
|
||||
| Feature | Status | Details |
|
||||
| :--- | :---: | :--- |
|
||||
| Create Table (visual editor) | ✅ | "Create Table…" workspace tab: columns grid (name / type dropdown / default / PK per row, drag-to-reorder, add/remove), single + composite PK (first column auto-PK), shorthand PG types (`int`/`int8`/`int2`/`float8`/`bool`), auto-increment gating, live Monaco SQL preview, transparent selects + form-row focus `amber-400/20` |
|
||||
| Edit Table (column diff) | ✅ | Same grid on existing tables; Stage diffs old vs new columns → one queue item per statement: `ADD COLUMN`, `DROP COLUMN`, `RENAME COLUMN`, `ALTER COLUMN … TYPE`, `SET|DROP DEFAULT`, `SET|DROP NOT NULL`; stale-write guard (re-fetches live columns, refuses if changed) |
|
||||
| Column reorder (atomic rebuild) | ✅ | Drag-to-reorder triggers a table rebuild: single transaction, preserves constraints/indexes/FKs/grants/sequences (copied DDL), fail-closed when triggers/RLS policies/inheritance/partitioning/generated columns are present (`get_table_rebuild_readiness`) |
|
||||
| FK management | ✅ | Multi-column FK composer (local col → ref col pairs, referenced PKs first, type-match preview `localType → refType`, auto-named constraints, ref-table picker with `name (type)` options); cross-schema references; ON DELETE / ON UPDATE actions; create mode inlines FKs into the single CREATE TABLE change, edit mode stages ALTERs; FK rows in the form with Edit/Remove (queued changes revert, DB FKs stage DROP CONSTRAINT) |
|
||||
| Table options | ✅ | Tablespace picker (non-system, from `pg_tablespace`) + row-level security toggle in the Options section |
|
||||
| Roles & grants management | ✅ | Objects → Roles: list non-system roles (attributes incl. connection limit — `rolconnlimit::int8` cast fix), role detail with attribute grid + memberships + privilege explorer; create/edit/drop roles staged through the queue |
|
||||
| Role privilege explorer | ✅ | Per-role privileges grouped by object class (tables/sequences/routines/schemas/databases) with collapsible sections — collapsed shows first 5 + fade, expand shows all, chevron hidden when ≤5; GRANT/REVOKE composer (object class + schema + name, WITH GRANT OPTION); aclexplode-based queries (`role_sequence_grants` removed in PG 15, `schema_privileges` never existed) |
|
||||
| SQLite table editor (Create/Edit Table) | ✅ | Visual Create Table / Edit Table extended to SQLite: SQLite-aware type mapping (`INTEGER PRIMARY KEY AUTOINCREMENT` instead of `serial`, `TEXT`/`REAL`/`BLOB`) and ALTER TABLE limits respected (v0.7.8) |
|
||||
|
||||
### Object Explorer (non-table objects)
|
||||
| Feature | Status | Details |
|
||||
@@ -290,7 +316,11 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
||||
| 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. |
|
||||
| Create/Edit table + column-diff + rebuild | ✅ | Create Table + Edit Table column-diff editor (ADD/DROP/RENAME/ALTER TYPE/SET|DROP DEFAULT/SET|DROP NOT NULL, staged one-per-queue-item); atomic column-reorder table rebuild in a single transaction, preserving constraints/indexes/FKs/grants/sequences (v0.7.7) |
|
||||
| Relationships (FK CRUD, cross-schema, ON DELETE/UPDATE) | ✅ | FK create/edit/drop with cross-schema references and ON DELETE/UPDATE actions (v0.7.7) |
|
||||
|
||||
### Query Editor
|
||||
| Feature | Status | Details |
|
||||
@@ -301,6 +331,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
||||
| Query history / recent queries | ✅ | v5 `query_history` table + v6 `favorite` column; consecutive-identical dedup + retention pruning (500/connection); `get_query_history`/`clear_query_history`/`set_history_favorite` commands; **QueryHistoryDropdown** in the query toolbar (load / run / favorite / clear, lazy fetch, loading + error states) |
|
||||
| SQL autocomplete (keywords, tables, columns) | ✅ | Completion provider in `src/lib/monacoSetup.ts` backed by `src/lib/sqlCompletion.ts` (pure, unit-tested): keywords (~60) + table names from the active schema; typing `table.` or `schema.table.` suggests that table's columns (introspected via `get_schema_graph`, cached per schema in memory, `incomplete: true` warm-up on first use) |
|
||||
| Multiple result sets | ❌ | |
|
||||
| Cancel long-running queries | ✅ | Per-connection cancel from the query toolbar (`pg_cancel_backend` / MySQL `KILL` / SQLite interruption) instead of waiting or killing the app (v0.7.8) |
|
||||
| Saved queries (named, organized) | ✅ | v6 `queries` table (nullable `connection_id` for global queries, `folder` field, `ON DELETE CASCADE`); `save_query`/`get_saved_queries`/`update_saved_query`/`delete_saved_query` commands with validation (name ≤200, folder ≤100, text ≤1MB); **SaveQueryDialog** (name + folder, empty-name guard); managed in the Queries view Saved tab |
|
||||
| Query favorites / pinning | ✅ | Star toggle per history entry via `set_history_favorite`; favorites-only filter in the Queries view History tab |
|
||||
| Queries view | ✅ | Two-pane layout following Explorer: left sidebar (Explorer-styled header — Queries title, History/Saved dropdown, favorites/clear/search icons, animated search) scoped to the **current connection**, right side reuses the shared tabbed query workspace (TabBar + toolbar + editor + results); clicking a history/saved row loads it into the editor |
|
||||
@@ -316,7 +347,8 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
||||
| 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 | ❌ | |
|
||||
| SQLite .dump (backup/restore) | ✅ | Backup a SQLite database to a portable SQL dump and restore it back, matching the pg_dump UX (v0.7.8) |
|
||||
| Backup / Restore / Sync for MySQL & SQLite | ✅ | MySQL backup/restore via mysqldump (system-first); DB-to-DB sync extended beyond PostgreSQL (v0.7.8) |
|
||||
| Table structure export (DDL) | ❌ | |
|
||||
|
||||
### Settings
|
||||
@@ -324,6 +356,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
||||
| :--- | :---: | :--- |
|
||||
| Settings screen (redesigned) | ✅ | DB-viewer-styled shell: icon+text sidebar (Back on top, accent background), header shows the active tab, border-sharp sections with gap-spaced rows (no cards), Back returns to the view it was opened from (push/pop in `uiStore`) |
|
||||
| Theme (dark/light/system) | ✅ | Applied live via a `.light` class on the document root (dark-first base palette); "system" follows the OS via `matchMedia` and live-updates; native window chrome synced through Tauri `setTheme`/`setBackgroundColor` with a macOS **Overlay** titlebar (in-flow drag strip) |
|
||||
| Windows/Linux title bar fix | ✅ | macOS-only overlay drag strip (`h-7` in `App.tsx`) gated to macOS; Windows/Linux use the native title bar for dragging (v0.7.8) |
|
||||
| Font size | ✅ | rem scale via `data-font-size` on the root (`small`/`medium`/`large`) |
|
||||
| Accent color | ✅ | 10-preset circle palette in General → Appearance; applied via `--color-accent` on the root; hover/muted shades derive from it via `color-mix` |
|
||||
| Default folder for new connections | ✅ | Honored on startup — Home opens into `default_folder_id` unless the user has already navigated |
|
||||
@@ -336,7 +369,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
||||
| More keyboard shortcuts | ❌ | Only 2 configurable actions |
|
||||
| Editor settings | ✅ | Five options wired to the settings store + live Monaco `updateOptions` |
|
||||
| SSH key management | ❌ | Only path inputs, no key file reading |
|
||||
| Settings export/import | ❌ | |
|
||||
| Settings export/import | ✅ | Export/import settings (theme, accent, editor options, page sizes, defaults) as a JSON file (v0.7.8) |
|
||||
|
||||
### Demo & Onboarding
|
||||
| Feature | Status | Details |
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
<p align="center">
|
||||
<img src="./screenshots/data-grid.png" alt="Gridline data grid with FK preview" width="92%" style="border-radius: 14px;" />
|
||||
</p>
|
||||
<p>
|
||||
<sub><b>Data Grid & FK Preview</b> — browse 50+ real demo orders and inspect related rows in one click.</sub>
|
||||
</p>
|
||||
|
||||
<h1>Gridline</h1>
|
||||
|
||||
<p>
|
||||
<i>A lightweight, open-source database GUI for PostgreSQL, MySQL, SQLite, and Redis.</i><br />
|
||||
Unlimited connections, tabs, and saved queries — with first-class <code>pg_dump</code>, <code>pg_restore</code>, and DB-to-DB sync.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<sub><b>Data Grid & FK Preview</b> — browse 50+ real demo orders and inspect related rows in one click.</sub>
|
||||
Unlimited connections, tabs, and saved queries — with first-class backup, restore, and DB-to-DB sync for PostgreSQL, MySQL, and SQLite.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
@@ -31,26 +30,27 @@
|
||||
|
||||
## Download
|
||||
|
||||
Grab the installer for your OS from the [latest release](https://github.com/AdrianBonpin/gridline/releases/latest) — links always point to the newest build:
|
||||
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.8**):
|
||||
|
||||
| OS | Architecture | Download |
|
||||
| :--- | :--- | :--- |
|
||||
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_darwin_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_darwin_aarch64.dmg) |
|
||||
| **macOS** | Intel | [Gridline_darwin_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_darwin_x64.dmg) |
|
||||
| **Windows** | x64 | [Gridline_windows_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_windows_x64-setup.exe) |
|
||||
| **Debian / Ubuntu** | amd64 | [Gridline_linux_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_linux_amd64.deb) |
|
||||
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline_linux_x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_linux_x86_64.rpm) |
|
||||
| **Other Linux** | amd64 | [Gridline_linux_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_linux_amd64.AppImage) |
|
||||
| :--------------------------- | :--------------------------- | :------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.8_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_aarch64.dmg) |
|
||||
| **macOS** | Intel | [Gridline_0.7.8_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_x64.dmg) |
|
||||
| **Windows** | x64 | [Gridline_0.7.8_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_x64-setup.exe) |
|
||||
| **Debian / Ubuntu** | amd64 | [Gridline_0.7.8_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_amd64.deb) |
|
||||
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.8-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline-0.7.8-1.x86_64.rpm) |
|
||||
| **Other Linux** | amd64 | [Gridline_0.7.8_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_amd64.AppImage) |
|
||||
|
||||
> Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below. All installers are **unsigned** — see the [notes](#which-file-should-i-download) on first-launch warnings.
|
||||
|
||||
<!--
|
||||
MAINTENANCE: The download links use GitHub's releases/latest/download/<file>
|
||||
redirect, so they always point at the newest published release. This only
|
||||
works because release.yml sets releaseAssetNamePattern to a version-free
|
||||
pattern ([name]_[platform]_[arch][setup][ext]). If the asset naming ever
|
||||
changes, update BOTH tables here to match the new filenames. No version
|
||||
bump is needed on release — do NOT re-add the version to these filenames.
|
||||
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.7.8
|
||||
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.
|
||||
-->
|
||||
|
||||
---
|
||||
@@ -61,6 +61,10 @@ Gridline is a modern, open-source database GUI client built with [Tauri 2.0](htt
|
||||
|
||||
- **No caps** on connections, tabs, or saved queries.
|
||||
- **Deep PostgreSQL tooling** — visual `pg_dump`, `pg_restore`, and DB-to-DB sync.
|
||||
- **Backup, restore & sync for every database** — PostgreSQL, MySQL (`mysqldump` / bundled MariaDB clients), and SQLite (`.dump`), with direct DB-to-DB sync between live connections.
|
||||
- **Excel export** — CSV, JSON, SQL, Markdown, and `.xlsx` from any data grid.
|
||||
- **Cancel long-running queries** — a per-connection cancel button instead of waiting it out or killing the app.
|
||||
- **SQLite table editor** — the visual Create Table / Edit Table flow now works on SQLite too (`INTEGER PRIMARY KEY AUTOINCREMENT` instead of `serial`).
|
||||
- **Full MySQL + SQLite browsing** — connect, browse, query, and edit MySQL and SQLite the same way you do PostgreSQL.
|
||||
- **Full object explorer** — not just tables, but functions, triggers, sequences, enums, extensions, materialized views, and procedures.
|
||||
- **Interactive ER diagram** — explore relationships visually with crow's-foot cardinality notation.
|
||||
@@ -82,6 +86,15 @@ Gridline is built for developers and small teams who manage multiple database en
|
||||
|
||||
## Recent Changes
|
||||
|
||||
<details>
|
||||
<summary>View the full changelog</summary>
|
||||
|
||||
- **v0.7.8** — **SQLite `.dump` backup/restore** plus **backup / restore / sync for MySQL & SQLite** (mysqldump-based, system-first); **Excel (.xlsx) export** in the grid toolbar alongside CSV/JSON/SQL/Markdown; **cancel long-running queries** per connection; **settings export/import** (JSON); **Windows/Linux title bar fix** (macOS-only overlay drag strip); **SQLite table editor** (Create/Edit Table with SQLite-aware type mapping); version bump.
|
||||
|
||||
- **v0.7.7** — PostgreSQL **roles & grants** management (create/edit/drop roles with attributes + a per-role privilege explorer across tables/sequences/routines/schemas/databases — collapsible grouped lists with GRANT/REVOKE staging) and **table maintenance** (VACUUM / ANALYZE / REINDEX from the table menu). **Create Table / Edit Table** visual editor: columns grid with type dropdowns and drag-to-reorder, single + composite primary keys, column-diff staging (ADD / DROP / RENAME COLUMN, ALTER TYPE, SET | DROP DEFAULT, SET | DROP NOT NULL — one statement per queue item), atomic column-reorder table rebuild (single transaction, preserves constraints/indexes/FKs/grants/sequences, fail-closed for triggers/RLS/inheritance/partitioning). **FK management** — multi-column composer with cross-schema references and ON DELETE/UPDATE, inlined into CREATE TABLE; **table options** (tablespace, row-level security); changes queue auto-refreshes the object tree after schema-modifying commits; version bump.
|
||||
|
||||
- **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)**.
|
||||
@@ -94,12 +107,17 @@ Gridline is built for developers and small teams who manage multiple database en
|
||||
|
||||
> See the full history in the [roadmap](#roadmap) below or the [git log](./commits).
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
### Connections & Workspace
|
||||
|
||||
<details>
|
||||
<summary>Show features</summary>
|
||||
|
||||
- **URI auto-fill** — paste `postgres://`, `mysql://`, `sqlite://`, or `redis://` strings and have all fields populate automatically.
|
||||
- **Provider grid** — pick PostgreSQL, MySQL, SQLite, Redis, Supabase, or NeonDB; managed presets surface in-app setup guides and an SSL hint.
|
||||
- **Workspace tree** — multi-level folders, color-coded tags, favorites, and recent connections.
|
||||
@@ -108,8 +126,13 @@ Gridline is built for developers and small teams who manage multiple database en
|
||||
- **TLS / SSL** — full PostgreSQL/MySQL TLS modes plus client-certificate support.
|
||||
- **Connection status** — on-demand per-card test with real server version and latency.
|
||||
|
||||
</details>
|
||||
|
||||
### Schema Explorer
|
||||
|
||||
<details>
|
||||
<summary>Show features</summary>
|
||||
|
||||
- **Tables, views, and materialized views** — column metadata, PK/FK, defaults, nullable flags.
|
||||
- **Functions & procedures** — syntax-highlighted source, argument signatures, overload disambiguation.
|
||||
- **Triggers, sequences, enums, extensions** — unified **Objects** view with type switcher.
|
||||
@@ -119,9 +142,17 @@ Gridline is built for developers and small teams who manage multiple database en
|
||||
- **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.
|
||||
- **Table creator & editor** — build or alter tables from a visual columns grid: type dropdowns, drag-to-reorder, single/composite primary keys, defaults, nullability — with a live SQL preview and column-diff staging.
|
||||
- **FK management** — create/edit/drop foreign keys with cross-schema references and ON DELETE/UPDATE actions, inlined into CREATE TABLE.
|
||||
- **Table options** — tablespace and row-level security per table.
|
||||
|
||||
</details>
|
||||
|
||||
### Data Grid
|
||||
|
||||
<details>
|
||||
<summary>Show features</summary>
|
||||
|
||||
- **Virtualized rows** — handles 100k+ rows via `@tanstack/react-virtual`.
|
||||
- **Server-side filtering & sorting** — pushed to SQL `WHERE`/`ORDER BY`.
|
||||
- **FK preview** — click the ↗ icon on a foreign-key cell to inspect the referenced row or open a filtered tab.
|
||||
@@ -132,8 +163,13 @@ Gridline is built for developers and small teams who manage multiple database en
|
||||
- **Export** — JSON, CSV, SQL, and Markdown downloads of visible rows.
|
||||
- **Auto-refresh** — configurable interval timer.
|
||||
|
||||
</details>
|
||||
|
||||
### Query Workbench
|
||||
|
||||
<details>
|
||||
<summary>Show features</summary>
|
||||
|
||||
- **Monaco SQL editor** — lazy-loaded, with keywords + table/column autocomplete.
|
||||
- **Custom query execution** — arbitrary SQL with destructive-query confirmation.
|
||||
- **Query tabs** — unlimited tabs, close with `Cmd/Ctrl+W`.
|
||||
@@ -141,12 +177,24 @@ Gridline is built for developers and small teams who manage multiple database en
|
||||
- **Saved queries** — name, folder, and manage them in the Queries view.
|
||||
- **Changes queue** — stage edits, review generated SQL, revert per change, then commit all.
|
||||
|
||||
</details>
|
||||
|
||||
### PostgreSQL Admin Tools
|
||||
|
||||
- **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.
|
||||
<details>
|
||||
<summary>Show features</summary>
|
||||
|
||||
- **Visual Backup** — `pg_dump` (format selector, schema filter, no-owner), `mysqldump`, and SQLite `.dump` wrappers with real-time progress.
|
||||
- **Visual Restore** — `pg_restore` / `mysql` / SQLite restore with clean toggle and destructive confirmation.
|
||||
- **DB-to-DB Sync** — pipe dump → restore between two live connections (PostgreSQL, MySQL, and SQLite).
|
||||
- **Bundled client tools** — `pg_dump`/`pg_restore`/`psql` plus `mariadb-dump`/`mariadb` ship with the app; system tools are preferred when present, bundled tools are the fallback.
|
||||
- **Roles & grants** — create/edit/drop roles with attributes; a per-role privilege explorer grouped by object class (tables, sequences, routines, schemas, databases) with collapsible lists and GRANT/REVOKE staging.
|
||||
- **Table maintenance** — VACUUM, ANALYZE, and REINDEX from the table menu.
|
||||
- **Excel export** — hand-rolled `.xlsx` writer (inline strings, formula-injection safe) alongside CSV/JSON/SQL/Markdown in the grid toolbar and table menu.
|
||||
- **Cancel long-running queries** — PG cancel request / MySQL `KILL QUERY` / SQLite interrupt, wired to the toolbar Cancel button.
|
||||
- **Settings export / import** — share theme, accent, editor options, page sizes, and shortcuts across machines (JSON).
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
@@ -198,7 +246,7 @@ Gridline is built for developers and small teams who manage multiple database en
|
||||
Capabilities below are fact-checked against each vendor's official docs and pricing (May 2026).
|
||||
|
||||
| Capability | DB Pro (Free) | Beekeeper (Free) | TablePlus (Free) | Gridline |
|
||||
| :----------------------------- | :--------------------------: | :-----------------------------: | :-------------------------: | :--------------------------------------------------------: |
|
||||
| :----------------------------- | :---------------------------: | :-------------------------------: | :----------------------------------: | :-----------------------------------------------------------------: |
|
||||
| Open tabs | 3 | Unlimited | 2 | **Unlimited** |
|
||||
| Saved connections | 2 | Unlimited | Unlimited | **Unlimited** |
|
||||
| Saved queries | 5 | Unlimited | Unlimited | **Unlimited** |
|
||||
@@ -214,14 +262,14 @@ Capabilities below are fact-checked against each vendor's official docs and pric
|
||||
| Desktop shell size | Electron | Electron (~250 MB) | Native | **Tauri 2.0 (~40 MB)** |
|
||||
| Open source | ❌ | ✅ GPLv3 | ❌ | **✅ Apache 2.0** |
|
||||
|
||||
*Notes: DB Pro is an Electron app (launched Nov 2025) whose marketing copy overclaims — its free plan caps connections/tabs/saved queries (FAQ inconsistently claims "unlimited local connections") and gates data imports + SSH tunneling to paid, though CSV/JSON export does work on the free tier; Beekeeper's free Community edition genuinely offers unlimited tabs/connections/queries but gates backup/restore, file import, multi-table export, ERD, AI, and premium DB connectors behind paid tiers; TablePlus's free tier includes every feature but caps you at 2 open tabs / 2 windows / 2 advanced filters.*
|
||||
_Notes: DB Pro is an Electron app (launched Nov 2025) whose marketing copy overclaims — its free plan caps connections/tabs/saved queries (FAQ inconsistently claims "unlimited local connections") and gates data imports + SSH tunneling to paid, though CSV/JSON export does work on the free tier; Beekeeper's free Community edition genuinely offers unlimited tabs/connections/queries but gates backup/restore, file import, multi-table export, ERD, AI, and premium DB connectors behind paid tiers; TablePlus's free tier includes every feature but caps you at 2 open tabs / 2 windows / 2 advanced filters._
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology | Role |
|
||||
| :------------------ | :-------------------------------------------------------------------------------------------------------- | :----------------------------------------------- |
|
||||
| :------------------ | :-------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------- |
|
||||
| **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 |
|
||||
@@ -255,16 +303,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. The links below always point at the newest build (the asset names are version-free, so they never go stale):
|
||||
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.8**):
|
||||
|
||||
| Your system | Download this | Notes |
|
||||
| :--- | :--- | :--- |
|
||||
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_darwin_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_darwin_aarch64.dmg) | `aarch64` = Apple's own chip |
|
||||
| macOS **Intel** | [Gridline_darwin_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_darwin_x64.dmg) | `x64` = Intel/AMD |
|
||||
| **Windows** (most PCs) | [Gridline_windows_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_windows_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
|
||||
| **Debian / Ubuntu** | [Gridline_linux_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_linux_amd64.deb) | Install: `sudo apt install ./Gridline_linux_amd64.deb` |
|
||||
| **Fedora / RHEL / openSUSE** | [Gridline_linux_x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_linux_x86_64.rpm) | Install: `sudo dnf install Gridline_linux_x86_64.rpm` |
|
||||
| **Any other Linux** | [Gridline_linux_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_linux_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it |
|
||||
| :------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- |
|
||||
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.7.8_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_aarch64.dmg) | `aarch64` = Apple's own chip |
|
||||
| macOS **Intel** | [Gridline_0.7.8_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_x64.dmg) | `x64` = Intel/AMD |
|
||||
| **Windows** (most PCs) | [Gridline_0.7.8_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
|
||||
| **Debian / Ubuntu** | [Gridline_0.7.8_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.8_amd64.deb` |
|
||||
| **Fedora / RHEL / openSUSE** | [Gridline-0.7.8-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline-0.7.8-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.8-1.x86_64.rpm` |
|
||||
| **Any other Linux** | [Gridline_0.7.8_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_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.
|
||||
|
||||
@@ -274,13 +322,13 @@ Cutting a release is one command — CI builds everything. **Releases are cut fr
|
||||
|
||||
```bash
|
||||
git checkout prod && git pull
|
||||
git tag v0.7.5
|
||||
git push origin v0.7.5
|
||||
git tag v0.7.8
|
||||
git push origin v0.7.8
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -359,14 +407,14 @@ gridline/
|
||||
|
||||
## Roadmap
|
||||
|
||||
The full plan — in-development (v0.7.5), next-up, queue, and shipped history — lives in **[ROADMAP.md](./ROADMAP.md)**.
|
||||
The full plan — next-up (v0.7.9), 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 (schema CRUD, global object search, copy-as-DDL, and object dependencies shipped in v0.7.5)
|
||||
- **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.5)
|
||||
- **Managed DB support** — PlanetScale, Turso (Supabase/Neon presets shipped in v0.7.0)
|
||||
- **AI integration (BYOK)** — natural-language → SQL, chat, summaries, charts
|
||||
|
||||
✅ **[View the full roadmap →](./ROADMAP.md)**
|
||||
|
||||
+60
-56
@@ -6,61 +6,20 @@ This file is the **source of truth** for what Gridline is building. [AGENTS.md](
|
||||
|
||||
---
|
||||
|
||||
## ✅ Shipped (0.7.5)
|
||||
## 🎯 Next up (0.7.9) — TBD
|
||||
|
||||
- **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**.
|
||||
Scope to be determined.
|
||||
|
||||
## ✅ Shipped (0.7.0)
|
||||
## ✅ Shipped (0.7.8)
|
||||
|
||||
- **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.
|
||||
- **Full MySQL DB viewer support** — connect (incl. SSL + SSH), browse databases/tables/columns/FKs, run queries, paginate, inline cell editing + changes queue (insert/update/delete/bulk/empty/drop), DDL copy (`SHOW CREATE TABLE`), CSV/JSON import.
|
||||
- **DB viewer capability gating** — pure `dbCapabilities.ts` matrix per DB type; unsupported views show a clean "not supported" state instead of broken UI. Redis browsing explicitly gated off.
|
||||
- **Supabase & NeonDB managed-PG presets** — provider cards with in-app setup instructions; persist as `postgresql` with an SSL hint.
|
||||
- **SQLite file-path mode** — URI field becomes a file-path input with `Browse…`.
|
||||
- **Tag overflow scroll** — connection cards show ≤3 tags, then scroll horizontally.
|
||||
- **Input styling sweep** — `rounded-full` → `rounded-lg` on all form controls.
|
||||
- **Version bump** 0.6.0 → **0.7.0**.
|
||||
|
||||
**Not in 0.7.0** (deferred, tracked below): Redis key browsing, MySQL Objects/ERD views, backup/restore/sync for MySQL + SQLite, multiple result sets, SSH key-file management, settings import/export, onboarding tour.
|
||||
|
||||
## 🎯 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
|
||||
|
||||
### Admin follow-up (after object management)
|
||||
|
||||
- **PostgreSQL users/roles + grants management** — create roles and set privileges from a UI (DB Pro has this at 0% on their roadmap — a differentiator to hold)
|
||||
- **Maintenance actions** — right-click table → VACUUM / ANALYZE / REINDEX
|
||||
|
||||
### Connection & credentials
|
||||
|
||||
- **Wire up the "Enable Keychain" toggle** — currently a form-only placeholder: the flag is submitted and stored with the connection record, but the Rust backend never reads it and the frontend store unconditionally calls `saveConnectionPassword`. Decide the intended behavior (e.g. off = store the password with the connection record / don't persist at all, on = OS keychain as today) and implement the conditional path + migration for existing records.
|
||||
|
||||
### DB viewer: ⌘K object search (0.7.6)
|
||||
|
||||
- **Fix: clicking a search result does nothing** — reported from 0.7.5 testing. `ObjectSearchPalette.handleSelect` sets store state but the UI doesn't react:
|
||||
- *Non-table results* (function/enum/sequence/…) set `selectedObjectType` but **never switch the view to the Objects page** — the palette needs to also call the view switch (e.g. `setCurrentView("objects")` or the DbViewerScreen equivalent) so `ObjectExplorerPage` mounts and consumes `selectedObjectType`.
|
||||
- *Table/view results* call `openTab(schema, name)` but nothing visibly happens from the Explorer — verify `openTab` reaches the tab system from the palette's context (tab store + active-view wiring) and opens the tab.
|
||||
- Add component tests that render the palette inside DbViewerScreen (or a harness) and assert the view/tab actually changes on click.
|
||||
- **Add arrow-key navigation + Enter to pick** — currently mouse-click only. Move the highlighted row with ↑/↓ (wrapping), Enter selects the highlighted hit (same `handleSelect` path), Esc still closes. Optional: home/end + typeahead on the result list.
|
||||
- **SQLite `.dump` support** — match the pg_dump UX for SQLite (backup a SQLite database to a portable SQL dump, restore it back)
|
||||
- **Backup / Restore / Sync for MySQL & SQLite** — extend the pg-only tooling today: SQLite backup/restore + MySQL via `mysqldump` (system-first; decide bundling)
|
||||
- **Excel (.xlsx) export** — alongside CSV/JSON/SQL/Markdown in the grid export toolbar
|
||||
- **Cancel long-running queries** — per-connection cancel button (`pg_cancel_backend` and equivalents) instead of waiting or killing the app
|
||||
- **Settings export / import** — share theme, accent, editor options, page sizes, and defaults across machines (JSON file)
|
||||
- **Windows/Linux title bar fix** — the macOS "Overlay" drag strip (`h-7` in `App.tsx`) renders on every Tauri platform, so Windows/Linux show a blank grabbable bar between the native title bar and the page; gate the strip to macOS only (native title bar already handles dragging elsewhere)
|
||||
- **SQLite table editor (Create/Edit Table)** — the visual Create Table / Edit Table flow is PostgreSQL-only today (`tableManagement` capability + PG-flavored SQL gen in `TableForm`); extend to SQLite: SQLite-aware type mapping (no `serial` — `INTEGER PRIMARY KEY AUTOINCREMENT` instead), `TEXT`/`REAL`/`BLOB`, and ALTER TABLE limits (`ADD COLUMN` can't add PK/UNIQUE, `DROP COLUMN` needs SQLite ≥3.35)
|
||||
- **Version bump** 0.7.7 → **0.7.8**.
|
||||
|
||||
## 📋 In the queue
|
||||
|
||||
@@ -90,17 +49,14 @@ Supabase and NeonDB presets shipped in v0.7.0. Remaining candidates:
|
||||
### Query Workbench Upgrades
|
||||
|
||||
- **Multiple result sets** — one query, multiple result tabs (stacked/scrollable) instead of only the last result *(deferred from 0.7.0)*
|
||||
- **Cancel long-running queries** — per-connection cancel button (`pg_cancel_backend` and equivalents) instead of waiting or killing the app
|
||||
- **Result streaming to file** — export 500k+ rows without loading them all into memory
|
||||
- **Visual query builder** — drag-and-drop tables/joins/filters that generate SQL (TablePlus has one; DB Pro plans one)
|
||||
|
||||
### Schema & Data Tooling
|
||||
|
||||
- **SQLite `.dump` support** — match the pg_dump UX for SQLite
|
||||
- **Schema diff / compare** — two-database structure diff that pairs naturally with DB-to-DB sync
|
||||
- **MySQL Objects view + schema visualizer** — functions/triggers/sequences/enums/extensions browsing and ER diagram for MySQL *(deferred from 0.7.0 and out of scope for the object-management release — PostgreSQL-only for now)*
|
||||
- **Backup/Restore/Sync for MySQL & SQLite** — pg_dump tooling is PostgreSQL-only today *(deferred from 0.7.0)*
|
||||
- **More export formats** — Excel (.xlsx), JSONL, Parquet alongside CSV/JSON/SQL/Markdown
|
||||
- **More export formats** — JSONL, Parquet alongside CSV/JSON/SQL/Markdown/Excel
|
||||
|
||||
## 🔮 Planned
|
||||
|
||||
@@ -140,8 +96,56 @@ Deferred from 0.7.0, slated for this bucket:
|
||||
|
||||
---
|
||||
|
||||
## ✅ Shipped (0.7.7)
|
||||
|
||||
### Admin follow-up
|
||||
|
||||
- **PostgreSQL users/roles + grants management** — create/edit/drop roles with attributes; a per-role **privilege explorer** grouped by object class (tables, sequences, routines, schemas, databases) with collapsible sections (5-entry preview + fade, expand to show all) and GRANT/REVOKE staging through the changes queue (DB Pro has this at 0% on their roadmap — a differentiator to hold)
|
||||
- **Maintenance actions** — right-click table → VACUUM / ANALYZE / REINDEX
|
||||
|
||||
### Table & relationship management
|
||||
|
||||
- **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, type dropdowns with shorthand PG types, drag-to-reorder, add/remove rows) with a live Monaco SQL preview; multi-column PRIMARY KEY on create
|
||||
- **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`
|
||||
- **FK management** — multi-column FK composer (local column → referenced column pairs, referenced PKs first, type-match preview) with cross-schema references and ON DELETE / ON UPDATE actions; inlined into CREATE TABLE in create mode, separate ALTER staging in edit mode; FK rows in the form with Edit/Remove
|
||||
- **Related niceties** — column reordering via atomic table rebuild (single transaction, preserves constraints/indexes/FKs/grants/sequences, fail-closed for triggers/RLS/inheritance/partitioning), table options (tablespace, row-level security), changes queue auto-refreshes the object tree after schema-modifying commits
|
||||
- **Version bump** 0.7.6 → **0.7.7**.
|
||||
|
||||
## ✅ 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.
|
||||
- **Full MySQL DB viewer support** — connect (incl. SSL + SSH), browse databases/tables/columns/FKs, run queries, paginate, inline cell editing + changes queue (insert/update/delete/bulk/empty/drop), DDL copy (`SHOW CREATE TABLE`), CSV/JSON import.
|
||||
- **DB viewer capability gating** — pure `dbCapabilities.ts` matrix per DB type; unsupported views show a clean "not supported" state instead of broken UI. Redis browsing explicitly gated off.
|
||||
- **Supabase & NeonDB managed-PG presets** — provider cards with in-app setup instructions; persist as `postgresql` with an SSL hint.
|
||||
- **SQLite file-path mode** — URI field becomes a file-path input with `Browse…`.
|
||||
- **Tag overflow scroll** — connection cards show ≤3 tags, then scroll horizontally.
|
||||
- **Input styling sweep** — `rounded-full` → `rounded-lg` on all form controls.
|
||||
- **Version bump** 0.6.0 → **0.7.0**.
|
||||
|
||||
**Not in 0.7.0** (deferred, tracked above): Redis key browsing, MySQL Objects/ERD views, backup/restore/sync for MySQL + SQLite, multiple result sets, SSH key-file management, settings import/export, onboarding tour.
|
||||
|
||||
## ✅ 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)
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"dagre": "^0.8.5",
|
||||
"fflate": "^0.8.3",
|
||||
"html-to-image": "^1.11.13",
|
||||
"lucide-react": "^1.26.0",
|
||||
"monaco-editor": "^0.56.0",
|
||||
@@ -461,6 +462,8 @@
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
|
||||
|
||||
"framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# v0.7.7 — Manual Smoke Checklist (release gate)
|
||||
|
||||
> Run against a **scratch PostgreSQL** instance. Mark each item only after verifying it live in the app. This checklist gates the release after `feat/v0.7.7-next-up` squash-merges to `prod` and before tagging `v0.7.7`. Source: architectural spec §8 (verbatim).
|
||||
|
||||
## Roles & grants
|
||||
|
||||
- [ ] Create a role with all options (LOGIN, SUPERUSER, CREATEDB, CREATEROLE, INHERIT, REPLICATION, BYPASSRLS, CONNECTION LIMIT, VALID UNTIL, PASSWORD) + a membership grant to another role.
|
||||
- [ ] Grant / revoke each privilege class (table, sequence, routine, schema, database) from the role detail grants editor.
|
||||
- [ ] Drop a role that has dependencies — the confirm dialog lists the dependencies before executing.
|
||||
|
||||
## Tables & relationships
|
||||
|
||||
- [ ] Create a table with a multi-column PRIMARY KEY.
|
||||
- [ ] Edit a table: rename a column, add a column, drop a column, change a column type, change default, toggle nullable — each change stages as its own queue item with an `(i/n)` description.
|
||||
- [ ] Reorder columns → rebuild succeeds (single transaction: constraints, indexes, FKs both directions, grants, owned sequences preserved).
|
||||
- [ ] Reorder columns on a trigger-bearing table → rebuild refused with the blocker copy (fail-closed).
|
||||
- [ ] Set tablespace + row-level security via table options.
|
||||
- [ ] Create a cross-schema FK with ON DELETE CASCADE (referenced table picked from another schema).
|
||||
- [ ] Edit an FK → 2-item commit (drop + re-add) lands cleanly.
|
||||
|
||||
## Maintenance
|
||||
|
||||
- [ ] VACUUM, ANALYZE, REINDEX run on a table via the overflow menu — confirm dialog shows the lock-warning copy; success reports duration.
|
||||
|
||||
## Regression
|
||||
|
||||
- [ ] Demo SQLite database opens and behaves unaffected.
|
||||
- [ ] Stale-guard: after an external `ALTER TABLE`, editing the table in the app blocks staging with the stale guard.
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gridline",
|
||||
"private": true,
|
||||
"version": "0.7.5",
|
||||
"version": "0.7.8",
|
||||
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -28,6 +28,7 @@
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"dagre": "^0.8.5",
|
||||
"fflate": "^0.8.3",
|
||||
"html-to-image": "^1.11.13",
|
||||
"lucide-react": "^1.26.0",
|
||||
"monaco-editor": "^0.56.0",
|
||||
|
||||
Generated
+1
-1
@@ -1783,7 +1783,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridline"
|
||||
version = "0.7.5"
|
||||
version = "0.7.8"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deadpool-postgres",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "gridline"
|
||||
version = "0.7.5"
|
||||
version = "0.7.8"
|
||||
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Bundled MariaDB client tools
|
||||
|
||||
Static `mariadb-dump` and `mariadb` client binaries (wire-compatible with MySQL
|
||||
servers) go here. They are NOT committed — `.github/workflows/release.yml`
|
||||
builds and checksum-verifies them per matrix target, copying the result into
|
||||
`resources/mysql_tools/{mariadb-dump,mariadb}` (plus `mariadb-dump.exe` /
|
||||
`mariadb.exe` and the runtime `libmariadb.dll` on Windows). The app resolves
|
||||
tools system-first and falls back to these bundled binaries (see
|
||||
`backup::resolve_mysql_tool`).
|
||||
|
||||
In `tauri dev`, this dir is usually empty — the app falls back to any
|
||||
`mariadb-dump`/`mariadb` on `PATH` (system-first resolution). MySQL
|
||||
backup/restore degrades with a clear error if neither is present.
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Per-connection cancellation handles, stored independently of the pool
|
||||
//! lock so `cancel_query` can dispatch while a long query holds the pool mutex.
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use rusqlite::InterruptHandle;
|
||||
use sqlx::mysql::MySqlConnectOptions;
|
||||
use tokio_postgres::CancelToken;
|
||||
|
||||
use crate::db::tls::TlsDecision;
|
||||
|
||||
/// PostgreSQL cancel data. `cancel_token` stores the socket address (incl.
|
||||
/// SSH tunnel endpoint) so `cancel_query` needs no host. `tls_config` is the
|
||||
/// rustls config used to **build** the connection (or `None` for NoTls) so the
|
||||
/// cancel connection reuses the exact same TLS decision.
|
||||
#[derive(Clone)]
|
||||
pub struct PgCancel {
|
||||
pub cancel_token: CancelToken,
|
||||
pub tls_decision: TlsDecision,
|
||||
pub tls_config: Option<std::sync::Arc<rustls::ClientConfig>>,
|
||||
}
|
||||
|
||||
/// MySQL cancel data. `conn_id` is the `CONNECTION_ID()` of the dedicated
|
||||
/// connection currently running a query (one active per connection because the
|
||||
/// pool lock serializes queries). `connect_options` lets `cancel` open a
|
||||
/// brand-new connection (bypassing the pool) to run `KILL QUERY ?`.
|
||||
#[derive(Clone)]
|
||||
pub struct MySqlCancel {
|
||||
pub conn_id: Option<i64>,
|
||||
pub connect_options: MySqlConnectOptions,
|
||||
}
|
||||
|
||||
/// SQLite cancel data — a cloneable, thread-safe interrupt handle.
|
||||
/// (`InterruptHandle` itself is not `Clone` in rusqlite 0.31, so it's kept
|
||||
/// behind an `Arc`.)
|
||||
#[derive(Clone)]
|
||||
pub struct SqliteCancel {
|
||||
handle: Arc<InterruptHandle>,
|
||||
}
|
||||
|
||||
impl SqliteCancel {
|
||||
pub fn new(handle: InterruptHandle) -> Self {
|
||||
Self { handle: Arc::new(handle) }
|
||||
}
|
||||
pub fn interrupt(&self) {
|
||||
self.handle.interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum CancelHandle {
|
||||
Pg(PgCancel),
|
||||
MySql(MySqlCancel),
|
||||
Sqlite(SqliteCancel),
|
||||
}
|
||||
|
||||
/// Send + Sync registry keyed by connection id. `cancel_query` takes only
|
||||
/// this `Mutex` (NOT the pool lock).
|
||||
#[derive(Default)]
|
||||
pub struct CancelRegistry {
|
||||
map: Mutex<HashMap<String, CancelHandle>>,
|
||||
}
|
||||
|
||||
impl CancelRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn set_pg(&self, id: &str, c: PgCancel) {
|
||||
self.set(id, CancelHandle::Pg(c));
|
||||
}
|
||||
pub fn set_mysql(&self, id: &str, c: MySqlCancel) {
|
||||
self.set(id, CancelHandle::MySql(c));
|
||||
}
|
||||
pub fn set_sqlite(&self, id: &str, c: SqliteCancel) {
|
||||
self.set(id, CancelHandle::Sqlite(c));
|
||||
}
|
||||
pub fn set_mysql_conn_id(&self, id: &str, conn_id: Option<i64>) {
|
||||
let mut g = self.map.lock().unwrap();
|
||||
if let Some(CancelHandle::MySql(m)) = g.get_mut(id) {
|
||||
m.conn_id = conn_id;
|
||||
}
|
||||
}
|
||||
fn set(&self, id: &str, h: CancelHandle) {
|
||||
self.map.lock().unwrap().insert(id.to_string(), h);
|
||||
}
|
||||
pub fn get(&self, id: &str) -> Option<CancelHandle> {
|
||||
self.map.lock().unwrap().get(id).cloned()
|
||||
}
|
||||
pub fn remove(&self, id: &str) {
|
||||
self.map.lock().unwrap().remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sqlite_insert_and_drain() {
|
||||
let reg = CancelRegistry::new();
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
let handle = conn.get_interrupt_handle();
|
||||
reg.set_sqlite("c1", SqliteCancel::new(handle));
|
||||
assert!(matches!(reg.get("c1"), Some(CancelHandle::Sqlite(_))));
|
||||
reg.remove("c1");
|
||||
assert!(reg.get("c1").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_interrupt_aborts_running_query() {
|
||||
use std::sync::{Arc, Mutex};
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch("CREATE TABLE t(n); INSERT INTO t VALUES (0);")
|
||||
.unwrap();
|
||||
let handle = conn.get_interrupt_handle();
|
||||
let conn2 = Arc::new(Mutex::new(conn));
|
||||
let c = conn2.clone();
|
||||
let done = Arc::new(Mutex::new(None::<Result<usize, String>>));
|
||||
let d = done.clone();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let l = c.lock().unwrap();
|
||||
// `query()` binds params only — the first sqlite3_step (where the
|
||||
// interrupt lands) happens in `rs.next()`, so errors must be
|
||||
// propagated with `?` rather than swallowed by `is_ok()`.
|
||||
let r = l
|
||||
.prepare("WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM c LIMIT 200000000) SELECT count(*) FROM c")
|
||||
.unwrap()
|
||||
.query([])
|
||||
.and_then(|mut rs| {
|
||||
let mut n = 0;
|
||||
while rs.next()?.is_some() {
|
||||
n += 1;
|
||||
}
|
||||
Ok(n)
|
||||
});
|
||||
*d.lock().unwrap() = Some(r.map_err(|e| e.to_string()));
|
||||
});
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
handle.interrupt();
|
||||
worker.join().unwrap();
|
||||
let outcome = done.lock().unwrap().clone();
|
||||
assert!(
|
||||
matches!(&outcome, Some(Err(e)) if e.to_lowercase().contains("interrupted")),
|
||||
"cancelled query must report interrupted; got {outcome:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_overwrites_single_active_slot() {
|
||||
let reg = CancelRegistry::new();
|
||||
reg.set_mysql("c1", MySqlCancel { conn_id: Some(1), connect_options: fake_opts() });
|
||||
reg.set_mysql("c1", MySqlCancel { conn_id: Some(2), connect_options: fake_opts() });
|
||||
match reg.get("c1") {
|
||||
Some(CancelHandle::MySql(m)) => assert_eq!(m.conn_id, Some(2)),
|
||||
_ => panic!("expected MySql"),
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_opts() -> sqlx::mysql::MySqlConnectOptions {
|
||||
sqlx::mysql::MySqlConnectOptions::new()
|
||||
.host("127.0.0.1").port(1).username("u").password("p").database("d")
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
use rusqlite::{types::ValueRef, Connection};
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
|
||||
@@ -317,6 +319,285 @@ pub fn run_db_sync(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SQLite dump / restore / sync (headless-testable core)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Quote a SQLite identifier with double quotes (preserving the spec's no-injection rule).
|
||||
fn sqlite_quote_ident(name: &str) -> String {
|
||||
format!("\"{}\"", name.replace('"', "\"\""))
|
||||
}
|
||||
|
||||
/// SQL literal for a rusqlite value (mirrors sqlite3 .dump output).
|
||||
fn sqlite_literal(v: ValueRef) -> String {
|
||||
match v {
|
||||
ValueRef::Null => "NULL".to_string(),
|
||||
ValueRef::Integer(i) => i.to_string(),
|
||||
ValueRef::Real(r) => r.to_string(),
|
||||
ValueRef::Text(t) => format!("'{}'", String::from_utf8_lossy(t).replace('\'', "''")),
|
||||
ValueRef::Blob(b) => {
|
||||
format!("X'{}'", b.iter().map(|x| format!("{:02x}", x)).collect::<String>())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a `.dump`-format SQL script from `conn`, streaming to `out`. Calls
|
||||
/// `on_progress(table_name)` per table. Fails closed on virtual tables.
|
||||
pub fn dump_sqlite_to<W: Write, F: FnMut(&str)>(
|
||||
conn: &Connection,
|
||||
out: &mut W,
|
||||
mut on_progress: F,
|
||||
) -> Result<(), String> {
|
||||
writeln!(out, "PRAGMA foreign_keys=OFF;").map_err(|e| e.to_string())?;
|
||||
writeln!(out, "BEGIN TRANSACTION;").map_err(|e| e.to_string())?;
|
||||
|
||||
// 1. Tables (schema + data), fail-closed on virtual tables.
|
||||
let table_names: Vec<String> = conn
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL ORDER BY name")
|
||||
.map_err(|e| e.to_string())?
|
||||
.query_map([], |r| r.get::<_, String>(0))
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
for name in &table_names {
|
||||
let create_sql: String = conn
|
||||
.query_row("SELECT sql FROM sqlite_master WHERE type='table' AND name=?1", [name], |r| {
|
||||
r.get::<_, String>(0)
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
if create_sql.to_lowercase().contains("create virtual table") {
|
||||
return Err(format!(
|
||||
"SQLite virtual tables are not supported for .dump in v0.7.8 (table: {name})"
|
||||
));
|
||||
}
|
||||
writeln!(out, "{create_sql};").map_err(|e| e.to_string())?;
|
||||
on_progress(name);
|
||||
|
||||
// Columns excluding generated/hidden (hidden != 0) via pragma_table_xinfo.
|
||||
let xinfo: Vec<(String, i64)> = conn
|
||||
.prepare(&format!(
|
||||
"SELECT name, hidden FROM pragma_table_xinfo(\"{}\")",
|
||||
name.replace('"', "\"\"")
|
||||
))
|
||||
.map_err(|e| e.to_string())?
|
||||
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
let emitted: Vec<String> = xinfo.iter().filter(|(_, h)| *h == 0).map(|(n, _)| n.clone()).collect();
|
||||
let col_list = emitted.iter().map(|n| sqlite_quote_ident(n)).collect::<Vec<_>>().join(", ");
|
||||
// Emit one INSERT per row (faithful to .dump).
|
||||
let select = format!(
|
||||
"SELECT {} FROM \"{}\"",
|
||||
emitted.iter().map(|n| sqlite_quote_ident(n)).collect::<Vec<_>>().join(", "),
|
||||
name.replace('"', "\"\"")
|
||||
);
|
||||
let mut stmt = conn.prepare(&select).map_err(|e| e.to_string())?;
|
||||
let nrows = stmt
|
||||
.query_map([], |row| {
|
||||
let vals: Vec<String> = (0..emitted.len())
|
||||
.map(|i| sqlite_literal(row.get_ref(i).unwrap_or(ValueRef::Null)))
|
||||
.collect();
|
||||
Ok(format!(
|
||||
"INSERT INTO {} ({}) VALUES ({});",
|
||||
sqlite_quote_ident(name),
|
||||
col_list,
|
||||
vals.join(", ")
|
||||
))
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
for r in nrows.filter_map(|r| r.ok()) {
|
||||
writeln!(out, "{r}").map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Indexes, triggers, views (sql not null).
|
||||
for ty in ["index", "trigger", "view"] {
|
||||
let sqls: Vec<String> = conn
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE type=?1 AND sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
.map_err(|e| e.to_string())?
|
||||
.query_map([ty], |r| r.get::<_, String>(0))
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
for s in sqls {
|
||||
writeln!(out, "{s};").map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. sqlite_sequence (AUTOINCREMENT counters) if it exists.
|
||||
if conn
|
||||
.prepare("SELECT name FROM sqlite_master WHERE name='sqlite_sequence'")
|
||||
.map_err(|e| e.to_string())?
|
||||
.exists([])
|
||||
.unwrap_or(false)
|
||||
{
|
||||
writeln!(out, "DELETE FROM sqlite_sequence;").map_err(|e| e.to_string())?;
|
||||
let rows: Vec<(String, i64)> = conn
|
||||
.prepare("SELECT name, seq FROM sqlite_sequence")
|
||||
.map_err(|e| e.to_string())?
|
||||
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
for (t, seq) in rows {
|
||||
writeln!(
|
||||
out,
|
||||
"INSERT INTO sqlite_sequence VALUES ('{}', {});",
|
||||
t.replace('\'', "''"),
|
||||
seq
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(out, "COMMIT;").map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restore a `.dump` SQL script into `conn`. `clean` drops existing
|
||||
/// user tables/views/indexes/triggers first.
|
||||
pub fn restore_sqlite(conn: &Connection, dump_text: &str, clean: bool) -> Result<(), String> {
|
||||
if clean {
|
||||
let names: Vec<String> = conn
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type IN ('table','view','index','trigger') AND name NOT LIKE 'sqlite_%' ORDER BY type DESC")
|
||||
.map_err(|e| e.to_string())?
|
||||
.query_map([], |r| r.get::<_, String>(0))
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
conn.execute_batch("PRAGMA foreign_keys=OFF;").map_err(|e| e.to_string())?;
|
||||
for n in names {
|
||||
let _ = conn.execute(&format!("DROP TABLE IF EXISTS \"{}\"", n.replace('"', "\"\"")), []);
|
||||
let _ = conn.execute(&format!("DROP VIEW IF EXISTS \"{}\"", n.replace('"', "\"\"")), []);
|
||||
let _ = conn.execute(&format!("DROP INDEX IF EXISTS \"{}\"", n.replace('"', "\"\"")), []);
|
||||
let _ = conn.execute(&format!("DROP TRIGGER IF EXISTS \"{}\"", n.replace('"', "\"\"")), []);
|
||||
}
|
||||
}
|
||||
// The dump text already wraps in PRAGMA foreign_keys=OFF + BEGIN/COMMIT.
|
||||
conn.execute_batch(dump_text).map_err(|e| format!("restore failed: {e}"))
|
||||
}
|
||||
|
||||
/// Dump `source_path` and restore into `target_path` (one-shot).
|
||||
pub fn run_sqlite_sync(source_path: &str, target_path: &str) -> Result<(), String> {
|
||||
let src = Connection::open(source_path).map_err(|e| format!("open source: {e}"))?;
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
dump_sqlite_to(&src, &mut std::io::Cursor::new(&mut buf), |_| {})?;
|
||||
let text = String::from_utf8(buf).map_err(|e| e.to_string())?;
|
||||
let dst = Connection::open(target_path).map_err(|e| format!("open target: {e}"))?;
|
||||
restore_sqlite(&dst, &text, true)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MySQL dump / restore / sync (headless-testable core)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn base_mysql_args(conn: &MySqlConnParams) -> Vec<String> {
|
||||
vec![
|
||||
format!("--host={}", conn.host),
|
||||
format!("--port={}", conn.port),
|
||||
format!("--user={}", conn.username),
|
||||
]
|
||||
}
|
||||
|
||||
/// `mariadb-dump`/`mysqldump` args. Passwords go via MYSQL_PWD env (set by the
|
||||
/// command), NEVER --password (process-list visibility).
|
||||
pub fn build_mysql_dump_args(conn: &MySqlConnParams, options: &MySqlBackupOptions) -> Vec<String> {
|
||||
let mut a = base_mysql_args(conn);
|
||||
if options.single_transaction { a.push("--single-transaction".into()); }
|
||||
if options.no_data { a.push("--no-data".into()); }
|
||||
if options.routines { a.push("--routines".into()); }
|
||||
if options.triggers { a.push("--triggers".into()); }
|
||||
if options.events { a.push("--events".into()); }
|
||||
a.push(format!("--databases={}", options.database));
|
||||
a.push(format!("--result-file={}", options.file_path));
|
||||
a.push("--skip-column-statistics".into());
|
||||
a
|
||||
}
|
||||
|
||||
pub fn build_mysql_restore_args(conn: &MySqlConnParams, options: &MySqlRestoreOptions) -> Vec<String> {
|
||||
let mut a = base_mysql_args(conn);
|
||||
a.push(format!("--database={}", options.database));
|
||||
a
|
||||
}
|
||||
|
||||
/// System-first mariadb-dump/mariadb (bundled fallback in resources/mysql_tools).
|
||||
pub fn resolve_mysql_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("mysql_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_mysql_tool_paths(app: &AppHandle) -> MySqlToolPaths {
|
||||
let (d, _) = resolve_mysql_tool(app, "mariadb-dump");
|
||||
let (m, _) = resolve_mysql_tool(app, "mariadb");
|
||||
MySqlToolPaths { mysqldump: d, mysql: m }
|
||||
}
|
||||
|
||||
/// Headless core: spawn dump with MYSQL_PWD env. `tls_mode` (e.g. `REQUIRED`)
|
||||
/// is appended as `--ssl-mode=` when routing through an SSH tunnel. (Live
|
||||
/// behavior = #[ignore] integration test.)
|
||||
pub fn run_mysql_dump(
|
||||
conn: &MySqlConnParams,
|
||||
options: &MySqlBackupOptions,
|
||||
tools: &MySqlToolPaths,
|
||||
tls_mode: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let mut args = build_mysql_dump_args(conn, options);
|
||||
if let Some(m) = tls_mode {
|
||||
args.push(format!("--ssl-mode={m}"));
|
||||
}
|
||||
let out = Command::new(&tools.mysqldump).env("MYSQL_PWD", &conn.password).args(&args).output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if out.status.success() { Ok(()) } else { Err(sanitize_error(&String::from_utf8_lossy(&out.stderr))) }
|
||||
}
|
||||
|
||||
pub fn run_mysql_restore(
|
||||
conn: &MySqlConnParams,
|
||||
options: &MySqlRestoreOptions,
|
||||
tools: &MySqlToolPaths,
|
||||
tls_mode: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let mut args = build_mysql_restore_args(conn, options);
|
||||
if let Some(m) = tls_mode {
|
||||
args.push(format!("--ssl-mode={m}"));
|
||||
}
|
||||
let file = std::fs::File::open(&options.file_path).map_err(|e| format!("open dump: {e}"))?;
|
||||
let out = Command::new(&tools.mysql).env("MYSQL_PWD", &conn.password).args(&args).stdin(Stdio::from(file)).output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if out.status.success() { Ok(()) } else { Err(sanitize_error(&String::from_utf8_lossy(&out.stderr))) }
|
||||
}
|
||||
|
||||
pub fn run_mysql_sync(
|
||||
source: &MySqlConnParams,
|
||||
target: &MySqlConnParams,
|
||||
tools: &MySqlToolPaths,
|
||||
tls_mode: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let mut dump_args = base_mysql_args(source);
|
||||
dump_args.push("--single-transaction".into());
|
||||
dump_args.push("--add-drop-table".into());
|
||||
dump_args.push(format!("--databases={}", source.database));
|
||||
if let Some(m) = tls_mode {
|
||||
dump_args.push(format!("--ssl-mode={m}"));
|
||||
}
|
||||
let mut dump = Command::new(&tools.mysqldump).env("MYSQL_PWD", &source.password).args(&dump_args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| format!("dump: {e}"))?;
|
||||
let stdout = dump.stdout.take().unwrap();
|
||||
let mut restore_args = base_mysql_args(target);
|
||||
restore_args.push(format!("--database={}", target.database));
|
||||
let restore = Command::new(&tools.mysql).env("MYSQL_PWD", &target.password).args(&restore_args).stdin(stdout).output();
|
||||
let _ = dump.wait();
|
||||
match restore {
|
||||
Ok(o) if o.status.success() => Ok(()),
|
||||
Ok(o) => Err(format!("restore: {}", sanitize_error(&String::from_utf8_lossy(&o.stderr)))),
|
||||
Err(e) => Err(format!("restore: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tauri commands (thin wrappers: store lookup + keychain + event emission)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -520,6 +801,346 @@ pub async fn db_sync(
|
||||
Ok(job_id)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MySQL dump / restore / sync commands (v0.7.8)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub fn detect_mysql_tools(app_handle: AppHandle) -> MySqlToolStatus {
|
||||
let (d, ds) = resolve_mysql_tool(&app_handle, "mariadb-dump");
|
||||
let (m, ms) = resolve_mysql_tool(&app_handle, "mariadb");
|
||||
MySqlToolStatus {
|
||||
mysqldump_found: Command::new(&d).arg("--version").output().is_ok(),
|
||||
mysql_found: Command::new(&m).arg("--version").output().is_ok(),
|
||||
mysqldump_version: get_version(&d),
|
||||
mysql_version: get_version(&m),
|
||||
mysqldump_source: ds,
|
||||
mysql_source: ms,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve (host, port, via_tunnel) for a MySQL connection, routing through the
|
||||
/// SSH tunnel endpoint when present.
|
||||
fn mysql_endpoint(
|
||||
state: &crate::AppState,
|
||||
connection_id: &str,
|
||||
conn: &crate::models::Connection,
|
||||
) -> (String, i64, bool) {
|
||||
let via_tunnel = state.ssh_manager.lock().unwrap().get_local_port(connection_id);
|
||||
if let Some(port) = via_tunnel {
|
||||
return ("127.0.0.1".into(), port as i64, true);
|
||||
}
|
||||
(conn.host.clone(), conn.port.unwrap_or(3306), false)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mysql_dump(
|
||||
connection_id: String,
|
||||
options: MySqlBackupOptions,
|
||||
state: State<'_, crate::AppState>,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<String, String> {
|
||||
let job_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let conn = {
|
||||
let store = state.db_store.lock().map_err(|e| e.to_string())?;
|
||||
let connections = store.get_connections().map_err(|e| e.to_string())?;
|
||||
connections
|
||||
.into_iter()
|
||||
.find(|c| c.id == connection_id)
|
||||
.ok_or_else(|| format!("Connection not found: {connection_id}"))?
|
||||
};
|
||||
|
||||
let password =
|
||||
crate::commands::keychain::get_connection_password_internal(&app_handle, &connection_id)
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default();
|
||||
|
||||
let (host, port, via_tunnel) = mysql_endpoint(&state, &connection_id, &conn);
|
||||
let params = MySqlConnParams::new(
|
||||
host,
|
||||
port,
|
||||
conn.username.clone().unwrap_or_else(|| "root".into()),
|
||||
options.database.clone(),
|
||||
password,
|
||||
);
|
||||
let tools = resolve_mysql_tool_paths(&app_handle);
|
||||
let tls = if via_tunnel { Some("REQUIRED") } else { None };
|
||||
|
||||
let job_id_clone = job_id.clone();
|
||||
let app_handle_clone = app_handle.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = run_mysql_dump(¶ms, &options, &tools, tls);
|
||||
emit_result(&app_handle_clone, &job_id_clone, result);
|
||||
});
|
||||
|
||||
Ok(job_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mysql_restore(
|
||||
connection_id: String,
|
||||
options: MySqlRestoreOptions,
|
||||
state: State<'_, crate::AppState>,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<String, String> {
|
||||
let job_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let conn = {
|
||||
let store = state.db_store.lock().map_err(|e| e.to_string())?;
|
||||
let connections = store.get_connections().map_err(|e| e.to_string())?;
|
||||
connections
|
||||
.into_iter()
|
||||
.find(|c| c.id == connection_id)
|
||||
.ok_or_else(|| format!("Connection not found: {connection_id}"))?
|
||||
};
|
||||
|
||||
let password =
|
||||
crate::commands::keychain::get_connection_password_internal(&app_handle, &connection_id)
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default();
|
||||
|
||||
let (host, port, via_tunnel) = mysql_endpoint(&state, &connection_id, &conn);
|
||||
let params = MySqlConnParams::new(
|
||||
host,
|
||||
port,
|
||||
conn.username.clone().unwrap_or_else(|| "root".into()),
|
||||
options.database.clone(),
|
||||
password,
|
||||
);
|
||||
let tools = resolve_mysql_tool_paths(&app_handle);
|
||||
let tls = if via_tunnel { Some("REQUIRED") } else { None };
|
||||
|
||||
let job_id_clone = job_id.clone();
|
||||
let app_handle_clone = app_handle.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = run_mysql_restore(¶ms, &options, &tools, tls);
|
||||
emit_result(&app_handle_clone, &job_id_clone, result);
|
||||
});
|
||||
|
||||
Ok(job_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mysql_sync(
|
||||
options: SyncOptions,
|
||||
state: State<'_, crate::AppState>,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<String, String> {
|
||||
let job_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
// Get both connections from store
|
||||
let (source_conn, target_conn) = {
|
||||
let store = state.db_store.lock().map_err(|e| e.to_string())?;
|
||||
let connections = store.get_connections().map_err(|e| e.to_string())?;
|
||||
|
||||
let src = connections
|
||||
.iter()
|
||||
.find(|c| c.id == options.source_connection_id)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"Source connection not found: {}",
|
||||
options.source_connection_id
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
|
||||
let tgt = connections
|
||||
.iter()
|
||||
.find(|c| c.id == options.target_connection_id)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"Target connection not found: {}",
|
||||
options.target_connection_id
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
|
||||
(src, tgt)
|
||||
};
|
||||
|
||||
let src_password =
|
||||
crate::commands::keychain::get_connection_password_internal(&app_handle, &source_conn.id)
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default();
|
||||
let tgt_password =
|
||||
crate::commands::keychain::get_connection_password_internal(&app_handle, &target_conn.id)
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default();
|
||||
|
||||
let (src_host, src_port, src_via_tunnel) = mysql_endpoint(&state, &source_conn.id, &source_conn);
|
||||
let (tgt_host, tgt_port, tgt_via_tunnel) = mysql_endpoint(&state, &target_conn.id, &target_conn);
|
||||
|
||||
let source = MySqlConnParams::new(
|
||||
src_host,
|
||||
src_port,
|
||||
source_conn
|
||||
.username
|
||||
.clone()
|
||||
.unwrap_or_else(|| "root".into()),
|
||||
source_conn
|
||||
.database
|
||||
.clone()
|
||||
.unwrap_or_else(|| "mysql".into()),
|
||||
src_password,
|
||||
);
|
||||
let target = MySqlConnParams::new(
|
||||
tgt_host,
|
||||
tgt_port,
|
||||
target_conn
|
||||
.username
|
||||
.clone()
|
||||
.unwrap_or_else(|| "root".into()),
|
||||
target_conn
|
||||
.database
|
||||
.clone()
|
||||
.unwrap_or_else(|| "mysql".into()),
|
||||
tgt_password,
|
||||
);
|
||||
|
||||
let tools = resolve_mysql_tool_paths(&app_handle);
|
||||
// Through a tunnel the peer is loopback, so force encrypt-only `REQUIRED`
|
||||
// (mirrors how the app degrades verify-ca/verify-full through tunnels).
|
||||
let tls = if src_via_tunnel || tgt_via_tunnel {
|
||||
Some("REQUIRED")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let job_id_clone = job_id.clone();
|
||||
let app_handle_clone = app_handle.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = run_mysql_sync(&source, &target, &tools, tls);
|
||||
emit_result(&app_handle_clone, &job_id_clone, result);
|
||||
});
|
||||
|
||||
Ok(job_id)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SQLite dump / restore / sync commands (v0.7.8) — the stored `conn.host` IS
|
||||
// the SQLite file path; each command opens its own connection in the blocking
|
||||
// task (the pool's SQLite handle is never touched).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sqlite_dump(
|
||||
connection_id: String,
|
||||
options: SqliteBackupOptions,
|
||||
state: State<'_, crate::AppState>,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<String, String> {
|
||||
let job_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let conn = {
|
||||
let store = state.db_store.lock().map_err(|e| e.to_string())?;
|
||||
let connections = store.get_connections().map_err(|e| e.to_string())?;
|
||||
connections
|
||||
.into_iter()
|
||||
.find(|c| c.id == connection_id)
|
||||
.ok_or_else(|| format!("Connection not found: {connection_id}"))?
|
||||
};
|
||||
|
||||
let path = conn.host.clone();
|
||||
let out_path = options.file_path.clone();
|
||||
let job_id_clone = job_id.clone();
|
||||
let app_handle_clone = app_handle.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = (|| -> Result<(), String> {
|
||||
let src = Connection::open(&path).map_err(|e| format!("open source: {e}"))?;
|
||||
let mut out =
|
||||
std::fs::File::create(&out_path).map_err(|e| format!("create dump file: {e}"))?;
|
||||
dump_sqlite_to(&src, &mut out, |_| {})
|
||||
})();
|
||||
emit_result(&app_handle_clone, &job_id_clone, result);
|
||||
});
|
||||
|
||||
Ok(job_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sqlite_restore(
|
||||
connection_id: String,
|
||||
options: SqliteRestoreOptions,
|
||||
state: State<'_, crate::AppState>,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<String, String> {
|
||||
let job_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let conn = {
|
||||
let store = state.db_store.lock().map_err(|e| e.to_string())?;
|
||||
let connections = store.get_connections().map_err(|e| e.to_string())?;
|
||||
connections
|
||||
.into_iter()
|
||||
.find(|c| c.id == connection_id)
|
||||
.ok_or_else(|| format!("Connection not found: {connection_id}"))?
|
||||
};
|
||||
|
||||
let path = conn.host.clone();
|
||||
let in_path = options.file_path.clone();
|
||||
let clean = options.clean;
|
||||
let job_id_clone = job_id.clone();
|
||||
let app_handle_clone = app_handle.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = (|| -> Result<(), String> {
|
||||
let dst = Connection::open(&path).map_err(|e| format!("open target: {e}"))?;
|
||||
let text =
|
||||
std::fs::read_to_string(&in_path).map_err(|e| format!("read dump: {e}"))?;
|
||||
restore_sqlite(&dst, &text, clean)
|
||||
})();
|
||||
emit_result(&app_handle_clone, &job_id_clone, result);
|
||||
});
|
||||
|
||||
Ok(job_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sqlite_sync(
|
||||
options: SyncOptions,
|
||||
state: State<'_, crate::AppState>,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<String, String> {
|
||||
let job_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let (source_path, target_path) = {
|
||||
let store = state.db_store.lock().map_err(|e| e.to_string())?;
|
||||
let connections = store.get_connections().map_err(|e| e.to_string())?;
|
||||
|
||||
let src = connections
|
||||
.iter()
|
||||
.find(|c| c.id == options.source_connection_id)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"Source connection not found: {}",
|
||||
options.source_connection_id
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
|
||||
let tgt = connections
|
||||
.iter()
|
||||
.find(|c| c.id == options.target_connection_id)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"Target connection not found: {}",
|
||||
options.target_connection_id
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
|
||||
(src.host, tgt.host)
|
||||
};
|
||||
|
||||
let job_id_clone = job_id.clone();
|
||||
let app_handle_clone = app_handle.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = run_sqlite_sync(&source_path, &target_path);
|
||||
emit_result(&app_handle_clone, &job_id_clone, result);
|
||||
});
|
||||
|
||||
Ok(job_id)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -418,3 +418,117 @@ 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"); }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// SQLite .dump / restore / sync core (Task 2.2)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
use rusqlite::Connection;
|
||||
use std::io::Cursor;
|
||||
|
||||
fn seed_sqlite() -> Connection {
|
||||
let c = Connection::open_in_memory().unwrap();
|
||||
c.execute_batch(
|
||||
"CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);
|
||||
CREATE INDEX users_name ON users(name);
|
||||
INSERT INTO users (name) VALUES ('Alice'),('Bob');
|
||||
CREATE TABLE blobs (id INTEGER PRIMARY KEY, data BLOB);
|
||||
INSERT INTO blobs VALUES (1, x'010203');",
|
||||
)
|
||||
.unwrap();
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_dump_and_restore_roundtrip() {
|
||||
let src = seed_sqlite();
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let mut cur = std::io::Cursor::new(&mut buf);
|
||||
dump_sqlite_to(&src, &mut cur, |_| {}).unwrap();
|
||||
let text = String::from_utf8(buf).unwrap();
|
||||
assert!(text.contains("PRAGMA foreign_keys=OFF"));
|
||||
assert!(text.contains("BEGIN TRANSACTION"));
|
||||
assert!(text.contains("CREATE TABLE users"));
|
||||
assert!(text.contains("INSERT INTO \"users\""));
|
||||
assert!(text.contains("X'010203'"), "BLOB must be hex-literal");
|
||||
assert!(text.contains("CREATE INDEX users_name"));
|
||||
|
||||
let dst = Connection::open_in_memory().unwrap();
|
||||
restore_sqlite(&dst, &text, false).unwrap();
|
||||
let n: i64 = dst.query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0)).unwrap();
|
||||
assert_eq!(n, 2);
|
||||
let blobs: i64 = dst.query_row("SELECT COUNT(*) FROM blobs", [], |r| r.get(0)).unwrap();
|
||||
assert_eq!(blobs, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_dump_preserves_autoincrement_sequence() {
|
||||
let src = Connection::open_in_memory().unwrap();
|
||||
src.execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, v TEXT); INSERT INTO t(v) VALUES ('a'),('b');").unwrap();
|
||||
let mut buf = Vec::new();
|
||||
dump_sqlite_to(&src, &mut Cursor::new(&mut buf), |_| {}).unwrap();
|
||||
let text = String::from_utf8(buf).unwrap();
|
||||
let dst = Connection::open_in_memory().unwrap();
|
||||
restore_sqlite(&dst, &text, false).unwrap();
|
||||
dst.execute("INSERT INTO t(v) VALUES ('c')", []).unwrap();
|
||||
let id: i64 = dst.query_row("SELECT id FROM t WHERE v='c'", [], |r| r.get(0)).unwrap();
|
||||
assert_eq!(id, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_dump_fail_closed_for_virtual_tables() {
|
||||
let src = Connection::open_in_memory().unwrap();
|
||||
src.execute_batch("CREATE VIRTUAL TABLE ft USING fts4(content)").unwrap();
|
||||
let mut buf = Vec::new();
|
||||
let err = dump_sqlite_to(&src, &mut Cursor::new(&mut buf), |_| {}).unwrap_err();
|
||||
assert!(err.to_lowercase().contains("virtual table"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_restore_clean_drops_existing() {
|
||||
let src = seed_sqlite();
|
||||
let mut buf = Vec::new();
|
||||
dump_sqlite_to(&src, &mut Cursor::new(&mut buf), |_| {}).unwrap();
|
||||
let text = String::from_utf8(buf).unwrap();
|
||||
let dst = Connection::open_in_memory().unwrap();
|
||||
dst.execute_batch("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT); INSERT INTO users VALUES (99,'old');").unwrap();
|
||||
restore_sqlite(&dst, &text, true).unwrap();
|
||||
let names: Vec<String> = dst.prepare("SELECT name FROM users ORDER BY id").unwrap().query_map([], |r| r.get::<_, String>(0)).unwrap().filter_map(|r| r.ok()).collect();
|
||||
assert_eq!(names, vec!["Alice".to_string(), "Bob".to_string()]);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// MySQL dump / restore / sync arg building (Task 2.3)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn mysql_dump_args_single_transaction_no_data_routines() {
|
||||
let opts = MySqlBackupOptions {
|
||||
database: "shop".into(), file_path: "/tmp/d.sql".into(),
|
||||
single_transaction: true, no_data: true, routines: true, triggers: false, events: false,
|
||||
};
|
||||
let args = build_mysql_dump_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into()), &opts);
|
||||
assert!(args.iter().any(|a| a == "--single-transaction"));
|
||||
assert!(args.iter().any(|a| a == "--no-data"));
|
||||
assert!(args.iter().any(|a| a == "--routines"));
|
||||
assert!(args.iter().any(|a| a == "--databases=shop"));
|
||||
assert!(args.iter().any(|a| a == "--result-file=/tmp/d.sql"));
|
||||
// no --password on the command line (uses MYSQL_PWD env)
|
||||
assert!(args.iter().all(|a| !a.starts_with("--password")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_restore_args_no_clean_flags() {
|
||||
let opts = MySqlRestoreOptions { database: "shop".into(), file_path: "/tmp/d.sql".into(), clean: false };
|
||||
let args = build_mysql_restore_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into()), &opts);
|
||||
assert!(args.iter().any(|a| a == "--database=shop"));
|
||||
assert!(args.iter().any(|a| a == "--host=h"));
|
||||
assert!(args.iter().all(|a| a != "--force"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_env_uses_mysql_pwd_not_password_arg() {
|
||||
let opts = MySqlBackupOptions { database: "db".into(), file_path: "/tmp/x.sql".into(), single_transaction: false, no_data: false, routines: false, triggers: false, events: false };
|
||||
let args = build_mysql_dump_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "db".into(), "p".into()), &opts);
|
||||
assert!(args.iter().all(|a| !a.starts_with("--password")));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -891,6 +891,7 @@ pub(crate) async fn run_mysql_connect(
|
||||
config: &crate::db::pool::DbConfig,
|
||||
ssh_manager: &std::sync::Mutex<crate::commands::ssh::SshTunnelManager>,
|
||||
pool_manager: &tokio::sync::Mutex<crate::db::pool::ConnectionPoolManager>,
|
||||
cancel_registry: &crate::cancel::CancelRegistry,
|
||||
) -> Result<(), String> {
|
||||
use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions, MySqlSslMode};
|
||||
if config.host.trim().is_empty() {
|
||||
@@ -968,6 +969,10 @@ pub(crate) async fn run_mysql_connect(
|
||||
}
|
||||
}
|
||||
|
||||
// Clone the (already final) options before `connect_with` consumes them so
|
||||
// `cancel_query` can open its own connection to run `KILL QUERY ?`.
|
||||
let cancel_opts = opts.clone();
|
||||
|
||||
match MySqlPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.acquire_timeout(std::time::Duration::from_secs(10))
|
||||
@@ -979,6 +984,13 @@ pub(crate) async fn run_mysql_connect(
|
||||
.lock()
|
||||
.await
|
||||
.register(connection_id, crate::db::pool::DbHandle::MySql(pool));
|
||||
cancel_registry.set_mysql(
|
||||
connection_id,
|
||||
crate::cancel::MySqlCancel {
|
||||
conn_id: None,
|
||||
connect_options: cancel_opts,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -1023,6 +1035,8 @@ pub async fn db_connect(
|
||||
config.ssl_key_path.as_deref(),
|
||||
)
|
||||
.map_err(|e| sanitize_error(&e))?;
|
||||
// Snapshot before `match tls` consumes the Option below.
|
||||
let cancel_cfg = tls.clone();
|
||||
|
||||
// SSH tunnel: if configured, open a loopback tunnel to the remote DB
|
||||
// and connect through it. The blocking ssh2 handshake runs in
|
||||
@@ -1080,6 +1094,18 @@ pub async fn db_connect(
|
||||
|
||||
match result {
|
||||
Ok((client, handle)) => {
|
||||
// Capture a cancel token + the exact TLS decision/config used to
|
||||
// build the connection so `cancel_query` can open an identical
|
||||
// (short-lived) cancel connection later.
|
||||
let pgtoken = client.cancel_token();
|
||||
state.cancel_registry.set_pg(
|
||||
&connection_id,
|
||||
crate::cancel::PgCancel {
|
||||
cancel_token: pgtoken,
|
||||
tls_decision: decision,
|
||||
tls_config: cancel_cfg,
|
||||
},
|
||||
);
|
||||
let mut pm = state.pool_manager.lock().await;
|
||||
pm.register(
|
||||
&connection_id,
|
||||
@@ -1101,8 +1127,14 @@ pub async fn db_connect(
|
||||
} else if config.db_type == "sqlite" {
|
||||
match rusqlite::Connection::open(&config.host) {
|
||||
Ok(conn) => {
|
||||
// Capture the per-connection interrupt handle so `cancel_query`
|
||||
// can abort a running SQLite query from another thread.
|
||||
let interrupt = conn.get_interrupt_handle();
|
||||
let mut pm = state.pool_manager.lock().await;
|
||||
pm.register(&connection_id, crate::db::pool::DbHandle::Sqlite(conn));
|
||||
state
|
||||
.cancel_registry
|
||||
.set_sqlite(&connection_id, crate::cancel::SqliteCancel::new(interrupt));
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(format!("Connection failed: {}", e)),
|
||||
@@ -1113,6 +1145,7 @@ pub async fn db_connect(
|
||||
&config,
|
||||
&state.ssh_manager,
|
||||
&state.pool_manager,
|
||||
&state.cancel_registry,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
@@ -1130,6 +1163,7 @@ pub async fn db_disconnect(
|
||||
) -> Result<(), String> {
|
||||
let mut pm = state.pool_manager.lock().await;
|
||||
pm.remove(&connection_id);
|
||||
state.cancel_registry.remove(&connection_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1864,6 +1898,60 @@ pub async fn get_table_data(
|
||||
.await
|
||||
}
|
||||
|
||||
/// Headless column introspection for a table, shared by the `get_table_columns`
|
||||
/// command and integration tests (thin-command principle — no Tauri `State`).
|
||||
/// Mirrors the `pg_columns_query` row mapping `get_table_data` uses.
|
||||
pub(crate) async fn get_table_columns_inner(
|
||||
pm: &tokio::sync::Mutex<ConnectionPoolManager>,
|
||||
connection_id: &str,
|
||||
schema: &str,
|
||||
table: &str,
|
||||
) -> Result<Vec<crate::models::ColumnInfo>, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
let client = match pm.get(connection_id) {
|
||||
Some(crate::db::pool::DbHandle::Postgresql(c, _)) => c,
|
||||
Some(_) => return Err("Table columns are PostgreSQL-only".into()),
|
||||
None => return Err("Connection not found".into()),
|
||||
};
|
||||
let rows = client
|
||||
.query(&crate::db::introspection::pg_columns_query(schema, table), &[])
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let is_fk: bool = r.get::<_, Option<String>>(10).is_some();
|
||||
let fk_schema: Option<String> = r.get(9);
|
||||
let fk_table: Option<String> = r.get(10);
|
||||
crate::models::ColumnInfo {
|
||||
name: r.get(0),
|
||||
data_type: r.get(1),
|
||||
is_nullable: r.get::<_, String>(2) == "YES",
|
||||
is_pk: r.get::<_, Option<String>>(8).as_deref() == Some("PRIMARY KEY"),
|
||||
is_fk,
|
||||
fk_ref: if is_fk {
|
||||
Some((fk_schema.unwrap_or_default(), fk_table.unwrap_or_default()))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
default_value: r.get::<_, Option<String>>(6),
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_table_columns(
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
state: State<'_, crate::AppState>,
|
||||
) -> Result<Vec<crate::models::ColumnInfo>, String> {
|
||||
get_table_columns_inner(&state.pool_manager, &connection_id, &schema, &table).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_fk_preview(
|
||||
connection_id: String,
|
||||
@@ -2110,14 +2198,15 @@ pub(crate) fn affected_count_error(n: u64) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn execute_change(
|
||||
connection_id: String,
|
||||
/// Headless change-application shared by the `execute_change` command and
|
||||
/// integration tests (thin-command principle — no Tauri `State`).
|
||||
pub(crate) async fn execute_change_inner(
|
||||
pm: &tokio::sync::Mutex<ConnectionPoolManager>,
|
||||
connection_id: &str,
|
||||
change: Change,
|
||||
state: State<'_, crate::AppState>,
|
||||
) -> Result<(), String> {
|
||||
let mut pm = state.pool_manager.lock().await;
|
||||
match pm.get(&connection_id) {
|
||||
let mut pm = pm.lock().await;
|
||||
match pm.get(connection_id) {
|
||||
Some(crate::db::pool::DbHandle::Postgresql(client, _)) => {
|
||||
// Build the parameterized SQL + bound values from the change.
|
||||
let (sql, params): (String, Vec<serde_json::Value>) = match &change {
|
||||
@@ -2155,6 +2244,27 @@ 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::RebuildTable { sql, .. } => {
|
||||
// Single transaction: all-or-nothing rebuild (multi-statement
|
||||
// DDL only; VACUUM never reaches here). Rolls back on any
|
||||
// statement failure.
|
||||
let tx = client
|
||||
.build_transaction()
|
||||
.start()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
tx.batch_execute(sql)
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
return Ok(());
|
||||
}
|
||||
Change::BulkInsert {
|
||||
schema,
|
||||
table,
|
||||
@@ -2247,6 +2357,16 @@ pub async fn execute_change(
|
||||
conn.execute(sql, []).map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
Change::Ddl { sql, .. } => {
|
||||
// Object DDL can be multi-statement; run as a batch.
|
||||
conn.execute_batch(sql).map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
Change::RebuildTable { sql, .. } => {
|
||||
// Rebuild script (create tmp / copy / drop / rename).
|
||||
conn.execute_batch(sql).map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
Change::BulkInsert {
|
||||
table,
|
||||
columns,
|
||||
@@ -2319,6 +2439,12 @@ 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::RebuildTable { .. } => {
|
||||
return Err("Object management is PostgreSQL-only".to_string());
|
||||
}
|
||||
Change::BulkInsert {
|
||||
schema,
|
||||
table,
|
||||
@@ -2374,6 +2500,15 @@ pub async fn execute_change(
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn execute_change(
|
||||
connection_id: String,
|
||||
change: Change,
|
||||
state: State<'_, crate::AppState>,
|
||||
) -> Result<(), String> {
|
||||
execute_change_inner(&state.pool_manager, &connection_id, change).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn refresh_connection(
|
||||
connection_id: String,
|
||||
@@ -2820,6 +2955,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]
|
||||
@@ -3185,8 +3347,9 @@ mod tests {
|
||||
};
|
||||
let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend)));
|
||||
let pm = fresh_pool_manager().await;
|
||||
let reg = crate::cancel::CancelRegistry::new();
|
||||
let id = "mysql-empty-host";
|
||||
let res = run_mysql_connect(id, &cfg, &ssh, &pm).await;
|
||||
let res = run_mysql_connect(id, &cfg, &ssh, &pm, ®).await;
|
||||
assert!(res.is_err(), "empty host must fail before any network call");
|
||||
let mut pmg = pm.lock().await;
|
||||
assert!(pmg.get(id).is_none(), "no handle registered on failure");
|
||||
@@ -3245,8 +3408,9 @@ mod tests {
|
||||
};
|
||||
let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend)));
|
||||
let pm = fresh_pool_manager().await;
|
||||
let reg = crate::cancel::CancelRegistry::new();
|
||||
let id = "mysql-unreachable";
|
||||
let res = run_mysql_connect(id, &cfg, &ssh, &pm).await;
|
||||
let res = run_mysql_connect(id, &cfg, &ssh, &pm, ®).await;
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"port 1 should refuse; must be a clean Err, not panic"
|
||||
@@ -3295,8 +3459,9 @@ mod tests {
|
||||
};
|
||||
let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend)));
|
||||
let pm = fresh_pool_manager().await;
|
||||
let reg = crate::cancel::CancelRegistry::new();
|
||||
let id = format!("mysql-it-{}", uuid::Uuid::new_v4());
|
||||
run_mysql_connect(&id, &cfg, &ssh, &pm).await.expect("connect");
|
||||
run_mysql_connect(&id, &cfg, &ssh, &pm, ®).await.expect("connect");
|
||||
let tables = get_tables_inner(&pm, &id, Some(&db)).await.expect("tables");
|
||||
assert!(!tables.is_empty(), "test DB must contain at least one table");
|
||||
let first = &tables[0];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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\""));
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
use tauri::State;
|
||||
use crate::db::pool::{ConnectionPoolManager, DbHandle};
|
||||
use crate::models::MaintenanceResult;
|
||||
|
||||
/// Run a table-scoped maintenance command via the SIMPLE query protocol
|
||||
/// (never inside a transaction — VACUUM cannot run in a transaction block).
|
||||
pub(crate) async fn run_maintenance_inner(
|
||||
pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, table: &str, action: &str,
|
||||
) -> Result<MaintenanceResult, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
let client = match pm.get(connection_id) {
|
||||
Some(DbHandle::Postgresql(c, _)) => c,
|
||||
Some(_) => return Err("Maintenance is PostgreSQL-only".into()),
|
||||
None => return Err("Connection not found".into()),
|
||||
};
|
||||
crate::db::object_ddl::validate_object_name(&schema)?;
|
||||
crate::db::object_ddl::validate_object_name(&table)?;
|
||||
let verb = match action {
|
||||
"vacuum" => "VACUUM",
|
||||
"analyze" => "ANALYZE",
|
||||
"reindex" => "REINDEX TABLE",
|
||||
_ => return Err(format!("Unknown maintenance action: {action}")),
|
||||
};
|
||||
let sql = format!("{} {}.{}", verb,
|
||||
crate::db::object_ddl::quote_ident(&schema), crate::db::object_ddl::quote_ident(&table));
|
||||
let start = std::time::Instant::now();
|
||||
client.simple_query(&sql).await
|
||||
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
|
||||
Ok(MaintenanceResult { duration_ms: start.elapsed().as_millis() as i64, message: format!("{} completed on {}.{}", verb, schema, table) })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn run_maintenance(connection_id: String, schema: String, table: String, action: String, state: State<'_, crate::AppState>) -> Result<MaintenanceResult, String> {
|
||||
run_maintenance_inner(&state.pool_manager, &connection_id, &schema, &table, &action).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "maintenance.test.rs"]
|
||||
mod maintenance_test;
|
||||
@@ -0,0 +1,28 @@
|
||||
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 run_maintenance_analyze_succeeds() {
|
||||
let (pm, id) = pool().await;
|
||||
let res = crate::commands::maintenance::run_maintenance_inner(&pm, &id, "public", "pg_type", "analyze").await.unwrap();
|
||||
assert!(res.duration_ms >= 0);
|
||||
assert!(res.message.to_lowercase().contains("analyze"));
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub mod demo;
|
||||
pub mod folders;
|
||||
pub mod import_export;
|
||||
pub mod keychain;
|
||||
pub mod maintenance;
|
||||
pub mod objects;
|
||||
pub mod query;
|
||||
pub mod schema_graph;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use tauri::State;
|
||||
use crate::db::pool::{ConnectionPoolManager, DbHandle};
|
||||
use crate::db::object_crud::{build_ddl, rebuild_script, RebuildConstraint, RebuildFk, RebuildFkIn, RebuildGrant, RebuildIndex, RebuildInput, RebuildOwnedSequence, TableColumn};
|
||||
use crate::db::object_ddl::*;
|
||||
use crate::models::db_viewer::{ObjectSearchHit, DependencyInfo};
|
||||
use crate::models::db_viewer::{ObjectSearchHit, DependencyInfo, ExtensionInfo};
|
||||
|
||||
fn sanitize(e: &str) -> String { crate::commands::db_viewer::sanitize_error(e) }
|
||||
|
||||
@@ -146,6 +147,441 @@ pub async fn get_object_dependencies(connection_id: String, schema: String, obje
|
||||
get_object_dependencies_inner(&state.pool_manager, &connection_id, &schema, &object_type, &name).await
|
||||
}
|
||||
|
||||
/// Introspect the live columns of a SQLite table (for the edit/rebuild diff).
|
||||
/// Mirrors the `PRAGMA table_info` + `PRAGMA index_list`/`index_info` reads used
|
||||
/// elsewhere in db_viewer; `auto_increment` requires an INTEGER PK whose stored
|
||||
/// DDL (sqlite_master) actually says AUTOINCREMENT, and `unique` means a
|
||||
/// single-column unique index (excluding the PK autoindex).
|
||||
fn sqlite_live_columns(conn: &rusqlite::Connection, table: &str) -> Result<Vec<SqliteColumn>, String> {
|
||||
let pragma_query = format!("PRAGMA table_info('{}')", table);
|
||||
let mut stmt = conn.prepare(&pragma_query).map_err(|e| e.to_string())?;
|
||||
let col_meta: Vec<(String, String, bool, bool, Option<String>)> = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(1)?, // name
|
||||
row.get::<_, String>(2)?, // type
|
||||
row.get::<_, bool>(3)?, // notnull
|
||||
row.get::<_, bool>(5)?, // pk
|
||||
row.get::<_, Option<String>>(4)?, // dflt_value
|
||||
))
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
// AUTOINCREMENT only appears in the stored DDL of an INTEGER PK table.
|
||||
let autoinc = conn
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?1",
|
||||
[table],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.map(|sql| sql.to_uppercase().contains("AUTOINCREMENT"))
|
||||
.unwrap_or(false);
|
||||
// Columns covered by a single-column unique index (origin != 'pk').
|
||||
let mut unique_cols: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
if let Ok(mut idx_stmt) = conn.prepare(&format!("PRAGMA index_list('{}')", table)) {
|
||||
let indexes: Vec<(String, bool, String)> = idx_stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(1)?, // name
|
||||
row.get::<_, bool>(2)?, // unique
|
||||
row.get::<_, String>(3)?, // origin
|
||||
))
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
for (idx_name, is_unique, origin) in indexes {
|
||||
if !is_unique || origin == "pk" {
|
||||
continue;
|
||||
}
|
||||
if let Ok(mut info_stmt) =
|
||||
conn.prepare(&format!("PRAGMA index_info('{}')", idx_name.replace('\'', "''")))
|
||||
{
|
||||
let cols: Vec<String> = info_stmt
|
||||
.query_map([], |row| row.get::<_, String>(2))
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
if cols.len() == 1 {
|
||||
unique_cols.insert(cols[0].clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(col_meta
|
||||
.iter()
|
||||
.map(|(name, dtype, notnull, is_pk, default)| SqliteColumn {
|
||||
name: name.clone(),
|
||||
type_: dtype.clone(),
|
||||
nullable: !notnull,
|
||||
default: default.clone(),
|
||||
is_pk: *is_pk,
|
||||
auto_increment: autoinc && *is_pk && dtype.trim().eq_ignore_ascii_case("INTEGER"),
|
||||
unique: unique_cols.contains(name),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// SQLite routes through the table-editor builders in `crate::db::object_ddl`
|
||||
/// (create / edit / rebuild ops on the `table` kind).
|
||||
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(DbHandle::Sqlite(conn)) => {
|
||||
// params: { schema, name, action: { op, columns: [...] } }
|
||||
let action = params.get("action").ok_or("missing action")?;
|
||||
let op = action
|
||||
.get("op")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing op")?;
|
||||
let cols: Vec<SqliteColumn> = match action.get("columns") {
|
||||
Some(v) => serde_json::from_value(v.clone()).map_err(|e| e.to_string())?,
|
||||
None => vec![],
|
||||
};
|
||||
let table = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing name")?
|
||||
.to_string();
|
||||
match op {
|
||||
"create" => Ok(vec![sqlite_create_table_sql(&table, &cols, &[])?]),
|
||||
"edit" => {
|
||||
let old = sqlite_live_columns(conn, &table)?;
|
||||
sqlite_column_diff_sql(&table, &old, &cols)
|
||||
}
|
||||
"rebuild" => {
|
||||
let old = sqlite_live_columns(conn, &table)?;
|
||||
sqlite_rebuild_script(&table, &old, &cols)
|
||||
}
|
||||
_ => Err(format!("unknown op {op}")),
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/// Build a reorder-only table rebuild script (executed transactionally via
|
||||
/// `execute_change`'s `RebuildTable` arm). Headless inner: locks the pool once,
|
||||
/// validates the new column list against the live snapshot (names + types must
|
||||
/// be preserved), assembles a `RebuildInput` from live introspection, and
|
||||
/// delegates to `rebuild_script`.
|
||||
pub(crate) async fn build_rebuild_script_inner(
|
||||
pm: &tokio::sync::Mutex<ConnectionPoolManager>,
|
||||
connection_id: &str,
|
||||
schema: &str,
|
||||
table: &str,
|
||||
new_columns: serde_json::Value,
|
||||
) -> Result<String, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
match pm.get(connection_id) {
|
||||
Some(DbHandle::Sqlite(conn)) => {
|
||||
let new_cols: Vec<SqliteColumn> =
|
||||
serde_json::from_value(new_columns).map_err(|e| e.to_string())?;
|
||||
let live = sqlite_live_columns(conn, table)?;
|
||||
if let Some(reason) = sqlite_rebuild_refusal(&live) {
|
||||
return Err(reason);
|
||||
}
|
||||
return Ok(sqlite_rebuild_script(table, &live, &new_cols)?.join(";\n"));
|
||||
}
|
||||
Some(DbHandle::Postgresql(client, _)) => {
|
||||
let new_cols: Vec<TableColumn> =
|
||||
serde_json::from_value(new_columns).map_err(|e| e.to_string())?;
|
||||
|
||||
// 1. live columns — validate reorder-only: the (name,type) multiset must be
|
||||
// unchanged (attribute edits belong in the diff path, not the rebuild).
|
||||
let live = client
|
||||
.query(&crate::db::introspection::pg_columns_query(schema, table), &[])
|
||||
.await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
let mut live_pairs: Vec<(String, String)> = live
|
||||
.iter()
|
||||
.map(|r| (r.get::<_, String>(0), r.get::<_, String>(1).trim().to_string()))
|
||||
.collect();
|
||||
let mut new_pairs: Vec<(String, String)> = new_cols
|
||||
.iter()
|
||||
.map(|c| (c.name.clone(), c.type_.trim().to_string()))
|
||||
.collect();
|
||||
live_pairs.sort();
|
||||
new_pairs.sort();
|
||||
if live_pairs != new_pairs {
|
||||
return Err(
|
||||
"Reorder must preserve column names and types; undo attribute changes or stage a diff"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
// 2. assemble RebuildInput from live introspection (one client, all sub-queries).
|
||||
let fk_out_rows = client
|
||||
.query(&crate::db::introspection::pg_table_fk_out_query(), &[&schema, &table])
|
||||
.await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
let fk_in_rows = client
|
||||
.query(&crate::db::introspection::pg_table_fk_in_query(), &[&schema, &table])
|
||||
.await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
let grant_rows = client
|
||||
.query(&crate::db::introspection::pg_table_grants_query(), &[&schema, &table])
|
||||
.await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
let seq_rows = client
|
||||
.query(&crate::db::introspection::pg_table_owned_sequences_query(), &[&schema, &table])
|
||||
.await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
let index_rows = client
|
||||
.query(&crate::db::introspection::pg_indexes_query(schema), &[&schema])
|
||||
.await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
// PK/UNIQUE/CHECK (contype p/u/c) scoped to this table; FKs are carried
|
||||
// separately as fks_out/fks_in so they are not double-applied.
|
||||
let constraint_rows = client
|
||||
.query(
|
||||
"SELECT c.conname AS name, ns.nspname AS schema, cl.relname AS table_name, \
|
||||
c.contype::text, pg_get_constraintdef(c.oid) AS definition \
|
||||
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 = $1 AND cl.relname = $2 AND c.contype IN ('p','u','c') \
|
||||
ORDER BY c.conname",
|
||||
&[&schema, &table],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
|
||||
let input = RebuildInput {
|
||||
schema: schema.to_string(),
|
||||
name: table.to_string(),
|
||||
constraints: constraint_rows
|
||||
.iter()
|
||||
.map(|r| RebuildConstraint {
|
||||
name: r.get(0),
|
||||
definition: r.get(4),
|
||||
})
|
||||
.collect(),
|
||||
indexes: index_rows
|
||||
.iter()
|
||||
.filter(|r| r.get::<_, String>(2) == table)
|
||||
.map(|r| RebuildIndex {
|
||||
name: r.get(0),
|
||||
definition: r.get(3),
|
||||
})
|
||||
.collect(),
|
||||
fks_out: fk_out_rows
|
||||
.iter()
|
||||
.map(|r| RebuildFk {
|
||||
name: r.get(0),
|
||||
definition: r.get(1),
|
||||
})
|
||||
.collect(),
|
||||
fks_in: fk_in_rows
|
||||
.iter()
|
||||
.map(|r| RebuildFkIn {
|
||||
name: r.get(0),
|
||||
own_schema: r.get(1),
|
||||
own_table: r.get(2),
|
||||
definition: r.get(3),
|
||||
})
|
||||
.collect(),
|
||||
grants: grant_rows
|
||||
.iter()
|
||||
.map(|r| RebuildGrant {
|
||||
grantee: r.get(0),
|
||||
privileges: r.get(1),
|
||||
grantable: r.get(2),
|
||||
})
|
||||
.collect(),
|
||||
owned_sequences: seq_rows
|
||||
.iter()
|
||||
.map(|r| RebuildOwnedSequence {
|
||||
seq_schema: r.get(0),
|
||||
seq_name: r.get(1),
|
||||
column: r.get(2),
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
rebuild_script(&input, &new_cols)
|
||||
}
|
||||
Some(_) => return Err("Rebuild is PostgreSQL-only".into()),
|
||||
None => return Err("Connection not found".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn build_rebuild_script(
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
new_columns: serde_json::Value,
|
||||
state: State<'_, crate::AppState>,
|
||||
) -> Result<String, String> {
|
||||
build_rebuild_script_inner(&state.pool_manager, &connection_id, &schema, &table, new_columns)
|
||||
.await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Roles, privileges, rebuild readiness, tablespaces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// List non-system roles with all attributes, memberships grouped by member role.
|
||||
pub(crate) async fn get_roles_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str) -> Result<Vec<crate::models::RoleInfo>, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
let client = match pm.get(connection_id) {
|
||||
Some(DbHandle::Postgresql(c, _)) => c,
|
||||
Some(_) => return Err("Roles are PostgreSQL-only".into()),
|
||||
None => return Err("Connection not found".into()),
|
||||
};
|
||||
let roles = client.query(&crate::db::introspection::pg_roles_query(), &[]).await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
let mems = client.query(&crate::db::introspection::pg_role_memberships_query(), &[]).await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
let mut by_name: std::collections::HashMap<String, crate::models::RoleInfo> = std::collections::HashMap::new();
|
||||
for r in roles {
|
||||
let name: String = r.get("rolname");
|
||||
by_name.insert(name.clone(), crate::models::RoleInfo {
|
||||
name, superuser: r.get("rolsuper"), inherit: r.get("rolinherit"),
|
||||
create_db: r.get("rolcreatedb"), create_role: r.get("rolcreaterole"),
|
||||
can_login: r.get("rolcanlogin"), replication: r.get("rolreplication"),
|
||||
bypass_rls: r.get("rolbypassrls"), connection_limit: r.get("rolconnlimit"),
|
||||
valid_until: { let v: String = r.get("rolvaliduntil"); if v.is_empty() { None } else { Some(v) } },
|
||||
memberships: vec![],
|
||||
});
|
||||
}
|
||||
for m in mems {
|
||||
let member: String = m.get("member");
|
||||
if let Some(ri) = by_name.get_mut(&member) {
|
||||
ri.memberships.push(crate::models::RoleMembership {
|
||||
role: m.get("role"), member, grantor: m.get("grantor"), admin_option: m.get("admin_option"),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(by_name.into_values().collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_roles(connection_id: String, state: State<'_, crate::AppState>) -> Result<Vec<crate::models::RoleInfo>, String> {
|
||||
get_roles_inner(&state.pool_manager, &connection_id).await
|
||||
}
|
||||
|
||||
/// All privilege grants for a role across tables, sequences, routines, schemas, and databases.
|
||||
pub(crate) async fn get_role_privileges_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, role: &str) -> Result<Vec<crate::models::PrivilegeEntry>, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
let client = match pm.get(connection_id) {
|
||||
Some(DbHandle::Postgresql(c, _)) => c,
|
||||
Some(_) => return Err("Privileges are PostgreSQL-only".into()),
|
||||
None => return Err("Connection not found".into()),
|
||||
};
|
||||
validate_object_name(role)?; // role is interpolated into the privilege queries
|
||||
let mut out: Vec<crate::models::PrivilegeEntry> = Vec::new();
|
||||
let push = |out: &mut Vec<crate::models::PrivilegeEntry>, class: &str, schema: Option<String>, name: String, privileges: Vec<String>, grantable: bool| {
|
||||
out.push(crate::models::PrivilegeEntry { object_class: class.into(), schema, name, privileges, grantable });
|
||||
};
|
||||
for row in client.query(&crate::db::introspection::pg_table_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
|
||||
push(&mut out, "table", Some(row.get("schema")), row.get("name"), row.get("privileges"), row.get("grantable"));
|
||||
}
|
||||
for row in client.query(&crate::db::introspection::pg_sequence_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
|
||||
push(&mut out, "sequence", Some(row.get("schema")), row.get("name"), row.get("privileges"), row.get("grantable"));
|
||||
}
|
||||
for row in client.query(&crate::db::introspection::pg_routine_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
|
||||
push(&mut out, "routine", Some(row.get("schema")), row.get("name"), row.get("privileges"), row.get("grantable"));
|
||||
}
|
||||
for row in client.query(&crate::db::introspection::pg_schema_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
|
||||
push(&mut out, "schema", None, row.get("name"), row.get("privileges"), row.get("grantable"));
|
||||
}
|
||||
for row in client.query(&crate::db::introspection::pg_database_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
|
||||
push(&mut out, "database", None, row.get("name"), row.get("privileges"), row.get("grantable"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_role_privileges(connection_id: String, role: String, state: State<'_, crate::AppState>) -> Result<Vec<crate::models::PrivilegeEntry>, String> {
|
||||
get_role_privileges_inner(&state.pool_manager, &connection_id, &role).await
|
||||
}
|
||||
|
||||
/// Check whether a table can be rebuilt (no triggers, policies, inheritance, partitioning, generated columns).
|
||||
pub(crate) async fn get_table_rebuild_readiness_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, table: &str) -> Result<crate::models::RebuildReadiness, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
match pm.get(connection_id) {
|
||||
Some(DbHandle::Sqlite(conn)) => {
|
||||
let live = sqlite_live_columns(conn, table)?;
|
||||
Ok(match sqlite_rebuild_refusal(&live) {
|
||||
Some(reason) => crate::models::RebuildReadiness { ok: false, reasons: vec![reason] },
|
||||
None => crate::models::RebuildReadiness { ok: true, reasons: vec![] },
|
||||
})
|
||||
}
|
||||
Some(DbHandle::Postgresql(client, _)) => {
|
||||
let row = client.query_one(&crate::db::introspection::pg_rebuild_readiness_query(), &[&schema, &table]).await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
let mut reasons = Vec::new();
|
||||
if row.get::<_, bool>("has_triggers") { reasons.push("table has triggers".into()); }
|
||||
if row.get::<_, bool>("has_policies") { reasons.push("table has RLS policies".into()); }
|
||||
if row.get::<_, bool>("is_inherits") { reasons.push("table participates in inheritance".into()); }
|
||||
if row.get::<_, bool>("is_partitioned") { reasons.push("table is partitioned".into()); }
|
||||
if row.get::<_, bool>("has_generated") { reasons.push("table has generated/identity columns".into()); }
|
||||
Ok(crate::models::RebuildReadiness { ok: reasons.is_empty(), reasons })
|
||||
}
|
||||
Some(_) => return Err("Rebuild is PostgreSQL-only".into()),
|
||||
None => return Err("Connection not found".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_table_rebuild_readiness(connection_id: String, schema: String, table: String, state: State<'_, crate::AppState>) -> Result<crate::models::RebuildReadiness, String> {
|
||||
get_table_rebuild_readiness_inner(&state.pool_manager, &connection_id, &schema, &table).await
|
||||
}
|
||||
|
||||
/// List non-system tablespaces for the table-options picker.
|
||||
pub(crate) async fn get_tablespaces_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str) -> Result<Vec<crate::models::TablespaceInfo>, String> {
|
||||
let mut pm = pm.lock().await;
|
||||
let client = match pm.get(connection_id) {
|
||||
Some(DbHandle::Postgresql(c, _)) => c,
|
||||
Some(_) => return Err("Tablespaces are PostgreSQL-only".into()),
|
||||
None => return Err("Connection not found".into()),
|
||||
};
|
||||
let rows = client.query(&crate::db::introspection::pg_tablespaces_query(), &[]).await
|
||||
.map_err(|e| sanitize(&e.to_string()))?;
|
||||
Ok(rows.into_iter().map(|r| crate::models::TablespaceInfo { name: r.get("spcname") }).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_tablespaces(connection_id: String, state: State<'_, crate::AppState>) -> Result<Vec<crate::models::TablespaceInfo>, String> {
|
||||
get_tablespaces_inner(&state.pool_manager, &connection_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "objects.test.rs"]
|
||||
mod tests;
|
||||
@@ -57,6 +57,96 @@ async fn object_ddl_for_sequence_enum_function() {
|
||||
assert!(f.clone().unwrap().contains("CREATE FUNCTION") || f.unwrap().contains("CREATE OR REPLACE FUNCTION"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_object_ddl_inner_guards_missing_connection_and_rejects_unknown_op() {
|
||||
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}");
|
||||
// SQLite now dispatches to the SQLite table builders; non-table ops are rejected.
|
||||
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("unknown op"), "{err}");
|
||||
}
|
||||
|
||||
/// In-memory SQLite pool registered under id "c" (no Tauri, no live PG).
|
||||
fn fresh_pool_with_sqlite() -> ConnectionPoolManager {
|
||||
let mut pm = ConnectionPoolManager::new();
|
||||
pm.register("c", DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()));
|
||||
pm
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_object_ddl_sqlite_create_yields_sqlite_sql() {
|
||||
let pm = fresh_pool_with_sqlite();
|
||||
let params = serde_json::json!({
|
||||
"schema": "main", "name": "users",
|
||||
"action": { "op": "create", "columns": [
|
||||
{ "name": "id", "type": "integer", "nullable": false, "default": null, "is_pk": true, "auto_increment": true, "unique": false },
|
||||
{ "name": "name", "type": "text", "nullable": true, "default": null, "is_pk": false, "auto_increment": false, "unique": false }
|
||||
] }
|
||||
});
|
||||
let sqls = build_object_ddl_inner(&tokio::sync::Mutex::new(pm), "c", "table", params).await.unwrap();
|
||||
assert!(sqls.iter().any(|s| s.contains("INTEGER PRIMARY KEY AUTOINCREMENT")), "{sqls:?}");
|
||||
assert!(sqls.iter().all(|s| !s.contains("serial")), "{sqls:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_change_sqlite_ddl_runs_create() {
|
||||
let pm = tokio::sync::Mutex::new(fresh_pool_with_sqlite());
|
||||
let change = crate::models::db_viewer::Change::Ddl { id: "x".into(), sql: "CREATE TABLE u(id INTEGER)".into() };
|
||||
let r = crate::commands::db_viewer::execute_change_inner(&pm, "c", change).await;
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
// the table really landed on the live connection
|
||||
{
|
||||
let mut g = pm.lock().await;
|
||||
match g.get("c").unwrap() {
|
||||
crate::db::pool::DbHandle::Sqlite(conn) => {
|
||||
let n: i64 = conn
|
||||
.query_row("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='u'", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(n, 1);
|
||||
}
|
||||
_ => panic!("expected a sqlite handle"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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() {
|
||||
@@ -68,3 +158,55 @@ async fn object_dependencies_for_table_includes_view() {
|
||||
let contents = get_object_dependencies_inner(&pm, &id, "public", "schema", "public").await.unwrap();
|
||||
assert!(!contents.is_empty(), "public schema should list contents");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn get_roles_returns_current_role_and_memberships() {
|
||||
let (pm, id) = pool().await;
|
||||
let roles = crate::commands::objects::get_roles_inner(&pm, &id).await.unwrap();
|
||||
assert!(roles.iter().any(|r| r.can_login), "at least one login role (the test user)");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn rebuild_table_rolls_back_on_failure() {
|
||||
let (pm, id) = pool().await;
|
||||
// setup: a table with a PK + one row
|
||||
{
|
||||
let mut g = pm.lock().await;
|
||||
if let crate::db::pool::DbHandle::Postgresql(c, _) = g.get(&id).unwrap() {
|
||||
c.batch_execute("DROP TABLE IF EXISTS rebuild_t; CREATE TABLE rebuild_t (id int PRIMARY KEY, v text); INSERT INTO rebuild_t VALUES (1,'a');").await.unwrap();
|
||||
}
|
||||
}
|
||||
// build a rebuild script whose final statement intentionally fails (syntax error)
|
||||
// so the whole transaction rolls back and rebuild_t keeps its row.
|
||||
let bad_script = "CREATE TABLE _gridline_rb_rebuild_t (id int PRIMARY KEY, v text); \
|
||||
INSERT INTO _gridline_rb_rebuild_t (id, v) SELECT id, v FROM rebuild_t; \
|
||||
DROP TABLE rebuild_t; \
|
||||
ALTER TABLE _gridline_rb_rebuild_t RENAME TO rebuild_t; \
|
||||
THIS IS NOT SQL;";
|
||||
let change = crate::models::db_viewer::Change::RebuildTable { id: "rb".into(), sql: bad_script.to_string() };
|
||||
let res = crate::commands::db_viewer::execute_change_inner(&pm, &id, change).await;
|
||||
assert!(res.is_err(), "expected rollback (transaction should fail on bad SQL)");
|
||||
// table still intact
|
||||
let mut g = pm.lock().await;
|
||||
if let crate::db::pool::DbHandle::Postgresql(c, _) = g.get(&id).unwrap() {
|
||||
let row = c.query_one("SELECT count(*) FROM rebuild_t", &[]).await.unwrap();
|
||||
assert_eq!(row.get::<_, i64>(0), 1, "rollback must preserve the original table");
|
||||
c.batch_execute("DROP TABLE rebuild_t").await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires live PG"]
|
||||
async fn get_role_privileges_returns_grants_across_object_classes() {
|
||||
let (pm, id) = pool().await;
|
||||
let out = crate::commands::objects::get_role_privileges_inner(&pm, &id, "read_only").await
|
||||
.unwrap_or_else(|e| panic!("privileges command failed: {e}"));
|
||||
let classes: std::collections::HashSet<String> =
|
||||
out.iter().map(|p| p.object_class.clone()).collect();
|
||||
assert!(classes.contains("table"), "expected a table grant: {out:?}");
|
||||
assert!(classes.contains("routine"), "expected a routine grant: {out:?}");
|
||||
assert!(classes.contains("schema"), "expected a schema grant: {out:?}");
|
||||
assert!(classes.contains("database"), "expected a database grant: {out:?}");
|
||||
}
|
||||
|
||||
+251
-11
@@ -17,6 +17,30 @@ use std::time::Instant;
|
||||
use tauri::State;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cancel-error classification
|
||||
// ---------------------------------------------------------------------------
|
||||
// The wrapped→raw fallback exists for queries that can't be wrapped (CTEs,
|
||||
// multi-statement, non-SELECT). A USER CANCELLATION is NOT a wrapping failure:
|
||||
// swallowing it would re-run the very query the user just cancelled — and for
|
||||
// SQLite the interrupt flag is consumed by the aborted step, so the re-run
|
||||
// runs completely free. These helpers let the fallbacks propagate cancellations.
|
||||
|
||||
fn is_sqlite_cancel_error(e: &rusqlite::Error) -> bool {
|
||||
e.sqlite_error_code() == Some(rusqlite::ErrorCode::OperationInterrupted)
|
||||
}
|
||||
|
||||
fn is_pg_cancel_error(e: &tokio_postgres::Error) -> bool {
|
||||
e.code() == Some(&tokio_postgres::error::SqlState::QUERY_CANCELED)
|
||||
}
|
||||
|
||||
fn is_mysql_cancel_error(e: &sqlx::Error) -> bool {
|
||||
match e.as_database_error().and_then(|d| d.code()) {
|
||||
Some(code) if code == "1317" => true, // ER_QUERY_INTERRUPTED (KILL QUERY)
|
||||
_ => e.to_string().to_lowercase().contains("interrupted"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// QueryHistoryEntry
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -87,6 +111,7 @@ impl From<crate::store::SavedQueryRow> for SavedQueryCommand {
|
||||
pub(crate) async fn execute_query_inner(
|
||||
pool_manager: &mut crate::db::pool::ConnectionPoolManager,
|
||||
db_store: &std::sync::Mutex<crate::store::Store>,
|
||||
cancel_registry: &crate::cancel::CancelRegistry,
|
||||
connection_id: &str,
|
||||
query: &str,
|
||||
page: i64,
|
||||
@@ -101,7 +126,25 @@ pub(crate) async fn execute_query_inner(
|
||||
execute_pg_query(client, query, page, page_size).await
|
||||
}
|
||||
Some(DbHandle::Sqlite(conn)) => execute_sqlite_query(conn, query, page, page_size),
|
||||
Some(DbHandle::MySql(pool)) => execute_mysql_query(pool, query, page, page_size).await,
|
||||
Some(DbHandle::MySql(pool)) => {
|
||||
// Run on a dedicated pooled connection so the query's MySQL
|
||||
// CONNECTION_ID can be tracked for cancellation (`KILL QUERY ?`).
|
||||
let mut conn = pool
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
|
||||
// CAST to SIGNED: MySQL returns CONNECTION_ID() as BIGINT UNSIGNED,
|
||||
// which sqlx refuses to decode into i64 (ColumnDecode error) — a
|
||||
// silent unwrap_or(-1) here would make KILL QUERY -1 fail.
|
||||
let conn_id: i64 = sqlx::query_scalar("SELECT CAST(CONNECTION_ID() AS SIGNED)")
|
||||
.fetch_one(&mut *conn)
|
||||
.await
|
||||
.unwrap_or(-1);
|
||||
cancel_registry.set_mysql_conn_id(connection_id, Some(conn_id));
|
||||
let result = execute_mysql_query_on(&mut *conn, query, page, page_size).await;
|
||||
cancel_registry.set_mysql_conn_id(connection_id, None);
|
||||
result
|
||||
}
|
||||
None => {
|
||||
let elapsed = start.elapsed().as_millis() as i64;
|
||||
let err = "Connection not found".to_string();
|
||||
@@ -210,6 +253,7 @@ async fn execute_pg_query(
|
||||
// Try the wrapped count query first — if this fails we fall back to raw.
|
||||
let total_rows: i64 = match client.query_one(&wrapped_count, &[]).await {
|
||||
Ok(row) => row.get::<_, i64>(0),
|
||||
Err(e) if is_pg_cancel_error(&e) => return Err("Query cancelled".to_string()),
|
||||
Err(_) => {
|
||||
// Wrapping failed — fall back to raw execution.
|
||||
return execute_pg_raw(client, trimmed, page, page_size, off).await;
|
||||
@@ -219,6 +263,7 @@ async fn execute_pg_query(
|
||||
// Now execute the wrapped data query.
|
||||
let data_rows = match client.query(&wrapped_data, &[&page_size, &off]).await {
|
||||
Ok(rows) => rows,
|
||||
Err(e) if is_pg_cancel_error(&e) => return Err("Query cancelled".to_string()),
|
||||
Err(_) => {
|
||||
return execute_pg_raw(client, trimmed, page, page_size, off).await;
|
||||
}
|
||||
@@ -381,6 +426,7 @@ fn execute_sqlite_query(
|
||||
// Try the wrapped count query first.
|
||||
let total_rows: i64 = match conn.query_row(&wrapped_count, [], |row| row.get::<_, i64>(0)) {
|
||||
Ok(n) => n,
|
||||
Err(e) if is_sqlite_cancel_error(&e) => return Err("Query cancelled".to_string()),
|
||||
Err(_) => {
|
||||
// Wrapping failed — fall back to raw execution.
|
||||
return execute_sqlite_raw(conn, trimmed, page, page_size, off);
|
||||
@@ -390,6 +436,11 @@ fn execute_sqlite_query(
|
||||
// Execute the wrapped data query.
|
||||
let (columns, all_rows) = match execute_sqlite_with_query(conn, &wrapped_data) {
|
||||
Ok(result) => result,
|
||||
// The data-step error is already a String (mapped inside
|
||||
// execute_sqlite_with_query); classify by the interrupt message.
|
||||
Err(e) if e.to_lowercase().contains("interrupted") => {
|
||||
return Err("Query cancelled".to_string());
|
||||
}
|
||||
Err(_) => {
|
||||
return execute_sqlite_raw(conn, trimmed, page, page_size, off);
|
||||
}
|
||||
@@ -533,8 +584,8 @@ pub(crate) fn mysql_cell_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde
|
||||
serde_json::Value::Null
|
||||
}
|
||||
|
||||
async fn execute_mysql_query(
|
||||
pool: &sqlx::MySqlPool,
|
||||
async fn execute_mysql_query_on(
|
||||
conn: &mut sqlx::mysql::MySqlConnection,
|
||||
query: &str,
|
||||
page: i64,
|
||||
page_size: i64,
|
||||
@@ -547,21 +598,23 @@ async fn execute_mysql_query(
|
||||
|
||||
// Try the wrapped count first; fall back to raw on failure.
|
||||
let total_rows: i64 = match sqlx::query_scalar::<_, i64>(&mysql_wrap_count(trimmed))
|
||||
.fetch_one(pool)
|
||||
.fetch_one(&mut *conn)
|
||||
.await
|
||||
{
|
||||
Ok(n) => n,
|
||||
Err(_) => return execute_mysql_raw(pool, trimmed, page, page_size, off).await,
|
||||
Err(e) if is_mysql_cancel_error(&e) => return Err("Query cancelled".to_string()),
|
||||
Err(_) => return execute_mysql_raw(&mut *conn, trimmed, page, page_size, off).await,
|
||||
};
|
||||
|
||||
let data_rows = match sqlx::query(&mysql_wrap_data(trimmed))
|
||||
.bind(page_size)
|
||||
.bind(off)
|
||||
.fetch_all(pool)
|
||||
.fetch_all(&mut *conn)
|
||||
.await
|
||||
{
|
||||
Ok(rows) => rows,
|
||||
Err(_) => return execute_mysql_raw(pool, trimmed, page, page_size, off).await,
|
||||
Err(e) if is_mysql_cancel_error(&e) => return Err("Query cancelled".to_string()),
|
||||
Err(_) => return execute_mysql_raw(&mut *conn, trimmed, page, page_size, off).await,
|
||||
};
|
||||
|
||||
let columns: Vec<ColumnInfo> = match data_rows.first() {
|
||||
@@ -580,7 +633,7 @@ async fn execute_mysql_query(
|
||||
is_generated: false,
|
||||
})
|
||||
.collect(),
|
||||
None => return execute_mysql_raw(pool, trimmed, page, page_size, off).await,
|
||||
None => return execute_mysql_raw(&mut *conn, trimmed, page, page_size, off).await,
|
||||
};
|
||||
|
||||
let rows: Vec<Vec<serde_json::Value>> = data_rows
|
||||
@@ -602,14 +655,14 @@ async fn execute_mysql_query(
|
||||
/// mirroring the PG `simple_query` raw path. Used when wrapping fails
|
||||
/// (e.g., multi-statement or non-selectable SQL).
|
||||
async fn execute_mysql_raw(
|
||||
pool: &sqlx::MySqlPool,
|
||||
conn: &mut sqlx::mysql::MySqlConnection,
|
||||
query: &str,
|
||||
page: i64,
|
||||
page_size: i64,
|
||||
off: i64,
|
||||
) -> Result<QueryResult, String> {
|
||||
let rows = sqlx::query(query)
|
||||
.fetch_all(pool)
|
||||
.fetch_all(&mut *conn)
|
||||
.await
|
||||
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
|
||||
|
||||
@@ -748,7 +801,70 @@ pub async fn execute_query(
|
||||
let p = page.unwrap_or(1);
|
||||
let ps = page_size.unwrap_or(50);
|
||||
let mut pm = state.pool_manager.lock().await;
|
||||
execute_query_inner(&mut pm, &state.db_store, &connection_id, &query, p, ps).await
|
||||
execute_query_inner(
|
||||
&mut pm,
|
||||
&state.db_store,
|
||||
&state.cancel_registry,
|
||||
&connection_id,
|
||||
&query,
|
||||
p,
|
||||
ps,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Cancel a query currently running on the given connection.
|
||||
///
|
||||
/// - PostgreSQL: opens a short-lived cancel connection (reusing the exact TLS
|
||||
/// decision/config from connect) and sends a cancel request keyed to the
|
||||
/// original backend.
|
||||
/// - MySQL: opens a fresh connection and runs `KILL QUERY <conn_id>` for the
|
||||
/// connection currently running the query (registered per-query).
|
||||
/// - SQLite: signals the per-connection `InterruptHandle` (thread-safe).
|
||||
#[tauri::command]
|
||||
pub async fn cancel_query(
|
||||
connection_id: String,
|
||||
state: State<'_, crate::AppState>,
|
||||
) -> Result<(), String> {
|
||||
use sqlx::ConnectOptions;
|
||||
match state.cancel_registry.get(&connection_id) {
|
||||
Some(crate::cancel::CancelHandle::Pg(pg)) => {
|
||||
// A Verify/Require decision always carries a built rustls config,
|
||||
// so the unwrap on the non-Disable branch is safe by construction.
|
||||
match pg.tls_decision {
|
||||
crate::db::tls::TlsDecision::Disable => {
|
||||
pg.cancel_token.cancel_query(tokio_postgres::NoTls).await
|
||||
}
|
||||
_ => {
|
||||
let connector = tokio_postgres_rustls::MakeRustlsConnect::new(
|
||||
(*pg.tls_config.expect("tls config for non-disable decision")).clone(),
|
||||
);
|
||||
pg.cancel_token.cancel_query(connector).await
|
||||
}
|
||||
}
|
||||
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))
|
||||
}
|
||||
Some(crate::cancel::CancelHandle::MySql(m)) => {
|
||||
let id = m
|
||||
.conn_id
|
||||
.ok_or_else(|| "No active query on this connection".to_string())?;
|
||||
let mut c = m
|
||||
.connect_options
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
|
||||
sqlx::query(&format!("KILL QUERY {id}"))
|
||||
.execute(&mut c)
|
||||
.await
|
||||
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
Some(crate::cancel::CancelHandle::Sqlite(s)) => {
|
||||
s.interrupt();
|
||||
Ok(())
|
||||
}
|
||||
None => Err("No active cancel handle for this connection".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1060,6 +1176,7 @@ mod tests {
|
||||
username: None,
|
||||
folder_id: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
password: None,
|
||||
database: None,
|
||||
environment: None,
|
||||
@@ -1137,4 +1254,127 @@ mod tests {
|
||||
let q = mysql_wrap_count("SELECT * FROM t");
|
||||
assert_eq!(q, "SELECT COUNT(*) FROM (SELECT * FROM t) AS _gridline_cnt");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Cancel propagation (v0.7.8 bugfix): a user cancel must NOT be swallowed
|
||||
// by the wrapped→raw fallback (which would re-run the cancelled query).
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn is_sqlite_cancel_error_classifies_interrupt() {
|
||||
let interrupted = rusqlite::Error::SqliteFailure(
|
||||
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_INTERRUPT),
|
||||
Some("interrupted".to_string()),
|
||||
);
|
||||
assert!(is_sqlite_cancel_error(&interrupted));
|
||||
|
||||
let other = rusqlite::Error::SqliteFailure(
|
||||
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR),
|
||||
Some("SQL logic error".to_string()),
|
||||
);
|
||||
assert!(!is_sqlite_cancel_error(&other));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_cancel_aborts_wrapped_query_without_rerun() {
|
||||
use std::sync::mpsc;
|
||||
// A slow query whose wrapped COUNT step takes seconds — interrupt() must
|
||||
// abort it and surface "Query cancelled" instead of falling back to the
|
||||
// raw re-run (which would consume the interrupt flag and run to
|
||||
// completion, hiding the cancellation).
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
let handle = conn.get_interrupt_handle();
|
||||
let slow = "WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM c LIMIT 50000000) SELECT count(*) AS n FROM c";
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let res = execute_sqlite_query(&conn, slow, 1, 50);
|
||||
let cancelled = match &res {
|
||||
Err(msg) => msg.contains("Query cancelled"),
|
||||
Ok(_) => false,
|
||||
};
|
||||
let _ = tx.send(cancelled);
|
||||
});
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
handle.interrupt();
|
||||
let cancelled = rx
|
||||
.recv_timeout(std::time::Duration::from_secs(15))
|
||||
.expect("query thread must finish");
|
||||
assert!(cancelled, "cancel must abort the wrapped query with 'Query cancelled' instead of re-running it");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn pg_cancel_aborts_wrapped_query_without_rerun() {
|
||||
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 token = client.cancel_token();
|
||||
let run = tokio::spawn(async move {
|
||||
execute_pg_query(&client, "SELECT pg_sleep(3)", 1, 50).await
|
||||
});
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
token
|
||||
.cancel_query(tokio_postgres::NoTls)
|
||||
.await
|
||||
.expect("cancel request");
|
||||
let res = run.await.expect("query task");
|
||||
assert!(res.is_err(), "pg_sleep must be cancelled, not re-run; got {res:?}");
|
||||
assert!(res.unwrap_err().contains("Query cancelled"));
|
||||
let _ = handle;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn mysql_cancel_aborts_wrapped_query_without_rerun() {
|
||||
use sqlx::ConnectOptions;
|
||||
let h = std::env::var("GRIDLINE_TEST_MYSQL_HOST").expect("set GRIDLINE_TEST_MYSQL_HOST");
|
||||
let p: u16 = std::env::var("GRIDLINE_TEST_MYSQL_PORT")
|
||||
.unwrap_or_else(|_| "3306".into())
|
||||
.parse()
|
||||
.unwrap();
|
||||
let u = std::env::var("GRIDLINE_TEST_MYSQL_USER").expect("set GRIDLINE_TEST_MYSQL_USER");
|
||||
let pw = std::env::var("GRIDLINE_TEST_MYSQL_PASS").unwrap_or_default();
|
||||
let db = std::env::var("GRIDLINE_TEST_MYSQL_DB").unwrap_or_default();
|
||||
let opts = sqlx::mysql::MySqlConnectOptions::new()
|
||||
.host(&h)
|
||||
.port(p)
|
||||
.username(&u)
|
||||
.password(&pw)
|
||||
.database(&db);
|
||||
let pool = sqlx::mysql::MySqlPoolOptions::new()
|
||||
.connect_with(opts.clone())
|
||||
.await
|
||||
.expect("connect to test MySQL");
|
||||
let mut conn = pool.acquire().await.expect("acquire");
|
||||
let conn_id: i64 = sqlx::query_scalar("SELECT CAST(CONNECTION_ID() AS SIGNED)")
|
||||
.fetch_one(&mut *conn)
|
||||
.await
|
||||
.unwrap();
|
||||
let run = tokio::spawn(async move {
|
||||
execute_mysql_query_on(&mut *conn, "SELECT SLEEP(3)", 1, 50).await
|
||||
});
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
let mut killer = opts.connect().await.expect("killer connect");
|
||||
sqlx::query(&format!("KILL QUERY {conn_id}"))
|
||||
.execute(&mut killer)
|
||||
.await
|
||||
.expect("kill");
|
||||
let res = run.await.expect("query task");
|
||||
assert!(res.is_err(), "SLEEP(3) must be killed, not re-run; got {res:?}");
|
||||
assert!(res.unwrap_err().contains("Query cancelled"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::models::Settings;
|
||||
use crate::store::Store;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub fn get_settings_inner(state: &Mutex<Store>) -> Result<Settings, String> {
|
||||
@@ -26,6 +27,68 @@ pub fn update_setting(
|
||||
update_setting_inner(&state.db_store, &key, &value)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings export / import (v0.7.8)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Flatten a `Settings` struct into the store's key/value map. Keys and value
|
||||
/// formats must round-trip through `Store::get_settings` (e.g. a `None`
|
||||
/// `default_folder_id` is stored as the literal `"null"` sentinel, which
|
||||
/// `get_settings` filters back to `None`).
|
||||
fn settings_to_kv(s: &crate::models::Settings) -> HashMap<String, String> {
|
||||
let mut m = HashMap::new();
|
||||
m.insert("confirm_before_delete".into(), s.confirm_before_delete.to_string());
|
||||
if let Some(f) = &s.default_folder_id {
|
||||
m.insert("default_folder_id".into(), f.clone());
|
||||
} else {
|
||||
m.insert("default_folder_id".into(), "null".into());
|
||||
}
|
||||
m.insert("theme".into(), s.theme.clone());
|
||||
m.insert("font_size".into(), s.font_size.clone());
|
||||
m.insert("accent_color".into(), s.accent_color.clone());
|
||||
m.insert("table_refresh_rate".into(), s.table_refresh_rate.to_string());
|
||||
m.insert("table_page_size".into(), s.table_page_size.to_string());
|
||||
m.insert("editor_font_size".into(), s.editor_font_size.to_string());
|
||||
m.insert("editor_font_family".into(), s.editor_font_family.clone());
|
||||
m.insert("editor_word_wrap".into(), s.editor_word_wrap.clone());
|
||||
m.insert("editor_minimap".into(), s.editor_minimap.to_string());
|
||||
m.insert("editor_tab_size".into(), s.editor_tab_size.to_string());
|
||||
if let Some(o) = &s.tag_order {
|
||||
m.insert("tag_order".into(), o.clone());
|
||||
}
|
||||
m.insert(
|
||||
"default_ports".into(),
|
||||
serde_json::to_string(&s.default_ports).unwrap_or_default(),
|
||||
);
|
||||
m.insert(
|
||||
"shortcuts".into(),
|
||||
serde_json::to_string(&s.shortcuts).unwrap_or_default(),
|
||||
);
|
||||
m
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn export_settings(state: tauri::State<crate::AppState>) -> Result<String, String> {
|
||||
let s = get_settings_inner(&state.db_store)?;
|
||||
serde_json::to_string(&crate::models::settings::SettingsExport {
|
||||
schema_version: 1,
|
||||
settings: s,
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn import_settings(
|
||||
json: String,
|
||||
state: tauri::State<crate::AppState>,
|
||||
) -> Result<(), String> {
|
||||
let env: crate::models::settings::SettingsExport =
|
||||
serde_json::from_str(&json).map_err(|e| format!("invalid settings file: {e}"))?;
|
||||
let map = settings_to_kv(&env.settings);
|
||||
let store = state.db_store.lock().map_err(|e| e.to_string())?;
|
||||
store.apply_settings(&map)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -59,4 +122,31 @@ mod tests {
|
||||
update_setting_inner(&st, "accent_color", "#EF4444").unwrap();
|
||||
assert_eq!(get_settings_inner(&st).unwrap().accent_color, "#EF4444");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_export_envelope_camel_case() {
|
||||
use std::collections::HashMap;
|
||||
let env = crate::models::settings::SettingsExport { schema_version: 1, settings: crate::models::Settings {
|
||||
confirm_before_delete: true, default_folder_id: None, theme: "dark".into(), font_size: "medium".into(),
|
||||
default_ports: HashMap::new(), tag_order: None, table_refresh_rate: 5, table_page_size: 50,
|
||||
shortcuts: HashMap::new(), accent_color: "#2563EB".into(), editor_font_size: 14,
|
||||
editor_font_family: "Menlo".into(), editor_word_wrap: "off".into(), editor_minimap: true, editor_tab_size: 2,
|
||||
}};
|
||||
let json = serde_json::to_string(&env).unwrap();
|
||||
assert!(json.contains("\"schemaVersion\":1"));
|
||||
assert!(json.contains("\"settings\":"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_apply_settings_writes_all_keys() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
crate::store::migrations::run_migrations(&conn).unwrap();
|
||||
let store = crate::store::Store::from_connection(conn);
|
||||
let mut map = std::collections::HashMap::new();
|
||||
map.insert("theme".to_string(), "light".to_string());
|
||||
map.insert("accent_color".to_string(), "#EF4444".to_string());
|
||||
store.apply_settings(&map).unwrap();
|
||||
assert_eq!(store.get_settings().unwrap().theme, "light");
|
||||
assert_eq!(store.get_settings().unwrap().accent_color, "#EF4444");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`),
|
||||
@@ -347,6 +355,165 @@ pub fn pg_constraints_query(_schema: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Roles, privileges, tablespaces, rebuild readiness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// List non-system roles with all attributes used by the role form.
|
||||
pub fn pg_roles_query() -> String {
|
||||
"SELECT rolname, rolsuper, rolinherit, rolcreatedb, rolcreaterole, rolcanlogin, \
|
||||
rolreplication, rolbypassrls, rolconnlimit::int8, COALESCE(rolvaliduntil::text, '') AS rolvaliduntil \
|
||||
FROM pg_roles WHERE rolname !~ '^pg_' ORDER BY rolname"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// All role-to-role memberships (member/admin/grantor).
|
||||
pub fn pg_role_memberships_query() -> String {
|
||||
"SELECT roleid::regrole::text AS role, member::regrole::text AS member, \
|
||||
grantor::regrole::text AS grantor, admin_option \
|
||||
FROM pg_auth_members ORDER BY role"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Table/view/matview privileges for a grantee, grouped one row per object.
|
||||
pub fn pg_table_privileges_query(role: &str) -> String {
|
||||
format!(
|
||||
"SELECT table_schema AS schema, table_name AS name, \
|
||||
array_agg(privilege_type::text) AS privileges, \
|
||||
bool_or(is_grantable = 'YES') AS grantable \
|
||||
FROM information_schema.table_privileges \
|
||||
WHERE grantee = '{}' AND table_schema NOT IN ('pg_catalog','information_schema') \
|
||||
GROUP BY table_schema, table_name \
|
||||
ORDER BY table_schema, table_name",
|
||||
role
|
||||
)
|
||||
}
|
||||
|
||||
/// Sequence privileges for a grantee, grouped one row per sequence.
|
||||
/// aclexplode-based (role_sequence_grants was removed in PG 15).
|
||||
pub fn pg_sequence_privileges_query(role: &str) -> String {
|
||||
format!(
|
||||
"SELECT n.nspname AS schema, c.relname AS name, \
|
||||
array_agg(p.privilege_type::text) AS privileges, \
|
||||
bool_or(p.is_grantable) AS grantable \
|
||||
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace, \
|
||||
LATERAL aclexplode(c.relacl) p \
|
||||
WHERE c.relkind = 'S' AND n.nspname NOT IN ('pg_catalog','information_schema') \
|
||||
AND p.grantee = (SELECT oid FROM pg_roles WHERE rolname = '{}') \
|
||||
GROUP BY n.nspname, c.relname \
|
||||
ORDER BY n.nspname, c.relname",
|
||||
role
|
||||
)
|
||||
}
|
||||
|
||||
/// Routine (function/procedure) privileges for a grantee.
|
||||
pub fn pg_routine_privileges_query(role: &str) -> String {
|
||||
format!(
|
||||
"SELECT routine_schema AS schema, routine_name AS name, \
|
||||
array_agg(privilege_type::text) AS privileges, \
|
||||
bool_or(is_grantable = 'YES') AS grantable \
|
||||
FROM information_schema.routine_privileges \
|
||||
WHERE grantee = '{}' \
|
||||
GROUP BY routine_schema, routine_name \
|
||||
ORDER BY routine_schema, routine_name",
|
||||
role
|
||||
)
|
||||
}
|
||||
|
||||
/// Schema privileges for a grantee (USAGE/CREATE). aclexplode-based —
|
||||
/// information_schema has no schema_privileges view.
|
||||
pub fn pg_schema_privileges_query(role: &str) -> String {
|
||||
format!(
|
||||
"SELECT n.nspname AS name, \
|
||||
array_agg(p.privilege_type::text) AS privileges, \
|
||||
bool_or(p.is_grantable) AS grantable \
|
||||
FROM pg_namespace n, LATERAL aclexplode(n.nspacl) p \
|
||||
WHERE n.nspname NOT IN ('pg_catalog','information_schema') \
|
||||
AND p.grantee = (SELECT oid FROM pg_roles WHERE rolname = '{}') \
|
||||
GROUP BY n.nspname ORDER BY n.nspname",
|
||||
role
|
||||
)
|
||||
}
|
||||
|
||||
/// Database privileges for a grantee via aclexplode (broadly compatible; avoids PG15-only view).
|
||||
pub fn pg_database_privileges_query(role: &str) -> String {
|
||||
format!(
|
||||
"SELECT d.datname AS name, array_agg(a.privilege_type) AS privileges, \
|
||||
bool_or(a.is_grantable) AS grantable \
|
||||
FROM pg_database d, LATERAL aclexplode(d.datacl) a \
|
||||
WHERE a.grantee = (SELECT oid FROM pg_roles WHERE rolname = '{}') \
|
||||
AND d.datistemplate = false \
|
||||
GROUP BY d.datname \
|
||||
ORDER BY d.datname",
|
||||
role
|
||||
)
|
||||
}
|
||||
|
||||
/// Non-system tablespaces for the table-options picker.
|
||||
pub fn pg_tablespaces_query() -> String {
|
||||
"SELECT spcname FROM pg_tablespace WHERE spcname !~ '^pg_' ORDER BY spcname".to_string()
|
||||
}
|
||||
|
||||
/// Single query returning boolean blockers for a table rebuild (triggers, policies,
|
||||
/// inheritance, partitioning, generated/identity columns). Parameterized via $1/$2.
|
||||
pub fn pg_rebuild_readiness_query() -> String {
|
||||
"SELECT \
|
||||
EXISTS(SELECT 1 FROM pg_trigger t JOIN pg_class c ON t.tgrelid = c.oid \
|
||||
JOIN pg_namespace n ON c.relnamespace = n.oid \
|
||||
WHERE n.nspname = $1 AND c.relname = $2 AND NOT t.tgisinternal) AS has_triggers, \
|
||||
EXISTS(SELECT 1 FROM pg_policy p JOIN pg_class c ON p.polrelid = c.oid \
|
||||
JOIN pg_namespace n ON c.relnamespace = n.oid \
|
||||
WHERE n.nspname = $1 AND c.relname = $2) AS has_policies, \
|
||||
EXISTS(SELECT 1 FROM pg_inherits i JOIN pg_class c ON i.inhrelid = c.oid \
|
||||
JOIN pg_namespace n ON c.relnamespace = n.oid \
|
||||
WHERE n.nspname = $1 AND c.relname = $2) AS is_inherits, \
|
||||
EXISTS(SELECT 1 FROM pg_partitioned_table pt JOIN pg_class c ON pt.partrelid = c.oid \
|
||||
JOIN pg_namespace n ON c.relnamespace = n.oid \
|
||||
WHERE n.nspname = $1 AND c.relname = $2) AS is_partitioned, \
|
||||
EXISTS(SELECT 1 FROM information_schema.columns \
|
||||
WHERE table_schema = $1 AND table_name = $2 AND is_generated <> '') AS has_generated"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// FKs owned by this table (contype='f'). Parameterized $1 schema, $2 table.
|
||||
pub fn pg_table_fk_out_query() -> String {
|
||||
"SELECT c.conname, pg_get_constraintdef(c.oid) AS definition \
|
||||
FROM pg_constraint c JOIN pg_class cl ON c.conrelid = cl.oid \
|
||||
JOIN pg_namespace n ON cl.relnamespace = n.oid \
|
||||
WHERE n.nspname = $1 AND cl.relname = $2 AND c.contype = 'f'".to_string()
|
||||
}
|
||||
|
||||
/// FKs from other tables referencing this table. Parameterized $1 schema, $2 table.
|
||||
pub fn pg_table_fk_in_query() -> String {
|
||||
"SELECT c.conname, cn.nspname AS own_schema, cl.relname AS own_table, \
|
||||
pg_get_constraintdef(c.oid) AS definition \
|
||||
FROM pg_constraint c JOIN pg_class cl ON c.conrelid = cl.oid \
|
||||
JOIN pg_namespace cn ON cl.relnamespace = cn.oid \
|
||||
JOIN pg_class r ON c.confrelid = r.oid \
|
||||
JOIN pg_namespace rn ON r.relnamespace = rn.oid \
|
||||
WHERE rn.nspname = $1 AND r.relname = $2 AND c.contype = 'f'".to_string()
|
||||
}
|
||||
|
||||
/// Grants on this table (all grantees), grouped. Parameterized $1 schema, $2 table.
|
||||
pub fn pg_table_grants_query() -> String {
|
||||
"SELECT grantee, array_agg(privilege_type::text) AS privileges, \
|
||||
bool_or(is_grantable = 'YES') AS grantable \
|
||||
FROM information_schema.table_privileges \
|
||||
WHERE table_schema = $1 AND table_name = $2 AND grantee <> 'PUBLIC' \
|
||||
GROUP BY grantee".to_string()
|
||||
}
|
||||
|
||||
/// Sequences owned by this table's columns (via pg_depend). Parameterized $1 schema, $2 table.
|
||||
pub fn pg_table_owned_sequences_query() -> String {
|
||||
"SELECT sn.nspname AS seq_schema, s.relname AS seq_name, a.attname AS column \
|
||||
FROM pg_depend d JOIN pg_class s ON d.objid = s.oid \
|
||||
JOIN pg_namespace sn ON s.relnamespace = sn.oid \
|
||||
JOIN pg_class t ON d.refobjid = t.oid \
|
||||
JOIN pg_namespace tn ON t.relnamespace = tn.oid \
|
||||
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid \
|
||||
WHERE tn.nspname = $1 AND t.relname = $2 AND d.classid = 'pg_class'::regclass AND s.relkind = 'S'".to_string()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -624,4 +791,99 @@ 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}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Roles, privileges, tablespaces, rebuild readiness
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn pg_roles_query_filters_pg_prefix() {
|
||||
let sql = pg_roles_query();
|
||||
assert!(sql.contains("pg_roles"));
|
||||
assert!(sql.contains("rolname !~ '^pg_'"));
|
||||
assert!(sql.contains("rolcanlogin"));
|
||||
assert!(sql.contains("rolconnlimit::int8"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_role_memberships_query_uses_pg_auth_members() {
|
||||
let sql = pg_role_memberships_query();
|
||||
assert!(sql.contains("pg_auth_members"));
|
||||
assert!(sql.contains("admin_option"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_table_privileges_query_filters_grantee_and_aggregates() {
|
||||
let sql = pg_table_privileges_query("appuser");
|
||||
assert!(sql.contains("information_schema.table_privileges"));
|
||||
assert!(sql.contains("grantee = 'appuser'"));
|
||||
assert!(sql.contains("array_agg"));
|
||||
assert!(sql.contains("GROUP BY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_database_privileges_query_uses_aclexplode() {
|
||||
let sql = pg_database_privileges_query("appuser");
|
||||
assert!(sql.contains("aclexplode"));
|
||||
assert!(sql.contains("pg_database"));
|
||||
assert!(sql.contains("appuser"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_rebuild_readiness_query_checks_blockers() {
|
||||
let sql = pg_rebuild_readiness_query();
|
||||
assert!(sql.contains("pg_trigger"));
|
||||
assert!(sql.contains("pg_policy"));
|
||||
assert!(sql.contains("pg_inherits"));
|
||||
assert!(sql.contains("pg_partitioned_table"));
|
||||
assert!(sql.contains("is_generated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_tablespaces_query_filters_pg_prefix() {
|
||||
let sql = pg_tablespaces_query();
|
||||
assert!(sql.contains("pg_tablespace"));
|
||||
assert!(sql.contains("spcname !~ '^pg_'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_table_fk_out_query_uses_pg_constraint_f() {
|
||||
let sql = pg_table_fk_out_query();
|
||||
assert!(sql.contains("pg_constraint"));
|
||||
assert!(sql.contains("contype = 'f'"));
|
||||
assert!(sql.contains("$1") && sql.contains("$2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_table_fk_in_query_finds_referencing_tables() {
|
||||
let sql = pg_table_fk_in_query();
|
||||
assert!(sql.contains("pg_constraint"));
|
||||
assert!(sql.contains("confrel"));
|
||||
assert!(sql.contains("$1") && sql.contains("$2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_table_grants_query_uses_table_privileges() {
|
||||
let sql = pg_table_grants_query();
|
||||
assert!(sql.contains("information_schema.table_privileges"));
|
||||
assert!(sql.contains("$1") && sql.contains("$2"));
|
||||
assert!(sql.contains("array_agg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_table_owned_sequences_query_uses_pg_depend() {
|
||||
let sql = pg_table_owned_sequences_query();
|
||||
assert!(sql.contains("pg_depend"));
|
||||
assert!(sql.contains("pg_class"));
|
||||
assert!(sql.contains("$1") && sql.contains("$2"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod introspection;
|
||||
pub mod mysql;
|
||||
pub mod object_crud;
|
||||
pub mod object_ddl;
|
||||
pub mod pool;
|
||||
pub mod tls;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,21 @@
|
||||
//! Pure builders for schema DDL, cross-object search, pg_depend lookups,
|
||||
//! and synthesized object DDL. No DB I/O — deterministic string builders.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::models::db_viewer::{SequenceInfo, EnumInfo, ExtensionInfo, ConstraintInfo};
|
||||
|
||||
/// Column model for the SQLite table editor. Mirrors the frontend payload.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SqliteColumn {
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub type_: String,
|
||||
pub nullable: bool,
|
||||
pub default: Option<String>,
|
||||
pub is_pk: bool,
|
||||
pub auto_increment: bool,
|
||||
pub unique: bool,
|
||||
}
|
||||
|
||||
/// Double-quote an identifier, doubling embedded quotes.
|
||||
pub fn quote_ident(name: &str) -> String {
|
||||
format!("\"{}\"", name.replace('"', "\"\""))
|
||||
@@ -125,6 +139,98 @@ pub fn constraint_ddl(c: &ConstraintInfo) -> String {
|
||||
quote_ident(&c.schema), quote_ident(&c.table), quote_ident(&c.name), c.definition)
|
||||
}
|
||||
|
||||
// --- SQLite table editor ---
|
||||
|
||||
fn validate_type_fragment(t: &str) -> Result<(), String> {
|
||||
let l = t.trim().to_lowercase();
|
||||
if l.is_empty() { return Err("column type is required".into()); }
|
||||
if l.contains(';') || l.contains("--") || l.contains("/*") { return Err("invalid characters in type".into()); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// CREATE TABLE for SQLite. PK inline for AUTOINCREMENT; single non-AUTOINCREMENT
|
||||
/// PKs get a table-level PRIMARY KEY clause; FKs appended inline (SQLite grammar).
|
||||
pub fn sqlite_create_table_sql(table: &str, cols: &[SqliteColumn], fks: &[(&str, &str)]) -> Result<String, String> {
|
||||
validate_object_name(table)?;
|
||||
let mut defs: Vec<String> = vec![];
|
||||
let mut pk_cols: Vec<String> = vec![];
|
||||
for c in cols {
|
||||
validate_object_name(&c.name)?;
|
||||
validate_type_fragment(&c.type_)?;
|
||||
let mut d = format!("{} {}", quote_ident(&c.name), c.type_.trim());
|
||||
if c.auto_increment && c.is_pk && c.type_.trim().eq_ignore_ascii_case("INTEGER") {
|
||||
d = format!("{} INTEGER PRIMARY KEY AUTOINCREMENT", quote_ident(&c.name));
|
||||
} else {
|
||||
if !c.nullable { d.push_str(" NOT NULL"); }
|
||||
if let Some(def) = &c.default { d.push_str(&format!(" DEFAULT {def}")); }
|
||||
if c.unique && !c.is_pk { d.push_str(" UNIQUE"); }
|
||||
if c.is_pk { pk_cols.push(quote_ident(&c.name)); }
|
||||
}
|
||||
defs.push(d);
|
||||
}
|
||||
// Always emit a table-level PRIMARY KEY when there are non-AUTOINCREMENT PK
|
||||
// columns (single or composite) — the plan draft's `len > 1` condition would
|
||||
// silently drop a single-column PK constraint.
|
||||
if !pk_cols.is_empty() {
|
||||
defs.push(format!("PRIMARY KEY ({})", pk_cols.join(", ")));
|
||||
}
|
||||
for (lc, refc) in fks {
|
||||
defs.push(format!("FOREIGN KEY ({}) REFERENCES {}", quote_ident(lc), refc));
|
||||
}
|
||||
Ok(format!("CREATE TABLE \"main\".{} ({})", quote_ident(table), defs.join(", ")))
|
||||
}
|
||||
|
||||
/// Emit one statement per needed edit; falls back to a rebuild script (multi-stmt)
|
||||
/// for edits SQLite's ALTER TABLE can't express.
|
||||
pub fn sqlite_column_diff_sql(table: &str, old: &[SqliteColumn], new: &[SqliteColumn]) -> Result<Vec<String>, String> {
|
||||
// rename detection: same position+type+nullable+default, name changed
|
||||
for (i, n) in new.iter().enumerate() {
|
||||
if let Some(o) = old.get(i) {
|
||||
if o.name != n.name && o.type_ == n.type_ && o.default == n.default && o.nullable == n.nullable {
|
||||
return Ok(vec![format!("ALTER TABLE \"main\".{} RENAME COLUMN {} TO {}", quote_ident(table), quote_ident(&o.name), quote_ident(&n.name))]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// add column (new tail column, safe only if nullable or defaulted)
|
||||
if new.len() > old.len() {
|
||||
if let Some(c) = new.last() {
|
||||
if c.nullable || c.default.is_some() {
|
||||
validate_object_name(&c.name)?;
|
||||
validate_type_fragment(&c.type_)?;
|
||||
let mut d = format!("ALTER TABLE \"main\".{} ADD COLUMN {} {}", quote_ident(table), quote_ident(&c.name), c.type_.trim());
|
||||
if !c.nullable {
|
||||
d.push_str(&format!(" DEFAULT {}", c.default.as_deref().unwrap_or("''")));
|
||||
}
|
||||
return Ok(vec![d]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// otherwise: full rebuild (type change, NOT NULL, drop, PK/UNIQUE/FK add, reorder)
|
||||
sqlite_rebuild_script(table, old, new)
|
||||
}
|
||||
|
||||
pub fn sqlite_rebuild_script(table: &str, _old: &[SqliteColumn], new: &[SqliteColumn]) -> Result<Vec<String>, String> {
|
||||
validate_object_name(table)?;
|
||||
let tmp = format!("_gl_{}_tmp", table);
|
||||
let create = sqlite_create_table_sql(&tmp, new, &[])?;
|
||||
let cols = new.iter().map(|c| quote_ident(&c.name)).collect::<Vec<_>>().join(", ");
|
||||
Ok(vec![
|
||||
create,
|
||||
format!("INSERT INTO \"main\".{} ({}) SELECT * FROM \"main\".{}", quote_ident(&tmp), cols, quote_ident(table)),
|
||||
format!("DROP TABLE \"main\".{}", quote_ident(table)),
|
||||
format!("ALTER TABLE \"main\".{} RENAME TO {}", quote_ident(&tmp), quote_ident(table)),
|
||||
])
|
||||
}
|
||||
|
||||
/// Fail-closed readiness reason, or None if rebuild is safe.
|
||||
/// AUTOINCREMENT tables are refused in v0.7.8 (rowid counter would be lost).
|
||||
pub fn sqlite_rebuild_refusal(old: &[SqliteColumn]) -> Option<String> {
|
||||
if old.iter().any(|c| c.auto_increment) {
|
||||
return Some("rebuild is not supported for AUTOINCREMENT tables in v0.7.8 (rowid counter would be lost)".into());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -258,4 +364,50 @@ mod tests {
|
||||
let ddl = constraint_ddl(&c);
|
||||
assert_eq!(ddl, "ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"ck_pos\" CHECK (amount > 0)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_create_with_autoincrement_pk() {
|
||||
let cols = vec![
|
||||
SqliteColumn { name: "id".into(), type_: "INTEGER".into(), nullable: false, default: None, is_pk: true, auto_increment: true, unique: false },
|
||||
SqliteColumn { name: "name".into(), type_: "TEXT".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false },
|
||||
];
|
||||
let sql = sqlite_create_table_sql("users", &cols, &[]).unwrap();
|
||||
assert!(sql.contains("\"id\" INTEGER PRIMARY KEY AUTOINCREMENT"));
|
||||
assert!(sql.contains("\"name\" TEXT"));
|
||||
assert!(sql.starts_with("CREATE TABLE \"main\".\"users\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_create_rejects_bad_identifier() {
|
||||
let cols = vec![SqliteColumn { name: "a;b".into(), type_: "INTEGER".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
|
||||
assert!(sqlite_create_table_sql("bad name", &cols, &[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_edit_add_column_when_safe() {
|
||||
let old = vec![SqliteColumn { name: "id".into(), type_: "INTEGER".into(), nullable: false, default: None, is_pk: true, auto_increment: true, unique: false }];
|
||||
let new = vec![
|
||||
old[0].clone(),
|
||||
SqliteColumn { name: "email".into(), type_: "TEXT".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false },
|
||||
];
|
||||
let stmts = sqlite_column_diff_sql("users", &old, &new).unwrap();
|
||||
assert_eq!(stmts.len(), 1);
|
||||
assert!(stmts[0].contains("ALTER TABLE \"main\".\"users\" ADD COLUMN \"email\" TEXT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_edit_rename_column() {
|
||||
let old = vec![SqliteColumn { name: "id".into(), type_: "INTEGER".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
|
||||
let new = vec![SqliteColumn { name: "id2".into(), type_: "INTEGER".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
|
||||
let stmts = sqlite_column_diff_sql("t", &old, &new).unwrap();
|
||||
assert!(stmts.iter().any(|s| s.contains("RENAME COLUMN \"id\" TO \"id2\"")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_edit_typechange_requires_rebuild() {
|
||||
let old = vec![SqliteColumn { name: "v".into(), type_: "TEXT".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
|
||||
let new = vec![SqliteColumn { name: "v".into(), type_: "INTEGER".into(), nullable: true, default: None, is_pk: false, auto_increment: false, unique: false }];
|
||||
let stmts = sqlite_column_diff_sql("t", &old, &new).unwrap();
|
||||
assert!(stmts.iter().any(|s| s.contains("CREATE TABLE \"main\".\"_gl_t_tmp\"")), "type change must rebuild; got {stmts:?}");
|
||||
}
|
||||
}
|
||||
+23
-1
@@ -2,6 +2,7 @@
|
||||
// of runtime usage, producing expected dead_code/unused warnings during development.
|
||||
#![allow(dead_code)]
|
||||
|
||||
mod cancel;
|
||||
mod commands;
|
||||
mod db;
|
||||
mod models;
|
||||
@@ -17,10 +18,11 @@ pub struct AppState {
|
||||
pub db_store: StdMutex<Store>,
|
||||
pub pool_manager: tokio::sync::Mutex<ConnectionPoolManager>,
|
||||
pub ssh_manager: StdMutex<SshTunnelManager>,
|
||||
pub cancel_registry: crate::cancel::CancelRegistry,
|
||||
}
|
||||
|
||||
use commands::{
|
||||
backup, connections, db_viewer, demo, folders, import_export, keychain, objects, query,
|
||||
backup, connections, db_viewer, demo, folders, import_export, keychain, maintenance, objects, query,
|
||||
schema_graph, settings, tags,
|
||||
};
|
||||
|
||||
@@ -48,6 +50,7 @@ pub fn run() {
|
||||
db_store: store_ref,
|
||||
pool_manager: tokio::sync::Mutex::new(ConnectionPoolManager::new()),
|
||||
ssh_manager: StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend))),
|
||||
cancel_registry: crate::cancel::CancelRegistry::default(),
|
||||
})
|
||||
.setup(move |app| {
|
||||
let state = app.state::<AppState>();
|
||||
@@ -69,6 +72,9 @@ pub fn run() {
|
||||
if let Ok(mut mgr) = s.ssh_manager.lock() {
|
||||
mgr.close_tunnel(id);
|
||||
}
|
||||
// Drop the cancel handles for the evicted connection
|
||||
// (tokens/interrupts outlive the pool otherwise).
|
||||
s.cancel_registry.remove(id);
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -105,6 +111,7 @@ pub fn run() {
|
||||
db_viewer::get_schemas,
|
||||
db_viewer::get_tables,
|
||||
db_viewer::get_table_data,
|
||||
db_viewer::get_table_columns,
|
||||
db_viewer::get_fk_preview,
|
||||
db_viewer::execute_change,
|
||||
db_viewer::get_table_ddl,
|
||||
@@ -122,6 +129,11 @@ pub fn run() {
|
||||
objects::search_objects,
|
||||
objects::get_object_ddl,
|
||||
objects::get_object_dependencies,
|
||||
objects::build_object_ddl,
|
||||
objects::build_rebuild_script,
|
||||
objects::get_available_extensions,
|
||||
objects::get_roles, objects::get_role_privileges, objects::get_table_rebuild_readiness, objects::get_tablespaces,
|
||||
maintenance::run_maintenance,
|
||||
keychain::save_connection_password,
|
||||
keychain::get_connection_password,
|
||||
keychain::delete_connection_password,
|
||||
@@ -137,8 +149,18 @@ pub fn run() {
|
||||
backup::pg_dump,
|
||||
backup::pg_restore,
|
||||
backup::db_sync,
|
||||
backup::detect_mysql_tools,
|
||||
backup::mysql_dump,
|
||||
backup::mysql_restore,
|
||||
backup::mysql_sync,
|
||||
backup::sqlite_dump,
|
||||
backup::sqlite_restore,
|
||||
backup::sqlite_sync,
|
||||
settings::export_settings,
|
||||
settings::import_settings,
|
||||
schema_graph::get_schema_graph,
|
||||
query::execute_query,
|
||||
query::cancel_query,
|
||||
query::get_query_history,
|
||||
query::clear_query_history,
|
||||
query::set_history_favorite,
|
||||
|
||||
@@ -26,6 +26,12 @@ pub struct SyncOptions {
|
||||
pub target_connection_id: String,
|
||||
pub schema: Option<String>,
|
||||
pub tables: Option<Vec<String>>,
|
||||
#[serde(default = "default_sync_db_type")]
|
||||
pub db_type: String,
|
||||
}
|
||||
|
||||
fn default_sync_db_type() -> String {
|
||||
"postgresql".into()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -46,6 +52,73 @@ pub struct PgToolPaths {
|
||||
pub psql: String,
|
||||
}
|
||||
|
||||
/// Connection params for a MySQL server (decoupled from store/keychain so the
|
||||
/// dump/restore/sync core stays headless-testable). Mirrors `PgConnParams`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MySqlConnParams {
|
||||
pub host: String,
|
||||
pub port: i64,
|
||||
pub username: String,
|
||||
pub database: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl MySqlConnParams {
|
||||
pub fn new(host: String, port: i64, username: String, database: String, password: String) -> Self {
|
||||
Self { host, port, username, database, password }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MySqlBackupOptions {
|
||||
pub database: String,
|
||||
pub file_path: String,
|
||||
pub single_transaction: bool,
|
||||
pub no_data: bool,
|
||||
pub routines: bool,
|
||||
pub triggers: bool,
|
||||
pub events: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MySqlRestoreOptions {
|
||||
pub database: String,
|
||||
pub file_path: String,
|
||||
pub clean: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SqliteBackupOptions {
|
||||
pub file_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SqliteRestoreOptions {
|
||||
pub file_path: String,
|
||||
pub clean: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MySqlToolStatus {
|
||||
pub mysqldump_found: bool,
|
||||
pub mysql_found: bool,
|
||||
pub mysqldump_version: Option<String>,
|
||||
pub mysql_version: Option<String>,
|
||||
pub mysqldump_source: Option<String>,
|
||||
pub mysql_source: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MySqlToolPaths {
|
||||
pub mysqldump: String,
|
||||
pub mysql: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BackupJob {
|
||||
pub id: String,
|
||||
@@ -117,4 +190,56 @@ mod tests {
|
||||
assert!(json.contains("\"pg_dump_source\":\"system\""));
|
||||
assert!(json.contains("\"pg_restore_source\":\"bundled\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_backup_options_serialize_camel_case() {
|
||||
let opts = MySqlBackupOptions {
|
||||
database: "shop".into(),
|
||||
file_path: "/tmp/dump.sql".into(),
|
||||
single_transaction: true,
|
||||
no_data: false,
|
||||
routines: true,
|
||||
triggers: true,
|
||||
events: false,
|
||||
};
|
||||
let json = serde_json::to_string(&opts).unwrap();
|
||||
assert!(json.contains("\"singleTransaction\":true"));
|
||||
assert!(json.contains("\"filePath\":\"/tmp/dump.sql\""));
|
||||
assert!(!json.contains("no_owner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_restore_options_serialize_camel_case() {
|
||||
let opts = SqliteRestoreOptions { file_path: "/tmp/in.sql".into(), clean: true };
|
||||
let json = serde_json::to_string(&opts).unwrap();
|
||||
assert!(json.contains("\"filePath\":\"/tmp/in.sql\""));
|
||||
assert!(json.contains("\"clean\":true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_tool_status_reports_source() {
|
||||
let s = MySqlToolStatus {
|
||||
mysqldump_found: true,
|
||||
mysql_found: true,
|
||||
mysqldump_version: Some("mariadb-dump 10.6".into()),
|
||||
mysql_version: Some("mariadb 10.6".into()),
|
||||
mysqldump_source: Some("bundled".into()),
|
||||
mysql_source: Some("system".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&s).unwrap();
|
||||
assert!(json.contains("\"mysqldumpSource\":\"bundled\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_options_carry_db_type() {
|
||||
let s = SyncOptions {
|
||||
source_connection_id: "a".into(),
|
||||
target_connection_id: "b".into(),
|
||||
schema: None,
|
||||
tables: None,
|
||||
db_type: "mysql".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&s).unwrap();
|
||||
assert!(json.contains("\"dbType\":\"mysql\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,55 @@ pub struct DependencyInfo {
|
||||
pub name: String, // resolved dependent object name
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RoleMembership {
|
||||
pub role: String,
|
||||
pub member: String,
|
||||
pub grantor: String,
|
||||
pub admin_option: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RoleInfo {
|
||||
pub name: String,
|
||||
pub superuser: bool,
|
||||
pub inherit: bool,
|
||||
pub create_db: bool,
|
||||
pub create_role: bool,
|
||||
pub can_login: bool,
|
||||
pub replication: bool,
|
||||
pub bypass_rls: bool,
|
||||
pub connection_limit: i64,
|
||||
pub valid_until: Option<String>,
|
||||
pub memberships: Vec<RoleMembership>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PrivilegeEntry {
|
||||
pub object_class: String, // "table" | "sequence" | "routine" | "schema" | "database"
|
||||
pub schema: Option<String>,
|
||||
pub name: String,
|
||||
pub privileges: Vec<String>,
|
||||
pub grantable: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RebuildReadiness {
|
||||
pub ok: bool,
|
||||
pub reasons: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MaintenanceResult {
|
||||
pub duration_ms: i64,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TablespaceInfo {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Change {
|
||||
@@ -193,6 +242,14 @@ pub enum Change {
|
||||
schema: String,
|
||||
table: String,
|
||||
},
|
||||
Ddl {
|
||||
id: String,
|
||||
sql: String,
|
||||
},
|
||||
RebuildTable {
|
||||
id: String,
|
||||
sql: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Change {
|
||||
@@ -204,7 +261,9 @@ impl Change {
|
||||
| Change::AlterTable { id, .. }
|
||||
| Change::BulkInsert { id, .. }
|
||||
| Change::DropTable { id, .. }
|
||||
| Change::EmptyTable { id, .. } => id,
|
||||
| Change::EmptyTable { id, .. }
|
||||
| Change::Ddl { id, .. }
|
||||
| Change::RebuildTable { id, .. } => id,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -614,4 +673,61 @@ mod tests {
|
||||
assert!(json.contains("\"deptype\":\"n\""));
|
||||
assert!(json.contains("v_users"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_rebuild_table_serializes_with_snake_case_tag() {
|
||||
let change = Change::RebuildTable {
|
||||
id: "chg-rb1".to_string(),
|
||||
sql: "CREATE TABLE _t ...".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&change).unwrap();
|
||||
assert!(
|
||||
json.contains(r#""type":"rebuild_table""#),
|
||||
"RebuildTable should serialize with tag 'rebuild_table'; got: {json}"
|
||||
);
|
||||
assert_eq!(change.id(), "chg-rb1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_rebuild_table_roundtrip() {
|
||||
let json = serde_json::json!({
|
||||
"type": "rebuild_table", "id": "rb", "sql": "SELECT 1"
|
||||
});
|
||||
let c: Change = serde_json::from_value(json).unwrap();
|
||||
match c {
|
||||
Change::RebuildTable { sql, .. } => assert_eq!(sql, "SELECT 1"),
|
||||
_ => panic!("expected RebuildTable"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_info_roundtrip() {
|
||||
let r = RoleInfo {
|
||||
name: "app".into(), superuser: false, inherit: true, create_db: false,
|
||||
create_role: false, can_login: true, replication: false, bypass_rls: false,
|
||||
connection_limit: -1, valid_until: None, memberships: vec![RoleMembership {
|
||||
role: "parent".into(), member: "app".into(), grantor: "admin".into(), admin_option: false,
|
||||
}],
|
||||
};
|
||||
let json = serde_json::to_string(&r).unwrap();
|
||||
assert!(json.contains(r#""name":"app""#));
|
||||
assert!(json.contains(r#""can_login":true"#));
|
||||
let back: RoleInfo = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.memberships.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privilege_entry_and_readiness_roundtrip() {
|
||||
let pe = PrivilegeEntry {
|
||||
object_class: "table".into(), schema: Some("public".into()), name: "users".into(),
|
||||
privileges: vec!["SELECT".into(), "INSERT".into()], grantable: false,
|
||||
};
|
||||
assert!(serde_json::to_string(&pe).unwrap().contains(r#""object_class":"table""#));
|
||||
let rr = RebuildReadiness { ok: false, reasons: vec!["has triggers".into()] };
|
||||
assert!(serde_json::to_string(&rr).unwrap().contains(r#""ok":false"#));
|
||||
let mr = MaintenanceResult { duration_ms: 42, message: "ok".into() };
|
||||
assert!(serde_json::to_string(&mr).unwrap().contains(r#""duration_ms":42"#));
|
||||
let ts = TablespaceInfo { name: "pg_default".into() };
|
||||
assert!(serde_json::to_string(&ts).unwrap().contains("pg_default"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ pub mod tag;
|
||||
|
||||
pub use connection::{Connection, ConnectionInput};
|
||||
#[allow(unused_imports)]
|
||||
pub use db_viewer::{Change, ColumnInfo, FilterRule, Pagination, QueryResult, SortRule, TableInfo};
|
||||
pub use db_viewer::{Change, ColumnInfo, FilterRule, MaintenanceResult, Pagination, PrivilegeEntry, QueryResult, RebuildReadiness, RoleInfo, RoleMembership, SortRule, TableInfo, TablespaceInfo};
|
||||
pub use folder::{Folder, FolderInput};
|
||||
pub use recent::RecentConnection;
|
||||
pub use settings::Settings;
|
||||
|
||||
@@ -20,3 +20,10 @@ pub struct Settings {
|
||||
pub editor_minimap: bool,
|
||||
pub editor_tab_size: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SettingsExport {
|
||||
pub schema_version: u32,
|
||||
pub settings: Settings,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -572,6 +575,21 @@ impl Store {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bulk-write settings keys in one transaction (used by settings import).
|
||||
pub fn apply_settings(&self, map: &std::collections::HashMap<String, String>) -> Result<(), String> {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
conn.execute_batch("BEGIN").map_err(|e| e.to_string())?;
|
||||
for (k, v) in map.iter() {
|
||||
conn.execute(
|
||||
"INSERT INTO settings(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||
rusqlite::params![k, v],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
conn.execute_batch("COMMIT").map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert a row into the `query_history` table.
|
||||
/// Dedups consecutive identical queries per connection (UPDATE the last row
|
||||
/// instead of INSERTing a new one) and prunes to at most 500 rows per connection.
|
||||
@@ -954,6 +972,7 @@ mod tests {
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
})
|
||||
.unwrap();
|
||||
let got = store.get_connections().unwrap();
|
||||
@@ -1002,6 +1021,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 +1063,7 @@ mod tests {
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
})
|
||||
.unwrap();
|
||||
store.delete_folder(&folder.id).unwrap();
|
||||
@@ -1082,6 +1103,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 +1164,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 +1217,7 @@ mod tests {
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -1278,6 +1302,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 +1359,7 @@ mod tests {
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
@@ -1378,6 +1404,7 @@ mod tests {
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
@@ -1480,6 +1507,7 @@ mod tests {
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Gridline",
|
||||
"version": "0.7.5",
|
||||
"version": "0.7.8",
|
||||
"identifier": "com.adrianbonpin.gridline",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
@@ -26,7 +26,7 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"resources": ["resources/pg_tools/*"],
|
||||
"resources": ["resources/pg_tools/*", "resources/mysql_tools/*"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
||||
+3
-1
@@ -10,6 +10,7 @@ import { ErrorBanner } from "./components/ui/ErrorBanner";
|
||||
import { ToastContainer } from "./components/ui/Toast";
|
||||
import { DbViewerScreen } from "./components/db-viewer/DbViewerScreen";
|
||||
import { useAppearance } from "./hooks/useAppearance";
|
||||
import { isMacOS } from "./lib/platform";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
const VIEW_TITLES: Record<string, string> = {
|
||||
@@ -79,7 +80,8 @@ export default function App() {
|
||||
return (
|
||||
<div className="h-svh bg-canvas select-none flex flex-col overflow-hidden">
|
||||
{typeof window !== "undefined" &&
|
||||
"__TAURI_INTERNALS__" in window && (
|
||||
"__TAURI_INTERNALS__" in window &&
|
||||
isMacOS() && (
|
||||
// macOS "Overlay" title bar: in-flow strip the window can be
|
||||
// dragged by; traffic lights float over it. Only in Tauri.
|
||||
<div
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,9 +8,15 @@ 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") }));
|
||||
|
||||
const mockConnections: any[] = [];
|
||||
vi.mock("../../stores/connectionStore", () => ({
|
||||
useConnectionStore: (selector: any) => selector({ connections: mockConnections }),
|
||||
}));
|
||||
|
||||
describe("BackupPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockConnections.length = 0;
|
||||
useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 });
|
||||
useNotificationStore.setState({ notifications: [] });
|
||||
vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
|
||||
@@ -25,8 +31,56 @@ describe("BackupPage", () => {
|
||||
pg_dump_source: "bundled",
|
||||
pg_restore_source: "bundled",
|
||||
});
|
||||
mockConnections.push({ id: "c1", db_type: "postgresql", name: "p" });
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BackupPage DB-aware", () => {
|
||||
beforeEach(() => {
|
||||
mockConnections.length = 0;
|
||||
});
|
||||
|
||||
it("shows a single SQL format for MySQL (no custom/tar/directory)", async () => {
|
||||
mockConnections.push({ id: "c1", db_type: "mysql", name: "m", database: "db1" });
|
||||
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({
|
||||
mysqldumpFound: true,
|
||||
mysqlFound: true,
|
||||
mysqldumpVersion: "8.0",
|
||||
mysqlVersion: "8.0",
|
||||
mysqldumpSource: "system",
|
||||
mysqlSource: "system",
|
||||
});
|
||||
render(<BackupPage connectionId="c1" />);
|
||||
await waitFor(() => expect(screen.queryByText(/checking for mysqldump/i)).not.toBeInTheDocument());
|
||||
expect(screen.queryByText("Custom Archive")).toBeNull();
|
||||
expect(screen.queryByText("Tarball")).toBeNull();
|
||||
expect(screen.queryByText("Directory")).toBeNull();
|
||||
expect(screen.getByText("Plain SQL")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders a plain file-picker backup for SQLite (no format selector, no tool card)", () => {
|
||||
mockConnections.push({ id: "c2", db_type: "sqlite", name: "s" });
|
||||
render(<BackupPage connectionId="c2" />);
|
||||
expect(screen.queryByText("Custom Archive")).toBeNull();
|
||||
expect(screen.queryByText(/pg_dump/i)).toBeNull();
|
||||
expect(screen.queryByText(/mysqldump/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("still lists Custom Archive for PostgreSQL (regression guard)", async () => {
|
||||
mockConnections.push({ id: "c3", db_type: "postgresql", name: "p" });
|
||||
vi.spyOn(commands, "detectPgTools").mockResolvedValue({
|
||||
pg_dump_found: true,
|
||||
pg_restore_found: true,
|
||||
pg_dump_version: "16",
|
||||
pg_restore_version: "16",
|
||||
pg_dump_source: "system",
|
||||
pg_restore_source: "system",
|
||||
});
|
||||
render(<BackupPage connectionId="c3" />);
|
||||
await waitFor(() => expect(screen.queryByText(/checking for pg_dump/i)).not.toBeInTheDocument());
|
||||
expect(screen.getByText("Custom Archive")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,16 @@ import { Button } from "../ui/Button";
|
||||
import { BackupProgress } from "./BackupProgress";
|
||||
import { useBackupStore } from "../../stores/backupStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { detectPgTools, pgDump, getSchemas } from "../../lib/commands";
|
||||
import type { PgToolStatus } from "../../lib/types";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import {
|
||||
detectPgTools,
|
||||
pgDump,
|
||||
getSchemas,
|
||||
detectMysqlTools,
|
||||
mysqlDump,
|
||||
sqliteDump,
|
||||
} from "../../lib/commands";
|
||||
import type { PgToolStatus, MySqlToolStatus, BackupJob } from "../../lib/types";
|
||||
|
||||
interface BackupPageProps {
|
||||
connectionId: string;
|
||||
@@ -14,30 +22,51 @@ interface BackupPageProps {
|
||||
|
||||
type BackupFormat = "plain" | "custom" | "tar" | "directory";
|
||||
|
||||
const PLATFORM_INSTALL_INSTRUCTIONS: Record<string, string> = {
|
||||
const PG_INSTALL_INSTRUCTIONS: Record<string, string> = {
|
||||
darwin: "brew install libpq",
|
||||
linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch",
|
||||
win32: "Download PostgreSQL installer from https://www.postgresql.org/download/windows/ and ensure pg_dump is in your PATH.",
|
||||
};
|
||||
|
||||
function getPlatformInstructions(): string {
|
||||
const MYSQL_INSTALL_INSTRUCTIONS: Record<string, string> = {
|
||||
darwin: "brew install mysql-client",
|
||||
linux: "sudo apt install mysql-client # Debian/Ubuntu\nsudo dnf install mysql # Fedora\nsudo pacman -S mariadb # Arch",
|
||||
win32: "Download MySQL installer from https://dev.mysql.com/downloads/installer/ and ensure mysqldump is in your PATH.",
|
||||
};
|
||||
|
||||
function getPlatformInstructions(map: Record<string, string>): string {
|
||||
const platform =
|
||||
typeof navigator !== "undefined"
|
||||
? navigator.platform.toLowerCase()
|
||||
: "";
|
||||
if (platform.includes("mac") || platform.includes("darwin"))
|
||||
return PLATFORM_INSTALL_INSTRUCTIONS.darwin;
|
||||
if (platform.includes("linux")) return PLATFORM_INSTALL_INSTRUCTIONS.linux;
|
||||
if (platform.includes("win")) return PLATFORM_INSTALL_INSTRUCTIONS.win32;
|
||||
return PLATFORM_INSTALL_INSTRUCTIONS.linux;
|
||||
return map.darwin;
|
||||
if (platform.includes("linux")) return map.linux;
|
||||
if (platform.includes("win")) return map.win32;
|
||||
return map.linux;
|
||||
}
|
||||
|
||||
export function BackupPage({ connectionId }: BackupPageProps) {
|
||||
const connection = useConnectionStore((s) =>
|
||||
s.connections.find((c) => c.id === connectionId),
|
||||
);
|
||||
const dbType = connection?.db_type ?? "postgresql";
|
||||
const database = connection?.database ?? null;
|
||||
const isPg = dbType === "postgresql";
|
||||
const isMysql = dbType === "mysql";
|
||||
const isSqlite = dbType === "sqlite";
|
||||
|
||||
const [format, setFormat] = useState<BackupFormat>("custom");
|
||||
const [filePath, setFilePath] = useState("");
|
||||
const [schema, setSchema] = useState("");
|
||||
const [noOwner, setNoOwner] = useState(true);
|
||||
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null);
|
||||
const [singleTransaction, setSingleTransaction] = useState(true);
|
||||
const [noData, setNoData] = useState(false);
|
||||
const [routines, setRoutines] = useState(true);
|
||||
const [triggers, setTriggers] = useState(true);
|
||||
const [events, setEvents] = useState(false);
|
||||
const [pgToolStatus, setPgToolStatus] = useState<PgToolStatus | null>(null);
|
||||
const [mysqlToolStatus, setMysqlToolStatus] = useState<MySqlToolStatus | null>(null);
|
||||
const [checkingTools, setCheckingTools] = useState(true);
|
||||
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
|
||||
|
||||
@@ -71,10 +100,15 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
||||
|
||||
useEffect(() => {
|
||||
setCheckingTools(true);
|
||||
setPgToolStatus(null);
|
||||
setMysqlToolStatus(null);
|
||||
setAvailableSchemas([]);
|
||||
|
||||
if (isPg) {
|
||||
detectPgTools()
|
||||
.then((status) => setToolStatus(status))
|
||||
.then((status) => setPgToolStatus(status))
|
||||
.catch(() =>
|
||||
setToolStatus({
|
||||
setPgToolStatus({
|
||||
pg_dump_found: false,
|
||||
pg_restore_found: false,
|
||||
pg_dump_version: null,
|
||||
@@ -88,57 +122,157 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
||||
getSchemas(connectionId)
|
||||
.then((schemas) => setAvailableSchemas(schemas))
|
||||
.catch(() => setAvailableSchemas([]));
|
||||
}, [connectionId]);
|
||||
} else if (isMysql) {
|
||||
detectMysqlTools()
|
||||
.then((status) => setMysqlToolStatus(status))
|
||||
.catch(() =>
|
||||
setMysqlToolStatus({
|
||||
mysqldumpFound: false,
|
||||
mysqlFound: false,
|
||||
mysqldumpVersion: null,
|
||||
mysqlVersion: null,
|
||||
mysqldumpSource: null,
|
||||
mysqlSource: null,
|
||||
}),
|
||||
)
|
||||
.finally(() => setCheckingTools(false));
|
||||
|
||||
getSchemas(connectionId)
|
||||
.then((schemas) => setAvailableSchemas(schemas))
|
||||
.catch(() => setAvailableSchemas([]));
|
||||
} else {
|
||||
setCheckingTools(false);
|
||||
}
|
||||
}, [connectionId, isPg, isMysql]);
|
||||
|
||||
const handlePickFile = useCallback(async () => {
|
||||
const extensions: Record<BackupFormat, string[]> = {
|
||||
let defaultPath = "backup";
|
||||
let extensions: string[] = [];
|
||||
|
||||
if (isPg) {
|
||||
const pgExtensions: Record<BackupFormat, string[]> = {
|
||||
plain: ["sql"],
|
||||
custom: ["dump", "custom"],
|
||||
tar: ["tar"],
|
||||
directory: [],
|
||||
};
|
||||
const picked = await save({
|
||||
defaultPath: `backup.${
|
||||
extensions = pgExtensions[format];
|
||||
defaultPath = `backup.${
|
||||
format === "custom"
|
||||
? "dump"
|
||||
: format === "plain"
|
||||
? "sql"
|
||||
: "tar"
|
||||
}`,
|
||||
filters: [{ name: "Backup", extensions: extensions[format] }],
|
||||
}`;
|
||||
} else if (isMysql) {
|
||||
extensions = ["sql"];
|
||||
defaultPath = "backup.sql";
|
||||
} else {
|
||||
extensions = ["db", "sqlite", "sql"];
|
||||
defaultPath = "backup.db";
|
||||
}
|
||||
|
||||
const picked = await save({
|
||||
defaultPath,
|
||||
filters: [{ name: "Backup", extensions }],
|
||||
});
|
||||
if (picked) setFilePath(picked);
|
||||
}, [format]);
|
||||
}, [format, isPg, isMysql, isSqlite]);
|
||||
|
||||
const handleStartBackup = useCallback(async () => {
|
||||
if (!filePath) {
|
||||
notify("Please select a file path", "error");
|
||||
return;
|
||||
}
|
||||
const jobId = `dump-${Date.now()}`;
|
||||
startJob(jobId, "dump");
|
||||
const runWithProgress = useCallback(
|
||||
async (type: BackupJob["type"], action: () => Promise<unknown>) => {
|
||||
const jobId = `${type}-${Date.now()}`;
|
||||
startJob(jobId, type);
|
||||
pendingJobRef.current = jobId;
|
||||
|
||||
try {
|
||||
// pgDump returns the job ID immediately — completion
|
||||
// Command returns the job ID immediately — completion
|
||||
// comes via Tauri events handled by the backupStore
|
||||
await pgDump(connectionId, {
|
||||
format,
|
||||
filePath,
|
||||
schema: schema || undefined,
|
||||
tables: undefined,
|
||||
noOwner,
|
||||
});
|
||||
await action();
|
||||
} catch (e) {
|
||||
// If the command itself fails (e.g. connection not found),
|
||||
// the event won't fire — handle here
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
useBackupStore.getState().failJob(jobId, msg);
|
||||
}
|
||||
}, [filePath, format, schema, noOwner, connectionId, startJob, notify]);
|
||||
},
|
||||
[startJob],
|
||||
);
|
||||
|
||||
const toolsMissing = toolStatus && !toolStatus.pg_dump_found;
|
||||
const toolsBundled = toolStatus?.pg_dump_source === "bundled";
|
||||
const handleStartBackup = useCallback(async () => {
|
||||
if (!filePath) {
|
||||
notify("Please select a file path", "error");
|
||||
return;
|
||||
}
|
||||
if (isMysql && !database) {
|
||||
notify("MySQL connection has no database selected", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
await runWithProgress("dump", () => {
|
||||
if (isPg) {
|
||||
return pgDump(connectionId, {
|
||||
format,
|
||||
filePath,
|
||||
schema: schema || undefined,
|
||||
tables: undefined,
|
||||
noOwner,
|
||||
});
|
||||
}
|
||||
if (isMysql) {
|
||||
return mysqlDump(connectionId, {
|
||||
database: database!,
|
||||
filePath,
|
||||
singleTransaction,
|
||||
noData,
|
||||
routines,
|
||||
triggers,
|
||||
events,
|
||||
});
|
||||
}
|
||||
return sqliteDump(connectionId, { filePath });
|
||||
});
|
||||
}, [
|
||||
filePath,
|
||||
database,
|
||||
isPg,
|
||||
isMysql,
|
||||
isSqlite,
|
||||
format,
|
||||
schema,
|
||||
noOwner,
|
||||
singleTransaction,
|
||||
noData,
|
||||
routines,
|
||||
triggers,
|
||||
events,
|
||||
connectionId,
|
||||
notify,
|
||||
runWithProgress,
|
||||
]);
|
||||
|
||||
const toolsMissing = isPg
|
||||
? pgToolStatus && !pgToolStatus.pg_dump_found
|
||||
: isMysql
|
||||
? mysqlToolStatus && !mysqlToolStatus.mysqldumpFound
|
||||
: false;
|
||||
const toolsBundled = isPg
|
||||
? pgToolStatus?.pg_dump_source === "bundled"
|
||||
: isMysql
|
||||
? mysqlToolStatus?.mysqldumpSource === "bundled"
|
||||
: false;
|
||||
|
||||
const checkingMessage = isPg
|
||||
? "Checking for pg_dump..."
|
||||
: isMysql
|
||||
? "Checking for mysqldump..."
|
||||
: null;
|
||||
|
||||
const headerDescription = isPg
|
||||
? "Create a database backup via pg_dump"
|
||||
: isMysql
|
||||
? "Create a database backup via mysqldump"
|
||||
: "Create a database backup";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
@@ -147,7 +281,7 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
||||
<HardDrive size={14} className="text-accent" />
|
||||
<span className="text-xs font-medium text-text">Backup</span>
|
||||
<span className="text-[11px] text-text-muted">
|
||||
Create a database backup via pg_dump
|
||||
{headerDescription}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -155,10 +289,10 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
|
||||
{/* Tool check */}
|
||||
{checkingTools && (
|
||||
{checkingTools && checkingMessage && (
|
||||
<div className="glass p-4 text-center">
|
||||
<p className="text-sm text-text-muted">
|
||||
Checking for pg_dump...
|
||||
{checkingMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -166,14 +300,18 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
||||
{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
|
||||
{isPg ? "pg_dump not found" : "mysqldump not found"}
|
||||
</p>
|
||||
<p className="text-amber-200/80 text-xs leading-relaxed">
|
||||
The PostgreSQL client tools are required for
|
||||
The {isPg ? "PostgreSQL" : "MySQL"} client tools are required for
|
||||
backup/restore operations. Install them using:
|
||||
</p>
|
||||
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">
|
||||
{getPlatformInstructions()}
|
||||
{getPlatformInstructions(
|
||||
isPg
|
||||
? PG_INSTALL_INSTRUCTIONS
|
||||
: MYSQL_INSTALL_INSTRUCTIONS,
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
@@ -183,6 +321,7 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
||||
{/* Configuration card */}
|
||||
<div className="p-5 space-y-5">
|
||||
{/* Format */}
|
||||
{isPg ? (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Format
|
||||
@@ -206,6 +345,16 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
) : isMysql ? (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Format
|
||||
</label>
|
||||
<div className="text-sm text-text py-2">
|
||||
Plain SQL
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Output file */}
|
||||
<div className="space-y-1 w-full">
|
||||
@@ -234,6 +383,7 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
||||
</div>
|
||||
|
||||
{/* Schema (optional) */}
|
||||
{(isPg || isMysql) && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Schema{" "}
|
||||
@@ -256,8 +406,10 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No-owner toggle */}
|
||||
{/* PostgreSQL: no-owner toggle */}
|
||||
{isPg && (
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -274,6 +426,93 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* MySQL: option toggles */}
|
||||
{isMysql && (
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={singleTransaction}
|
||||
onChange={(e) =>
|
||||
setSingleTransaction(e.target.checked)
|
||||
}
|
||||
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
|
||||
Single Transaction{" "}
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
||||
--single-transaction
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={noData}
|
||||
onChange={(e) =>
|
||||
setNoData(e.target.checked)
|
||||
}
|
||||
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
|
||||
No Data{" "}
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
||||
--no-data
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={routines}
|
||||
onChange={(e) =>
|
||||
setRoutines(e.target.checked)
|
||||
}
|
||||
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
|
||||
Routines{" "}
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
||||
--routines
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={triggers}
|
||||
onChange={(e) =>
|
||||
setTriggers(e.target.checked)
|
||||
}
|
||||
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
|
||||
Triggers{" "}
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
||||
--triggers
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={events}
|
||||
onChange={(e) =>
|
||||
setEvents(e.target.checked)
|
||||
}
|
||||
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
|
||||
Events{" "}
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
||||
--events
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
@@ -305,4 +544,3 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
@@ -130,6 +144,23 @@ describe("ChangesQueuePanel", () => {
|
||||
await waitFor(() => expect(getSchemas).toHaveBeenCalledWith("c1"));
|
||||
});
|
||||
|
||||
it("refreshes the schema tree after committing a schema-modifying ddl change", async () => {
|
||||
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
|
||||
const getSchemas = vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
|
||||
vi.spyOn(commands, "getDatabases").mockResolvedValue(["mydb"]);
|
||||
vi.spyOn(commands, "getTables").mockResolvedValue([] as any);
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "ddl",
|
||||
schema: "public",
|
||||
table: "products",
|
||||
sql: 'CREATE TABLE "public"."products" ("id" integer NOT NULL)',
|
||||
description: "Create Table",
|
||||
});
|
||||
render(<ChangesQueuePanel />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
|
||||
await waitFor(() => expect(getSchemas).toHaveBeenCalledWith("c1"));
|
||||
});
|
||||
|
||||
it("SQL toggle shows the generated SQL", async () => {
|
||||
const user = userEvent.setup();
|
||||
useDbViewerStore.getState().addChange({
|
||||
@@ -208,4 +239,18 @@ describe("ChangesQueuePanel", () => {
|
||||
expect(tabs.some((t) => t.table === "posts")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a rebuild_table change with an amber REBUILD badge and SQL preview", () => {
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "rebuild_table",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
sql: "BEGIN; ALTER TABLE \"public\".\"users\" ...; COMMIT;",
|
||||
description: "Rebuild public.users",
|
||||
} as any);
|
||||
render(<ChangesQueuePanel />);
|
||||
expect(screen.getByText("REBUILD")).toBeInTheDocument();
|
||||
expect(screen.getByText(/rebuild public\.users/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/BEGIN;/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { useUiStore } from "../../stores/uiStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import { buildChangePayload, buildChangeSql } from "../../lib/changePayload";
|
||||
import { isSchemaModifyingQuery } from "../../lib/utils";
|
||||
import type { QueueItem, QueueStatus } from "../../stores/dbViewerStore";
|
||||
|
||||
const statusBg: Record<QueueStatus, string> = {
|
||||
@@ -25,6 +26,10 @@ function formatChangeLabel(change: QueueItem): string {
|
||||
return `Empty Table: ${fullName}`;
|
||||
case "drop_table":
|
||||
return `Drop Table: ${fullName}`;
|
||||
case "rebuild_table":
|
||||
return change.description ?? `Rebuild ${fullName}`;
|
||||
case "ddl":
|
||||
return change.description ?? "DDL";
|
||||
default:
|
||||
return change.table ?? "-";
|
||||
}
|
||||
@@ -50,6 +55,13 @@ function capitalizeType(type: string) {
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
}
|
||||
|
||||
/** Badge label for a queue-item type — ddl/rebuild render uppercase. */
|
||||
function badgeLabel(type: string): string {
|
||||
if (type === "ddl") return "DDL";
|
||||
if (type === "rebuild_table") return "REBUILD";
|
||||
return capitalizeType(type);
|
||||
}
|
||||
|
||||
function tableRef(change: QueueItem): string {
|
||||
if (change.schema && change.table) return `${change.schema}.${change.table}`;
|
||||
return change.table ?? "-";
|
||||
@@ -90,6 +102,11 @@ export function ChangesQueuePanel({ onCommitted }: { onCommitted?: () => void }
|
||||
treeDirty = true;
|
||||
const st = useDbViewerStore.getState();
|
||||
st.closeTabsForTable(change.schema ?? "", change.table ?? "");
|
||||
} else if (
|
||||
change.type === "rebuild_table" ||
|
||||
(change.type === "ddl" && change.sql && isSchemaModifyingQuery(change.sql))
|
||||
) {
|
||||
treeDirty = true;
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
@@ -170,8 +187,15 @@ 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)}
|
||||
<span
|
||||
className={[
|
||||
"rounded px-1.5 py-0.5 text-xs font-medium",
|
||||
change.type === "rebuild_table"
|
||||
? "bg-amber-500/10 text-amber-400"
|
||||
: "bg-surface-raised text-text-muted",
|
||||
].join(" ")}
|
||||
>
|
||||
{badgeLabel(change.type)}
|
||||
</span>
|
||||
<span className="text-sm text-text truncate">
|
||||
{tableRef(change)}
|
||||
@@ -197,6 +221,17 @@ export function ChangesQueuePanel({ onCommitted }: { onCommitted?: () => void }
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{change.type === "rebuild_table" ? (
|
||||
<>
|
||||
<div className="mt-1 text-xs text-text-muted truncate">
|
||||
{formatChangeLabel(change)}
|
||||
</div>
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded-md bg-canvas border border-border p-2 text-xs text-text-muted font-mono">
|
||||
{buildChangeSql(change)}
|
||||
</pre>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-1 text-xs text-text-muted truncate">
|
||||
{formatChangeLabel(change)}
|
||||
</div>
|
||||
@@ -211,6 +246,8 @@ export function ChangesQueuePanel({ onCommitted }: { onCommitted?: () => void }
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -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,6 +899,65 @@ describe("DbViewerScreen", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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: [
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TooltipProvider } from "../ui/Tooltip";
|
||||
import { DbViewerSidebar, NAV_CAPABILITY_KEY } from "./DbViewerSidebar";
|
||||
import { DbViewerToolbar } from "./DbViewerToolbar";
|
||||
import { isDestructiveQuery, isSchemaModifyingQuery } from "../../lib/utils";
|
||||
import { executeQuery } from "../../lib/commands";
|
||||
import { executeQuery, cancelQuery } from "../../lib/commands";
|
||||
import { getCapabilities } from "../../lib/dbCapabilities";
|
||||
|
||||
const QueryEditor = lazy(() => import("../editor/QueryEditor").then((m) => ({ default: m.QueryEditor })));
|
||||
@@ -15,15 +15,19 @@ 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";
|
||||
import { useSettingsStore } from "../../stores/settingsStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { useShortcut } from "../../hooks/useShortcut";
|
||||
import { ConnectionDropBanner } from "./ConnectionDropBanner";
|
||||
import { ToolsPage } from "./ToolsPage";
|
||||
@@ -162,7 +166,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);
|
||||
@@ -180,6 +185,7 @@ export function DbViewerScreen({
|
||||
const viewCapabilityKey = NAV_CAPABILITY_KEY[currentView] ?? "explorer";
|
||||
const viewSupported = capabilities[viewCapabilityKey];
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
const setDefaultPageSize = useDbViewerStore((s) => s.setDefaultPageSize);
|
||||
const clearColumnFilter = useDbViewerStore((s) => s.clearColumnFilter);
|
||||
const setFilterRules = useDbViewerStore((s) => s.setFilterRules);
|
||||
@@ -226,6 +232,8 @@ export function DbViewerScreen({
|
||||
const tables = useDbViewerStore((s) => s.tables);
|
||||
const stageCellEdit = useDbViewerStore((s) => s.stageCellEdit);
|
||||
|
||||
const isRunning = activeTab?.tabType === "query" && !!activeTab?.loading;
|
||||
|
||||
const isMatview =
|
||||
activeTab && activeTab.tabType === "table"
|
||||
? tables.some(
|
||||
@@ -984,15 +992,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"
|
||||
: 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={
|
||||
@@ -1009,6 +1032,18 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
onRestore={handleRestoreSql}
|
||||
onRunFromHistory={handleRunFromHistory}
|
||||
dbType={currentConnection?.db_type}
|
||||
isRunning={isRunning}
|
||||
onCancel={async () => {
|
||||
try {
|
||||
await cancelQuery(connectionId);
|
||||
notify("Query cancelled", "info");
|
||||
} catch (e) {
|
||||
notify(
|
||||
e instanceof Error ? e.message : String(e),
|
||||
"error",
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<QueryEditor
|
||||
@@ -1420,6 +1455,7 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
setCurrentSchema={setCurrentSchema}
|
||||
onEdit={() => setEditModalOpen(true)}
|
||||
connectionId={connectionId}
|
||||
dbType={currentConnection?.db_type}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
/>
|
||||
@@ -1427,7 +1463,7 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
className="flex-1 overflow-y-auto"
|
||||
style={{ overscrollBehavior: "none" }}
|
||||
>
|
||||
<TableTree searchQuery={searchQuery} />
|
||||
<TableTree searchQuery={searchQuery} dbType={currentConnection?.db_type} />
|
||||
</div>
|
||||
</div>
|
||||
{/* panel resize handle */}
|
||||
@@ -1439,7 +1475,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" ? (
|
||||
@@ -1473,6 +1522,12 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
)}
|
||||
<PasswordPromptDialog
|
||||
open={passwordPromptOpen}
|
||||
connectionName={currentConnection?.name ?? ""}
|
||||
onConnect={submitPassword}
|
||||
onCancel={cancelPassword}
|
||||
/>
|
||||
{capabilities.objects && (
|
||||
<ObjectSearchPalette connectionId={connectionId} />
|
||||
)}
|
||||
|
||||
@@ -83,7 +83,7 @@ describe("DbViewerSidebar", () => {
|
||||
expect(screen.getByLabelText(/tools/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides Objects and Tools for SQLite", () => {
|
||||
it("shows Tools but hides Objects for SQLite", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} capabilities={DB_CAPABILITIES.sqlite} />
|
||||
@@ -92,11 +92,11 @@ describe("DbViewerSidebar", () => {
|
||||
expect(screen.getByLabelText(/explorer/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Queries")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/schema visualizer/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/tools/i)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/objects/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/tools/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides Objects, Visualizer, and Tools for MySQL", () => {
|
||||
it("shows Tools but hides Objects and Visualizer for MySQL", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} capabilities={DB_CAPABILITIES.mysql} />
|
||||
@@ -104,9 +104,9 @@ describe("DbViewerSidebar", () => {
|
||||
);
|
||||
expect(screen.getByLabelText(/explorer/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Queries")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/tools/i)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/schema visualizer/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/objects/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/tools/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no top nav items for Redis (unsupported browsing)", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { DbViewerToolbar } from "./DbViewerToolbar";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
@@ -84,6 +84,17 @@ describe("DbViewerToolbar", () => {
|
||||
expect(screen.getByLabelText(/create table/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a create table form tab when Create Table is clicked", () => {
|
||||
const openFormTab = vi.spyOn(useDbViewerStore.getState(), "openFormTab");
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerToolbar {...defaultProps} currentSchema="public" />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText(/create table/i));
|
||||
expect(openFormTab).toHaveBeenCalledWith(expect.objectContaining({ kind: "table", mode: "create" }));
|
||||
});
|
||||
|
||||
it("shows a disabled schema loading indicator while schema tree is loading", () => {
|
||||
useDbViewerStore.setState({ schemaTreeLoading: true });
|
||||
render(
|
||||
|
||||
@@ -13,6 +13,8 @@ import { Tooltip } from "../ui/Tooltip";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import { SchemaMenu } from "./SchemaMenu";
|
||||
import { getCapabilities } from "../../lib/dbCapabilities";
|
||||
import type { DbType } from "../../lib/types";
|
||||
|
||||
export function DbViewerToolbar({
|
||||
databases,
|
||||
@@ -23,6 +25,7 @@ export function DbViewerToolbar({
|
||||
setCurrentSchema,
|
||||
onEdit,
|
||||
connectionId,
|
||||
dbType,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
}: {
|
||||
@@ -34,6 +37,7 @@ export function DbViewerToolbar({
|
||||
setCurrentSchema: (schema: string | null) => void;
|
||||
onEdit?: () => void;
|
||||
connectionId?: string;
|
||||
dbType?: DbType;
|
||||
searchQuery: string;
|
||||
onSearchChange: (q: string) => void;
|
||||
}) {
|
||||
@@ -78,6 +82,21 @@ export function DbViewerToolbar({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCreateTable = useCallback(() => {
|
||||
const schema = currentSchema ?? "public";
|
||||
useDbViewerStore.getState().openFormTab({
|
||||
kind: "table",
|
||||
schema,
|
||||
name: "",
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
params: { schema, name: "", action: { op: "create", columns: [] } },
|
||||
});
|
||||
}, [currentSchema]);
|
||||
|
||||
const showCreateTable = getCapabilities(dbType ?? "postgresql").tableManagement;
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (!connectionId || refreshing) return;
|
||||
setRefreshing(true);
|
||||
@@ -147,14 +166,17 @@ export function DbViewerToolbar({
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{showCreateTable && (
|
||||
<Tooltip content="Create Table" side="bottom">
|
||||
<button
|
||||
aria-label="Create Table"
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer opacity-50"
|
||||
onClick={handleCreateTable}
|
||||
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>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content="Search Tables" side="bottom">
|
||||
<button
|
||||
aria-label="Search Tables"
|
||||
|
||||
@@ -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", () => ({
|
||||
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(() => {});
|
||||
|
||||
@@ -204,7 +204,7 @@ describe("ObjectExplorerPage", () => {
|
||||
fireEvent.click(screen.getByLabelText("Object type"));
|
||||
fireEvent.click(screen.getByText("Enums"));
|
||||
await waitFor(() => screen.getByText("role"));
|
||||
fireEvent.click(screen.getAllByLabelText(/options/i)[0]);
|
||||
fireEvent.click(screen.getAllByLabelText(/actions/i)[0]);
|
||||
fireEvent.click(screen.getByText(/copy ddl/i));
|
||||
await waitFor(() =>
|
||||
expect(commands.getObjectDdl).toHaveBeenCalledWith("c1", "public", "enum", "role"),
|
||||
@@ -231,12 +231,159 @@ describe("ObjectExplorerPage", () => {
|
||||
]);
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
await waitFor(() => screen.getByText("add_one(int)"));
|
||||
fireEvent.click(screen.getAllByLabelText(/options/i)[0]);
|
||||
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("roles fetch ignores the schema filter", async () => {
|
||||
const user = userEvent.setup();
|
||||
const getRoles = vi.spyOn(commands, "getRoles").mockResolvedValue([
|
||||
{
|
||||
name: "app",
|
||||
superuser: false,
|
||||
inherit: true,
|
||||
create_db: false,
|
||||
create_role: false,
|
||||
can_login: true,
|
||||
replication: false,
|
||||
bypass_rls: false,
|
||||
connection_limit: -1,
|
||||
valid_until: null,
|
||||
memberships: [],
|
||||
},
|
||||
]);
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
await user.click(screen.getByLabelText("Object type"));
|
||||
await user.click(screen.getByText("Roles"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("app")).toBeInTheDocument(),
|
||||
);
|
||||
// Roles are cluster-scoped: no schema argument is ever passed, even when
|
||||
// the schema changes (no schema-change refetch for roles).
|
||||
expect(getRoles).toHaveBeenCalledTimes(1);
|
||||
expect(getRoles).toHaveBeenCalledWith("c1");
|
||||
expect(getRoles).not.toHaveBeenCalledWith("c1", "public");
|
||||
useDbViewerStore.setState({ currentSchema: "analytics" });
|
||||
await waitFor(() => expect(getRoles).toHaveBeenCalledTimes(1));
|
||||
expect(getRoles).toHaveBeenCalledWith("c1");
|
||||
});
|
||||
|
||||
it("preselects type from store on mount", () => {
|
||||
useDbViewerStore.setState({ selectedObjectType: "sequences" });
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { RestorePage } from "./RestorePage";
|
||||
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", () => ({ open: vi.fn().mockResolvedValue("/tmp/backup.dump") }));
|
||||
|
||||
const mockConnections: any[] = [];
|
||||
vi.mock("../../stores/connectionStore", () => ({
|
||||
useConnectionStore: (selector: any) => selector({ connections: mockConnections }),
|
||||
}));
|
||||
|
||||
const pgToolsOk = {
|
||||
pg_dump_found: true,
|
||||
pg_restore_found: true,
|
||||
pg_dump_version: "16",
|
||||
pg_restore_version: "16",
|
||||
pg_dump_source: "system",
|
||||
pg_restore_source: "system",
|
||||
};
|
||||
|
||||
const mysqlToolsOk = {
|
||||
mysqldumpFound: true,
|
||||
mysqlFound: true,
|
||||
mysqldumpVersion: "8.0",
|
||||
mysqlVersion: "8.0",
|
||||
mysqldumpSource: "system",
|
||||
mysqlSource: "system",
|
||||
};
|
||||
|
||||
describe("RestorePage DB-aware", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockConnections.length = 0;
|
||||
useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 });
|
||||
useNotificationStore.setState({ notifications: [] });
|
||||
vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
|
||||
});
|
||||
|
||||
it("shows format selector for PostgreSQL", async () => {
|
||||
mockConnections.push({ id: "c1", db_type: "postgresql", name: "p" });
|
||||
vi.spyOn(commands, "detectPgTools").mockResolvedValue(pgToolsOk);
|
||||
render(<RestorePage connectionId="c1" />);
|
||||
await waitFor(() => expect(screen.queryByText(/checking for pg_restore/i)).not.toBeInTheDocument());
|
||||
expect(screen.getByText("Custom Archive")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders a plain MySQL restore (no format selector)", async () => {
|
||||
mockConnections.push({ id: "c2", db_type: "mysql", name: "m", database: "db1" });
|
||||
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue(mysqlToolsOk);
|
||||
render(<RestorePage connectionId="c2" />);
|
||||
await waitFor(() => expect(screen.queryByText(/checking for mysqldump/i)).not.toBeInTheDocument());
|
||||
expect(screen.queryByText("Custom Archive")).toBeNull();
|
||||
expect(screen.queryByText(/Plain SQL restores run via psql/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a plain SQLite restore (no format selector, no tool card)", () => {
|
||||
mockConnections.push({ id: "c3", db_type: "sqlite", name: "s" });
|
||||
render(<RestorePage connectionId="c3" />);
|
||||
expect(screen.queryByText("Custom Archive")).toBeNull();
|
||||
expect(screen.queryByText(/pg_restore/i)).toBeNull();
|
||||
expect(screen.queryByText(/mysqldump/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -5,38 +5,62 @@ import { Button } from "../ui/Button";
|
||||
import { BackupProgress } from "./BackupProgress";
|
||||
import { useBackupStore } from "../../stores/backupStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { detectPgTools, pgRestore, getSchemas } from "../../lib/commands";
|
||||
import type { PgToolStatus } from "../../lib/types";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import {
|
||||
detectPgTools,
|
||||
pgRestore,
|
||||
getSchemas,
|
||||
detectMysqlTools,
|
||||
mysqlRestore,
|
||||
sqliteRestore,
|
||||
} from "../../lib/commands";
|
||||
import type { PgToolStatus, MySqlToolStatus, BackupJob } from "../../lib/types";
|
||||
|
||||
interface RestorePageProps {
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
const PLATFORM_INSTALL_INSTRUCTIONS: Record<string, string> = {
|
||||
const PG_INSTALL_INSTRUCTIONS: Record<string, string> = {
|
||||
darwin: "brew install libpq",
|
||||
linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch",
|
||||
win32: "Download PostgreSQL installer from https://www.postgresql.org/download/windows/ and ensure pg_restore is in your PATH.",
|
||||
};
|
||||
|
||||
function getPlatformInstructions(): string {
|
||||
const MYSQL_INSTALL_INSTRUCTIONS: Record<string, string> = {
|
||||
darwin: "brew install mysql-client",
|
||||
linux: "sudo apt install mysql-client # Debian/Ubuntu\nsudo dnf install mysql # Fedora\nsudo pacman -S mariadb # Arch",
|
||||
win32: "Download MySQL installer from https://dev.mysql.com/downloads/installer/ and ensure mysql is in your PATH.",
|
||||
};
|
||||
|
||||
function getPlatformInstructions(map: Record<string, string>): string {
|
||||
const platform =
|
||||
typeof navigator !== "undefined"
|
||||
? navigator.platform.toLowerCase()
|
||||
: "";
|
||||
if (platform.includes("mac") || platform.includes("darwin"))
|
||||
return PLATFORM_INSTALL_INSTRUCTIONS.darwin;
|
||||
if (platform.includes("linux")) return PLATFORM_INSTALL_INSTRUCTIONS.linux;
|
||||
if (platform.includes("win")) return PLATFORM_INSTALL_INSTRUCTIONS.win32;
|
||||
return PLATFORM_INSTALL_INSTRUCTIONS.linux;
|
||||
return map.darwin;
|
||||
if (platform.includes("linux")) return map.linux;
|
||||
if (platform.includes("win")) return map.win32;
|
||||
return map.linux;
|
||||
}
|
||||
|
||||
export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
const connection = useConnectionStore((s) =>
|
||||
s.connections.find((c) => c.id === connectionId),
|
||||
);
|
||||
const dbType = connection?.db_type ?? "postgresql";
|
||||
const database = connection?.database ?? null;
|
||||
const isPg = dbType === "postgresql";
|
||||
const isMysql = dbType === "mysql";
|
||||
const isSqlite = dbType === "sqlite";
|
||||
|
||||
const [filePath, setFilePath] = useState("");
|
||||
const [format, setFormat] = useState("custom");
|
||||
const [clean, setClean] = useState(true);
|
||||
const [schema, setSchema] = useState("");
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null);
|
||||
const [pgToolStatus, setPgToolStatus] = useState<PgToolStatus | null>(null);
|
||||
const [mysqlToolStatus, setMysqlToolStatus] = useState<MySqlToolStatus | null>(null);
|
||||
const [checkingTools, setCheckingTools] = useState(true);
|
||||
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
|
||||
|
||||
@@ -68,10 +92,15 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
useEffect(() => {
|
||||
setCheckingTools(true);
|
||||
setConfirmed(false);
|
||||
setPgToolStatus(null);
|
||||
setMysqlToolStatus(null);
|
||||
setAvailableSchemas([]);
|
||||
|
||||
if (isPg) {
|
||||
detectPgTools()
|
||||
.then((status) => setToolStatus(status))
|
||||
.then((status) => setPgToolStatus(status))
|
||||
.catch(() =>
|
||||
setToolStatus({
|
||||
setPgToolStatus({
|
||||
pg_dump_found: false,
|
||||
pg_restore_found: false,
|
||||
pg_dump_version: null,
|
||||
@@ -85,47 +114,130 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
getSchemas(connectionId)
|
||||
.then((schemas) => setAvailableSchemas(schemas))
|
||||
.catch(() => setAvailableSchemas([]));
|
||||
}, [connectionId]);
|
||||
} else if (isMysql) {
|
||||
detectMysqlTools()
|
||||
.then((status) => setMysqlToolStatus(status))
|
||||
.catch(() =>
|
||||
setMysqlToolStatus({
|
||||
mysqldumpFound: false,
|
||||
mysqlFound: false,
|
||||
mysqldumpVersion: null,
|
||||
mysqlVersion: null,
|
||||
mysqldumpSource: null,
|
||||
mysqlSource: null,
|
||||
}),
|
||||
)
|
||||
.finally(() => setCheckingTools(false));
|
||||
|
||||
getSchemas(connectionId)
|
||||
.then((schemas) => setAvailableSchemas(schemas))
|
||||
.catch(() => setAvailableSchemas([]));
|
||||
} else {
|
||||
setCheckingTools(false);
|
||||
}
|
||||
}, [connectionId, isPg, isMysql]);
|
||||
|
||||
const handlePickFile = useCallback(async () => {
|
||||
const extensions = isPg
|
||||
? ["dump", "sql", "tar", "custom", "gz"]
|
||||
: isMysql
|
||||
? ["sql"]
|
||||
: ["db", "sqlite", "sql"];
|
||||
|
||||
const picked = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "Backup Files",
|
||||
extensions: ["dump", "sql", "tar", "custom", "gz"],
|
||||
extensions,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (picked && typeof picked === "string") setFilePath(picked);
|
||||
}, []);
|
||||
}, [isPg, isMysql, isSqlite]);
|
||||
|
||||
const runWithProgress = useCallback(
|
||||
async (type: BackupJob["type"], action: () => Promise<unknown>) => {
|
||||
const jobId = `${type}-${Date.now()}`;
|
||||
startJob(jobId, type);
|
||||
pendingJobRef.current = jobId;
|
||||
|
||||
try {
|
||||
await action();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
useBackupStore.getState().failJob(jobId, msg);
|
||||
}
|
||||
},
|
||||
[startJob],
|
||||
);
|
||||
|
||||
const handleStartRestore = useCallback(async () => {
|
||||
if (!filePath) {
|
||||
notify("Please select a file path", "error");
|
||||
return;
|
||||
}
|
||||
const jobId = `restore-${Date.now()}`;
|
||||
startJob(jobId, "restore");
|
||||
pendingJobRef.current = jobId;
|
||||
if (isMysql && !database) {
|
||||
notify("MySQL connection has no database selected", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await pgRestore(connectionId, {
|
||||
await runWithProgress("restore", () => {
|
||||
if (isPg) {
|
||||
return pgRestore(connectionId, {
|
||||
format,
|
||||
filePath,
|
||||
clean,
|
||||
schema: schema || undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
useBackupStore.getState().failJob(jobId, msg);
|
||||
}
|
||||
}, [filePath, format, clean, schema, connectionId, startJob, notify]);
|
||||
if (isMysql) {
|
||||
return mysqlRestore(connectionId, {
|
||||
database: database!,
|
||||
filePath,
|
||||
clean,
|
||||
});
|
||||
}
|
||||
return sqliteRestore(connectionId, { filePath, clean });
|
||||
});
|
||||
}, [
|
||||
filePath,
|
||||
database,
|
||||
isPg,
|
||||
isMysql,
|
||||
isSqlite,
|
||||
format,
|
||||
clean,
|
||||
schema,
|
||||
connectionId,
|
||||
notify,
|
||||
runWithProgress,
|
||||
]);
|
||||
|
||||
const toolsMissing = toolStatus && !toolStatus.pg_restore_found;
|
||||
const toolsBundled = toolStatus?.pg_restore_source === "bundled";
|
||||
const toolsMissing = isPg
|
||||
? pgToolStatus && !pgToolStatus.pg_restore_found
|
||||
: isMysql
|
||||
? mysqlToolStatus && !mysqlToolStatus.mysqlFound
|
||||
: false;
|
||||
const toolsBundled = isPg
|
||||
? pgToolStatus?.pg_restore_source === "bundled"
|
||||
: isMysql
|
||||
? mysqlToolStatus?.mysqlSource === "bundled"
|
||||
: false;
|
||||
const canStart = filePath && confirmed && !isRunning;
|
||||
|
||||
const checkingMessage = isPg
|
||||
? "Checking for pg_restore..."
|
||||
: isMysql
|
||||
? "Checking for mysql..."
|
||||
: null;
|
||||
|
||||
const headerDescription = isPg
|
||||
? "Restore a database from a backup file"
|
||||
: isMysql
|
||||
? "Restore a database from a SQL dump"
|
||||
: "Restore a database from a backup file";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Toolbar header */}
|
||||
@@ -133,7 +245,7 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
<Upload size={14} className="text-accent" />
|
||||
<span className="text-xs font-medium text-text">Restore</span>
|
||||
<span className="text-[11px] text-text-muted">
|
||||
Restore a database from a backup file
|
||||
{headerDescription}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -141,10 +253,10 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
|
||||
{/* Tool check */}
|
||||
{checkingTools && (
|
||||
{checkingTools && checkingMessage && (
|
||||
<div className="glass p-4 text-center">
|
||||
<p className="text-sm text-text-muted">
|
||||
Checking for pg_restore...
|
||||
{checkingMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -152,14 +264,18 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
{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
|
||||
{isPg ? "pg_restore not found" : "mysql client not found"}
|
||||
</p>
|
||||
<p className="text-amber-200/80 text-xs leading-relaxed">
|
||||
The PostgreSQL client tools are required for
|
||||
The {isPg ? "PostgreSQL" : "MySQL"} client tools are required for
|
||||
backup/restore operations. Install them using:
|
||||
</p>
|
||||
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">
|
||||
{getPlatformInstructions()}
|
||||
{getPlatformInstructions(
|
||||
isPg
|
||||
? PG_INSTALL_INSTRUCTIONS
|
||||
: MYSQL_INSTALL_INSTRUCTIONS,
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
@@ -168,7 +284,8 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
<>
|
||||
{/* Configuration card */}
|
||||
<div className="p-5 space-y-5">
|
||||
{/* Format */}
|
||||
{/* Format (PostgreSQL only) */}
|
||||
{isPg && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Format
|
||||
@@ -190,6 +307,7 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backup file */}
|
||||
<div className="space-y-1 w-full">
|
||||
@@ -218,6 +336,7 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
</div>
|
||||
|
||||
{/* Schema (optional) */}
|
||||
{(isPg || isMysql) && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Schema{" "}
|
||||
@@ -240,11 +359,13 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clean toggle */}
|
||||
{(isPg || isMysql || isSqlite) && (
|
||||
<label
|
||||
className={`flex items-center gap-2.5 cursor-pointer group ${
|
||||
format === "plain"
|
||||
isPg && format === "plain"
|
||||
? "opacity-40 pointer-events-none"
|
||||
: ""
|
||||
}`}
|
||||
@@ -255,7 +376,7 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
onChange={(e) =>
|
||||
setClean(e.target.checked)
|
||||
}
|
||||
disabled={format === "plain"}
|
||||
disabled={isPg && format === "plain"}
|
||||
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
|
||||
@@ -265,7 +386,8 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
{format === "plain" && (
|
||||
)}
|
||||
{isPg && format === "plain" && (
|
||||
<p className="text-[11px] text-text-muted/70 -mt-3">
|
||||
Plain SQL restores run via psql and don't
|
||||
support DROP-before-CREATE. Use Custom
|
||||
@@ -325,4 +447,3 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export function SyncDialog({ open, onClose }: SyncDialogProps) {
|
||||
targetConnectionId,
|
||||
schema: schema || undefined,
|
||||
tables: undefined,
|
||||
dbType: "postgresql",
|
||||
});
|
||||
notify("Sync completed successfully", "success");
|
||||
onClose();
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { SyncPage } from "./SyncPage";
|
||||
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(() => {})) }));
|
||||
|
||||
const mockConnections: any[] = [];
|
||||
vi.mock("../../stores/connectionStore", () => ({
|
||||
useConnectionStore: (selector: any) => selector({ connections: mockConnections }),
|
||||
}));
|
||||
|
||||
const pgToolsOk = {
|
||||
pg_dump_found: true,
|
||||
pg_restore_found: true,
|
||||
pg_dump_version: "16",
|
||||
pg_restore_version: "16",
|
||||
pg_dump_source: "system",
|
||||
pg_restore_source: "system",
|
||||
};
|
||||
|
||||
const mysqlToolsOk = {
|
||||
mysqldumpFound: true,
|
||||
mysqlFound: true,
|
||||
mysqldumpVersion: "8.0",
|
||||
mysqlVersion: "8.0",
|
||||
mysqldumpSource: "system",
|
||||
mysqlSource: "system",
|
||||
};
|
||||
|
||||
describe("SyncPage DB-aware", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockConnections.length = 0;
|
||||
useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 });
|
||||
useNotificationStore.setState({ notifications: [] });
|
||||
});
|
||||
|
||||
it("filters target connections to the same db_type as source (mysql)", async () => {
|
||||
mockConnections.push(
|
||||
{ id: "pg1", db_type: "postgresql", name: "Postgres 1" },
|
||||
{ id: "my1", db_type: "mysql", name: "MySQL 1" },
|
||||
{ id: "my2", db_type: "mysql", name: "MySQL 2" },
|
||||
{ id: "sq1", db_type: "sqlite", name: "SQLite 1" },
|
||||
);
|
||||
vi.spyOn(commands, "detectPgTools").mockResolvedValue(pgToolsOk);
|
||||
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue(mysqlToolsOk);
|
||||
render(<SyncPage />);
|
||||
await waitFor(() => expect(screen.queryByText(/checking for/i)).not.toBeInTheDocument());
|
||||
|
||||
const [sourceSelect, targetSelect] = screen.getAllByRole("combobox") as HTMLSelectElement[];
|
||||
fireEvent.change(sourceSelect, { target: { value: "my1" } });
|
||||
|
||||
const options = Array.from(targetSelect.options).map((o) => o.value);
|
||||
expect(options).toContain("my1");
|
||||
expect(options).toContain("my2");
|
||||
expect(options).not.toContain("pg1");
|
||||
expect(options).not.toContain("sq1");
|
||||
});
|
||||
});
|
||||
@@ -5,16 +5,24 @@ import { BackupProgress } from "./BackupProgress";
|
||||
import { useBackupStore } from "../../stores/backupStore";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { detectPgTools, dbSync, getSchemas } from "../../lib/commands";
|
||||
import type { PgToolStatus } from "../../lib/types";
|
||||
import {
|
||||
detectPgTools,
|
||||
dbSync,
|
||||
getSchemas,
|
||||
detectMysqlTools,
|
||||
mysqlSync,
|
||||
sqliteSync,
|
||||
} from "../../lib/commands";
|
||||
import type { PgToolStatus, MySqlToolStatus, BackupJob, DbType } from "../../lib/types";
|
||||
|
||||
export function SyncPage() {
|
||||
const [sourceConnectionId, setSourceConnectionId] = useState("");
|
||||
const [targetConnectionId, setTargetConnectionId] = useState("");
|
||||
const [schema, setSchema] = useState("");
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null);
|
||||
const [checkingTools, setCheckingTools] = useState(true);
|
||||
const [pgToolStatus, setPgToolStatus] = useState<PgToolStatus | null>(null);
|
||||
const [mysqlToolStatus, setMysqlToolStatus] = useState<MySqlToolStatus | null>(null);
|
||||
const [checkingTools, setCheckingTools] = useState(false);
|
||||
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
|
||||
|
||||
const connections = useConnectionStore((s) => s.connections);
|
||||
@@ -27,6 +35,13 @@ export function SyncPage() {
|
||||
const isRunning = activeJob?.status === "running";
|
||||
const pendingJobRef = useRef<string | null>(null);
|
||||
|
||||
const sourceConnection = connections.find(
|
||||
(c) => c.id === sourceConnectionId,
|
||||
);
|
||||
const dbType: DbType | null = sourceConnection?.db_type ?? null;
|
||||
const isPg = dbType === "postgresql";
|
||||
const isMysql = dbType === "mysql";
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingJobRef.current || !activeJob) return;
|
||||
if (activeJob.id !== pendingJobRef.current) return;
|
||||
@@ -43,13 +58,18 @@ export function SyncPage() {
|
||||
}
|
||||
}, [activeJob, notify]);
|
||||
|
||||
// Tool detection: depends on the selected source connection's DB type
|
||||
useEffect(() => {
|
||||
setCheckingTools(true);
|
||||
setPgToolStatus(null);
|
||||
setMysqlToolStatus(null);
|
||||
setConfirmed(false);
|
||||
|
||||
if (isPg) {
|
||||
setCheckingTools(true);
|
||||
detectPgTools()
|
||||
.then((status) => setToolStatus(status))
|
||||
.then((status) => setPgToolStatus(status))
|
||||
.catch(() =>
|
||||
setToolStatus({
|
||||
setPgToolStatus({
|
||||
pg_dump_found: false,
|
||||
pg_restore_found: false,
|
||||
pg_dump_version: null,
|
||||
@@ -59,7 +79,23 @@ export function SyncPage() {
|
||||
}),
|
||||
)
|
||||
.finally(() => setCheckingTools(false));
|
||||
}, []);
|
||||
} else if (isMysql) {
|
||||
setCheckingTools(true);
|
||||
detectMysqlTools()
|
||||
.then((status) => setMysqlToolStatus(status))
|
||||
.catch(() =>
|
||||
setMysqlToolStatus({
|
||||
mysqldumpFound: false,
|
||||
mysqlFound: false,
|
||||
mysqldumpVersion: null,
|
||||
mysqlVersion: null,
|
||||
mysqldumpSource: null,
|
||||
mysqlSource: null,
|
||||
}),
|
||||
)
|
||||
.finally(() => setCheckingTools(false));
|
||||
}
|
||||
}, [isPg, isMysql]);
|
||||
|
||||
// Fetch schemas from the source connection when it changes
|
||||
useEffect(() => {
|
||||
@@ -68,10 +104,31 @@ export function SyncPage() {
|
||||
setSchema("");
|
||||
return;
|
||||
}
|
||||
if (!isPg && !isMysql) {
|
||||
setAvailableSchemas([]);
|
||||
setSchema("");
|
||||
return;
|
||||
}
|
||||
getSchemas(sourceConnectionId)
|
||||
.then((schemas) => setAvailableSchemas(schemas))
|
||||
.catch(() => setAvailableSchemas([]));
|
||||
}, [sourceConnectionId]);
|
||||
}, [sourceConnectionId, isPg, isMysql]);
|
||||
|
||||
const runWithProgress = useCallback(
|
||||
async (type: BackupJob["type"], action: () => Promise<unknown>) => {
|
||||
const jobId = `${type}-${Date.now()}`;
|
||||
startJob(jobId, type);
|
||||
pendingJobRef.current = jobId;
|
||||
|
||||
try {
|
||||
await action();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
useBackupStore.getState().failJob(jobId, msg);
|
||||
}
|
||||
},
|
||||
[startJob],
|
||||
);
|
||||
|
||||
const handleStartSync = useCallback(async () => {
|
||||
if (!sourceConnectionId || !targetConnectionId) {
|
||||
@@ -82,35 +139,61 @@ export function SyncPage() {
|
||||
notify("Source and target must be different", "error");
|
||||
return;
|
||||
}
|
||||
const jobId = `sync-${Date.now()}`;
|
||||
startJob(jobId, "sync");
|
||||
pendingJobRef.current = jobId;
|
||||
if (!dbType) {
|
||||
notify("Unable to determine database type for sync", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await dbSync({
|
||||
const options = {
|
||||
sourceConnectionId,
|
||||
targetConnectionId,
|
||||
schema: schema || undefined,
|
||||
tables: undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
useBackupStore.getState().failJob(jobId, msg);
|
||||
}
|
||||
}, [sourceConnectionId, targetConnectionId, schema, startJob, notify]);
|
||||
dbType,
|
||||
};
|
||||
|
||||
const toolsMissing =
|
||||
toolStatus &&
|
||||
(!toolStatus.pg_dump_found || !toolStatus.pg_restore_found);
|
||||
const toolsBundled =
|
||||
toolStatus?.pg_dump_source === "bundled" &&
|
||||
toolStatus?.pg_restore_source === "bundled";
|
||||
await runWithProgress("sync", () => {
|
||||
if (isPg) return dbSync(options);
|
||||
if (isMysql) return mysqlSync(options);
|
||||
return sqliteSync(options);
|
||||
});
|
||||
}, [
|
||||
sourceConnectionId,
|
||||
targetConnectionId,
|
||||
dbType,
|
||||
isPg,
|
||||
isMysql,
|
||||
schema,
|
||||
notify,
|
||||
runWithProgress,
|
||||
]);
|
||||
|
||||
const toolsMissing = isPg
|
||||
? pgToolStatus &&
|
||||
(!pgToolStatus.pg_dump_found || !pgToolStatus.pg_restore_found)
|
||||
: isMysql
|
||||
? mysqlToolStatus &&
|
||||
(!mysqlToolStatus.mysqldumpFound || !mysqlToolStatus.mysqlFound)
|
||||
: false;
|
||||
const toolsBundled = isPg
|
||||
? pgToolStatus?.pg_dump_source === "bundled" &&
|
||||
pgToolStatus?.pg_restore_source === "bundled"
|
||||
: isMysql
|
||||
? mysqlToolStatus?.mysqldumpSource === "bundled" &&
|
||||
mysqlToolStatus?.mysqlSource === "bundled"
|
||||
: false;
|
||||
const canStart =
|
||||
sourceConnectionId && targetConnectionId && confirmed && !isRunning;
|
||||
|
||||
const postgresqlConnections = connections.filter(
|
||||
(c) => c.db_type === "postgresql",
|
||||
);
|
||||
const checkingMessage = isPg
|
||||
? "Checking for pg_dump / pg_restore..."
|
||||
: isMysql
|
||||
? "Checking for mysqldump / mysql..."
|
||||
: null;
|
||||
|
||||
const targetConnections = dbType
|
||||
? connections.filter((c) => c.db_type === dbType)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
@@ -119,7 +202,7 @@ export function SyncPage() {
|
||||
<ArrowLeftRight size={14} className="text-accent" />
|
||||
<span className="text-xs font-medium text-text">DB Sync</span>
|
||||
<span className="text-[11px] text-text-muted">
|
||||
Transfer data between PostgreSQL databases via pipe
|
||||
Transfer data between databases via pipe
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -127,10 +210,10 @@ export function SyncPage() {
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
|
||||
{/* Tool check */}
|
||||
{checkingTools && (
|
||||
{checkingTools && checkingMessage && (
|
||||
<div className="glass p-4 text-center">
|
||||
<p className="text-sm text-text-muted">
|
||||
Checking for pg_dump / pg_restore...
|
||||
{checkingMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -138,19 +221,27 @@ export function SyncPage() {
|
||||
{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
|
||||
{isPg
|
||||
? "PostgreSQL tools not found"
|
||||
: "MySQL tools not found"}
|
||||
</p>
|
||||
<p className="text-amber-200/80 text-xs leading-relaxed">
|
||||
Both pg_dump and pg_restore are required for
|
||||
Both {isPg ? "pg_dump and pg_restore" : "mysqldump and mysql"} are required for
|
||||
database sync.
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-xs text-amber-200/70 space-y-0.5">
|
||||
{!toolStatus?.pg_dump_found && (
|
||||
{isPg && !pgToolStatus?.pg_dump_found && (
|
||||
<li>pg_dump is missing.</li>
|
||||
)}
|
||||
{!toolStatus?.pg_restore_found && (
|
||||
{isPg && !pgToolStatus?.pg_restore_found && (
|
||||
<li>pg_restore is missing.</li>
|
||||
)}
|
||||
{isMysql && !mysqlToolStatus?.mysqldumpFound && (
|
||||
<li>mysqldump is missing.</li>
|
||||
)}
|
||||
{isMysql && !mysqlToolStatus?.mysqlFound && (
|
||||
<li>mysql is missing.</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
@@ -168,24 +259,21 @@ export function SyncPage() {
|
||||
</label>
|
||||
<select
|
||||
value={sourceConnectionId}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
setSourceConnectionId(
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
);
|
||||
setTargetConnectionId("");
|
||||
}}
|
||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<option value="">
|
||||
Select source...
|
||||
</option>
|
||||
{postgresqlConnections.map((c) => (
|
||||
{connections.map((c) => (
|
||||
<option
|
||||
key={c.id}
|
||||
value={c.id}
|
||||
disabled={
|
||||
c.id ===
|
||||
targetConnectionId
|
||||
}
|
||||
>
|
||||
{c.name}
|
||||
</option>
|
||||
@@ -204,18 +292,20 @@ export function SyncPage() {
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
|
||||
disabled={!sourceConnectionId}
|
||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value="">
|
||||
Select target...
|
||||
{sourceConnectionId
|
||||
? "Select target..."
|
||||
: "Select a source first"}
|
||||
</option>
|
||||
{postgresqlConnections.map((c) => (
|
||||
{targetConnections.map((c) => (
|
||||
<option
|
||||
key={c.id}
|
||||
value={c.id}
|
||||
disabled={
|
||||
c.id ===
|
||||
sourceConnectionId
|
||||
c.id === sourceConnectionId
|
||||
}
|
||||
>
|
||||
{c.name}
|
||||
@@ -226,6 +316,7 @@ export function SyncPage() {
|
||||
</div>
|
||||
|
||||
{/* Schema (optional) */}
|
||||
{(isPg || isMysql) && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Schema{" "}
|
||||
@@ -253,12 +344,13 @@ export function SyncPage() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Flow indicator */}
|
||||
{sourceConnectionId && targetConnectionId && (
|
||||
<div className="flex items-center gap-3 text-[11px] text-text-muted">
|
||||
<span className="font-medium text-text">
|
||||
{postgresqlConnections.find(
|
||||
{connections.find(
|
||||
(c) =>
|
||||
c.id === sourceConnectionId,
|
||||
)?.name ?? sourceConnectionId}
|
||||
@@ -268,7 +360,7 @@ export function SyncPage() {
|
||||
className="text-accent shrink-0"
|
||||
/>
|
||||
<span className="font-medium text-text">
|
||||
{postgresqlConnections.find(
|
||||
{connections.find(
|
||||
(c) =>
|
||||
c.id === targetConnectionId,
|
||||
)?.name ?? targetConnectionId}
|
||||
@@ -329,4 +421,3 @@ export function SyncPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { ComponentProps } from "react";
|
||||
import { TableControls, formatDuration } from "./TableControls";
|
||||
import { TooltipProvider } from "../ui/Tooltip";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import * as exportData from "../../lib/exportData";
|
||||
import type { ViewerTab } from "../../stores/dbViewerStore";
|
||||
|
||||
const columns = [
|
||||
@@ -331,4 +333,28 @@ describe("TableControls", () => {
|
||||
screen.getByText("Drop columns here to add filters"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("export dropdown includes the Excel option", () => {
|
||||
seed([makeTab()], "tab-1");
|
||||
renderControls();
|
||||
fireEvent.click(screen.getByLabelText(/export/i));
|
||||
expect(screen.getByText("JSON")).toBeInTheDocument();
|
||||
expect(screen.getByText("CSV")).toBeInTheDocument();
|
||||
expect(screen.getByText("SQL")).toBeInTheDocument();
|
||||
expect(screen.getByText("Markdown")).toBeInTheDocument();
|
||||
expect(screen.getByText("Excel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("notifies after a successful export", () => {
|
||||
seed([makeTab()], "tab-1");
|
||||
useNotificationStore.getState().notifications.length = 0;
|
||||
vi.spyOn(exportData, "exportData").mockImplementation(() => {});
|
||||
renderControls();
|
||||
fireEvent.click(screen.getByLabelText(/export/i));
|
||||
fireEvent.click(screen.getByText("Excel"));
|
||||
const st = useNotificationStore.getState();
|
||||
expect(
|
||||
st.notifications.some((n) => n.message.toLowerCase().includes("exported")),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ChevronDown, FileJson, FileText, Terminal,
|
||||
} from "lucide-react";
|
||||
import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { FilterBuilder } from "./FilterBuilder";
|
||||
import { Tooltip } from "../ui/Tooltip";
|
||||
import { exportData } from "../../lib/exportData";
|
||||
@@ -26,6 +27,7 @@ const EXPORT_FORMATS = [
|
||||
{ label: "CSV", ext: "csv" },
|
||||
{ label: "SQL", ext: "sql" },
|
||||
{ label: "Markdown", ext: "md" },
|
||||
{ label: "Excel", ext: "xlsx" },
|
||||
] as const;
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────
|
||||
@@ -430,6 +432,7 @@ export function TableControls({
|
||||
variant = "table",
|
||||
}: TableControlsProps) {
|
||||
const isQuery = variant === "query";
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
const tabs = useDbViewerStore((s) => s.tabs);
|
||||
const activeTabId = useDbViewerStore((s) => s.activeTabId);
|
||||
const setPage = useDbViewerStore((s) => s.setPage);
|
||||
@@ -495,7 +498,20 @@ export function TableControls({
|
||||
};
|
||||
|
||||
const handleExport = (format: string) => {
|
||||
const label =
|
||||
EXPORT_FORMATS.find((f) => f.ext === format)?.label ?? format.toUpperCase();
|
||||
try {
|
||||
exportData(rows, columns, format, table);
|
||||
notify(
|
||||
`Exported ${rows.length} row${rows.length === 1 ? "" : "s"} as ${label}`,
|
||||
"success",
|
||||
);
|
||||
} catch (e) {
|
||||
notify(
|
||||
`Export failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
setExportOpen(false);
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -31,6 +56,7 @@ describe("TableOverflowMenu", () => {
|
||||
expect(screen.getByText("Open in new tab")).toBeInTheDocument();
|
||||
expect(screen.getByText("Copy table schema")).toBeInTheDocument();
|
||||
expect(screen.getByText("Export data (CSV)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Export data (Excel)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fires onOpenTab when menu item clicked", async () => {
|
||||
@@ -92,4 +118,57 @@ describe("TableOverflowMenu", () => {
|
||||
fireEvent.click(screen.getByText(/export data \(csv\)/i));
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith([[1]], columns, "csv", "public.t"));
|
||||
});
|
||||
|
||||
it("Excel export calls exportData and notifies", async () => {
|
||||
const spy = vi.spyOn(exportData, "exportData").mockImplementation(() => {});
|
||||
const { useNotificationStore } = await import("../../stores/notificationStore");
|
||||
useNotificationStore.getState().notifications.length = 0;
|
||||
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 }];
|
||||
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} columns={columns} rows={[[1]]} />);
|
||||
fireEvent.click(screen.getByLabelText(/table options/i));
|
||||
fireEvent.click(screen.getByText(/export data \(excel\)/i));
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith([[1]], columns, "xlsx", "public.t"));
|
||||
const st = useNotificationStore.getState();
|
||||
expect(st.notifications.some((n) => n.message.toLowerCase().includes("exported"))).toBe(true);
|
||||
});
|
||||
|
||||
it("tree kebab export fetches table data when rows are absent", async () => {
|
||||
const spy = vi.spyOn(exportData, "exportData").mockImplementation(() => {});
|
||||
const getSpy = vi.spyOn(commands, "getTableData").mockResolvedValue({
|
||||
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 }],
|
||||
rows: [[42]],
|
||||
total_rows: 1,
|
||||
page: 1,
|
||||
page_size: 1000,
|
||||
});
|
||||
const { useNotificationStore } = await import("../../stores/notificationStore");
|
||||
useNotificationStore.getState().notifications.length = 0;
|
||||
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} connectionId="c1" />);
|
||||
fireEvent.click(screen.getByLabelText(/table options/i));
|
||||
fireEvent.click(screen.getByText(/export data \(excel\)/i));
|
||||
await waitFor(() =>
|
||||
expect(getSpy).toHaveBeenCalledWith("c1", "public", "t", 1, 1000),
|
||||
);
|
||||
await waitFor(() => expect(spy).toHaveBeenCalled());
|
||||
const st = useNotificationStore.getState();
|
||||
expect(st.notifications.some((n) => n.message.includes("Exported"))).toBe(true);
|
||||
});
|
||||
|
||||
it("maintenance items are gated by capability and run via confirm", async () => {
|
||||
vi.spyOn(commands, "runMaintenance").mockResolvedValue({ duration_ms: 3, message: "VACUUM completed" });
|
||||
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => ""} connectionId="c1" />);
|
||||
fireEvent.click(screen.getByLabelText("Table options"));
|
||||
fireEvent.click(screen.getByText("VACUUM"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /vacuum/i }));
|
||||
await screen.findByText(/completed/i);
|
||||
expect(commands.runMaintenance).toHaveBeenCalledWith("c1", "public", "users", "vacuum");
|
||||
});
|
||||
|
||||
it("Edit Table opens a table form tab", () => {
|
||||
const openFormTab = vi.spyOn(useDbViewerStore.getState(), "openFormTab");
|
||||
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => ""} connectionId="c1" columns={[{ name: "id", data_type: "int", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }]} />);
|
||||
fireEvent.click(screen.getByLabelText("Table options"));
|
||||
fireEvent.click(screen.getByText("Edit Table…"));
|
||||
expect(openFormTab).toHaveBeenCalledWith(expect.objectContaining({ kind: "table", mode: "edit" }));
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,18 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
import { MoreVertical, RefreshCw } from "lucide-react";
|
||||
import { ConfirmDialog } from "../ui/ConfirmDialog";
|
||||
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||
import { Button } from "../ui/Button";
|
||||
import { ImportDialog } from "./ImportDialog";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { exportData } from "../../lib/exportData";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import { getCapabilities } from "../../lib/dbCapabilities";
|
||||
import { DependencyDialog } from "./DependencyDialog";
|
||||
import type { ColumnInfo, DependencyInfo } from "../../lib/types";
|
||||
import { initialCrudParams } from "../../lib/objectCrud";
|
||||
import type { ColumnInfo, DependencyInfo, MaintenanceResult } from "../../lib/types";
|
||||
|
||||
interface TableOverflowMenuProps {
|
||||
schema: string;
|
||||
@@ -16,12 +21,26 @@ interface TableOverflowMenuProps {
|
||||
connectionId?: string;
|
||||
columns?: ColumnInfo[];
|
||||
rows?: unknown[][];
|
||||
dbType?: string;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
label?: string;
|
||||
danger?: boolean;
|
||||
divider?: boolean;
|
||||
}
|
||||
|
||||
type MaintenanceAction = "vacuum" | "analyze" | "reindex";
|
||||
|
||||
const maintenanceLockCopy: Record<MaintenanceAction, string> = {
|
||||
vacuum: "VACUUM blocks concurrent DDL only on this table.",
|
||||
analyze: "ANALYZE blocks concurrent DDL only on this table.",
|
||||
reindex: "REINDEX takes an ACCESS EXCLUSIVE lock — blocks reads and writes on this table until complete.",
|
||||
};
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
export function TableOverflowMenu({
|
||||
@@ -31,15 +50,23 @@ export function TableOverflowMenu({
|
||||
connectionId: connectionIdProp,
|
||||
columns,
|
||||
rows,
|
||||
dbType,
|
||||
}: TableOverflowMenuProps) {
|
||||
const storeConnectionId = useUiStore((s) => s.activeConnectionId);
|
||||
const connectionId = connectionIdProp ?? storeConnectionId;
|
||||
const addChange = useDbViewerStore((s) => s.addChange);
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
const caps = getCapabilities(dbType ?? "postgresql");
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [confirmAction, setConfirmAction] = useState<"empty" | "delete" | null>(null);
|
||||
const [dropDeps, setDropDeps] = useState<DependencyInfo[]>([]);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [maintenance, setMaintenance] = useState<{ action: MaintenanceAction; lockCopy: string } | null>(null);
|
||||
const [maintenanceRunning, setMaintenanceRunning] = useState(false);
|
||||
const [maintenanceResult, setMaintenanceResult] = useState<
|
||||
{ type: "success" | "error"; message: string; duration_ms: number } | null
|
||||
>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -62,6 +89,28 @@ export function TableOverflowMenu({
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleMaintenanceConfirm = async () => {
|
||||
if (!connectionId || !maintenance) return;
|
||||
setMaintenanceRunning(true);
|
||||
setMaintenanceResult(null);
|
||||
try {
|
||||
const result: MaintenanceResult = await cmd.runMaintenance(
|
||||
connectionId,
|
||||
schema,
|
||||
table,
|
||||
maintenance.action,
|
||||
);
|
||||
setMaintenanceResult({ type: "success", message: result.message, duration_ms: result.duration_ms });
|
||||
notify(`${result.message} · ${result.duration_ms}ms`, "success");
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
setMaintenanceResult({ type: "error", message, duration_ms: 0 });
|
||||
notify(message, "error");
|
||||
} finally {
|
||||
setMaintenanceRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = async (id: string) => {
|
||||
switch (id) {
|
||||
case "open":
|
||||
@@ -84,10 +133,47 @@ export function TableOverflowMenu({
|
||||
case "export-csv":
|
||||
case "export-json":
|
||||
case "export-sql":
|
||||
case "export-md": {
|
||||
case "export-md":
|
||||
case "export-xlsx": {
|
||||
const format = id.replace("export-", "");
|
||||
const label =
|
||||
format === "xlsx"
|
||||
? "Excel"
|
||||
: format === "md"
|
||||
? "Markdown"
|
||||
: format.toUpperCase();
|
||||
try {
|
||||
if (rows && rows.length > 0 && columns && columns.length > 0) {
|
||||
exportData(rows, columns, format, `${schema}.${table}`);
|
||||
notify(
|
||||
`Exported ${rows.length} row${rows.length === 1 ? "" : "s"} as ${label}`,
|
||||
"success",
|
||||
);
|
||||
} else if (connectionId) {
|
||||
// Tree kebab: no rows are loaded here — fetch the table data
|
||||
// first, then export (capped at 1000 rows per fetch).
|
||||
const result = await cmd.getTableData(connectionId, schema, table, 1, 1000);
|
||||
if (!result.rows.length) {
|
||||
notify("Nothing to export", "info");
|
||||
} else {
|
||||
exportData(result.rows, result.columns, format, `${schema}.${table}`);
|
||||
const truncated =
|
||||
result.total_rows > result.rows.length
|
||||
? ` (first ${result.rows.length} of ${result.total_rows})`
|
||||
: "";
|
||||
notify(
|
||||
`Exported ${result.rows.length} row${result.rows.length === 1 ? "" : "s"} as ${label}${truncated}`,
|
||||
"success",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
notify("Nothing to export", "info");
|
||||
}
|
||||
} catch (e) {
|
||||
notify(
|
||||
`Export failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
setOpen(false);
|
||||
break;
|
||||
@@ -96,6 +182,66 @@ export function TableOverflowMenu({
|
||||
setImportOpen(true);
|
||||
setOpen(false);
|
||||
break;
|
||||
case "edit_table": {
|
||||
const columnMeta = (columns ?? []).map((c) => ({
|
||||
name: c.name,
|
||||
type: c.data_type,
|
||||
nullable: c.is_nullable,
|
||||
default: c.default_value,
|
||||
is_pk: c.is_pk,
|
||||
}));
|
||||
useDbViewerStore.getState().openFormTab({
|
||||
kind: "table",
|
||||
schema,
|
||||
name: table,
|
||||
title: "Edit Table",
|
||||
description: `Edit ${schema}.${table}`,
|
||||
mode: "edit",
|
||||
params: {
|
||||
schema,
|
||||
name: table,
|
||||
action: {
|
||||
op: "edit",
|
||||
columns: columnMeta,
|
||||
old_columns: columnMeta,
|
||||
},
|
||||
},
|
||||
});
|
||||
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 "vacuum":
|
||||
case "analyze":
|
||||
case "reindex":
|
||||
setMaintenance({ action: id, lockCopy: maintenanceLockCopy[id] });
|
||||
setOpen(false);
|
||||
break;
|
||||
case "empty":
|
||||
setConfirmAction("empty");
|
||||
setOpen(false);
|
||||
@@ -124,7 +270,19 @@ export function TableOverflowMenu({
|
||||
{ id: "export-json", label: "Export data (JSON)" },
|
||||
{ id: "export-sql", label: "Export data (SQL)" },
|
||||
{ id: "export-md", label: "Export data (Markdown)" },
|
||||
{ id: "export-xlsx", label: "Export data (Excel)" },
|
||||
{ id: "import", label: "Import data (CSV/JSON)" },
|
||||
{ id: "create_index", label: "Create Index…" },
|
||||
{ id: "create_constraint", label: "Create Constraint…" },
|
||||
...(caps.tableManagement ? [{ id: "edit_table", label: "Edit Table…" }] : []),
|
||||
...(caps.maintenance
|
||||
? [
|
||||
{ id: "maintenance-divider", divider: true },
|
||||
{ id: "vacuum", label: "VACUUM" },
|
||||
{ id: "analyze", label: "ANALYZE" },
|
||||
{ id: "reindex", label: "REINDEX", danger: true },
|
||||
]
|
||||
: []),
|
||||
{ id: "empty", label: "Empty Table", danger: true },
|
||||
{ id: "delete", label: "Delete Table", danger: true },
|
||||
];
|
||||
@@ -140,7 +298,10 @@ export function TableOverflowMenu({
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 mt-1 rounded-xl bg-surface border border-border py-1 z-20 min-w-[180px] shadow-lg">
|
||||
{items.map((item) => (
|
||||
{items.map((item) =>
|
||||
item.divider ? (
|
||||
<div key={item.id} className="border-t border-border my-1" />
|
||||
) : (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
@@ -152,10 +313,54 @@ export function TableOverflowMenu({
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{maintenance && (
|
||||
<AnimatedModal open onClose={() => setMaintenance(null)}>
|
||||
<div className="w-80">
|
||||
<h3 className="font-heading text-text text-lg mb-3">
|
||||
{maintenanceResult
|
||||
? maintenanceResult.type === "success"
|
||||
? "Maintenance Complete"
|
||||
: "Maintenance Failed"
|
||||
: `${capitalize(maintenance.action)}: ${schema}.${table}`}
|
||||
</h3>
|
||||
<p className="text-sm text-text-muted mb-4">
|
||||
{maintenanceResult
|
||||
? `${maintenanceResult.message} · ${maintenanceResult.duration_ms}ms`
|
||||
: maintenance.lockCopy}
|
||||
</p>
|
||||
{maintenanceRunning && (
|
||||
<div className="flex items-center gap-2 text-sm text-text-muted mb-4">
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
<span>Running {maintenance.action}…</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setMaintenance(null)}
|
||||
disabled={maintenanceRunning}
|
||||
>
|
||||
{maintenanceResult ? "Close" : "Cancel"}
|
||||
</Button>
|
||||
{!maintenanceResult && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleMaintenanceConfirm}
|
||||
disabled={maintenanceRunning}
|
||||
>
|
||||
{maintenanceRunning ? "Running…" : capitalize(maintenance.action)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
)}
|
||||
|
||||
{confirmAction === "delete" && dropDeps.length > 0 && (
|
||||
<DependencyDialog
|
||||
open
|
||||
|
||||
@@ -7,7 +7,7 @@ import { abbreviateType } from "../../lib/utils";
|
||||
import type { ColumnInfo } from "../../lib/types";
|
||||
import * as cmd from "../../lib/commands";
|
||||
|
||||
export function TableTree({ searchQuery }: { searchQuery?: string }) {
|
||||
export function TableTree({ searchQuery, dbType }: { searchQuery?: string; dbType?: string }) {
|
||||
const tables = useDbViewerStore((s) => s.tables);
|
||||
const currentSchema = useDbViewerStore((s) => s.currentSchema);
|
||||
const schemaTreeLoading = useDbViewerStore((s) => s.schemaTreeLoading);
|
||||
@@ -113,6 +113,7 @@ export function TableTree({ searchQuery }: { searchQuery?: string }) {
|
||||
schema={table.schema}
|
||||
table={table.name}
|
||||
connectionId={connectionId ?? undefined}
|
||||
dbType={dbType}
|
||||
columns={cols}
|
||||
onOpenTab={handleOpenTab}
|
||||
/>
|
||||
|
||||
@@ -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,168 @@
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { ConstraintForm } from "./ConstraintForm";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(cmd, "getSchemaGraph").mockReset().mockResolvedValue({ tables: [], relationships: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
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", () => {
|
||||
it("check: emits the expression", () => {
|
||||
(cmd.getSchemaGraph as any).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 () => {
|
||||
(cmd.getSchemaGraph as any).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.findByLabelText("Referenced table");
|
||||
fireEvent.change(refTable, { target: { value: "public.users" } });
|
||||
expect(onChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
action: expect.objectContaining({
|
||||
ref_schema: "public",
|
||||
ref_table: "users",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("FK form includes ON DELETE/UPDATE + deferrable and cross-schema ref picker", async () => {
|
||||
// mock getSchemaGraph to return tables across two schemas
|
||||
(cmd.getSchemaGraph as any).mockResolvedValue({
|
||||
tables: [
|
||||
{
|
||||
name: "users",
|
||||
schema: "public",
|
||||
table_type: "TABLE",
|
||||
columns: [
|
||||
{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: false, is_nullable: false, fk_ref: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "orgs",
|
||||
schema: "auth",
|
||||
table_type: "TABLE",
|
||||
columns: [
|
||||
{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: false, is_nullable: false, fk_ref: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
} as any);
|
||||
render(
|
||||
<ConstraintForm
|
||||
connectionId="c1"
|
||||
schemas={["public", "auth"]}
|
||||
params={{
|
||||
schema: "public",
|
||||
table: "orders",
|
||||
name: "fk1",
|
||||
action: {
|
||||
op: "foreign_key",
|
||||
columns: ["org_id"],
|
||||
ref_schema: "auth",
|
||||
ref_table: "orgs",
|
||||
ref_columns: ["id"],
|
||||
on_delete: "CASCADE",
|
||||
on_update: "NO ACTION",
|
||||
deferrable: false,
|
||||
initially_deferred: false,
|
||||
},
|
||||
}}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByLabelText("On delete")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("On update")).toBeInTheDocument();
|
||||
// referenced-table picker includes the auth.orgs option
|
||||
const ref = screen.getByLabelText("Referenced table") as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
expect([...ref.options].map((o) => o.value)).toContain("auth.orgs");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
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"];
|
||||
const FK_ACTIONS = ["NO ACTION", "RESTRICT", "CASCADE", "SET NULL", "SET DEFAULT"];
|
||||
|
||||
function patchAction(params: DdlParams, patch: Record<string, unknown>): DdlParams {
|
||||
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||
return { ...params, action: { ...action, ...patch } };
|
||||
}
|
||||
|
||||
function refKey(schema: string, table: string): string {
|
||||
return `${schema}.${table}`;
|
||||
}
|
||||
|
||||
function parseRefKey(key: string): { schema: string; table: string } {
|
||||
const parts = key.split(".");
|
||||
if (parts.length >= 2) return { schema: parts[0] ?? "", table: parts.slice(1).join(".") };
|
||||
return { schema: "", table: key };
|
||||
}
|
||||
|
||||
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, undefined)
|
||||
.then((g: SchemaGraph) => setGraph(g))
|
||||
.catch(() => setGraph({ tables: [], relationships: [] }));
|
||||
}, [connectionId]);
|
||||
|
||||
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">
|
||||
<select
|
||||
aria-label="Referenced table"
|
||||
value={refKey(
|
||||
(action.ref_schema as string) ?? "",
|
||||
(action.ref_table as string) ?? "",
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const { schema, table } = parseRefKey(e.target.value);
|
||||
const t = graph.tables.find(
|
||||
(tbl) => tbl.name === table && tbl.schema === schema,
|
||||
);
|
||||
setAction({
|
||||
ref_table: table,
|
||||
ref_schema: schema,
|
||||
ref_columns: [],
|
||||
});
|
||||
if (!t) return;
|
||||
const pkCols = t.columns.filter((c) => c.is_pk).map((c) => c.name);
|
||||
if (pkCols.length > 0 && selected.length > 0) {
|
||||
setAction({
|
||||
ref_table: table,
|
||||
ref_schema: schema,
|
||||
ref_columns: pkCols.slice(0, selected.length),
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={controlClass}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select a table
|
||||
</option>
|
||||
{graph.tables.map((t) => (
|
||||
<option key={refKey(t.schema, t.name)} value={refKey(t.schema, t.name)}>
|
||||
{t.schema}.{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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>
|
||||
<FormRow label="On delete">
|
||||
<select
|
||||
aria-label="On delete"
|
||||
value={(action.on_delete as string) ?? "NO ACTION"}
|
||||
onChange={(e) => setAction({ on_delete: e.target.value })}
|
||||
className={controlClass}
|
||||
>
|
||||
{FK_ACTIONS.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{a}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
<FormRow label="On update">
|
||||
<select
|
||||
aria-label="On update"
|
||||
value={(action.on_update as string) ?? "NO ACTION"}
|
||||
onChange={(e) => setAction({ on_update: e.target.value })}
|
||||
className={controlClass}
|
||||
>
|
||||
{FK_ACTIONS.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{a}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
<FormRow label="Deferrable">
|
||||
<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"
|
||||
aria-label="DEFERRABLE"
|
||||
checked={!!action.deferrable}
|
||||
onChange={(e) =>
|
||||
setAction({
|
||||
deferrable: e.target.checked,
|
||||
initially_deferred: e.target.checked ? action.initially_deferred ?? false : false,
|
||||
})
|
||||
}
|
||||
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||
/>
|
||||
<span>DEFERRABLE</span>
|
||||
</label>
|
||||
</FormRow>
|
||||
{!!action.deferrable && (
|
||||
<FormRow label="Initially deferred">
|
||||
<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"
|
||||
aria-label="INITIALLY DEFERRED"
|
||||
checked={!!action.initially_deferred}
|
||||
onChange={(e) => setAction({ initially_deferred: e.target.checked })}
|
||||
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||
/>
|
||||
<span>INITIALLY DEFERRED</span>
|
||||
</label>
|
||||
</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,223 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { FkPanel } from "./FkPanel";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
import * as objectCrud from "../../../lib/objectCrud";
|
||||
|
||||
const graph = {
|
||||
tables: [
|
||||
{
|
||||
name: "orders",
|
||||
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 },
|
||||
{ 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 },
|
||||
{ name: "email", data_type: "text", is_pk: false, is_fk: false, is_unique: false, is_nullable: true, fk_ref: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "orgs",
|
||||
schema: "auth",
|
||||
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 },
|
||||
{ name: "name", data_type: "text", is_pk: false, is_fk: false, is_unique: false, is_nullable: true, fk_ref: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
vi.spyOn(cmd, "getSchemaGraph").mockReset().mockResolvedValue(graph as any);
|
||||
vi.spyOn(objectCrud, "buildObjectDdl").mockReset().mockResolvedValue(["ALTER TABLE ... ADD CONSTRAINT ..."]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function renderPanel(props: Partial<React.ComponentProps<typeof FkPanel>> = {}) {
|
||||
const onClose = vi.fn();
|
||||
const onStaged = vi.fn();
|
||||
const utils = render(
|
||||
<FkPanel
|
||||
connectionId="c1"
|
||||
schema="public"
|
||||
table="orders"
|
||||
column="user_id"
|
||||
mode="edit"
|
||||
localColumns={[
|
||||
{ name: "user_id", data_type: "int" },
|
||||
{ name: "amount", data_type: "numeric" },
|
||||
{ name: "created_at", data_type: "timestamp" },
|
||||
]}
|
||||
onClose={onClose}
|
||||
onStaged={onStaged}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
return { ...utils, onClose, onStaged };
|
||||
}
|
||||
|
||||
/** The columns/actions sections only render once a referenced table is chosen. */
|
||||
async function waitForTable() {
|
||||
await screen.findByLabelText("Table");
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText("Local column 1")).not.toBeNull();
|
||||
});
|
||||
}
|
||||
|
||||
describe("FkPanel", () => {
|
||||
it("renders cascading dropdowns with schemas, tables, and PK-first columns", async () => {
|
||||
renderPanel();
|
||||
expect(await screen.findByText("Foreign key")).toBeInTheDocument();
|
||||
|
||||
// Schema select contains public and auth
|
||||
const schema = screen.getByLabelText("Schema") as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
expect([...schema.options].map((o) => o.value)).toContain("public");
|
||||
expect([...schema.options].map((o) => o.value)).toContain("auth");
|
||||
});
|
||||
|
||||
// Table select contains public tables by default
|
||||
const table = screen.getByLabelText("Table") as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
expect([...table.options].map((o) => o.value)).toContain("users");
|
||||
expect([...table.options].map((o) => o.value)).not.toContain("orgs");
|
||||
});
|
||||
|
||||
// Once a table is referenced, the local column pair appears, preselected
|
||||
await waitForTable();
|
||||
const local = screen.getByLabelText("Local column 1") as HTMLSelectElement;
|
||||
expect(local.value).toBe("user_id");
|
||||
expect([...local.options].map((o) => o.textContent)).toContain("user_id (int)");
|
||||
|
||||
// Referenced column select lists PKs first and marks them
|
||||
const refCol = screen.getByLabelText("Referenced column 1") as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
const opts = [...refCol.options].map((o) => o.textContent);
|
||||
expect(opts[0]).toMatch(/id/);
|
||||
});
|
||||
});
|
||||
|
||||
it("updates table and column lists when schema changes", async () => {
|
||||
renderPanel();
|
||||
const schema = await screen.findByLabelText("Schema");
|
||||
fireEvent.change(schema, { target: { value: "auth" } });
|
||||
|
||||
const table = screen.getByLabelText("Table") as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
expect([...table.options].map((o) => o.value)).toContain("orgs");
|
||||
expect([...table.options].map((o) => o.value)).not.toContain("users");
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the column-pairs and action sections until a referenced table is chosen", async () => {
|
||||
let resolveGraph: (g: unknown) => void = () => {};
|
||||
(cmd.getSchemaGraph as any).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveGraph = resolve;
|
||||
}),
|
||||
);
|
||||
renderPanel();
|
||||
await screen.findByText("Foreign key");
|
||||
// while the graph is still loading, the rest is hidden
|
||||
expect(screen.queryByLabelText("Local column 1")).toBeNull();
|
||||
// resolve the graph → table auto-selects → sections appear
|
||||
resolveGraph(graph);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Local column 1")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByLabelText("On update")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("On delete")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supports adding multiple column pairs", async () => {
|
||||
renderPanel();
|
||||
await waitForTable();
|
||||
fireEvent.click(screen.getByLabelText("Add another column"));
|
||||
expect(screen.getAllByLabelText(/Local column/)).toHaveLength(2);
|
||||
expect(screen.getAllByLabelText(/Referenced column/)).toHaveLength(2);
|
||||
// removing a pair works
|
||||
fireEvent.click(screen.getByLabelText("Remove pair 2"));
|
||||
expect(screen.getAllByLabelText(/Local column/)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("stages a foreign key DDL change and closes the panel", async () => {
|
||||
const { onStaged } = renderPanel();
|
||||
await screen.findByText("Foreign key");
|
||||
|
||||
await waitForTable();
|
||||
fireEvent.change(screen.getByLabelText("On delete"), { target: { value: "CASCADE" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add FK" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(objectCrud.buildObjectDdl).toHaveBeenCalledWith(
|
||||
"c1",
|
||||
"constraint",
|
||||
expect.objectContaining({
|
||||
schema: "public",
|
||||
table: "orders",
|
||||
name: "",
|
||||
action: expect.objectContaining({
|
||||
op: "foreign_key",
|
||||
columns: ["user_id"],
|
||||
ref_schema: "public",
|
||||
ref_table: "orders",
|
||||
ref_columns: ["id"],
|
||||
on_delete: "CASCADE",
|
||||
on_update: "NO ACTION",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const q = useDbViewerStore.getState().changesQueue;
|
||||
expect(q).toHaveLength(1);
|
||||
expect(q[0].type).toBe("ddl");
|
||||
expect(q[0].sql).toBe("ALTER TABLE ... ADD CONSTRAINT ...");
|
||||
// hands back the local column + the referenced column's type (for auto-matching)
|
||||
expect(onStaged).toHaveBeenCalledWith({
|
||||
pairs: [{ localCol: "user_id", refType: "int" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("create mode returns the FK inline instead of staging a separate ALTER", async () => {
|
||||
const { onStaged } = renderPanel({ mode: "create" });
|
||||
await screen.findByText("Foreign key");
|
||||
await waitForTable();
|
||||
const refCol = (await screen.findByLabelText("Referenced column 1")) as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
expect([...refCol.options].map((o) => o.value)).toContain("id");
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add FK" }));
|
||||
|
||||
// no separate ALTER change is staged
|
||||
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
|
||||
expect(objectCrud.buildObjectDdl).not.toHaveBeenCalled();
|
||||
|
||||
// the FK definition is handed back for inlining into the CREATE TABLE
|
||||
expect(onStaged).toHaveBeenCalledWith({
|
||||
pairs: [{ localCol: "user_id", refType: "int" }],
|
||||
fk: expect.objectContaining({
|
||||
columns: ["user_id"],
|
||||
ref_schema: "public",
|
||||
ref_table: "orders",
|
||||
ref_columns: ["id"],
|
||||
on_delete: "NO ACTION",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,566 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { X, Link, Plus } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
import { buildObjectDdl } from "../../../lib/objectCrud";
|
||||
import type { SchemaGraph } from "../../../lib/types";
|
||||
|
||||
// Panel selects: transparent, fill the available width (no surface bg like the grid).
|
||||
const panelSelect =
|
||||
"w-full bg-transparent font-heading text-xs text-text outline-none placeholder:text-text-muted cursor-pointer";
|
||||
|
||||
export interface FkColumn {
|
||||
name: string;
|
||||
data_type: string;
|
||||
}
|
||||
|
||||
export interface FkStagedPair {
|
||||
localCol: string;
|
||||
refType: string;
|
||||
}
|
||||
|
||||
export interface FkDefinition {
|
||||
columns: string[];
|
||||
ref_schema: string;
|
||||
ref_table: string;
|
||||
ref_columns: string[];
|
||||
on_delete?: string;
|
||||
on_update?: string;
|
||||
deferrable?: boolean;
|
||||
initially_deferred?: boolean;
|
||||
}
|
||||
|
||||
interface FkPair {
|
||||
localCol: string;
|
||||
refCol: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
schema: string;
|
||||
table: string;
|
||||
column: string | null;
|
||||
/** Columns of the table being edited (from the form grid — works even when the table isn't created yet). */
|
||||
localColumns: FkColumn[];
|
||||
mode: "create" | "edit";
|
||||
onClose: () => void;
|
||||
/** Called after staging: pairs (for type auto-match); in create mode the FK definition is returned
|
||||
* so the form can inline it into the CREATE TABLE instead of a separate ALTER change. */
|
||||
onStaged?: (result: { pairs: FkStagedPair[]; fk?: FkDefinition }) => void;
|
||||
}
|
||||
|
||||
const FK_ACTIONS = [
|
||||
"NO ACTION",
|
||||
"RESTRICT",
|
||||
"CASCADE",
|
||||
"SET NULL",
|
||||
"SET DEFAULT",
|
||||
];
|
||||
|
||||
function Section({
|
||||
label,
|
||||
children,
|
||||
flush = false,
|
||||
}: {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
flush?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<div className="border-b border-border px-4 py-2 text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
{label}
|
||||
</div>
|
||||
<div className={flush ? "" : "px-2 py-2"}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FkPanel({
|
||||
connectionId,
|
||||
schema,
|
||||
table,
|
||||
column,
|
||||
localColumns,
|
||||
mode,
|
||||
onClose,
|
||||
onStaged,
|
||||
}: Props) {
|
||||
const [graph, setGraph] = useState<SchemaGraph>({
|
||||
tables: [],
|
||||
relationships: [],
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [staging, setStaging] = useState(false);
|
||||
|
||||
const [pairs, setPairs] = useState<FkPair[]>(() =>
|
||||
column
|
||||
? [{ localCol: column, refCol: "" }]
|
||||
: [{ localCol: "", refCol: "" }],
|
||||
);
|
||||
const [refSchema, setRefSchema] = useState<string>(schema);
|
||||
const [refTable, setRefTable] = useState<string>("");
|
||||
const [onDelete, setOnDelete] = useState<string>("NO ACTION");
|
||||
const [onUpdate, setOnUpdate] = useState<string>("NO ACTION");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
cmd.getSchemaGraph(connectionId, undefined)
|
||||
.then((g) => {
|
||||
if (!active) return;
|
||||
setGraph(g);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!active) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [connectionId]);
|
||||
|
||||
const localNames = useMemo(
|
||||
() => localColumns.map((c) => c.name),
|
||||
[localColumns],
|
||||
);
|
||||
|
||||
const schemas = useMemo(
|
||||
() => Array.from(new Set(graph.tables.map((t) => t.schema))).sort(),
|
||||
[graph],
|
||||
);
|
||||
|
||||
const tablesInSchema = useMemo(
|
||||
() =>
|
||||
graph.tables
|
||||
.filter((t) => t.schema === refSchema)
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[graph, refSchema],
|
||||
);
|
||||
|
||||
const refTableInfo = useMemo(
|
||||
() => tablesInSchema.find((t) => t.name === refTable),
|
||||
[tablesInSchema, refTable],
|
||||
);
|
||||
|
||||
const refColumns = useMemo(() => {
|
||||
if (!refTableInfo) return [];
|
||||
return [...refTableInfo.columns].sort((a, b) => {
|
||||
if (a.is_pk && !b.is_pk) return -1;
|
||||
if (!a.is_pk && b.is_pk) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [refTableInfo]);
|
||||
|
||||
// Defaults: keep selections valid as the graph loads / inputs change.
|
||||
useEffect(() => {
|
||||
if (schemas.length > 0 && !schemas.includes(refSchema)) {
|
||||
setRefSchema(schemas[0] ?? "");
|
||||
}
|
||||
}, [schemas, refSchema]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
tablesInSchema.length > 0 &&
|
||||
!tablesInSchema.some((t) => t.name === refTable)
|
||||
) {
|
||||
setRefTable(tablesInSchema[0]?.name ?? "");
|
||||
} else if (tablesInSchema.length === 0) {
|
||||
setRefTable("");
|
||||
}
|
||||
}, [tablesInSchema, refTable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (localNames.length === 0) return;
|
||||
setPairs((prev) =>
|
||||
prev.map((p) =>
|
||||
localNames.includes(p.localCol)
|
||||
? p
|
||||
: { ...p, localCol: localNames[0] ?? "" },
|
||||
),
|
||||
);
|
||||
}, [localNames]);
|
||||
|
||||
useEffect(() => {
|
||||
setPairs((prev) =>
|
||||
prev.map((p) =>
|
||||
p.refCol && refColumns.some((c) => c.name === p.refCol)
|
||||
? p
|
||||
: { ...p, refCol: refColumns[0]?.name ?? "" },
|
||||
),
|
||||
);
|
||||
}, [refColumns]);
|
||||
|
||||
const setPair = (i: number, key: keyof FkPair, value: string) =>
|
||||
setPairs((prev) =>
|
||||
prev.map((p, j) => (j === i ? { ...p, [key]: value } : p)),
|
||||
);
|
||||
|
||||
const addPair = () =>
|
||||
setPairs((prev) => [
|
||||
...prev,
|
||||
{
|
||||
localCol: localNames[0] ?? "",
|
||||
refCol: refColumns[0]?.name ?? "",
|
||||
},
|
||||
]);
|
||||
|
||||
const removePair = (i: number) =>
|
||||
setPairs((prev) =>
|
||||
prev.length > 1 ? prev.filter((_, j) => j !== i) : prev,
|
||||
);
|
||||
|
||||
const typeMappings = pairs
|
||||
.map((p) => ({
|
||||
localType:
|
||||
localColumns.find((c) => c.name === p.localCol)?.data_type ??
|
||||
"",
|
||||
refType:
|
||||
refTableInfo?.columns.find((c) => c.name === p.refCol)
|
||||
?.data_type ?? "",
|
||||
}))
|
||||
.filter((m) => m.localType !== "" || m.refType !== "");
|
||||
|
||||
const tableLabel = table.trim() === "" ? "unnamed table" : table;
|
||||
|
||||
const addFk = async () => {
|
||||
const cols = pairs.map((p) => p.localCol);
|
||||
const refCols = pairs.map((p) => p.refCol);
|
||||
if (
|
||||
cols.length === 0 ||
|
||||
cols.some((c) => c === "") ||
|
||||
refCols.some((c) => c === "")
|
||||
)
|
||||
return;
|
||||
setStaging(true);
|
||||
setError(null);
|
||||
const refTypes: Record<string, string> = {};
|
||||
for (const c of refTableInfo?.columns ?? []) refTypes[c.name] = c.data_type;
|
||||
const pairsResult = pairs.map((p) => ({
|
||||
localCol: p.localCol,
|
||||
refType: refTypes[p.refCol] ?? "",
|
||||
}));
|
||||
const fk: FkDefinition = {
|
||||
columns: cols,
|
||||
ref_schema: refSchema,
|
||||
ref_table: refTable,
|
||||
ref_columns: refCols,
|
||||
on_delete: onDelete,
|
||||
on_update: onUpdate,
|
||||
deferrable: false,
|
||||
initially_deferred: false,
|
||||
};
|
||||
try {
|
||||
if (mode === "create") {
|
||||
// Inline the FK into the CREATE TABLE — no separate ALTER change.
|
||||
onStaged?.({ pairs: pairsResult, fk });
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
const sqls = await buildObjectDdl(connectionId, "constraint", {
|
||||
schema,
|
||||
table,
|
||||
name: "",
|
||||
action: {
|
||||
op: "foreign_key",
|
||||
columns: cols,
|
||||
ref_schema: refSchema,
|
||||
ref_table: refTable,
|
||||
ref_columns: refCols,
|
||||
on_delete: onDelete,
|
||||
on_update: onUpdate,
|
||||
},
|
||||
});
|
||||
sqls.forEach((sql, i) =>
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "ddl",
|
||||
sql,
|
||||
description:
|
||||
sqls.length > 1
|
||||
? `Add FK ${cols.join(", ")} → ${refSchema}.${refTable} (${i + 1}/${sqls.length})`
|
||||
: `Add FK ${cols.join(", ")} → ${refSchema}.${refTable}`,
|
||||
}),
|
||||
);
|
||||
onStaged?.({ pairs: pairsResult });
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setStaging(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={{ duration: 0.3, ease: [0.32, 0.72, 0, 1] }}
|
||||
style={{ willChange: "transform" }}
|
||||
className="fixed top-0 right-0 h-full w-[420px] bg-canvas border-l border-border z-40 flex flex-col"
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<div className="flex items-center gap-2 text-text">
|
||||
<Link size={14} />
|
||||
<span className="text-sm font-semibold">Foreign key</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="text-text-muted hover:text-text"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-1 overflow-auto"
|
||||
style={{ overscrollBehavior: "none" }}
|
||||
>
|
||||
{table.trim() === "" && (
|
||||
<p className="border-b border-border px-4 py-2 text-xs text-amber-400">
|
||||
Enter a table name in the form first — a foreign key
|
||||
needs a named table.
|
||||
</p>
|
||||
)}
|
||||
{loading && (
|
||||
<p className="border-b border-border px-4 py-2 text-xs text-text-muted">
|
||||
Loading schema graph…
|
||||
</p>
|
||||
)}
|
||||
{!loading && graph.tables.length === 0 && (
|
||||
<p className="border-b border-border px-4 py-2 text-xs text-text-muted">
|
||||
No tables available for foreign key reference.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && graph.tables.length > 0 && (
|
||||
<>
|
||||
<Section label="Select a Schema">
|
||||
<select
|
||||
aria-label="Schema"
|
||||
value={refSchema}
|
||||
onChange={(e) => setRefSchema(e.target.value)}
|
||||
className={panelSelect}
|
||||
>
|
||||
{schemas.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Section>
|
||||
|
||||
<Section label="Select a Table to reference to">
|
||||
<select
|
||||
aria-label="Table"
|
||||
value={refTable}
|
||||
onChange={(e) => setRefTable(e.target.value)}
|
||||
className={panelSelect}
|
||||
>
|
||||
{tablesInSchema.map((t) => (
|
||||
<option key={t.name} value={t.name}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Section>
|
||||
|
||||
{refSchema && refTable && (
|
||||
<>
|
||||
<Section
|
||||
flush
|
||||
label={`Select columns from ${schema}.${tableLabel} to reference to`}
|
||||
>
|
||||
<div className="grid grid-cols-2 text-xs text-text-muted border-b border-border">
|
||||
<div className="border-r border-border px-4 py-1">
|
||||
<span className="truncate">
|
||||
{schema}.{tableLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 py-1">
|
||||
<span className="truncate">
|
||||
{refSchema}.{refTable || "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{pairs.map((pair, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="grid grid-cols-2 items-center border-b border-border"
|
||||
>
|
||||
<div className="border-r border-border px-2 py-1">
|
||||
<select
|
||||
aria-label={`Local column ${i + 1}`}
|
||||
value={pair.localCol}
|
||||
onChange={(e) =>
|
||||
setPair(
|
||||
i,
|
||||
"localCol",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className={panelSelect}
|
||||
>
|
||||
{localColumns.map((c) => (
|
||||
<option
|
||||
key={c.name}
|
||||
value={c.name}
|
||||
>
|
||||
{c.name} (
|
||||
{c.data_type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 px-2 py-1">
|
||||
<select
|
||||
aria-label={`Referenced column ${i + 1}`}
|
||||
value={pair.refCol}
|
||||
onChange={(e) =>
|
||||
setPair(
|
||||
i,
|
||||
"refCol",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className={panelSelect}
|
||||
>
|
||||
{refColumns.map((c) => (
|
||||
<option
|
||||
key={c.name}
|
||||
value={c.name}
|
||||
>
|
||||
{c.name} (
|
||||
{c.data_type})
|
||||
{c.is_pk
|
||||
? " (PK)"
|
||||
: ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{pairs.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove pair ${i + 1}`}
|
||||
onClick={() =>
|
||||
removePair(i)
|
||||
}
|
||||
className="shrink-0 text-text-muted hover:text-red-400"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add another column"
|
||||
onClick={addPair}
|
||||
className="text-xs text-accent hover:text-accent-hover px-4 py-2 cursor-pointer"
|
||||
>
|
||||
<Plus size={12} className="inline" />{" "}
|
||||
Add another column
|
||||
</button>
|
||||
</Section>
|
||||
|
||||
{typeMappings.length > 0 && (
|
||||
<Section label="Types will be updated">
|
||||
<ul className="space-y-0.5">
|
||||
{typeMappings.map((m, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="px-2 font-mono text-xs text-text"
|
||||
>
|
||||
<span className="text-text-muted mr-2">
|
||||
{m.localType || "?"}
|
||||
</span>
|
||||
<span className="text-accent">
|
||||
→
|
||||
</span>{" "}
|
||||
<span className="">
|
||||
{m.refType || "?"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section label="Action if referenced row is updated">
|
||||
<select
|
||||
aria-label="On update"
|
||||
value={onUpdate}
|
||||
onChange={(e) =>
|
||||
setOnUpdate(e.target.value)
|
||||
}
|
||||
className={panelSelect}
|
||||
>
|
||||
{FK_ACTIONS.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{a}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Section>
|
||||
|
||||
<Section label="Action if referenced row is removed">
|
||||
<select
|
||||
aria-label="On delete"
|
||||
value={onDelete}
|
||||
onChange={(e) =>
|
||||
setOnDelete(e.target.value)
|
||||
}
|
||||
className={panelSelect}
|
||||
>
|
||||
{FK_ACTIONS.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{a}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="border-b border-border px-4 py-2 text-xs text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 text-xs font-medium text-text hover:text-text"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addFk}
|
||||
disabled={
|
||||
staging ||
|
||||
table.trim() === "" ||
|
||||
pairs.length === 0 ||
|
||||
pairs.some((p) => p.localCol === "" || p.refCol === "")
|
||||
}
|
||||
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"
|
||||
>
|
||||
Add FK
|
||||
</button>
|
||||
</div>
|
||||
</motion.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,75 @@
|
||||
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 { RoleForm } from "./RoleForm";
|
||||
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}
|
||||
/>
|
||||
);
|
||||
case "table":
|
||||
// Unreachable in the form-tab flow (ObjectFormTab delegates table to
|
||||
// TableForm directly); kept only so the switch is exhaustive.
|
||||
return null;
|
||||
case "role":
|
||||
return <RoleForm connectionId={connectionId} params={params} onChange={onChange} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { ObjectContextMenu, DROP_TITLE } from "./ObjectContextMenu";
|
||||
import * as objectCrud from "../../../lib/objectCrud";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
|
||||
describe("ObjectContextMenu", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useDbViewerStore.getState().reset();
|
||||
});
|
||||
|
||||
it("DROP_TITLE covers the table and role kinds (exhaustiveness)", () => {
|
||||
expect(DROP_TITLE.table).toBe("table");
|
||||
expect(DROP_TITLE.role).toBe("role");
|
||||
});
|
||||
|
||||
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.spyOn(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,190 @@
|
||||
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 }[];
|
||||
}
|
||||
|
||||
export const DROP_TITLE: Record<ObjectKind, string> = {
|
||||
sequence: "sequence",
|
||||
enum: "type",
|
||||
view: "view",
|
||||
extension: "extension",
|
||||
index: "index",
|
||||
constraint: "constraint",
|
||||
function: "function",
|
||||
procedure: "procedure",
|
||||
trigger: "trigger",
|
||||
table: "table",
|
||||
role: "role",
|
||||
};
|
||||
|
||||
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,75 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import {
|
||||
ObjectDetail,
|
||||
OBJECT_ICONS,
|
||||
SINGULAR_LABELS,
|
||||
TYPE_LABELS,
|
||||
} from "./ObjectDetail";
|
||||
import * as commands from "../../../lib/commands";
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("roles detail renders RoleDetail", () => {
|
||||
vi.spyOn(commands, "getRolePrivileges").mockResolvedValue([]);
|
||||
render(
|
||||
<ObjectDetail
|
||||
connectionId="c1"
|
||||
type="roles"
|
||||
item={{
|
||||
name: "app",
|
||||
superuser: false,
|
||||
inherit: true,
|
||||
create_db: false,
|
||||
create_role: false,
|
||||
can_login: true,
|
||||
replication: false,
|
||||
bypass_rls: false,
|
||||
connection_limit: -1,
|
||||
valid_until: null,
|
||||
memberships: [],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("app")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("registries include roles", () => {
|
||||
expect(TYPE_LABELS.roles).toBe("Roles");
|
||||
expect(SINGULAR_LABELS.roles).toBe("role");
|
||||
expect(OBJECT_ICONS.roles).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,983 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
BookMarked,
|
||||
FunctionSquare,
|
||||
GitBranch,
|
||||
ListChecks,
|
||||
ListOrdered,
|
||||
Puzzle,
|
||||
ShieldCheck,
|
||||
SquareFunction,
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
ObjectType,
|
||||
FunctionInfo,
|
||||
TriggerInfo,
|
||||
SequenceInfo,
|
||||
EnumInfo,
|
||||
ExtensionInfo,
|
||||
IndexInfo,
|
||||
ConstraintInfo,
|
||||
RoleInfo,
|
||||
} from "../../../lib/types";
|
||||
import { RoleDetail } from "./RoleDetail";
|
||||
|
||||
export const TYPE_LABELS: Record<ObjectType, string> = {
|
||||
functions: "Functions",
|
||||
triggers: "Triggers",
|
||||
sequences: "Sequences",
|
||||
enums: "Enums",
|
||||
extensions: "Extensions",
|
||||
indexes: "Indexes",
|
||||
constraints: "Constraints",
|
||||
procedures: "Procedures",
|
||||
roles: "Roles",
|
||||
};
|
||||
|
||||
export const SINGULAR_LABELS: Record<ObjectType, string> = {
|
||||
functions: "function",
|
||||
triggers: "trigger",
|
||||
sequences: "sequence",
|
||||
enums: "enum",
|
||||
extensions: "extension",
|
||||
indexes: "index",
|
||||
constraints: "constraint",
|
||||
procedures: "procedure",
|
||||
roles: "role",
|
||||
};
|
||||
|
||||
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" />,
|
||||
roles: <ShieldCheck size={14} className="text-text-muted shrink-0" />,
|
||||
};
|
||||
|
||||
export type AnyObject =
|
||||
| FunctionInfo
|
||||
| TriggerInfo
|
||||
| SequenceInfo
|
||||
| EnumInfo
|
||||
| ExtensionInfo
|
||||
| IndexInfo
|
||||
| ConstraintInfo
|
||||
| RoleInfo;
|
||||
|
||||
// ─── 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(connectionId: string, type: ObjectType, item: AnyObject) {
|
||||
switch (type) {
|
||||
case "roles":
|
||||
return <RoleDetail connectionId={connectionId} item={item as RoleInfo} />;
|
||||
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, type, item }: ObjectDetailProps) {
|
||||
return <>{renderDetail(connectionId, 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,160 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { KindForm } from "./KindForm";
|
||||
import { TableForm } from "./TableForm";
|
||||
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;
|
||||
|
||||
if (form.kind === "table") {
|
||||
return <TableForm connectionId={connectionId} tab={tab} />;
|
||||
}
|
||||
|
||||
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,49 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { RoleDetail } from "./RoleDetail";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
import type { RoleInfo } from "../../../lib/types";
|
||||
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
vi.spyOn(cmd, "getRolePrivileges").mockReset().mockResolvedValue([
|
||||
{ object_class: "table", schema: "public", name: "users", privileges: ["SELECT"], grantable: false },
|
||||
]);
|
||||
});
|
||||
|
||||
const baseRole: RoleInfo = {
|
||||
name: "app",
|
||||
superuser: false,
|
||||
inherit: true,
|
||||
create_db: false,
|
||||
create_role: false,
|
||||
can_login: true,
|
||||
replication: false,
|
||||
bypass_rls: false,
|
||||
connection_limit: -1,
|
||||
valid_until: null,
|
||||
memberships: [],
|
||||
};
|
||||
|
||||
describe("RoleDetail", () => {
|
||||
it("renders role attributes + memberships + grants editor", async () => {
|
||||
render(<RoleDetail connectionId="c1" item={baseRole} />);
|
||||
expect(screen.getByText("app")).toBeInTheDocument();
|
||||
expect(screen.getByText("Can login")).toBeInTheDocument();
|
||||
expect(await screen.findByText("public.users")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders memberships when present", () => {
|
||||
render(
|
||||
<RoleDetail
|
||||
connectionId="c1"
|
||||
item={{
|
||||
...baseRole,
|
||||
memberships: [{ role: "app", member: "other", grantor: "postgres", admin_option: true }],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("other")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { RoleInfo } from "../../../lib/types";
|
||||
import { FormSectionHeader } from "./formRow";
|
||||
import { RoleGrantsEditor } from "./RoleGrantsEditor";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
item: RoleInfo;
|
||||
}
|
||||
|
||||
interface AttributeRow {
|
||||
label: string;
|
||||
value: string;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
export function RoleDetail({ connectionId, item }: Props) {
|
||||
const attributes: AttributeRow[] = [
|
||||
{ label: "Can login", value: item.can_login ? "Yes" : "No", highlight: item.can_login },
|
||||
{ label: "Superuser", value: item.superuser ? "Yes" : "No", highlight: item.superuser },
|
||||
{ label: "Create databases", value: item.create_db ? "Yes" : "No", highlight: item.create_db },
|
||||
{ label: "Create roles", value: item.create_role ? "Yes" : "No", highlight: item.create_role },
|
||||
{ label: "Inherit privileges", value: item.inherit ? "Yes" : "No", highlight: item.inherit },
|
||||
{ label: "Replication", value: item.replication ? "Yes" : "No", highlight: item.replication },
|
||||
{ label: "Bypass RLS", value: item.bypass_rls ? "Yes" : "No", highlight: item.bypass_rls },
|
||||
{ label: "Connection limit", value: String(item.connection_limit) },
|
||||
{ label: "Valid until", value: item.valid_until ?? "Never" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="border-b border-border px-4 py-2">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">Role</span>
|
||||
</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">Name</span>
|
||||
<span className="text-sm text-accent font-mono">{item.name}</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">Attributes</span>
|
||||
</div>
|
||||
{attributes.map((attr) => (
|
||||
<div key={attr.label} className="border-b border-border flex flex-row">
|
||||
<div className="border-r border-border px-4 py-2 flex items-center w-40 shrink-0">
|
||||
<span className="text-xs text-text-muted">{attr.label}</span>
|
||||
</div>
|
||||
<div className="px-4 py-2 flex items-center flex-1">
|
||||
<span className={`text-sm ${attr.highlight ? "text-emerald-400" : "text-text-muted"}`}>
|
||||
{attr.value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="border-b border-border">
|
||||
<FormSectionHeader label="Member of" count={item.memberships.length} />
|
||||
{item.memberships.length === 0 && (
|
||||
<p className="px-4 py-2 text-xs text-text-muted">Not a member of any role.</p>
|
||||
)}
|
||||
{item.memberships.map((m, i) => (
|
||||
<div key={`${m.role}-${m.member}-${i}`} className="border-b border-border px-4 py-2 flex items-center">
|
||||
<span className="text-xs text-text-muted w-28 shrink-0">Role</span>
|
||||
<span className="text-sm text-accent font-mono">{m.member}</span>
|
||||
<span className="ml-2 text-xs text-text-muted">of {m.role}</span>
|
||||
{m.admin_option && (
|
||||
<span className="ml-2 text-[10px] text-amber-400 uppercase tracking-wider">admin</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<RoleGrantsEditor connectionId={connectionId} role={item.name} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { RoleForm } from "./RoleForm";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
vi.spyOn(cmd, "buildObjectDdl").mockReset().mockResolvedValue(["CREATE ROLE app;"]);
|
||||
vi.spyOn(cmd, "getRoles").mockReset().mockResolvedValue([]);
|
||||
});
|
||||
|
||||
function baseParams(mode: "create" | "edit") {
|
||||
return {
|
||||
schema: "",
|
||||
name: mode === "edit" ? "app" : "",
|
||||
action: {
|
||||
op: mode === "edit" ? "edit" : "create",
|
||||
login: mode === "edit",
|
||||
superuser: false,
|
||||
createdb: false,
|
||||
createrole: false,
|
||||
inherit: true,
|
||||
replication: false,
|
||||
bypassrls: false,
|
||||
connection_limit: -1,
|
||||
valid_until: "",
|
||||
password: "",
|
||||
members: [],
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("RoleForm", () => {
|
||||
it("create role with options writes params", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<RoleForm
|
||||
connectionId="c1"
|
||||
params={baseParams("create")}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByPlaceholderText("Role name"), { target: { value: "app" } });
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ name: "app" }));
|
||||
|
||||
fireEvent.click(screen.getByLabelText("LOGIN"));
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: expect.objectContaining({ login: true }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("edit mode password field is blank with keep hint", () => {
|
||||
render(
|
||||
<RoleForm
|
||||
connectionId="c1"
|
||||
mode="edit"
|
||||
params={baseParams("edit")}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByPlaceholderText(/leave blank to keep/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stages a create role ddl via the queue", async () => {
|
||||
render(
|
||||
<RoleForm
|
||||
connectionId="c1"
|
||||
params={{
|
||||
schema: "",
|
||||
name: "app",
|
||||
action: {
|
||||
op: "create",
|
||||
login: true,
|
||||
superuser: false,
|
||||
createdb: false,
|
||||
createrole: false,
|
||||
inherit: true,
|
||||
replication: false,
|
||||
bypassrls: false,
|
||||
connection_limit: -1,
|
||||
valid_until: "",
|
||||
password: "secret",
|
||||
members: [],
|
||||
},
|
||||
}}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stage" }));
|
||||
await waitFor(() => {
|
||||
const q = useDbViewerStore.getState().changesQueue;
|
||||
expect(q).toHaveLength(1);
|
||||
expect(q[0].type).toBe("ddl");
|
||||
expect(q[0].sql).toBe("CREATE ROLE app;");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
import type { RoleInfo } from "../../../lib/types";
|
||||
import { FormRow, FormSectionHeader, inputClass, controlClass } from "./formRow";
|
||||
|
||||
interface RoleMember {
|
||||
name: string;
|
||||
admin: boolean;
|
||||
}
|
||||
|
||||
interface RoleAction {
|
||||
op: "create" | "edit" | "drop";
|
||||
login: boolean;
|
||||
superuser: boolean;
|
||||
createdb: boolean;
|
||||
createrole: boolean;
|
||||
inherit: boolean;
|
||||
replication: boolean;
|
||||
bypassrls: boolean;
|
||||
connection_limit: number;
|
||||
valid_until: string;
|
||||
password: string;
|
||||
members: RoleMember[];
|
||||
}
|
||||
|
||||
interface RoleParams {
|
||||
schema: string;
|
||||
name: string;
|
||||
action: RoleAction;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
mode?: "create" | "edit";
|
||||
params: Record<string, unknown>;
|
||||
onChange: (p: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const OPTIONS: { key: keyof Omit<RoleAction, "op" | "connection_limit" | "valid_until" | "password" | "members">; label: string }[] = [
|
||||
{ key: "login", label: "LOGIN" },
|
||||
{ key: "superuser", label: "SUPERUSER" },
|
||||
{ key: "createdb", label: "CREATEDB" },
|
||||
{ key: "createrole", label: "CREATEROLE" },
|
||||
{ key: "inherit", label: "INHERIT" },
|
||||
{ key: "replication", label: "REPLICATION" },
|
||||
{ key: "bypassrls", label: "BYPASSRLS" },
|
||||
];
|
||||
|
||||
function patchAction(params: RoleParams, patch: Partial<RoleAction>): RoleParams {
|
||||
return { ...params, action: { ...params.action, ...patch } };
|
||||
}
|
||||
|
||||
export function RoleForm({ connectionId, mode = "create", params, onChange }: Props) {
|
||||
const typed = params as unknown as RoleParams;
|
||||
const action = typed.action;
|
||||
|
||||
const [view, setView] = useState<"visual" | "sql">("visual");
|
||||
const [preview, setPreview] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [staging, setStaging] = useState(false);
|
||||
const [roles, setRoles] = useState<RoleInfo[]>([]);
|
||||
|
||||
const setParams = (next: RoleParams) => onChange(next as unknown as Record<string, unknown>);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
cmd
|
||||
.getRoles(connectionId)
|
||||
.then((list) => {
|
||||
if (active) setRoles(list);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setRoles([]);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [connectionId]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setError(null);
|
||||
cmd
|
||||
.buildObjectDdl(connectionId, "role", params)
|
||||
.then((sqls) => {
|
||||
if (active) setPreview(Array.isArray(sqls) ? sqls.join("\n;\n") : (sqls as string));
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (active) {
|
||||
setPreview("");
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [connectionId, params]);
|
||||
|
||||
const availableRoles = useMemo(
|
||||
() => roles.filter((r) => r.name !== typed.name),
|
||||
[roles, typed.name],
|
||||
);
|
||||
|
||||
const toggleMember = (name: string, checked: boolean) => {
|
||||
const next = checked
|
||||
? [...action.members, { name, admin: false }]
|
||||
: action.members.filter((m) => m.name !== name);
|
||||
setParams(patchAction(typed, { members: next }));
|
||||
};
|
||||
|
||||
const setMemberAdmin = (name: string, admin: boolean) => {
|
||||
const next = action.members.map((m) => (m.name === name ? { ...m, admin } : m));
|
||||
setParams(patchAction(typed, { members: next }));
|
||||
};
|
||||
|
||||
const stage = async () => {
|
||||
setStaging(true);
|
||||
setError(null);
|
||||
try {
|
||||
const sqls = await cmd.buildObjectDdl(connectionId, "role", params);
|
||||
const list = Array.isArray(sqls) ? sqls : [sqls];
|
||||
list.forEach((sql) =>
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "ddl",
|
||||
sql,
|
||||
description: `${mode === "edit" ? "Edit" : "Create"} role ${typed.name}`,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setStaging(false);
|
||||
}
|
||||
};
|
||||
|
||||
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} role
|
||||
</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 || staging || !typed.name.trim()}
|
||||
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 p-4 space-y-3">
|
||||
{view === "visual" ? (
|
||||
<>
|
||||
<FormRow label="Name">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Role name"
|
||||
value={typed.name}
|
||||
onChange={(e) => setParams({ ...typed, name: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<div className="border-b border-border">
|
||||
<FormSectionHeader label="Options" />
|
||||
<div className="px-4 py-2 grid grid-cols-2 gap-2">
|
||||
{OPTIONS.map(({ key, label }) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-2 text-xs text-text cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={label}
|
||||
checked={!!action[key]}
|
||||
onChange={(e) =>
|
||||
setParams(patchAction(typed, { [key]: e.target.checked } as Partial<RoleAction>))
|
||||
}
|
||||
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormRow label="Connection limit">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="-1 for unlimited"
|
||||
value={action.connection_limit}
|
||||
onChange={(e) =>
|
||||
setParams(
|
||||
patchAction(typed, {
|
||||
connection_limit: e.target.value === "" ? -1 : Number(e.target.value),
|
||||
}),
|
||||
)
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow label="Valid until">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={action.valid_until}
|
||||
onChange={(e) => setParams(patchAction(typed, { valid_until: e.target.value }))}
|
||||
className={controlClass}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow label="Password">
|
||||
<input
|
||||
type="password"
|
||||
placeholder={
|
||||
mode === "edit"
|
||||
? "leave blank to keep current password"
|
||||
: "Password"
|
||||
}
|
||||
value={action.password}
|
||||
onChange={(e) => setParams(patchAction(typed, { password: e.target.value }))}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<div className="border-b border-border">
|
||||
<FormSectionHeader label="Member of" count={action.members.length} />
|
||||
{availableRoles.length === 0 && (
|
||||
<p className="px-4 py-2 text-xs text-text-muted">No other roles available.</p>
|
||||
)}
|
||||
<div className="px-4 py-2 space-y-1">
|
||||
{availableRoles.map((r) => {
|
||||
const member = action.members.find((m) => m.name === r.name);
|
||||
return (
|
||||
<div key={r.name} className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-2 text-xs text-text cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!member}
|
||||
onChange={(e) => toggleMember(r.name, e.target.checked)}
|
||||
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||
/>
|
||||
<span className="font-mono">{r.name}</span>
|
||||
</label>
|
||||
{member && (
|
||||
<label className="flex items-center gap-1 text-xs text-text-muted cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={member.admin}
|
||||
onChange={(e) => setMemberAdmin(r.name, e.target.checked)}
|
||||
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||
/>
|
||||
ADMIN
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<pre className="text-xs leading-6 font-mono whitespace-pre-wrap text-text">{preview}</pre>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { RoleGrantsEditor } from "./RoleGrantsEditor";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
import type { PrivilegeEntry } from "../../../lib/types";
|
||||
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
vi.spyOn(cmd, "getRolePrivileges").mockReset().mockResolvedValue([]);
|
||||
vi.spyOn(cmd, "buildObjectDdl").mockReset().mockResolvedValue(["GRANT SELECT ON TABLE public.users TO app;"]);
|
||||
});
|
||||
|
||||
describe("RoleGrantsEditor", () => {
|
||||
it("toggling a privilege stages a GRANT ddl change", async () => {
|
||||
render(<RoleGrantsEditor connectionId="c1" role="app" />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Object class"), { target: { value: "table" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("Schema"), { target: { value: "public" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("Object name"), { target: { value: "users" } });
|
||||
|
||||
fireEvent.click(screen.getByLabelText("SELECT"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /grant/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const q = useDbViewerStore.getState().changesQueue;
|
||||
expect(q.some((c) => c.type === "ddl" && c.sql.startsWith("GRANT"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows first 5 privileges while collapsed, expands to show all", async () => {
|
||||
vi.spyOn(cmd, "getRolePrivileges").mockResolvedValue([
|
||||
{ object_class: "table", schema: "public", name: "users", privileges: ["SELECT"], grantable: false },
|
||||
]);
|
||||
render(<RoleGrantsEditor connectionId="c1" role="app" />);
|
||||
// Fewer than 5 entries — visible immediately without expanding.
|
||||
expect(await screen.findByText("public.users")).toBeInTheDocument();
|
||||
expect((await screen.findAllByText("SELECT")).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("truncates long sections to 5 entries with a fade and Show more", async () => {
|
||||
const entries: PrivilegeEntry[] = Array.from({ length: 7 }, (_, i) => ({
|
||||
object_class: "table",
|
||||
schema: "public",
|
||||
name: `t${i + 1}`,
|
||||
privileges: ["SELECT"],
|
||||
grantable: false,
|
||||
}));
|
||||
vi.spyOn(cmd, "getRolePrivileges").mockResolvedValue(entries);
|
||||
render(<RoleGrantsEditor connectionId="c1" role="app" />);
|
||||
// Collapsed by default: first 5 visible, rest hidden.
|
||||
expect(await screen.findByText("public.t1")).toBeInTheDocument();
|
||||
expect(screen.getByText("public.t5")).toBeInTheDocument();
|
||||
expect(screen.queryByText("public.t6")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show 2 more/i }));
|
||||
expect(await screen.findByText("public.t6")).toBeInTheDocument();
|
||||
expect(screen.getByText("public.t7")).toBeInTheDocument();
|
||||
// Header chevron can collapse it back.
|
||||
fireEvent.click(screen.getByRole("button", { name: /toggle table privileges/i }));
|
||||
expect(screen.queryByText("public.t6")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
import type { PrivilegeEntry } from "../../../lib/types";
|
||||
import { FormRow, FormSectionHeader, inputClass, controlClass } from "./formRow";
|
||||
|
||||
type ObjectClass = "table" | "sequence" | "routine" | "schema" | "database";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const CLASS_LABELS: Record<ObjectClass, string> = {
|
||||
table: "Table",
|
||||
sequence: "Sequence",
|
||||
routine: "Routine",
|
||||
schema: "Schema",
|
||||
database: "Database",
|
||||
};
|
||||
|
||||
const PRIVILEGES: Record<ObjectClass, string[]> = {
|
||||
table: ["SELECT", "INSERT", "UPDATE", "TRUNCATE", "REFERENCES", "TRIGGER"],
|
||||
sequence: ["USAGE", "SELECT", "UPDATE"],
|
||||
routine: ["EXECUTE"],
|
||||
schema: ["USAGE", "CREATE"],
|
||||
database: ["CONNECT", "CREATE", "TEMP"],
|
||||
};
|
||||
|
||||
export function RoleGrantsEditor({ connectionId, role }: Props) {
|
||||
const [privileges, setPrivileges] = useState<PrivilegeEntry[]>([]);
|
||||
const [objectClass, setObjectClass] = useState<ObjectClass>("table");
|
||||
const [objectSchema, setObjectSchema] = useState("");
|
||||
const [objectName, setObjectName] = useState("");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [grantOption, setGrantOption] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const PREVIEW_COUNT = 5;
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
cmd
|
||||
.getRolePrivileges(connectionId, role)
|
||||
.then((list) => {
|
||||
if (active) {
|
||||
setPrivileges(Array.isArray(list) ? list : []);
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (active) setError(e instanceof Error ? e.message : String(e));
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [connectionId, role]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<ObjectClass, PrivilegeEntry[]>();
|
||||
privileges.forEach((p) => {
|
||||
const list = map.get(p.object_class) ?? [];
|
||||
list.push(p);
|
||||
map.set(p.object_class, list);
|
||||
});
|
||||
return map;
|
||||
}, [privileges]);
|
||||
|
||||
const togglePrivilege = (priv: string) => {
|
||||
const next = new Set(selected);
|
||||
if (next.has(priv)) next.delete(priv);
|
||||
else next.add(priv);
|
||||
setSelected(next);
|
||||
};
|
||||
|
||||
const submit = async (op: "grant" | "revoke") => {
|
||||
setError(null);
|
||||
try {
|
||||
const sqls = await cmd.buildObjectDdl(connectionId, "role", {
|
||||
schema: "",
|
||||
name: "",
|
||||
action: {
|
||||
op,
|
||||
object_class: objectClass,
|
||||
object_schema: objectSchema || null,
|
||||
object_name: objectName,
|
||||
privileges: Array.from(selected),
|
||||
grantee: role,
|
||||
grant_option: grantOption,
|
||||
},
|
||||
});
|
||||
const list = Array.isArray(sqls) ? sqls : [sqls];
|
||||
list.forEach((sql) =>
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "ddl",
|
||||
sql,
|
||||
description: `${op.toUpperCase()} ${Array.from(selected).join(",")} on ${objectClass} ${objectSchema ? `${objectSchema}.` : ""}${objectName} to ${role}`,
|
||||
}),
|
||||
);
|
||||
setSelected(new Set());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<FormSectionHeader label="Current privileges" count={privileges.length} />
|
||||
{loading && <p className="px-4 py-2 text-xs text-text-muted">Loading...</p>}
|
||||
{grouped.size === 0 && !loading && (
|
||||
<p className="px-4 py-2 text-xs text-text-muted">No privileges found for this role.</p>
|
||||
)}
|
||||
{Array.from(grouped.entries()).map(([cls, list]) => {
|
||||
const isExpanded = !!expanded[cls];
|
||||
const hasMore = list.length > PREVIEW_COUNT;
|
||||
const visible = !isExpanded && hasMore ? list.slice(0, PREVIEW_COUNT) : list;
|
||||
return (
|
||||
<div key={cls} className="border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Toggle ${CLASS_LABELS[cls]} privileges`}
|
||||
onClick={() => setExpanded((e) => ({ ...e, [cls]: !e[cls] }))}
|
||||
className="w-full px-4 py-1 flex items-center justify-between hover:bg-surface-raised/40 cursor-pointer"
|
||||
>
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
{CLASS_LABELS[cls]}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] text-text-muted tabular-nums">
|
||||
{list.length}
|
||||
</span>
|
||||
{hasMore && (
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 text-text-muted transition-transform duration-150 ${
|
||||
isExpanded ? "-rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<div className="relative overflow-hidden">
|
||||
{visible.map((entry) => (
|
||||
<div
|
||||
key={`${entry.object_class}:${entry.schema ?? ""}:${entry.name}`}
|
||||
className="px-4 py-1.5 flex items-center gap-2 text-xs text-text"
|
||||
>
|
||||
<span className="font-mono text-accent">
|
||||
{entry.schema ? `${entry.schema}.` : ""}
|
||||
{entry.name}
|
||||
</span>
|
||||
<span className="text-text-muted">
|
||||
{entry.privileges.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{!isExpanded && hasMore && (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-canvas to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
{!isExpanded && hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => ({ ...e, [cls]: true }))}
|
||||
className="px-4 pb-2 text-[11px] text-accent hover:text-accent-hover cursor-pointer"
|
||||
>
|
||||
Show {list.length - PREVIEW_COUNT} more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<FormSectionHeader label="Edit privileges" />
|
||||
|
||||
<FormRow label="Object class">
|
||||
<select
|
||||
aria-label="Object class"
|
||||
value={objectClass}
|
||||
onChange={(e) => {
|
||||
setObjectClass(e.target.value as ObjectClass);
|
||||
setSelected(new Set());
|
||||
}}
|
||||
className={controlClass}
|
||||
>
|
||||
{(Object.keys(CLASS_LABELS) as ObjectClass[]).map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{CLASS_LABELS[c]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
|
||||
<FormRow label="Schema">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Schema"
|
||||
value={objectSchema}
|
||||
onChange={(e) => setObjectSchema(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow label="Object name">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Object name"
|
||||
value={objectName}
|
||||
onChange={(e) => setObjectName(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow label="Privileges">
|
||||
<div className="flex-1 px-4 py-2 grid grid-cols-2 gap-2">
|
||||
{PRIVILEGES[objectClass].map((priv) => (
|
||||
<label key={priv} className="flex items-center gap-2 text-xs text-text cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={priv}
|
||||
checked={selected.has(priv)}
|
||||
onChange={() => togglePrivilege(priv)}
|
||||
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||
/>
|
||||
<span>{priv}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</FormRow>
|
||||
|
||||
<FormRow label="Grant option">
|
||||
<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={grantOption}
|
||||
onChange={(e) => setGrantOption(e.target.checked)}
|
||||
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||
/>
|
||||
<span>WITH GRANT OPTION</span>
|
||||
</label>
|
||||
</FormRow>
|
||||
|
||||
<div className="flex items-center gap-2 px-4 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit("grant")}
|
||||
disabled={selected.size === 0 || !objectName.trim()}
|
||||
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"
|
||||
>
|
||||
Grant
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit("revoke")}
|
||||
disabled={selected.size === 0 || !objectName.trim()}
|
||||
className="rounded-lg bg-surface px-3 py-1.5 text-xs font-medium text-text border border-border hover:bg-surface-raised disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="px-4 text-xs text-red-400">{error}</p>}
|
||||
</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/20");
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { DdlParams } from "../../../lib/objectCrud";
|
||||
import { FormRow, inputClass, controlClass } from "./formRow";
|
||||
|
||||
interface Props {
|
||||
params: DdlParams;
|
||||
schemas?: string[];
|
||||
onChange: (p: DdlParams) => void;
|
||||
}
|
||||
|
||||
type SequenceOp = "create" | "alter" | "restart";
|
||||
|
||||
const OP_LABELS: Record<SequenceOp, string> = {
|
||||
create: "Create",
|
||||
alter: "Alter",
|
||||
restart: "Restart",
|
||||
};
|
||||
|
||||
function getOp(params: DdlParams): SequenceOp {
|
||||
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||
const op = action.op;
|
||||
if (op === "alter" || op === "restart") 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 } };
|
||||
}
|
||||
|
||||
export function SequenceForm({ params, schemas, onChange }: Props) {
|
||||
const op = getOp(params);
|
||||
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||
|
||||
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="Sequence 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(patchAction(params, { op: e.target.value }))
|
||||
}
|
||||
className={controlClass}
|
||||
>
|
||||
{(Object.keys(OP_LABELS) as SequenceOp[]).map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{OP_LABELS[key]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
|
||||
{(op === "create" || op === "alter") && (
|
||||
<>
|
||||
<FormRow label="Increment">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Increment"
|
||||
value={(action.increment as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange(patchAction(params, { increment: e.target.value }))
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow label="Min value">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Min value"
|
||||
value={(action.min_value as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange(patchAction(params, { min_value: e.target.value }))
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow label="Max value">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Max value"
|
||||
value={(action.max_value as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange(patchAction(params, { max_value: e.target.value }))
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
{op === "create" && (
|
||||
<FormRow label="Start">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Start"
|
||||
value={(action.start as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange(patchAction(params, { start: e.target.value }))
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
)}
|
||||
<FormRow label="Cycle">
|
||||
<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.cycle}
|
||||
onChange={(e) =>
|
||||
onChange(patchAction(params, { cycle: e.target.checked }))
|
||||
}
|
||||
aria-label="CYCLE"
|
||||
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||
/>
|
||||
<span>CYCLE</span>
|
||||
</label>
|
||||
</FormRow>
|
||||
</>
|
||||
)}
|
||||
|
||||
{op === "restart" && (
|
||||
<FormRow label="Restart with">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Restart with"
|
||||
value={(action.with as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange(patchAction(params, { with: e.target.value }))
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { TableForm } from "./TableForm";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
import { useConnectionStore } from "../../../stores/connectionStore";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
import * as objectCrud from "../../../lib/objectCrud";
|
||||
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
vi.spyOn(cmd, "buildObjectDdl").mockReset().mockResolvedValue([]);
|
||||
vi.spyOn(cmd, "buildRebuildScript").mockReset().mockResolvedValue("");
|
||||
vi.spyOn(cmd, "getTableRebuildReadiness").mockReset().mockResolvedValue({ ok: true, reasons: [] });
|
||||
vi.spyOn(cmd, "getTableColumns").mockReset().mockResolvedValue([]);
|
||||
vi.spyOn(cmd, "getTablespaces").mockReset().mockResolvedValue([]);
|
||||
vi.spyOn(cmd, "getConstraints").mockReset().mockResolvedValue([]);
|
||||
vi.spyOn(cmd, "getSchemaGraph").mockReset().mockResolvedValue({ tables: [], relationships: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function baseParams(mode: "create" | "edit") {
|
||||
return {
|
||||
schema: "public",
|
||||
name: mode === "edit" ? "users" : "",
|
||||
action: {
|
||||
op: mode === "edit" ? "edit" : "create",
|
||||
columns: [],
|
||||
old_columns:
|
||||
mode === "edit"
|
||||
? [{ name: "id", type: "integer", nullable: false, default: null, is_pk: true }]
|
||||
: undefined,
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
function seedFormTab(tab: any) {
|
||||
useDbViewerStore.setState({ tabs: [{ ...tab, tabType: tab.tabType ?? "objectForm" }], activeTabId: tab.id });
|
||||
}
|
||||
|
||||
describe("TableForm", () => {
|
||||
it("add/remove rows; composite PK allowed", () => {
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: baseParams("create"),
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
fireEvent.click(screen.getByLabelText("Add column"));
|
||||
fireEvent.click(screen.getByLabelText("Add column"));
|
||||
const cols = () => (useDbViewerStore.getState().tabs[0].form?.params.action as any).columns;
|
||||
expect(cols()).toHaveLength(2);
|
||||
// the first added column auto-starts as the PK
|
||||
expect(cols()[0].is_pk).toBe(true);
|
||||
// marking a second column as PK keeps both (composite key)
|
||||
fireEvent.click(screen.getAllByLabelText("Column settings")[1]);
|
||||
fireEvent.click(screen.getByLabelText("PK"));
|
||||
expect(cols().filter((c: any) => c.is_pk)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("reorder triggers rebuild path and readiness refusal blocks staging", async () => {
|
||||
(cmd.getTableRebuildReadiness as any).mockResolvedValue({ ok: false, reasons: ["table has triggers"] });
|
||||
render(
|
||||
<TableForm
|
||||
connectionId="c1"
|
||||
tab={{
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "users",
|
||||
action: {
|
||||
op: "edit",
|
||||
columns: [
|
||||
{ name: "email", type: "text", nullable: true, default: null, is_pk: false },
|
||||
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
|
||||
],
|
||||
old_columns: [
|
||||
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
|
||||
{ name: "email", type: "text", nullable: true, default: null, is_pk: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
title: "Edit Table",
|
||||
description: "Edit Table",
|
||||
mode: "edit",
|
||||
},
|
||||
title: "Edit Table",
|
||||
} as any}
|
||||
/>,
|
||||
);
|
||||
// order differs => rebuild; readiness not ok => refusal copy + Stage disabled
|
||||
expect(await screen.findByText(/has triggers/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Stage" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("rebuild ok stages a single rebuild_table change", async () => {
|
||||
(cmd.getTableRebuildReadiness as any).mockResolvedValue({ ok: true, reasons: [] });
|
||||
(cmd.buildRebuildScript as any).mockResolvedValue("CREATE TABLE _t();");
|
||||
render(
|
||||
<TableForm
|
||||
connectionId="c1"
|
||||
tab={{
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "users",
|
||||
action: {
|
||||
op: "edit",
|
||||
columns: [
|
||||
{ name: "email", type: "text", nullable: true, default: null, is_pk: false },
|
||||
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
|
||||
],
|
||||
old_columns: [
|
||||
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
|
||||
{ name: "email", type: "text", nullable: true, default: null, is_pk: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
title: "Edit Table",
|
||||
description: "Edit Table",
|
||||
mode: "edit",
|
||||
},
|
||||
title: "Edit Table",
|
||||
} as any}
|
||||
/>,
|
||||
);
|
||||
await screen.findByLabelText(/SQL/i);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stage" }));
|
||||
await screen.findByText(/Rebuild/i).catch(() => null); // staging is sync-ish
|
||||
const q = useDbViewerStore.getState().changesQueue;
|
||||
expect(q).toHaveLength(1);
|
||||
expect(q[0].type).toBe("rebuild_table");
|
||||
expect(q[0].sql).toBe("CREATE TABLE _t();");
|
||||
});
|
||||
|
||||
it("edit diff (no reorder) stages ddl per statement after stale guard passes", async () => {
|
||||
(cmd.getTableColumns as any).mockResolvedValue([
|
||||
{
|
||||
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,
|
||||
},
|
||||
]);
|
||||
(cmd.buildObjectDdl as any).mockResolvedValue([
|
||||
'ALTER TABLE "public"."users" ADD COLUMN "email" text',
|
||||
]);
|
||||
render(
|
||||
<TableForm
|
||||
connectionId="c1"
|
||||
tab={{
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "users",
|
||||
action: {
|
||||
op: "edit",
|
||||
columns: [
|
||||
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
|
||||
{ name: "email", type: "text", nullable: true, default: null, is_pk: false },
|
||||
],
|
||||
old_columns: [{ name: "id", type: "integer", nullable: false, default: null, is_pk: true }],
|
||||
},
|
||||
},
|
||||
title: "Edit Table",
|
||||
description: "Edit users",
|
||||
mode: "edit",
|
||||
},
|
||||
title: "Edit Table",
|
||||
} as any}
|
||||
/>,
|
||||
);
|
||||
await screen.findByLabelText(/SQL/i);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stage" }));
|
||||
await screen.findByText(/committed/i).catch(() => null); // staging is sync-ish
|
||||
const q = useDbViewerStore.getState().changesQueue;
|
||||
expect(q).toHaveLength(1);
|
||||
expect(q[0].type).toBe("ddl");
|
||||
});
|
||||
|
||||
it("stale guard blocks staging when live columns differ", async () => {
|
||||
(cmd.getTableColumns as any).mockResolvedValue([
|
||||
{
|
||||
name: "id",
|
||||
data_type: "bigint",
|
||||
is_nullable: false,
|
||||
is_pk: true,
|
||||
is_fk: false,
|
||||
fk_ref: null,
|
||||
default_value: null,
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
},
|
||||
]);
|
||||
render(
|
||||
<TableForm
|
||||
connectionId="c1"
|
||||
tab={{
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "users",
|
||||
action: {
|
||||
op: "edit",
|
||||
columns: [{ name: "id", type: "integer", nullable: false, default: null, is_pk: true }],
|
||||
old_columns: [{ name: "id", type: "integer", nullable: false, default: null, is_pk: true }],
|
||||
},
|
||||
},
|
||||
title: "Edit Table",
|
||||
description: "Edit users",
|
||||
mode: "edit",
|
||||
},
|
||||
title: "Edit Table",
|
||||
} as any}
|
||||
/>,
|
||||
);
|
||||
await screen.findByLabelText(/SQL/i);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stage" }));
|
||||
expect(await screen.findByText(/changed since you opened/i)).toBeInTheDocument();
|
||||
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("create-mode grid renders header and cog settings menus", () => {
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "products",
|
||||
action: {
|
||||
op: "create",
|
||||
columns: [
|
||||
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
|
||||
{ name: "sku", type: "text", nullable: true, default: null, is_pk: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
const headers = screen.getAllByText("Name");
|
||||
expect(headers.length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("Type")).toBeInTheDocument();
|
||||
expect(screen.getByText("Parameters")).toBeInTheDocument();
|
||||
expect(screen.getByText("Default Value")).toBeInTheDocument();
|
||||
// first row (id, integer): Auto-Increment is available
|
||||
fireEvent.click(screen.getAllByLabelText("Column settings")[0]);
|
||||
expect(screen.getByLabelText("PK")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Auto-Increment")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Unique")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Nullable")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getAllByLabelText("Column settings")[0]); // close
|
||||
// second row (sku, text): no Auto-Increment
|
||||
fireEvent.click(screen.getAllByLabelText("Column settings")[1]);
|
||||
expect(screen.getByLabelText("PK")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Auto-Increment")).toBeNull();
|
||||
expect(screen.getByLabelText("Unique")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Nullable")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("folds auto_increment integer to serial in the DDL payload", async () => {
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "products",
|
||||
action: {
|
||||
op: "create",
|
||||
columns: [{ name: "id", type: "integer", nullable: false, default: null, is_pk: true }],
|
||||
},
|
||||
},
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
fireEvent.click(screen.getByLabelText("Column settings"));
|
||||
fireEvent.click(screen.getByLabelText("Auto-Increment"));
|
||||
await waitFor(() => {
|
||||
expect((useDbViewerStore.getState().tabs[0].form?.params.action as any).columns[0].auto_increment).toBe(true);
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Column settings")); // close menu
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Table" }));
|
||||
await waitFor(() => expect(cmd.buildObjectDdl).toHaveBeenCalled());
|
||||
const calls = (cmd.buildObjectDdl as any).mock.calls;
|
||||
const stageCall = calls.find((call: any) => call[2].action.columns.some((c: any) => c.type === "serial"));
|
||||
expect(stageCall).toBeTruthy();
|
||||
expect(stageCall[2].action.columns).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ name: "id", type: "serial", nullable: false, is_pk: true })]),
|
||||
);
|
||||
});
|
||||
|
||||
it("folds unique to the DDL payload column", async () => {
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "products",
|
||||
action: {
|
||||
op: "create",
|
||||
columns: [
|
||||
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
|
||||
{ name: "sku", type: "text", nullable: false, default: null, is_pk: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
fireEvent.click(screen.getAllByLabelText("Column settings")[1]);
|
||||
fireEvent.click(screen.getByLabelText("Unique"));
|
||||
await waitFor(() => {
|
||||
expect((useDbViewerStore.getState().tabs[0].form?.params.action as any).columns[1].unique).toBe(true);
|
||||
});
|
||||
fireEvent.click(screen.getAllByLabelText("Column settings")[1]); // close menu
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Table" }));
|
||||
await waitFor(() => expect(cmd.buildObjectDdl).toHaveBeenCalled());
|
||||
const calls = (cmd.buildObjectDdl as any).mock.calls;
|
||||
const stageCall = calls.find((call: any) => call[2].action.columns.some((c: any) => c.name === "sku" && c.unique === true));
|
||||
expect(stageCall).toBeTruthy();
|
||||
expect(stageCall[2].action.columns).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ name: "sku", unique: true })]),
|
||||
);
|
||||
});
|
||||
|
||||
it("schema select auto-selects the current schema in create mode", () => {
|
||||
useDbViewerStore.setState({ schemas: ["public", "audit"] });
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "audit",
|
||||
name: "",
|
||||
action: { op: "create", columns: [] },
|
||||
},
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
const sel = screen.getByLabelText("Schema") as HTMLSelectElement;
|
||||
expect(sel.value).toBe("audit");
|
||||
fireEvent.change(sel, { target: { value: "public" } });
|
||||
expect((useDbViewerStore.getState().tabs[0].form?.params as any).schema).toBe("public");
|
||||
});
|
||||
|
||||
it("staging an FK applies the referenced type to the local column", async () => {
|
||||
(cmd.getSchemaGraph as any).mockResolvedValue({
|
||||
tables: [
|
||||
{ name: "products", 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 }] },
|
||||
{ name: "categories", 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: [],
|
||||
});
|
||||
vi.spyOn(objectCrud, "buildObjectDdl").mockResolvedValue(["ALTER TABLE \"public\".\"products\" ADD FOREIGN KEY (\"category_id\") REFERENCES \"public\".\"categories\" (\"id\")"]);
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "products",
|
||||
action: {
|
||||
op: "create",
|
||||
columns: [
|
||||
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
|
||||
{ name: "category_id", type: "integer", nullable: true, default: null, is_pk: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
fireEvent.click(screen.getByLabelText("Set foreign key")); // category_id (id is PK → no FK icon)
|
||||
await screen.findByText("Foreign key");
|
||||
const refColSel = (await screen.findByLabelText("Referenced column 1")) as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
expect([...refColSel.options].map((o) => o.value)).toContain("id");
|
||||
});
|
||||
const addFkButtons = screen.getAllByRole("button", { name: "Add FK" });
|
||||
fireEvent.click(addFkButtons[addFkButtons.length - 1]);
|
||||
await waitFor(() => {
|
||||
const cols = (useDbViewerStore.getState().tabs[0].form?.params.action as any).columns;
|
||||
const col = cols.find((c: any) => c.name === "category_id");
|
||||
expect(col.type).toBe("int");
|
||||
expect(col.fk).toBe(true);
|
||||
});
|
||||
// the FK is staged inline into the CREATE TABLE (single change), not as a separate ALTER
|
||||
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Table" }));
|
||||
await waitFor(() => {
|
||||
const stageCall = (cmd.buildObjectDdl as any).mock.calls.find((call: any) =>
|
||||
call[1] === "table" &&
|
||||
call[2]?.action?.foreign_keys?.some((fk: any) => fk.columns[0] === "category_id"),
|
||||
);
|
||||
expect(stageCall).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows staged FK changes in the Foreign keys section", async () => {
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "products",
|
||||
action: { op: "create", columns: [] },
|
||||
},
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "ddl",
|
||||
sql: 'ALTER TABLE "public"."products" ADD FOREIGN KEY ("category_id") REFERENCES "public"."categories" ("id") ON DELETE CASCADE',
|
||||
description: "Add FK category_id → public.categories",
|
||||
});
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
expect(await screen.findByText(/Foreign key relation to/)).toBeInTheDocument();
|
||||
expect(screen.getByText("public.categories")).toBeInTheDocument();
|
||||
expect(screen.getByText(/products.category_id → categories.id/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/· CASCADE/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the FK panel with the column preselected", async () => {
|
||||
(cmd.getSchemaGraph as any).mockResolvedValue({
|
||||
tables: [
|
||||
{ name: "products", schema: "public", table_type: "BASE TABLE", columns: [{ name: "category_id", data_type: "int", is_pk: false, is_fk: false, is_unique: false, is_nullable: true, fk_ref: null }] },
|
||||
{ name: "categories", 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: [],
|
||||
});
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "products",
|
||||
action: {
|
||||
op: "create",
|
||||
columns: [
|
||||
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
|
||||
{ name: "category_id", type: "integer", nullable: true, default: null, is_pk: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
const fkButtons = screen.getAllByLabelText("Set foreign key");
|
||||
// id is the PK column → its FK icon is hidden; category_id is the only one
|
||||
expect(fkButtons).toHaveLength(1);
|
||||
fireEvent.click(fkButtons[0]);
|
||||
expect(await screen.findByText("Foreign key")).toBeInTheDocument();
|
||||
const local = (await screen.findByLabelText("Local column 1")) as HTMLSelectElement;
|
||||
expect(local.value).toBe("category_id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TableForm SQLite mode", () => {
|
||||
beforeEach(() => {
|
||||
useConnectionStore.setState({
|
||||
connections: [
|
||||
{
|
||||
id: "c1",
|
||||
db_type: "sqlite",
|
||||
name: "SQLite",
|
||||
host: "",
|
||||
port: null,
|
||||
username: null,
|
||||
database: null,
|
||||
folder_id: null,
|
||||
keychain_ref: null,
|
||||
tag_ids: [],
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
favorite: false,
|
||||
} as any,
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useConnectionStore.setState({ connections: [] });
|
||||
});
|
||||
|
||||
it("shows SQLite types in the dropdown and not serial", () => {
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "main",
|
||||
name: "products",
|
||||
action: {
|
||||
op: "create",
|
||||
columns: [{ name: "id", type: "integer", nullable: false, default: null, is_pk: true }],
|
||||
},
|
||||
},
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
const typeSel = screen.getByLabelText("type") as HTMLSelectElement;
|
||||
const values = Array.from(typeSel.options).map((o) => o.value);
|
||||
expect(values).toContain("integer");
|
||||
expect(values).toContain("text");
|
||||
expect(values).toContain("real");
|
||||
expect(values).toContain("blob");
|
||||
expect(values).not.toContain("serial");
|
||||
});
|
||||
|
||||
it("shows INTEGER PRIMARY KEY AUTOINCREMENT in the SQL preview", async () => {
|
||||
(cmd.buildObjectDdl as any).mockResolvedValue([
|
||||
'CREATE TABLE "main"."t" ("id" INTEGER PRIMARY KEY AUTOINCREMENT)',
|
||||
]);
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "main",
|
||||
name: "t",
|
||||
action: {
|
||||
op: "create",
|
||||
columns: [{ name: "id", type: "integer", nullable: false, default: null, is_pk: true }],
|
||||
},
|
||||
},
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
fireEvent.click(screen.getByLabelText("Column settings"));
|
||||
fireEvent.click(screen.getByLabelText("Auto-Increment"));
|
||||
fireEvent.click(screen.getByLabelText("SQL"));
|
||||
expect(
|
||||
await screen.findByText('CREATE TABLE "main"."t" ("id" INTEGER PRIMARY KEY AUTOINCREMENT)'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fixes schema to main and hides the schema picker", async () => {
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "t",
|
||||
action: { op: "create", columns: [] },
|
||||
},
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
expect(screen.queryByLabelText("Schema")).toBeNull();
|
||||
await waitFor(() => {
|
||||
expect((useDbViewerStore.getState().tabs[0].form?.params as any).schema).toBe("main");
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user