Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
091d9df6f0 | ||
|
|
751746f784 | ||
|
|
32a7b852ec | ||
|
|
9fb3222956 | ||
|
|
76cd1ded16 | ||
|
|
a424e31de8 | ||
|
|
7401e91a12 |
@@ -7,12 +7,16 @@ name: Release
|
||||
# git checkout prod && git pull
|
||||
# git tag v0.5.0 && git push origin v0.5.0
|
||||
#
|
||||
# SIGNING STATUS: builds are UNSIGNED for now (no code-signing certs yet —
|
||||
# see README "Download a release"). tauri-action automatically signs +
|
||||
# notarizes when the signing secrets are present, so the moment we add
|
||||
# APPLE_CERTIFICATE / APPLE_API_KEY / WINDOWS_CERTIFICATE (or Azure Trusted
|
||||
# Signing) to repo secrets, future builds are signed — no changes to this
|
||||
# file required.
|
||||
# SIGNING STATUS: macOS bundles are AD-HOC signed via tauri.conf.json
|
||||
# (bundle.macOS.signingIdentity: "-", hardenedRuntime: false). That replaces
|
||||
# the linker-only signature that made macOS report the app as "damaged" and
|
||||
# refuse Finder launches — users now get the standard "Open Anyway" prompt
|
||||
# (or run `xattr -dr com.apple.quarantine` for the damaged-error case, see
|
||||
# README). Still NOT Developer-ID signed / notarized (no certs yet).
|
||||
# tauri-action automatically signs + notarizes when the signing secrets are
|
||||
# present, so the moment we add APPLE_CERTIFICATE / APPLE_API_KEY /
|
||||
# WINDOWS_CERTIFICATE (or Azure Trusted Signing) to repo secrets, future
|
||||
# builds are properly signed — no changes to this file required.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -167,6 +171,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:
|
||||
|
||||
@@ -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 are static (versioned)** — both download tables (top **Download** section + **Which file should I download?**) link directly to the release-tag assets (`releases/download/v0.7.6/<file>`). tauri-action uses default versioned asset names (`Gridline_<ver>_aarch64.dmg`, `Gridline-<ver>-1.x86_64.rpm`, etc.) — update BOTH tables to the new names on every release (see the MAINTENANCE comment in README.md).
|
||||
- **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.9/<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
|
||||
@@ -215,6 +225,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
||||
| 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 |
|
||||
@@ -259,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 |
|
||||
@@ -273,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 |
|
||||
@@ -294,6 +319,8 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
||||
| 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 |
|
||||
@@ -304,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 |
|
||||
@@ -319,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
|
||||
@@ -327,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 |
|
||||
@@ -339,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,21 +30,21 @@
|
||||
|
||||
## Download
|
||||
|
||||
Grab the installer for your OS from the [latest release](https://github.com/AdrianBonpin/gridline/releases/latest) — the links below point at the current release (**v0.7.6**):
|
||||
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.9**):
|
||||
|
||||
| OS | Architecture | Download |
|
||||
| :--- | :--- | :--- |
|
||||
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.6_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_aarch64.dmg) |
|
||||
| **macOS** | Intel | [Gridline_0.7.6_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_x64.dmg) |
|
||||
| **Windows** | x64 | [Gridline_0.7.6_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_x64-setup.exe) |
|
||||
| **Debian / Ubuntu** | amd64 | [Gridline_0.7.6_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_amd64.deb) |
|
||||
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.6-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline-0.7.6-1.x86_64.rpm) |
|
||||
| **Other Linux** | amd64 | [Gridline_0.7.6_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_amd64.AppImage) |
|
||||
| OS | Architecture | Download |
|
||||
| :--------------------------- | :--------------------------- | :------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.9_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.9/Gridline_0.7.9_aarch64.dmg) |
|
||||
| **macOS** | Intel | [Gridline_0.7.9_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.9/Gridline_0.7.9_x64.dmg) |
|
||||
| **Windows** | x64 | [Gridline_0.7.9_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.9/Gridline_0.7.9_x64-setup.exe) |
|
||||
| **Debian / Ubuntu** | amd64 | [Gridline_0.7.9_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.9/Gridline_0.7.9_amd64.deb) |
|
||||
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.9-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.9/Gridline-0.7.9-1.x86_64.rpm) |
|
||||
| **Other Linux** | amd64 | [Gridline_0.7.9_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.9/Gridline_0.7.9_amd64.AppImage) |
|
||||
|
||||
> Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below. All installers are **unsigned** — see the [notes](#which-file-should-i-download) on first-launch warnings.
|
||||
|
||||
<!--
|
||||
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.7.6
|
||||
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.7.9
|
||||
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:
|
||||
@@ -62,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.
|
||||
@@ -83,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.9** — **macOS install/launch fixes**: the app's local store now opens under the OS app-data directory (a cwd-relative `gridline.db` made Finder-launched copies silently exit with a Rust panic before the UI started); macOS bundles are **ad-hoc signed at build time** (`signingIdentity "-"`), replacing the Xcode linker-only signature that macOS treated as unsigned ("damaged and can't be opened", silent Finder refusals) — users now get a standard one-time Gatekeeper prompt (see the [install notes](#installers-are-unsigned-for-now), incl. the `xattr` workaround); **OpenSSL is vendored** into the `ssh2` build so the release no longer depends on a Homebrew OpenSSL path; version bump.
|
||||
|
||||
- **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.
|
||||
@@ -97,12 +109,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.
|
||||
@@ -111,8 +128,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.
|
||||
@@ -122,9 +144,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.
|
||||
@@ -135,8 +165,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`.
|
||||
@@ -144,12 +179,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>
|
||||
|
||||
---
|
||||
|
||||
@@ -200,41 +247,41 @@ 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** |
|
||||
| Data export (CSV, JSON, SQL) | ✅ (unlimited = paid) | Basic only | ✅ | **JSON, CSV, SQL, Markdown** |
|
||||
| `pg_dump` / `pg_restore` GUI | ❌ | ❌ paid only | ✅ | **First-class UI** |
|
||||
| DB-to-DB sync | ❌ | ❌ | ❌ | **Built-in pipe sync** |
|
||||
| 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** |
|
||||
| Data export (CSV, JSON, SQL) | ✅ (unlimited = paid) | Basic only | ✅ | **JSON, CSV, SQL, Markdown** |
|
||||
| `pg_dump` / `pg_restore` GUI | ❌ | ❌ paid only | ✅ | **First-class UI** |
|
||||
| DB-to-DB sync | ❌ | ❌ | ❌ | **Built-in pipe sync** |
|
||||
| Object explorer depth | Tables, views, indexes, enums | Tables, views, routines, triggers | Tables, views, functions, procedures | **Functions, Triggers, Sequences, Enums, Extensions + full detail** |
|
||||
| ER diagram / schema visualizer | ✅ view-only | ❌ paid only | ❌ | **✅ React Flow + dagre** |
|
||||
| SSH tunneling | ❌ paid only | ✅ | ✅ | **✅ Password + key auth, keychain** |
|
||||
| OS credential vault | ✅ | ✅ | ✅ | **Keychain / Secret Service / Credential Manager** |
|
||||
| Workspace / folder hierarchy | ✅ query/dash folders + tags | ✅ (5.7+, local) | ❌ | **Multi-level tree + tags** |
|
||||
| Changes queue (stage → commit) | ❌ | ✅ Apply/Discard | ✅ Safe mode | **✅ Queue → Commit All** |
|
||||
| Desktop shell size | Electron | Electron (~250 MB) | Native | **Tauri 2.0 (~40 MB)** |
|
||||
| Open source | ❌ | ✅ GPLv3 | ❌ | **✅ Apache 2.0** |
|
||||
| ER diagram / schema visualizer | ✅ view-only | ❌ paid only | ❌ | **✅ React Flow + dagre** |
|
||||
| SSH tunneling | ❌ paid only | ✅ | ✅ | **✅ Password + key auth, keychain** |
|
||||
| OS credential vault | ✅ | ✅ | ✅ | **Keychain / Secret Service / Credential Manager** |
|
||||
| Workspace / folder hierarchy | ✅ query/dash folders + tags | ✅ (5.7+, local) | ❌ | **Multi-level tree + tags** |
|
||||
| Changes queue (stage → commit) | ❌ | ✅ Apply/Discard | ✅ Safe mode | **✅ Queue → Commit All** |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| **CLI integration** | `std::process::Command` | Wraps bundled or system `pg_dump` / `pg_restore` |
|
||||
| **Frontend** | [React 19](https://react.dev) + [TypeScript](https://www.typescriptlang.org) | Component-based UI |
|
||||
| **Styling** | [Tailwind CSS](https://tailwindcss.com) | Utility-first, dark mode, glassmorphic design |
|
||||
| **State** | [Zustand](https://zustand.docs.pmnd.rs) / Jotai | Domain stores |
|
||||
| **Editor** | [Monaco Editor](https://microsoft.github.io/monaco-editor/) | IDE-grade SQL editing |
|
||||
| **Data grid** | [TanStack Virtual](https://tanstack.com/virtual) | 100k+ row virtualization |
|
||||
| **Local store** | SQLite via [rusqlite](https://github.com/rusqlite/rusqlite) | Settings, saved queries, workspace state |
|
||||
| **CLI integration** | `std::process::Command` | Wraps bundled or system `pg_dump` / `pg_restore` |
|
||||
| **Frontend** | [React 19](https://react.dev) + [TypeScript](https://www.typescriptlang.org) | Component-based UI |
|
||||
| **Styling** | [Tailwind CSS](https://tailwindcss.com) | Utility-first, dark mode, glassmorphic design |
|
||||
| **State** | [Zustand](https://zustand.docs.pmnd.rs) / Jotai | Domain stores |
|
||||
| **Editor** | [Monaco Editor](https://microsoft.github.io/monaco-editor/) | IDE-grade SQL editing |
|
||||
| **Data grid** | [TanStack Virtual](https://tanstack.com/virtual) | 100k+ row virtualization |
|
||||
| **Local store** | SQLite via [rusqlite](https://github.com/rusqlite/rusqlite) | Settings, saved queries, workspace state |
|
||||
|
||||
---
|
||||
|
||||
@@ -248,9 +295,16 @@ Pre-built installers for macOS, Windows, and Linux are published on the [Release
|
||||
|
||||
#### Installers are unsigned (for now)
|
||||
|
||||
Gridline is currently distributed **unsigned** — it doesn't pay for code-signing certificates yet. Your OS will warn you the first time you open it. This is expected — the app is safe, it just hasn't paid the signing fee:
|
||||
Gridline is currently distributed **unsigned** — it doesn't pay for code-signing certificates yet (macOS builds are *ad-hoc signed*, so they pass Apple Silicon's launch checks but aren't Developer-ID signed or notarized). Your OS will warn you the first time you open it. This is expected — the app is safe, it just hasn't paid the signing fee:
|
||||
|
||||
- **macOS:** right-click the app → **Open** → **Open** (or System Settings → Privacy & Security → **Open Anyway**). Do this once per version.
|
||||
- **macOS:** if you see *"Gridline can't be opened because the developer cannot be verified"* (or the app simply won't open from Finder), right-click the app → **Open** → **Open** (or System Settings → Privacy & Security → **Open Anyway**). Do this once per version.
|
||||
- **macOS — "Gridline is damaged and can't be opened":** remove the quarantine flag macOS attaches to downloaded apps, then launch normally:
|
||||
|
||||
```bash
|
||||
xattr -dr com.apple.quarantine /Applications/Gridline.app
|
||||
```
|
||||
|
||||
You need to re-run this after **every upgrade** (each freshly-downloaded copy gets re-quarantined).
|
||||
- **Windows:** on the SmartScreen prompt, click **More info** → **Run anyway**.
|
||||
- **Linux:** no warning — install and run normally.
|
||||
|
||||
@@ -258,16 +312,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 point at the current release (**v0.7.6**):
|
||||
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.9**):
|
||||
|
||||
| Your system | Download this | Notes |
|
||||
| :--- | :--- | :--- |
|
||||
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.7.6_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_aarch64.dmg) | `aarch64` = Apple's own chip |
|
||||
| macOS **Intel** | [Gridline_0.7.6_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_x64.dmg) | `x64` = Intel/AMD |
|
||||
| **Windows** (most PCs) | [Gridline_0.7.6_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
|
||||
| **Debian / Ubuntu** | [Gridline_0.7.6_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.6_amd64.deb` |
|
||||
| **Fedora / RHEL / openSUSE** | [Gridline-0.7.6-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline-0.7.6-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.6-1.x86_64.rpm` |
|
||||
| **Any other Linux** | [Gridline_0.7.6_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it |
|
||||
| Your system | Download this | Notes |
|
||||
| :------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- |
|
||||
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.7.9_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.9/Gridline_0.7.9_aarch64.dmg) | `aarch64` = Apple's own chip |
|
||||
| macOS **Intel** | [Gridline_0.7.9_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.9/Gridline_0.7.9_x64.dmg) | `x64` = Intel/AMD |
|
||||
| **Windows** (most PCs) | [Gridline_0.7.9_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.9/Gridline_0.7.9_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
|
||||
| **Debian / Ubuntu** | [Gridline_0.7.9_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.9/Gridline_0.7.9_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.9_amd64.deb` |
|
||||
| **Fedora / RHEL / openSUSE** | [Gridline-0.7.9-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.9/Gridline-0.7.9-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.9-1.x86_64.rpm` |
|
||||
| **Any other Linux** | [Gridline_0.7.9_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.9/Gridline_0.7.9_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.
|
||||
|
||||
@@ -277,8 +331,8 @@ Cutting a release is one command — CI builds everything. **Releases are cut fr
|
||||
|
||||
```bash
|
||||
git checkout prod && git pull
|
||||
git tag v0.7.6
|
||||
git push origin v0.7.6
|
||||
git tag v0.7.9
|
||||
git push origin v0.7.9
|
||||
```
|
||||
|
||||
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**.
|
||||
@@ -362,7 +416,7 @@ gridline/
|
||||
|
||||
## Roadmap
|
||||
|
||||
The full plan — in-development (v0.7.6), 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:
|
||||
|
||||
|
||||
+62
-42
@@ -6,49 +6,27 @@ This file is the **source of truth** for what Gridline is building. [AGENTS.md](
|
||||
|
||||
---
|
||||
|
||||
## ✅ Shipped (0.7.6)
|
||||
## ✅ Shipped (0.7.9)
|
||||
|
||||
- **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**.
|
||||
- **macOS Finder launch fix** — the local store opens under the OS app-data directory instead of a cwd-relative `gridline.db` (Finder/LaunchServices launches run with cwd `/`, so the old path made the app silently exit with a Rust panic before the UI started).
|
||||
- **macOS ad-hoc code signing** — bundles are signed at build time (`bundle.macOS.signingIdentity "-"`, hardened runtime off), replacing the Xcode linker-only signature that macOS treated as unsigned ("damaged and can't be opened", silent Finder refusal). Users still get a one-time Gatekeeper prompt; README documents the `xattr -dr com.apple.quarantine` workaround.
|
||||
- **Vendored OpenSSL for `ssh2`** — the release binary no longer links an absolute Homebrew `/opt/homebrew/.../libssl.3.dylib` path (dyld aborted on machines without it).
|
||||
- **Version bump** 0.7.8 → **0.7.9**.
|
||||
|
||||
## ✅ Shipped (0.7.5)
|
||||
## 🎯 Next up (0.8.0) — 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
|
||||
|
||||
### Admin follow-up (0.7.7)
|
||||
|
||||
- **PostgreSQL users/roles + grants management** — create roles and set privileges from a UI (DB Pro has this at 0% on their roadmap — a differentiator to hold)
|
||||
- **Maintenance actions** — right-click table → VACUUM / ANALYZE / REINDEX
|
||||
|
||||
### Table & relationship management (next)
|
||||
|
||||
- **Create table** — a "Create Table…" tab in the workspace using the same Visual ⇄ SQL flow as object create/edit: a columns grid (name / type / nullable / default / PK per row, add/remove rows) with a live SQL preview
|
||||
- **Edit table (robust column diff)** — open an existing table's columns in the same grid; on Stage, diff old vs new columns and emit the right statements, one per queue item: `ADD COLUMN`, `DROP COLUMN`, `RENAME COLUMN`, `ALTER COLUMN … TYPE`, `ALTER COLUMN … SET|DROP DEFAULT`, `SET|DROP NOT NULL`
|
||||
- **Relationships between tables & schemas** — FK create/edit/drop from a table editor (including cross-schema FKs) and schema-level relationship maintenance
|
||||
- **Related niceties** — column reordering (PG requires a table rebuild — decide semantics), table options (tablespace, row-level security), multi-column PRIMARY KEY on create
|
||||
- **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
|
||||
|
||||
@@ -78,17 +56,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
|
||||
|
||||
@@ -128,6 +103,51 @@ 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)
|
||||
|
||||
@@ -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.6",
|
||||
"version": "0.7.9",
|
||||
"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
+11
-1
@@ -1783,7 +1783,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridline"
|
||||
version = "0.7.6"
|
||||
version = "0.7.9"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deadpool-postgres",
|
||||
@@ -3047,6 +3047,15 @@ version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-src"
|
||||
version = "300.6.1+3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.117"
|
||||
@@ -3055,6 +3064,7 @@ checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"openssl-src",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "gridline"
|
||||
version = "0.7.6"
|
||||
version = "0.7.9"
|
||||
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
@@ -34,7 +34,10 @@ tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1"
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "mysql", "tls-rustls", "json"] }
|
||||
redis = { version = "0.27", features = ["tokio-comp"] }
|
||||
# async-ssh2 is not available at v0.4; using synchronous ssh2 via tokio::task::spawn_blocking per plan's fallback
|
||||
ssh2 = { version = "0.9" }
|
||||
# vendored-openssl: link OpenSSL statically so the release binary has NO
|
||||
# runtime dependency on the build machine's Homebrew openssl (@/opt/homebrew
|
||||
# absolute LC_LOAD_DYLIB — dyld aborts on user machines without it).
|
||||
ssh2 = { version = "0.9", features = ["vendored-openssl"] }
|
||||
deadpool-postgres = { version = "0.14" }
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
tauri-plugin-keyring-store = { version = "0.2.0", default-features = false }
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -2159,6 +2248,23 @@ pub async fn execute_change(
|
||||
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,
|
||||
@@ -2251,8 +2357,15 @@ pub async fn execute_change(
|
||||
conn.execute(sql, []).map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
Change::Ddl { .. } => {
|
||||
return Err("Object management is PostgreSQL-only".to_string());
|
||||
Change::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,
|
||||
@@ -2329,6 +2442,9 @@ pub async fn execute_change(
|
||||
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,
|
||||
@@ -2384,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,
|
||||
@@ -3222,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");
|
||||
@@ -3282,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"
|
||||
@@ -3332,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];
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
use tauri::State;
|
||||
use crate::db::pool::{ConnectionPoolManager, DbHandle};
|
||||
use crate::db::object_crud::build_ddl;
|
||||
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, ExtensionInfo};
|
||||
|
||||
@@ -147,13 +147,120 @@ 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()),
|
||||
}
|
||||
@@ -188,6 +295,293 @@ pub async fn get_available_extensions(connection_id: String, state: State<'_, cr
|
||||
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;
|
||||
@@ -58,19 +58,62 @@ async fn object_ddl_for_sequence_enum_function() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_object_ddl_inner_guards_postgresql_only() {
|
||||
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}");
|
||||
// Non-PostgreSQL handle -> PostgreSQL-only error
|
||||
// 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("PostgreSQL-only"), "{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]
|
||||
@@ -114,4 +157,56 @@ async fn object_dependencies_for_table_includes_view() {
|
||||
// schema contents path
|
||||
let contents = get_object_dependencies_inner(&pm, &id, "public", "schema", "public").await.unwrap();
|
||||
assert!(!contents.is_empty(), "public schema should list contents");
|
||||
}
|
||||
}
|
||||
|
||||
#[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:?}");
|
||||
}
|
||||
|
||||
+250
-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]
|
||||
@@ -1138,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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -641,4 +800,90 @@ mod tests {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
+716
-10
@@ -249,35 +249,55 @@ pub enum ConstraintAction {
|
||||
Check { expression: String },
|
||||
Unique { columns: Vec<String> },
|
||||
PrimaryKey { columns: Vec<String> },
|
||||
ForeignKey { columns: Vec<String>, ref_schema: String, ref_table: String, ref_columns: Vec<String> },
|
||||
ForeignKey { columns: Vec<String>, ref_schema: String, ref_table: String, ref_columns: Vec<String>, on_delete: Option<String>, on_update: Option<String>, deferrable: Option<bool>, initially_deferred: Option<bool> },
|
||||
Drop,
|
||||
}
|
||||
|
||||
pub fn constraint_ddl(p: &ConstraintParams) -> Result<Vec<String>, String> {
|
||||
validate_object_name(&p.schema)?;
|
||||
validate_object_name(&p.table)?;
|
||||
validate_object_name(&p.name)?;
|
||||
let table = format!("{}.{}", quote_ident(&p.schema), quote_ident(&p.table));
|
||||
let name = quote_ident(&p.name);
|
||||
Ok(vec![match &p.action {
|
||||
ConstraintAction::Check { expression } =>
|
||||
format!("ALTER TABLE {} ADD CONSTRAINT {} CHECK ({})", table, name, validate_expression(expression)?),
|
||||
ConstraintAction::Check { expression } => {
|
||||
validate_object_name(&p.name)?;
|
||||
format!("ALTER TABLE {} ADD CONSTRAINT {} CHECK ({})", table, quote_ident(&p.name), validate_expression(expression)?)
|
||||
}
|
||||
ConstraintAction::Unique { columns } => {
|
||||
if columns.is_empty() { return Err("UNIQUE constraint requires a column".into()); }
|
||||
format!("ALTER TABLE {} ADD CONSTRAINT {} UNIQUE ({})", table, name, quoted_cols(columns))
|
||||
validate_object_name(&p.name)?;
|
||||
format!("ALTER TABLE {} ADD CONSTRAINT {} UNIQUE ({})", table, quote_ident(&p.name), quoted_cols(columns))
|
||||
}
|
||||
ConstraintAction::PrimaryKey { columns } => {
|
||||
if columns.is_empty() { return Err("PRIMARY KEY requires a column".into()); }
|
||||
format!("ALTER TABLE {} ADD CONSTRAINT {} PRIMARY KEY ({})", table, name, quoted_cols(columns))
|
||||
validate_object_name(&p.name)?;
|
||||
format!("ALTER TABLE {} ADD CONSTRAINT {} PRIMARY KEY ({})", table, quote_ident(&p.name), quoted_cols(columns))
|
||||
}
|
||||
ConstraintAction::ForeignKey { columns, ref_schema, ref_table, ref_columns } => {
|
||||
ConstraintAction::ForeignKey { columns, ref_schema, ref_table, ref_columns, on_delete, on_update, deferrable, initially_deferred } => {
|
||||
if columns.is_empty() || ref_columns.is_empty() { return Err("FOREIGN KEY requires source and referenced columns".into()); }
|
||||
validate_object_name(ref_schema)?;
|
||||
validate_object_name(ref_table)?;
|
||||
let refq = format!("{}.{}", quote_ident(ref_schema), quote_ident(ref_table));
|
||||
format!("ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({})", table, name, quoted_cols(columns), refq, quoted_cols(ref_columns))
|
||||
// An empty name is allowed for FKs — PG auto-generates it (ADD FOREIGN KEY …).
|
||||
let name_clause = if p.name.trim().is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
validate_object_name(&p.name)?;
|
||||
format!("CONSTRAINT {} ", quote_ident(&p.name))
|
||||
};
|
||||
let mut sql = format!("ALTER TABLE {} ADD {}FOREIGN KEY ({}) REFERENCES {} ({})", table, name_clause, quoted_cols(columns), refq, quoted_cols(ref_columns));
|
||||
if let Some(d) = on_delete { sql.push_str(&format!(" ON DELETE {}", d)); }
|
||||
if let Some(u) = on_update { sql.push_str(&format!(" ON UPDATE {}", u)); }
|
||||
match (deferrable, initially_deferred) {
|
||||
(Some(true), Some(true)) => sql.push_str(" DEFERRABLE INITIALLY DEFERRED"),
|
||||
(Some(true), _) => sql.push_str(" DEFERRABLE INITIALLY IMMEDIATE"),
|
||||
_ => {}
|
||||
}
|
||||
sql
|
||||
}
|
||||
ConstraintAction::Drop => {
|
||||
validate_object_name(&p.name)?;
|
||||
format!("ALTER TABLE {} DROP CONSTRAINT {}", table, quote_ident(&p.name))
|
||||
}
|
||||
ConstraintAction::Drop => format!("ALTER TABLE {} DROP CONSTRAINT {}", table, name),
|
||||
}])
|
||||
}
|
||||
|
||||
@@ -369,10 +389,403 @@ pub fn trigger_ddl(p: &TriggerParams) -> Result<Vec<String>, String> {
|
||||
}])
|
||||
}
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn validate_type(t: &str) -> Result<String, String> {
|
||||
let s = t.trim();
|
||||
if s.is_empty() { return Err("Column type must not be empty".into()); }
|
||||
if s.ends_with(';') { return Err("Column type must not end with ';'".into()); }
|
||||
if s.contains("--") || s.contains("/*") { return Err("Column type must not contain comments".into()); }
|
||||
Ok(s.to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct TableColumn {
|
||||
pub name: String,
|
||||
#[serde(rename = "type")] pub type_: String,
|
||||
pub nullable: bool,
|
||||
pub default: Option<Option<String>>, // null | Some(null) | Some(expr)
|
||||
pub is_pk: bool,
|
||||
pub unique: Option<bool>,
|
||||
}
|
||||
|
||||
impl TableColumn {
|
||||
fn default_sql(&self) -> Option<&str> {
|
||||
match &self.default { Some(Some(d)) => Some(d.as_str()), _ => None }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TableParams { pub schema: String, pub name: String, pub action: TableAction }
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case")]
|
||||
pub enum TableAction {
|
||||
Create {
|
||||
columns: Vec<TableColumn>,
|
||||
tablespace: Option<String>,
|
||||
#[serde(default)]
|
||||
foreign_keys: Vec<TableForeignKey>,
|
||||
},
|
||||
Edit { columns: Vec<TableColumn>, old_columns: Vec<TableColumn> },
|
||||
Options { tablespace: Option<String>, rls: Option<String> },
|
||||
Drop,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct TableForeignKey {
|
||||
pub columns: Vec<String>,
|
||||
pub ref_schema: String,
|
||||
pub ref_table: String,
|
||||
pub ref_columns: Vec<String>,
|
||||
pub on_delete: Option<String>,
|
||||
pub on_update: Option<String>,
|
||||
pub deferrable: Option<bool>,
|
||||
pub initially_deferred: Option<bool>,
|
||||
}
|
||||
|
||||
fn col_def(c: &TableColumn) -> Result<String, String> {
|
||||
validate_object_name(&c.name)?;
|
||||
let ty = validate_type(&c.type_)?;
|
||||
let mut s = format!("{} {}", quote_ident(&c.name), ty);
|
||||
if c.unique == Some(true) { s.push_str(" UNIQUE"); }
|
||||
if !c.nullable { s.push_str(" NOT NULL"); }
|
||||
if let Some(d) = c.default_sql() { s.push_str(&format!(" DEFAULT {}", validate_expression(d)?)); }
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
pub fn table_ddl(p: &TableParams) -> Result<Vec<String>, String> {
|
||||
let q = qual(&p.schema, &p.name)?;
|
||||
Ok(match &p.action {
|
||||
TableAction::Create { columns, tablespace, foreign_keys } => {
|
||||
if columns.is_empty() { return Err("CREATE TABLE requires at least one column".into()); }
|
||||
let mut seen = HashSet::new();
|
||||
for c in columns {
|
||||
validate_object_name(&c.name)?;
|
||||
if !seen.insert(c.name.clone()) { return Err(format!("Duplicate column name: {}", c.name)); }
|
||||
}
|
||||
let mut defs: Vec<String> = columns.iter().map(col_def).collect::<Result<_, _>>()?;
|
||||
let pk: Vec<String> = columns.iter().filter(|c| c.is_pk).map(|c| quote_ident(&c.name)).collect();
|
||||
if !pk.is_empty() { defs.push(format!("PRIMARY KEY ({})", pk.join(", "))); }
|
||||
for fk in foreign_keys {
|
||||
if fk.columns.is_empty() || fk.ref_columns.is_empty() {
|
||||
return Err("FOREIGN KEY requires source and referenced columns".into());
|
||||
}
|
||||
for c in &fk.columns { validate_object_name(c)?; }
|
||||
for c in &fk.ref_columns { validate_object_name(c)?; }
|
||||
validate_object_name(&fk.ref_schema)?;
|
||||
validate_object_name(&fk.ref_table)?;
|
||||
let refq = format!("{}.{}", quote_ident(&fk.ref_schema), quote_ident(&fk.ref_table));
|
||||
let mut s = format!("FOREIGN KEY ({}) REFERENCES {} ({})", quoted_cols(&fk.columns), refq, quoted_cols(&fk.ref_columns));
|
||||
if let Some(d) = &fk.on_delete { s.push_str(&format!(" ON DELETE {}", d)); }
|
||||
if let Some(u) = &fk.on_update { s.push_str(&format!(" ON UPDATE {}", u)); }
|
||||
match (fk.deferrable, fk.initially_deferred) {
|
||||
(Some(true), Some(true)) => s.push_str(" DEFERRABLE INITIALLY DEFERRED"),
|
||||
(Some(true), _) => s.push_str(" DEFERRABLE"),
|
||||
_ => {}
|
||||
}
|
||||
defs.push(s);
|
||||
}
|
||||
let mut sql = format!("CREATE TABLE {} (\n {}\n)", q, defs.join(",\n "));
|
||||
if let Some(ts) = tablespace { validate_object_name(ts)?; sql.push_str(&format!(" TABLESPACE {}", quote_ident(ts))); }
|
||||
vec![sql]
|
||||
}
|
||||
TableAction::Edit { columns, old_columns } => table_diff(&q, old_columns, columns)?,
|
||||
TableAction::Options { tablespace, rls } => {
|
||||
let mut out = Vec::new();
|
||||
if let Some(ts) = tablespace { validate_object_name(ts)?; out.push(format!("ALTER TABLE {} SET TABLESPACE {}", q, quote_ident(ts))); }
|
||||
match rls.as_deref() {
|
||||
Some("enable") | Some("force") => out.push(format!("ALTER TABLE {} ENABLE ROW LEVEL SECURITY", q)),
|
||||
_ => {}
|
||||
}
|
||||
if rls.as_deref() == Some("force") { out.push(format!("ALTER TABLE {} FORCE ROW LEVEL SECURITY", q)); }
|
||||
if rls.as_deref() == Some("disable") { out.push(format!("ALTER TABLE {} DISABLE ROW LEVEL SECURITY", q)); }
|
||||
out
|
||||
}
|
||||
TableAction::Drop => vec![format!("DROP TABLE {}", q)],
|
||||
})
|
||||
}
|
||||
|
||||
fn table_diff(q: &str, old: &[TableColumn], new: &[TableColumn]) -> Result<Vec<String>, String> {
|
||||
for c in new { validate_object_name(&c.name)?; validate_type(&c.type_)?; }
|
||||
let mut old_by_name: std::collections::HashMap<&str, &TableColumn> = old.iter().map(|c| (c.name.as_str(), c)).collect();
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut used_old: HashSet<usize> = HashSet::new();
|
||||
// 1. RENAMEs: unmatched new col at ordinal i matching an unmatched old col at ordinal i with identical type/nullable/default
|
||||
let mut renames: Vec<(usize, String, String)> = Vec::new(); // (old_idx, old_name, new_name)
|
||||
for (i, nc) in new.iter().enumerate() {
|
||||
if old_by_name.contains_key(nc.name.as_str()) { continue; }
|
||||
if let Some(oc) = old.get(i) {
|
||||
if !used_old.contains(&i) && oc.type_.trim() == nc.type_.trim()
|
||||
&& oc.nullable == nc.nullable && oc.default_sql() == nc.default_sql() {
|
||||
renames.push((i, oc.name.clone(), nc.name.clone()));
|
||||
used_old.insert(i);
|
||||
old_by_name.remove(oc.name.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (_, oldn, newn) in &renames {
|
||||
out.push(format!("ALTER TABLE {} RENAME COLUMN \"{}\" TO \"{}\"", q, oldn, newn));
|
||||
}
|
||||
// 2. ADDs: remaining unmatched new cols
|
||||
for nc in new {
|
||||
if old_by_name.contains_key(nc.name.as_str()) || renames.iter().any(|r| r.2 == nc.name) { continue; }
|
||||
let s = format!("ALTER TABLE {} ADD COLUMN {}", q, col_def(nc)?);
|
||||
out.push(s);
|
||||
}
|
||||
// 3. ALTERs for kept (name-matched) cols
|
||||
for nc in new {
|
||||
let Some(oc) = old.iter().find(|c| c.name == nc.name) else { continue; };
|
||||
if oc.type_.trim() != nc.type_.trim() {
|
||||
out.push(format!("ALTER TABLE {} ALTER COLUMN {} TYPE {}", q, quote_ident(&nc.name), validate_type(&nc.type_)?));
|
||||
}
|
||||
match (oc.default_sql(), nc.default_sql()) {
|
||||
(None, Some(d)) => out.push(format!("ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {}", q, quote_ident(&nc.name), validate_expression(d)?)),
|
||||
(Some(_), None) => out.push(format!("ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT", q, quote_ident(&nc.name))),
|
||||
(Some(a), Some(b)) if a != b => out.push(format!("ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {}", q, quote_ident(&nc.name), validate_expression(b)?)),
|
||||
_ => {}
|
||||
}
|
||||
match (oc.nullable, nc.nullable) {
|
||||
(true, false) => out.push(format!("ALTER TABLE {} ALTER COLUMN {} SET NOT NULL", q, quote_ident(&nc.name))),
|
||||
(false, true) => out.push(format!("ALTER TABLE {} ALTER COLUMN {} DROP NOT NULL", q, quote_ident(&nc.name))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// 4. DROPs last: old cols not present in new and not renamed-away
|
||||
let new_names: HashSet<&str> = new.iter().map(|c| c.name.as_str()).collect();
|
||||
for oc in old {
|
||||
if !new_names.contains(oc.name.as_str()) && !renames.iter().any(|r| r.1 == oc.name) {
|
||||
out.push(format!("ALTER TABLE {} DROP COLUMN {}", q, quote_ident(&oc.name)));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RebuildConstraint { pub name: String, pub definition: String }
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RebuildIndex { pub name: String, pub definition: String }
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RebuildFk { pub name: String, pub definition: String }
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RebuildFkIn {
|
||||
pub name: String,
|
||||
pub own_schema: String,
|
||||
pub own_table: String,
|
||||
pub definition: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RebuildGrant {
|
||||
pub grantee: String,
|
||||
pub privileges: Vec<String>,
|
||||
pub grantable: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RebuildOwnedSequence {
|
||||
pub seq_schema: String,
|
||||
pub seq_name: String,
|
||||
pub column: String,
|
||||
}
|
||||
|
||||
/// Everything needed to reconstruct a table's metadata after a reorder-only rebuild.
|
||||
/// Rebuild is reorder-only: column names + types in `new_columns` must equal the live
|
||||
/// snapshot (validated by the Task 2.4 command). These structs are assembled in Rust
|
||||
/// from introspection rows, so they derive Clone/Debug only (no IPC Deserialize).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RebuildInput {
|
||||
pub schema: String,
|
||||
pub name: String,
|
||||
pub constraints: Vec<RebuildConstraint>,
|
||||
pub indexes: Vec<RebuildIndex>,
|
||||
pub fks_out: Vec<RebuildFk>,
|
||||
pub fks_in: Vec<RebuildFkIn>,
|
||||
pub grants: Vec<RebuildGrant>,
|
||||
pub owned_sequences: Vec<RebuildOwnedSequence>,
|
||||
}
|
||||
|
||||
/// Assemble the full rebuild script for a reorder-only table rebuild.
|
||||
///
|
||||
/// Order (transactional at the caller — the script itself is BEGIN-free):
|
||||
/// detach owned sequences → drop inbound FKs → create temp table in the new column
|
||||
/// order → INSERT..SELECT (cast-free: names/types unchanged) → DROP original → RENAME
|
||||
/// temp → recreate PK/unique/check constraints → recreate indexes → re-add outbound
|
||||
/// FKs → re-add inbound FKs → re-grant privileges → re-attach owned sequences.
|
||||
///
|
||||
/// The caller executes this via transactional `batch_execute` (Task 2.4), so the
|
||||
/// script must NOT contain BEGIN/COMMIT.
|
||||
pub fn rebuild_script(input: &RebuildInput, new_columns: &[TableColumn]) -> Result<String, String> {
|
||||
validate_object_name(&input.schema)?;
|
||||
validate_object_name(&input.name)?;
|
||||
for c in new_columns {
|
||||
validate_object_name(&c.name)?;
|
||||
validate_type(&c.type_)?;
|
||||
}
|
||||
let q = qual(&input.schema, &input.name)?;
|
||||
let tmp = qual(&input.schema, &format!("_gridline_rb_{}", input.name))?;
|
||||
let mut s = String::new();
|
||||
// 1. detach owned sequences (OWNED BY NONE keeps the sequence alive but decoupled)
|
||||
for seq in &input.owned_sequences {
|
||||
validate_object_name(&seq.seq_schema)?;
|
||||
validate_object_name(&seq.seq_name)?;
|
||||
s.push_str(&format!("ALTER SEQUENCE \"{}\".\"{}\" OWNED BY NONE;\n", seq.seq_schema, seq.seq_name));
|
||||
}
|
||||
// 2. drop FKs IN (from other tables) before DROP TABLE
|
||||
for fk in &input.fks_in {
|
||||
validate_object_name(&fk.own_schema)?;
|
||||
validate_object_name(&fk.own_table)?;
|
||||
validate_object_name(&fk.name)?;
|
||||
s.push_str(&format!("ALTER TABLE \"{}\".\"{}\" DROP CONSTRAINT \"{}\";\n", fk.own_schema, fk.own_table, fk.name));
|
||||
}
|
||||
// 3. create temp table (new order, NOT NULL + DEFAULT only)
|
||||
let defs: Vec<String> = new_columns.iter().map(col_def).collect::<Result<_, _>>()?;
|
||||
s.push_str(&format!("CREATE TABLE {} (\n {}\n);\n", tmp, defs.join(",\n ")));
|
||||
// 4. copy data (names unchanged = cast-free SELECT in new order)
|
||||
let cols: Vec<String> = new_columns.iter().map(|c| quote_ident(&c.name)).collect();
|
||||
s.push_str(&format!("INSERT INTO {} ({}) SELECT {} FROM {};\n", tmp, cols.join(", "), cols.join(", "), q));
|
||||
// 5. drop old + 6. rename temp
|
||||
s.push_str(&format!("DROP TABLE {};\n", q));
|
||||
s.push_str(&format!("ALTER TABLE {} RENAME TO \"{}\";\n", tmp, input.name));
|
||||
// 7. recreate constraints (PK/unique/check)
|
||||
for c in &input.constraints {
|
||||
validate_object_name(&c.name)?;
|
||||
s.push_str(&format!("ALTER TABLE {} ADD CONSTRAINT \"{}\" {};\n", q, c.name, c.definition));
|
||||
}
|
||||
// 8. recreate indexes (pg_get_indexdef references schema.name = the renamed table)
|
||||
for idx in &input.indexes {
|
||||
s.push_str(&format!("{};\n", idx.definition));
|
||||
}
|
||||
// 9. recreate FKs OUT (this table's FKs)
|
||||
for fk in &input.fks_out {
|
||||
validate_object_name(&fk.name)?;
|
||||
s.push_str(&format!("ALTER TABLE {} ADD CONSTRAINT \"{}\" {};\n", q, fk.name, fk.definition));
|
||||
}
|
||||
// 10. recreate FKs IN (other tables)
|
||||
for fk in &input.fks_in {
|
||||
s.push_str(&format!("ALTER TABLE \"{}\".\"{}\" ADD CONSTRAINT \"{}\" {};\n", fk.own_schema, fk.own_table, fk.name, fk.definition));
|
||||
}
|
||||
// 11. re-apply grants
|
||||
for g in &input.grants {
|
||||
validate_object_name(&g.grantee)?;
|
||||
let opt = if g.grantable { " WITH GRANT OPTION" } else { "" };
|
||||
s.push_str(&format!("GRANT {} ON {} TO {}{};\n", g.privileges.join(", "), q, quote_ident(&g.grantee), opt));
|
||||
}
|
||||
// 12. re-attach owned sequences
|
||||
for seq in &input.owned_sequences {
|
||||
validate_object_name(&seq.column)?;
|
||||
s.push_str(&format!("ALTER SEQUENCE \"{}\".\"{}\" OWNED BY {}.\"{}\";\n", seq.seq_schema, seq.seq_name, q, seq.column));
|
||||
}
|
||||
Ok(s.trim_end().to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RoleParams {
|
||||
pub schema: String,
|
||||
pub name: String,
|
||||
pub action: RoleAction,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case")]
|
||||
pub enum RoleAction {
|
||||
Create { login: bool, superuser: bool, createdb: bool, createrole: bool, inherit: bool,
|
||||
replication: bool, bypassrls: bool, connection_limit: i64, valid_until: String,
|
||||
password: String, members: Vec<String> },
|
||||
Edit { login: bool, superuser: bool, createdb: bool, createrole: bool, inherit: bool,
|
||||
replication: bool, bypassrls: bool, connection_limit: i64, valid_until: String,
|
||||
password: String, members: Vec<String> },
|
||||
Drop,
|
||||
Grant { object_class: String, object_schema: Option<String>, object_name: String,
|
||||
privileges: Vec<String>, grantee: String, grant_option: bool },
|
||||
Revoke { object_class: String, object_schema: Option<String>, object_name: String,
|
||||
privileges: Vec<String>, grantee: String, grant_option: bool },
|
||||
}
|
||||
|
||||
/// Compose the option-clause portion of CREATE/ALTER ROLE.
|
||||
fn role_options(a: &RoleAction) -> String {
|
||||
let (login, superuser, createdb, createrole, inherit, replication, bypassrls, conn, until, pw) = match a {
|
||||
RoleAction::Create { login, superuser, createdb, createrole, inherit, replication, bypassrls, connection_limit, valid_until, password, .. }
|
||||
| RoleAction::Edit { login, superuser, createdb, createrole, inherit, replication, bypassrls, connection_limit, valid_until, password, .. } =>
|
||||
(*login, *superuser, *createdb, *createrole, *inherit, *replication, *bypassrls, *connection_limit, valid_until.clone(), password.clone()),
|
||||
_ => return String::new(),
|
||||
};
|
||||
let mut o: Vec<String> = Vec::new();
|
||||
if login { o.push("LOGIN".into()); }
|
||||
if superuser { o.push("SUPERUSER".into()); }
|
||||
if createdb { o.push("CREATEDB".into()); }
|
||||
if createrole { o.push("CREATEROLE".into()); }
|
||||
o.push(if inherit { "INHERIT".into() } else { "NOINHERIT".into() });
|
||||
if replication { o.push("REPLICATION".into()); }
|
||||
if bypassrls { o.push("BYPASSRLS".into()); }
|
||||
o.push(format!("CONNECTION LIMIT {}", conn));
|
||||
if !pw.is_empty() { o.push(format!("PASSWORD '{}'", pw.replace('\'', "''"))); }
|
||||
if !until.is_empty() { o.push(format!("VALID UNTIL '{}'", until.replace('\'', "''"))); }
|
||||
o.join(" ")
|
||||
}
|
||||
|
||||
/// Qualified object reference for GRANT/REVOKE. Schemaless classes (schema, database)
|
||||
/// quote the bare name; everything else is emitted as schema.name.
|
||||
fn grant_object_ref(class: &str, schema: Option<&str>, name: &str) -> Result<String, String> {
|
||||
validate_object_name(name)?;
|
||||
match class {
|
||||
"schema" | "database" => Ok(quote_ident(name)),
|
||||
_ => {
|
||||
let s = match schema {
|
||||
Some(s) => { validate_object_name(s)?; quote_ident(s) }
|
||||
None => String::new(),
|
||||
};
|
||||
Ok(format!("{}.{}", s, quote_ident(name)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn role_ddl(p: &RoleParams) -> Result<Vec<String>, String> {
|
||||
if !p.name.is_empty() { validate_object_name(&p.name)?; }
|
||||
let name = quote_ident(&p.name);
|
||||
Ok(match &p.action {
|
||||
RoleAction::Create { members, .. } => {
|
||||
let mut out = vec![format!("CREATE ROLE {} {}", name, role_options(&p.action))];
|
||||
for m in members {
|
||||
validate_object_name(m)?;
|
||||
out.push(format!("GRANT {} TO {}", quote_ident(m), name));
|
||||
}
|
||||
out
|
||||
}
|
||||
RoleAction::Edit { members, .. } => {
|
||||
// membership edits are advisory in v1 (no diff); skip
|
||||
let _ = members;
|
||||
vec![format!("ALTER ROLE {} {}", name, role_options(&p.action))]
|
||||
}
|
||||
RoleAction::Drop => vec![format!("DROP ROLE {}", name)],
|
||||
RoleAction::Grant { object_class, object_schema, object_name, privileges, grantee, grant_option } => {
|
||||
validate_object_name(grantee)?;
|
||||
let obj = grant_object_ref(object_class, object_schema.as_deref(), object_name)?;
|
||||
let privs = privileges.join(", ");
|
||||
let opt = if *grant_option { " WITH GRANT OPTION" } else { "" };
|
||||
vec![format!("GRANT {} ON {} TO {}{}", privs, obj, quote_ident(grantee), opt)]
|
||||
}
|
||||
RoleAction::Revoke { object_class, object_schema, object_name, privileges, grantee, grant_option } => {
|
||||
validate_object_name(grantee)?;
|
||||
let obj = grant_object_ref(object_class, object_schema.as_deref(), object_name)?;
|
||||
let privs = privileges.join(", ");
|
||||
let opt = if *grant_option { " GRANT OPTION FOR" } else { "" };
|
||||
vec![format!("REVOKE{} {} ON {} FROM {}", opt, privs, obj, quote_ident(grantee))]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Dispatch a DDL build by kind. `params` is the JSON payload from the frontend.
|
||||
/// Returns one or more single SQL statements.
|
||||
pub fn build_ddl(kind: &str, params: serde_json::Value) -> Result<Vec<String>, String> {
|
||||
match kind {
|
||||
"role" => {
|
||||
let p: RoleParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
|
||||
role_ddl(&p)
|
||||
}
|
||||
"sequence" => {
|
||||
let p: SequenceParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
|
||||
sequence_ddl(&p)
|
||||
@@ -406,6 +819,10 @@ pub fn build_ddl(kind: &str, params: serde_json::Value) -> Result<Vec<String>, S
|
||||
let p: TriggerParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
|
||||
trigger_ddl(&p)
|
||||
}
|
||||
"table" => {
|
||||
let p: TableParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
|
||||
table_ddl(&p)
|
||||
}
|
||||
other => Err(format!("Unsupported object kind: {other}")),
|
||||
}
|
||||
}
|
||||
@@ -642,6 +1059,43 @@ mod tests {
|
||||
vec!["ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"fk_user\" FOREIGN KEY (\"user_id\") REFERENCES \"public\".\"users\" (\"id\")"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constraint_foreign_key_with_actions() {
|
||||
let p = serde_json::json!({
|
||||
"schema": "public", "table": "orders", "name": "fk_user",
|
||||
"action": {
|
||||
"op": "foreign_key",
|
||||
"columns": ["user_id"],
|
||||
"ref_schema": "public",
|
||||
"ref_table": "users",
|
||||
"ref_columns": ["id"],
|
||||
"on_delete": "CASCADE",
|
||||
"on_update": "SET NULL",
|
||||
"deferrable": true,
|
||||
"initially_deferred": true
|
||||
}
|
||||
});
|
||||
assert_eq!(build_ddl("constraint", p).unwrap(),
|
||||
vec!["ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"fk_user\" FOREIGN KEY (\"user_id\") REFERENCES \"public\".\"users\" (\"id\") ON DELETE CASCADE ON UPDATE SET NULL DEFERRABLE INITIALLY DEFERRED"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constraint_foreign_key_empty_name_auto_names() {
|
||||
let p = serde_json::json!({
|
||||
"schema": "public", "table": "orders", "name": "",
|
||||
"action": {
|
||||
"op": "foreign_key",
|
||||
"columns": ["user_id"],
|
||||
"ref_schema": "public",
|
||||
"ref_table": "users",
|
||||
"ref_columns": ["id"],
|
||||
"on_delete": "CASCADE"
|
||||
}
|
||||
});
|
||||
assert_eq!(build_ddl("constraint", p).unwrap(),
|
||||
vec!["ALTER TABLE \"public\".\"orders\" ADD FOREIGN KEY (\"user_id\") REFERENCES \"public\".\"users\" (\"id\") ON DELETE CASCADE"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constraint_drop() {
|
||||
let p = serde_json::json!({ "schema": "public", "table": "orders", "name": "ck_pos", "action": { "op": "drop" } });
|
||||
@@ -724,4 +1178,256 @@ mod tests {
|
||||
assert_eq!(build_ddl("trigger", base("disable")).unwrap(), vec!["ALTER TABLE \"public\".\"orders\" DISABLE TRIGGER \"tr_audit\""]);
|
||||
assert_eq!(build_ddl("trigger", base("drop")).unwrap(), vec!["DROP TRIGGER \"tr_audit\" ON \"public\".\"orders\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_create_multi_col_pk_and_default() {
|
||||
let p = serde_json::json!({
|
||||
"schema": "public", "name": "users",
|
||||
"action": { "op": "create", "columns": [
|
||||
{ "name": "id", "type": "integer", "nullable": false, "default": null, "is_pk": true },
|
||||
{ "name": "email", "type": "text", "nullable": false, "default": null, "is_pk": false }
|
||||
], "tablespace": null }
|
||||
});
|
||||
let sql = build_ddl("table", p).unwrap();
|
||||
assert_eq!(sql, vec![
|
||||
"CREATE TABLE \"public\".\"users\" (\n \"id\" integer NOT NULL,\n \"email\" text NOT NULL,\n PRIMARY KEY (\"id\")\n)"
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_create_with_tablespace() {
|
||||
let p = serde_json::json!({
|
||||
"schema": "public", "name": "t",
|
||||
"action": { "op": "create", "columns": [
|
||||
{ "name": "id", "type": "integer", "nullable": false, "default": null, "is_pk": true }
|
||||
], "tablespace": "fastdisk" }
|
||||
});
|
||||
let sql = build_ddl("table", p).unwrap();
|
||||
assert!(sql[0].ends_with(" TABLESPACE \"fastdisk\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_create_unique_column_emits_unique_keyword() {
|
||||
let p = serde_json::json!({
|
||||
"schema": "public", "name": "users",
|
||||
"action": { "op": "create", "columns": [
|
||||
{ "name": "id", "type": "integer", "nullable": false, "default": null, "is_pk": true },
|
||||
{ "name": "email", "type": "text", "nullable": false, "default": null, "is_pk": false, "unique": true }
|
||||
], "tablespace": null }
|
||||
});
|
||||
let sql = build_ddl("table", p).unwrap();
|
||||
assert!(sql[0].contains("\"email\" text UNIQUE NOT NULL"), "expected UNIQUE in column def; got: {}", sql[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_column_deserialization_ignores_unknown_fields() {
|
||||
let p = serde_json::json!({
|
||||
"schema": "public", "name": "t",
|
||||
"action": { "op": "create", "columns": [
|
||||
{ "name": "id", "type": "integer", "nullable": false, "default": null, "is_pk": true, "params": "(50)", "auto_increment": true }
|
||||
], "tablespace": null }
|
||||
});
|
||||
let sql = build_ddl("table", p).unwrap();
|
||||
assert!(sql[0].contains("\"id\" integer NOT NULL"), "expected column def; got: {}", sql[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_create_rejects_empty_and_duplicate_columns() {
|
||||
let empty = serde_json::json!({ "schema": "public", "name": "t", "action": { "op": "create", "columns": [], "tablespace": null } });
|
||||
assert!(build_ddl("table", empty).is_err());
|
||||
let dup = serde_json::json!({ "schema": "public", "name": "t", "action": { "op": "create", "columns": [
|
||||
{ "name": "id", "type": "int", "nullable": false, "default": null, "is_pk": true },
|
||||
{ "name": "id", "type": "int", "nullable": false, "default": null, "is_pk": false }
|
||||
], "tablespace": null } });
|
||||
assert!(build_ddl("table", dup).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_create_with_inline_foreign_key() {
|
||||
let p = serde_json::json!({
|
||||
"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 }
|
||||
],
|
||||
"tablespace": null,
|
||||
"foreign_keys": [
|
||||
{ "columns": ["category_id"], "ref_schema": "public", "ref_table": "categories",
|
||||
"ref_columns": ["id"], "on_delete": "CASCADE", "on_update": "SET NULL",
|
||||
"deferrable": true, "initially_deferred": false }
|
||||
]
|
||||
}
|
||||
});
|
||||
assert_eq!(build_ddl("table", p).unwrap(), vec![
|
||||
"CREATE TABLE \"public\".\"products\" (\n \"id\" integer NOT NULL,\n \"category_id\" integer,\n PRIMARY KEY (\"id\"),\n FOREIGN KEY (\"category_id\") REFERENCES \"public\".\"categories\" (\"id\") ON DELETE CASCADE ON UPDATE SET NULL DEFERRABLE\n)"
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_edit_emits_rename_add_alter_drop_in_order() {
|
||||
let p = serde_json::json!({
|
||||
"schema": "public", "name": "users",
|
||||
"action": { "op": "edit",
|
||||
"old_columns": [
|
||||
{ "name": "id", "type": "integer", "nullable": false, "default": null, "is_pk": true },
|
||||
{ "name": "name", "type": "text", "nullable": true, "default": null, "is_pk": false },
|
||||
{ "name": "age", "type": "int", "nullable": true, "default": null, "is_pk": false }
|
||||
],
|
||||
"columns": [
|
||||
{ "name": "id", "type": "integer", "nullable": false, "default": null, "is_pk": true },
|
||||
{ "name": "label", "type": "text", "nullable": true, "default": null, "is_pk": false },
|
||||
{ "name": "email", "type": "text", "nullable": false, "default": "'x'", "is_pk": false }
|
||||
]
|
||||
}
|
||||
});
|
||||
let sql = build_ddl("table", p).unwrap();
|
||||
let joined = sql.join("\n");
|
||||
let rename_idx = joined.find("ALTER TABLE \"public\".\"users\" RENAME COLUMN \"name\" TO \"label\"").unwrap();
|
||||
let add_idx = joined.find("ALTER TABLE \"public\".\"users\" ADD COLUMN \"email\" text NOT NULL DEFAULT 'x'").unwrap();
|
||||
let drop_idx = joined.find("ALTER TABLE \"public\".\"users\" DROP COLUMN \"age\"").unwrap();
|
||||
assert!(rename_idx < add_idx, "RENAME before ADD; got: {}", joined);
|
||||
assert!(add_idx < drop_idx, "ADD before DROP; got: {}", joined);
|
||||
assert!(sql.iter().any(|s| s == "ALTER TABLE \"public\".\"users\" RENAME COLUMN \"name\" TO \"label\""));
|
||||
assert!(sql.iter().any(|s| s == "ALTER TABLE \"public\".\"users\" ADD COLUMN \"email\" text NOT NULL DEFAULT 'x'"));
|
||||
assert!(sql.iter().any(|s| s == "ALTER TABLE \"public\".\"users\" DROP COLUMN \"age\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_edit_emits_alter_type_default_notnull() {
|
||||
let p = serde_json::json!({
|
||||
"schema": "public", "name": "t",
|
||||
"action": { "op": "edit",
|
||||
"old_columns": [ { "name": "c", "type": "int", "nullable": true, "default": null, "is_pk": false } ],
|
||||
"columns": [ { "name": "c", "type": "bigint", "nullable": false, "default": "0", "is_pk": false } ]
|
||||
}
|
||||
});
|
||||
let sql = build_ddl("table", p).unwrap();
|
||||
assert!(sql.iter().any(|s| s == "ALTER TABLE \"public\".\"t\" ALTER COLUMN \"c\" TYPE bigint"));
|
||||
assert!(sql.iter().any(|s| s == "ALTER TABLE \"public\".\"t\" ALTER COLUMN \"c\" SET DEFAULT 0"));
|
||||
assert!(sql.iter().any(|s| s == "ALTER TABLE \"public\".\"t\" ALTER COLUMN \"c\" SET NOT NULL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_options_rls_and_tablespace() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "t", "action": { "op": "options", "tablespace": "fastdisk", "rls": "force" } });
|
||||
let sql = build_ddl("table", p).unwrap();
|
||||
assert!(sql.iter().any(|s| s == "ALTER TABLE \"public\".\"t\" SET TABLESPACE \"fastdisk\""));
|
||||
assert!(sql.iter().any(|s| s == "ALTER TABLE \"public\".\"t\" ENABLE ROW LEVEL SECURITY"));
|
||||
assert!(sql.iter().any(|s| s == "ALTER TABLE \"public\".\"t\" FORCE ROW LEVEL SECURITY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_drop() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "t", "action": { "op": "drop" } });
|
||||
assert_eq!(build_ddl("table", p).unwrap(), vec!["DROP TABLE \"public\".\"t\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_create_with_options_and_membership() {
|
||||
let p = serde_json::json!({
|
||||
"schema": "", "name": "app",
|
||||
"action": { "op": "create", "login": true, "superuser": false, "createdb": true,
|
||||
"createrole": false, "inherit": true, "replication": false, "bypassrls": false,
|
||||
"connection_limit": 10, "valid_until": "", "password": "s3cr3t", "members": ["reader"] }
|
||||
});
|
||||
let sql = build_ddl("role", p).unwrap();
|
||||
assert_eq!(sql[0], "CREATE ROLE \"app\" LOGIN CREATEDB INHERIT CONNECTION LIMIT 10 PASSWORD 's3cr3t'");
|
||||
assert!(sql.iter().any(|s| s == "GRANT \"reader\" TO \"app\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_create_escapes_password_literal() {
|
||||
let p = serde_json::json!({ "schema": "", "name": "r", "action": { "op": "create",
|
||||
"login": false, "superuser": false, "createdb": false, "createrole": false, "inherit": true,
|
||||
"replication": false, "bypassrls": false, "connection_limit": -1, "valid_until": "", "password": "a'b", "members": [] } });
|
||||
let sql = build_ddl("role", p).unwrap();
|
||||
assert!(sql[0].contains("PASSWORD 'a''b'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_edit_blank_password_omits_password_clause() {
|
||||
let p = serde_json::json!({ "schema": "", "name": "app",
|
||||
"action": { "op": "edit", "login": true, "superuser": false, "createdb": false,
|
||||
"createrole": false, "inherit": true, "replication": false, "bypassrls": false,
|
||||
"connection_limit": -1, "valid_until": "2027-01-01", "password": "", "members": [] } });
|
||||
let sql = build_ddl("role", p).unwrap();
|
||||
assert!(sql[0].contains("ALTER ROLE \"app\""));
|
||||
assert!(!sql[0].contains("PASSWORD")); // blank = keep
|
||||
assert!(sql[0].contains("VALID UNTIL '2027-01-01'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_drop() {
|
||||
let p = serde_json::json!({ "schema": "", "name": "app", "action": { "op": "drop" } });
|
||||
assert_eq!(build_ddl("role", p).unwrap(), vec!["DROP ROLE \"app\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_grant_and_revoke() {
|
||||
let g = serde_json::json!({ "schema": "", "name": "",
|
||||
"action": { "op": "grant", "object_class": "table", "object_schema": "public", "object_name": "users",
|
||||
"privileges": ["SELECT", "INSERT"], "grantee": "app", "grant_option": false } });
|
||||
assert_eq!(build_ddl("role", g).unwrap(), vec!["GRANT SELECT, INSERT ON \"public\".\"users\" TO \"app\""]);
|
||||
let r = serde_json::json!({ "schema": "", "name": "",
|
||||
"action": { "op": "revoke", "object_class": "table", "object_schema": "public", "object_name": "users",
|
||||
"privileges": ["SELECT"], "grantee": "app", "grant_option": false } });
|
||||
assert_eq!(build_ddl("role", r).unwrap(), vec!["REVOKE SELECT ON \"public\".\"users\" FROM \"app\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_grant_rejects_bad_identifier() {
|
||||
let p = serde_json::json!({ "schema": "", "name": "",
|
||||
"action": { "op": "grant", "object_class": "table", "object_schema": "public", "object_name": "a; DROP",
|
||||
"privileges": ["SELECT"], "grantee": "app", "grant_option": false } });
|
||||
assert!(build_ddl("role", p).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_script_preserves_order_and_recreates_fk_index_grant() {
|
||||
let input = RebuildInput {
|
||||
schema: "public".into(), name: "users".into(),
|
||||
constraints: vec![ RebuildConstraint { name: "users_pkey".into(), definition: "PRIMARY KEY (id)".into() } ],
|
||||
indexes: vec![ RebuildIndex { name: "users_email_key".into(), definition: "CREATE UNIQUE INDEX users_email_key ON public.users (email)".into() } ],
|
||||
fks_out: vec![],
|
||||
fks_in: vec![ RebuildFkIn { name: "orders_user_fk".into(), own_schema: "public".into(), own_table: "orders".into(), definition: "FOREIGN KEY (user_id) REFERENCES public.users(id)".into() } ],
|
||||
grants: vec![ RebuildGrant { grantee: "reader".into(), privileges: vec!["SELECT".into()], grantable: false } ],
|
||||
owned_sequences: vec![],
|
||||
};
|
||||
let new = vec![ TableColumn { name: "id".into(), type_: "integer".into(), nullable: false, default: None, is_pk: false, unique: None },
|
||||
TableColumn { name: "email".into(), type_: "text".into(), nullable: true, default: None, is_pk: false, unique: None } ];
|
||||
let script = rebuild_script(&input, &new).unwrap();
|
||||
assert!(script.contains("ALTER TABLE \"public\".\"orders\" DROP CONSTRAINT \"orders_user_fk\""), "drop fks_in first; got: {script}");
|
||||
assert!(script.contains("CREATE TABLE \"public\".\"_gridline_rb_users\" ("));
|
||||
assert!(script.contains("INSERT INTO \"public\".\"_gridline_rb_users\" (\"id\", \"email\") SELECT \"id\", \"email\" FROM \"public\".\"users\""));
|
||||
assert!(script.contains("DROP TABLE \"public\".\"users\""));
|
||||
assert!(script.contains("ALTER TABLE \"public\".\"_gridline_rb_users\" RENAME TO \"users\""));
|
||||
assert!(script.contains("ALTER TABLE \"public\".\"users\" ADD CONSTRAINT \"users_pkey\" PRIMARY KEY (id)"));
|
||||
assert!(script.contains("CREATE UNIQUE INDEX users_email_key ON public.users (email)"));
|
||||
assert!(script.contains("ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"orders_user_fk\" FOREIGN KEY (user_id) REFERENCES public.users(id)"));
|
||||
assert!(script.contains("GRANT SELECT ON \"public\".\"users\" TO \"reader\""));
|
||||
// ordering: drop fks_in before DROP TABLE; DROP before RENAME; RENAME before recreate
|
||||
let d_fk = script.find("DROP CONSTRAINT \"orders_user_fk\"").unwrap();
|
||||
let drop = script.find("DROP TABLE \"public\".\"users\"").unwrap();
|
||||
let rename = script.find("RENAME TO \"users\"").unwrap();
|
||||
let addcon = script.find("ADD CONSTRAINT \"users_pkey\"").unwrap();
|
||||
assert!(d_fk < drop && drop < rename && rename < addcon, "ordering wrong; got: {script}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_script_detaches_and_reattaches_owned_sequence() {
|
||||
let input = RebuildInput {
|
||||
schema: "public".into(), name: "t".into(), constraints: vec![], indexes: vec![],
|
||||
fks_out: vec![], fks_in: vec![], grants: vec![],
|
||||
owned_sequences: vec![ RebuildOwnedSequence { seq_schema: "public".into(), seq_name: "t_id_seq".into(), column: "id".into() } ],
|
||||
};
|
||||
let new = vec![ TableColumn { name: "id".into(), type_: "integer".into(), nullable: false, default: Some(Some("nextval('t_id_seq'::regclass)".into())), is_pk: false, unique: None } ];
|
||||
let script = rebuild_script(&input, &new).unwrap();
|
||||
assert!(script.contains("ALTER SEQUENCE \"public\".\"t_id_seq\" OWNED BY NONE"));
|
||||
assert!(script.contains("ALTER SEQUENCE \"public\".\"t_id_seq\" OWNED BY \"public\".\"t\".\"id\""));
|
||||
let detach = script.find("OWNED BY NONE").unwrap();
|
||||
let drop = script.find("DROP TABLE").unwrap();
|
||||
let attach = script.find("OWNED BY \"public\".\"t\".\"id\"").unwrap();
|
||||
assert!(detach < drop && drop < attach, "detach before drop before reattach; got: {script}");
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
+41
-9
@@ -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,
|
||||
};
|
||||
|
||||
@@ -36,20 +38,33 @@ pub fn run() {
|
||||
// another provider is already installed).
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let store = Store::open("gridline.db").expect("failed to open db");
|
||||
let store_ref = StdMutex::new(store);
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_keyring_store::init())
|
||||
.manage(AppState {
|
||||
db_store: store_ref,
|
||||
pool_manager: tokio::sync::Mutex::new(ConnectionPoolManager::new()),
|
||||
ssh_manager: StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend))),
|
||||
})
|
||||
.setup(move |app| {
|
||||
// Open the local store under the OS app-data directory. When the
|
||||
// app is launched from Finder/LaunchServices the working directory
|
||||
// is `/`, so a relative "gridline.db" path panics ("failed to
|
||||
// open db", exit 101) before the UI ever starts. The demo DB uses
|
||||
// the same directory (see commands/demo.rs).
|
||||
let data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("failed to resolve app data dir: {e}"))?;
|
||||
std::fs::create_dir_all(&data_dir)
|
||||
.map_err(|e| format!("failed to create app data dir: {e}"))?;
|
||||
let store =
|
||||
Store::open(&data_dir.join("gridline.db").to_string_lossy()).expect("failed to open db");
|
||||
|
||||
app.manage(AppState {
|
||||
db_store: StdMutex::new(store),
|
||||
pool_manager: tokio::sync::Mutex::new(ConnectionPoolManager::new()),
|
||||
ssh_manager: StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend))),
|
||||
cancel_registry: crate::cancel::CancelRegistry::default(),
|
||||
});
|
||||
|
||||
let state = app.state::<AppState>();
|
||||
demo::ensure_demo_db(app.handle(), &state.db_store)
|
||||
.map_err(|e| {
|
||||
@@ -69,6 +84,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 +123,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,
|
||||
@@ -123,7 +142,10 @@ pub fn run() {
|
||||
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,
|
||||
@@ -139,8 +161,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\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -197,6 +246,10 @@ pub enum Change {
|
||||
id: String,
|
||||
sql: String,
|
||||
},
|
||||
RebuildTable {
|
||||
id: String,
|
||||
sql: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Change {
|
||||
@@ -209,7 +262,8 @@ impl Change {
|
||||
| Change::BulkInsert { id, .. }
|
||||
| Change::DropTable { id, .. }
|
||||
| Change::EmptyTable { id, .. }
|
||||
| Change::Ddl { id, .. } => id,
|
||||
| Change::Ddl { id, .. }
|
||||
| Change::RebuildTable { id, .. } => id,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -619,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,
|
||||
}
|
||||
|
||||
@@ -575,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.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Gridline",
|
||||
"version": "0.7.6",
|
||||
"version": "0.7.9",
|
||||
"identifier": "com.adrianbonpin.gridline",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
@@ -26,7 +26,11 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"resources": ["resources/pg_tools/*"],
|
||||
"resources": ["resources/pg_tools/*", "resources/mysql_tools/*"],
|
||||
"macOS": {
|
||||
"signingIdentity": "-",
|
||||
"hardenedRuntime": false
|
||||
},
|
||||
"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,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,74 +100,179 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
||||
|
||||
useEffect(() => {
|
||||
setCheckingTools(true);
|
||||
detectPgTools()
|
||||
.then((status) => setToolStatus(status))
|
||||
.catch(() =>
|
||||
setToolStatus({
|
||||
pg_dump_found: false,
|
||||
pg_restore_found: false,
|
||||
pg_dump_version: null,
|
||||
pg_restore_version: null,
|
||||
pg_dump_source: null,
|
||||
pg_restore_source: null,
|
||||
}),
|
||||
)
|
||||
.finally(() => setCheckingTools(false));
|
||||
setPgToolStatus(null);
|
||||
setMysqlToolStatus(null);
|
||||
setAvailableSchemas([]);
|
||||
|
||||
getSchemas(connectionId)
|
||||
.then((schemas) => setAvailableSchemas(schemas))
|
||||
.catch(() => setAvailableSchemas([]));
|
||||
}, [connectionId]);
|
||||
if (isPg) {
|
||||
detectPgTools()
|
||||
.then((status) => setPgToolStatus(status))
|
||||
.catch(() =>
|
||||
setPgToolStatus({
|
||||
pg_dump_found: false,
|
||||
pg_restore_found: false,
|
||||
pg_dump_version: null,
|
||||
pg_restore_version: null,
|
||||
pg_dump_source: null,
|
||||
pg_restore_source: null,
|
||||
}),
|
||||
)
|
||||
.finally(() => setCheckingTools(false));
|
||||
|
||||
getSchemas(connectionId)
|
||||
.then((schemas) => setAvailableSchemas(schemas))
|
||||
.catch(() => setAvailableSchemas([]));
|
||||
} 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[]> = {
|
||||
plain: ["sql"],
|
||||
custom: ["dump", "custom"],
|
||||
tar: ["tar"],
|
||||
directory: [],
|
||||
};
|
||||
const picked = await save({
|
||||
defaultPath: `backup.${
|
||||
let defaultPath = "backup";
|
||||
let extensions: string[] = [];
|
||||
|
||||
if (isPg) {
|
||||
const pgExtensions: Record<BackupFormat, string[]> = {
|
||||
plain: ["sql"],
|
||||
custom: ["dump", "custom"],
|
||||
tar: ["tar"],
|
||||
directory: [],
|
||||
};
|
||||
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 runWithProgress = useCallback(
|
||||
async (type: BackupJob["type"], action: () => Promise<unknown>) => {
|
||||
const jobId = `${type}-${Date.now()}`;
|
||||
startJob(jobId, type);
|
||||
pendingJobRef.current = jobId;
|
||||
|
||||
try {
|
||||
// Command returns the job ID immediately — completion
|
||||
// comes via Tauri events handled by the backupStore
|
||||
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);
|
||||
}
|
||||
},
|
||||
[startJob],
|
||||
);
|
||||
|
||||
const handleStartBackup = useCallback(async () => {
|
||||
if (!filePath) {
|
||||
notify("Please select a file path", "error");
|
||||
return;
|
||||
}
|
||||
const jobId = `dump-${Date.now()}`;
|
||||
startJob(jobId, "dump");
|
||||
pendingJobRef.current = jobId;
|
||||
|
||||
try {
|
||||
// pgDump 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,
|
||||
});
|
||||
} 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);
|
||||
if (isMysql && !database) {
|
||||
notify("MySQL connection has no database selected", "error");
|
||||
return;
|
||||
}
|
||||
}, [filePath, format, schema, noOwner, connectionId, startJob, notify]);
|
||||
|
||||
const toolsMissing = toolStatus && !toolStatus.pg_dump_found;
|
||||
const toolsBundled = toolStatus?.pg_dump_source === "bundled";
|
||||
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,29 +321,40 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
||||
{/* Configuration card */}
|
||||
<div className="p-5 space-y-5">
|
||||
{/* Format */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Format
|
||||
</label>
|
||||
<select
|
||||
value={format}
|
||||
onChange={(e) =>
|
||||
setFormat(
|
||||
e.target.value as BackupFormat,
|
||||
)
|
||||
}
|
||||
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="custom">
|
||||
Custom Archive
|
||||
</option>
|
||||
<option value="plain">Plain SQL</option>
|
||||
<option value="tar">Tarball</option>
|
||||
<option value="directory">
|
||||
Directory
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
{isPg ? (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Format
|
||||
</label>
|
||||
<select
|
||||
value={format}
|
||||
onChange={(e) =>
|
||||
setFormat(
|
||||
e.target.value as BackupFormat,
|
||||
)
|
||||
}
|
||||
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="custom">
|
||||
Custom Archive
|
||||
</option>
|
||||
<option value="plain">Plain SQL</option>
|
||||
<option value="tar">Tarball</option>
|
||||
<option value="directory">
|
||||
Directory
|
||||
</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,46 +383,136 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
||||
</div>
|
||||
|
||||
{/* Schema (optional) */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Schema{" "}
|
||||
<span className="font-normal normal-case tracking-normal">
|
||||
(optional)
|
||||
{(isPg || isMysql) && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Schema{" "}
|
||||
<span className="font-normal normal-case tracking-normal">
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
value={schema}
|
||||
onChange={(e) =>
|
||||
setSchema(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"
|
||||
>
|
||||
<option value="">All schemas</option>
|
||||
{availableSchemas.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PostgreSQL: no-owner toggle */}
|
||||
{isPg && (
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={noOwner}
|
||||
onChange={(e) =>
|
||||
setNoOwner(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 Owner{" "}
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
||||
--no-owner
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
value={schema}
|
||||
onChange={(e) =>
|
||||
setSchema(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"
|
||||
>
|
||||
<option value="">All schemas</option>
|
||||
{availableSchemas.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No-owner toggle */}
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={noOwner}
|
||||
onChange={(e) =>
|
||||
setNoOwner(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 Owner{" "}
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
||||
--no-owner
|
||||
</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 */}
|
||||
@@ -304,5 +543,4 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -144,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({
|
||||
@@ -222,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,8 @@ 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:
|
||||
@@ -52,9 +55,11 @@ function capitalizeType(type: string) {
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
}
|
||||
|
||||
/** Badge label for a queue-item type — ddl renders uppercase to match its acronym. */
|
||||
/** Badge label for a queue-item type — ddl/rebuild render uppercase. */
|
||||
function badgeLabel(type: string): string {
|
||||
return type === "ddl" ? "DDL" : capitalizeType(type);
|
||||
if (type === "ddl") return "DDL";
|
||||
if (type === "rebuild_table") return "REBUILD";
|
||||
return capitalizeType(type);
|
||||
}
|
||||
|
||||
function tableRef(change: QueueItem): string {
|
||||
@@ -97,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);
|
||||
@@ -177,7 +187,14 @@ 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">
|
||||
<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">
|
||||
@@ -204,19 +221,32 @@ export function ChangesQueuePanel({ onCommitted }: { onCommitted?: () => void }
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-text-muted truncate">
|
||||
{formatChangeLabel(change)}
|
||||
</div>
|
||||
{formatValueDiff(change) && (
|
||||
<div className="mt-0.5 font-mono text-xs text-text">
|
||||
<span className="text-text-muted line-through">
|
||||
{formatValueDiff(change)!.split(" → ")[0]}
|
||||
</span>
|
||||
<span className="mx-1 text-text-muted">→</span>
|
||||
<span className="text-accent">
|
||||
{formatValueDiff(change)!.split(" → ")[1]}
|
||||
</span>
|
||||
</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>
|
||||
{formatValueDiff(change) && (
|
||||
<div className="mt-0.5 font-mono text-xs text-text">
|
||||
<span className="text-text-muted line-through">
|
||||
{formatValueDiff(change)!.split(" → ")[0]}
|
||||
</span>
|
||||
<span className="mx-1 text-text-muted">→</span>
|
||||
<span className="text-accent">
|
||||
{formatValueDiff(change)!.split(" → ")[1]}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
|
||||
@@ -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 })));
|
||||
@@ -27,6 +27,7 @@ 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";
|
||||
@@ -184,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);
|
||||
@@ -230,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(
|
||||
@@ -1028,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
|
||||
@@ -1439,6 +1455,7 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
setCurrentSchema={setCurrentSchema}
|
||||
onEdit={() => setEditModalOpen(true)}
|
||||
connectionId={connectionId}
|
||||
dbType={currentConnection?.db_type}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
/>
|
||||
@@ -1446,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 */}
|
||||
|
||||
@@ -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>
|
||||
<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"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{showCreateTable && (
|
||||
<Tooltip content="Create Table" side="bottom">
|
||||
<button
|
||||
aria-label="Create Table"
|
||||
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"
|
||||
|
||||
@@ -351,6 +351,39 @@ describe("ObjectExplorerPage", () => {
|
||||
).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" />);
|
||||
|
||||
@@ -59,7 +59,8 @@ function itemLabel(item: AnyObject): string {
|
||||
}
|
||||
|
||||
function schemaOf(item: AnyObject): string {
|
||||
return item.schema;
|
||||
// Roles are cluster-scoped and carry no schema — default to "".
|
||||
return (item as { schema?: string }).schema ?? "";
|
||||
}
|
||||
|
||||
function objectName(item: AnyObject): string {
|
||||
@@ -76,6 +77,7 @@ function typeToDdlType(type: ObjectType): string {
|
||||
extensions: "extension",
|
||||
indexes: "index",
|
||||
constraints: "constraint",
|
||||
roles: "role",
|
||||
};
|
||||
return map[type];
|
||||
}
|
||||
@@ -149,7 +151,10 @@ export function ObjectExplorerPage({
|
||||
setError(null);
|
||||
try {
|
||||
let result: AnyObject[];
|
||||
if (type === "extensions") {
|
||||
if (type === "roles") {
|
||||
// Roles are cluster-scoped — no schema filter.
|
||||
result = await cmd.getRoles(connectionId);
|
||||
} else if (type === "extensions") {
|
||||
result = await cmd.getExtensions(connectionId);
|
||||
} else if (type === "functions") {
|
||||
result = (await cmd.getFunctions(
|
||||
@@ -201,8 +206,14 @@ export function ObjectExplorerPage({
|
||||
}, [type, connectionId, currentSchema]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only re-fetch if schema actually changed (or first load)
|
||||
if (lastSchemaRef.current !== (currentSchema ?? undefined)) {
|
||||
// Only re-fetch if schema actually changed (or first load). Roles are
|
||||
// cluster-scoped — skip the refetch when the schema changes (the mount
|
||||
// fetch still runs via lastSchemaRef === undefined).
|
||||
const schema = currentSchema ?? undefined;
|
||||
const schemaChanged = lastSchemaRef.current !== schema;
|
||||
const skipForRoles =
|
||||
schemaChanged && type === "roles" && lastSchemaRef.current !== undefined;
|
||||
if (schemaChanged && !skipForRoles) {
|
||||
fetch();
|
||||
}
|
||||
}, [currentSchema, fetch]);
|
||||
@@ -488,7 +499,7 @@ export function ObjectExplorerPage({
|
||||
sidebarMode
|
||||
? openObjectTab(
|
||||
type,
|
||||
item.schema,
|
||||
schemaOf(item),
|
||||
item.name,
|
||||
item,
|
||||
)
|
||||
@@ -517,7 +528,10 @@ export function ObjectExplorerPage({
|
||||
objectType={
|
||||
typeToDdlType(type) as ObjectKind
|
||||
}
|
||||
item={item}
|
||||
item={{
|
||||
...item,
|
||||
schema: schemaOf(item),
|
||||
}}
|
||||
onRefresh={fetch}
|
||||
open={openKey === key}
|
||||
onOpenChange={(o) =>
|
||||
|
||||
@@ -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,64 +92,152 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
useEffect(() => {
|
||||
setCheckingTools(true);
|
||||
setConfirmed(false);
|
||||
detectPgTools()
|
||||
.then((status) => setToolStatus(status))
|
||||
.catch(() =>
|
||||
setToolStatus({
|
||||
pg_dump_found: false,
|
||||
pg_restore_found: false,
|
||||
pg_dump_version: null,
|
||||
pg_restore_version: null,
|
||||
pg_dump_source: null,
|
||||
pg_restore_source: null,
|
||||
}),
|
||||
)
|
||||
.finally(() => setCheckingTools(false));
|
||||
setPgToolStatus(null);
|
||||
setMysqlToolStatus(null);
|
||||
setAvailableSchemas([]);
|
||||
|
||||
getSchemas(connectionId)
|
||||
.then((schemas) => setAvailableSchemas(schemas))
|
||||
.catch(() => setAvailableSchemas([]));
|
||||
}, [connectionId]);
|
||||
if (isPg) {
|
||||
detectPgTools()
|
||||
.then((status) => setPgToolStatus(status))
|
||||
.catch(() =>
|
||||
setPgToolStatus({
|
||||
pg_dump_found: false,
|
||||
pg_restore_found: false,
|
||||
pg_dump_version: null,
|
||||
pg_restore_version: null,
|
||||
pg_dump_source: null,
|
||||
pg_restore_source: null,
|
||||
}),
|
||||
)
|
||||
.finally(() => setCheckingTools(false));
|
||||
|
||||
getSchemas(connectionId)
|
||||
.then((schemas) => setAvailableSchemas(schemas))
|
||||
.catch(() => setAvailableSchemas([]));
|
||||
} 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;
|
||||
|
||||
try {
|
||||
await pgRestore(connectionId, {
|
||||
format,
|
||||
filePath,
|
||||
clean,
|
||||
schema: schema || undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
useBackupStore.getState().failJob(jobId, msg);
|
||||
if (isMysql && !database) {
|
||||
notify("MySQL connection has no database selected", "error");
|
||||
return;
|
||||
}
|
||||
}, [filePath, format, clean, schema, connectionId, startJob, notify]);
|
||||
|
||||
const toolsMissing = toolStatus && !toolStatus.pg_restore_found;
|
||||
const toolsBundled = toolStatus?.pg_restore_source === "bundled";
|
||||
await runWithProgress("restore", () => {
|
||||
if (isPg) {
|
||||
return pgRestore(connectionId, {
|
||||
format,
|
||||
filePath,
|
||||
clean,
|
||||
schema: schema || undefined,
|
||||
});
|
||||
}
|
||||
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 = 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,28 +284,30 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
<>
|
||||
{/* Configuration card */}
|
||||
<div className="p-5 space-y-5">
|
||||
{/* Format */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Format
|
||||
</label>
|
||||
<select
|
||||
value={format}
|
||||
onChange={(e) =>
|
||||
setFormat(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"
|
||||
>
|
||||
<option value="custom">
|
||||
Custom Archive
|
||||
</option>
|
||||
<option value="plain">Plain SQL</option>
|
||||
<option value="tar">Tarball</option>
|
||||
<option value="directory">
|
||||
Directory
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* Format (PostgreSQL only) */}
|
||||
{isPg && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Format
|
||||
</label>
|
||||
<select
|
||||
value={format}
|
||||
onChange={(e) =>
|
||||
setFormat(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"
|
||||
>
|
||||
<option value="custom">
|
||||
Custom Archive
|
||||
</option>
|
||||
<option value="plain">Plain SQL</option>
|
||||
<option value="tar">Tarball</option>
|
||||
<option value="directory">
|
||||
Directory
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backup file */}
|
||||
<div className="space-y-1 w-full">
|
||||
@@ -218,54 +336,58 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
</div>
|
||||
|
||||
{/* Schema (optional) */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Schema{" "}
|
||||
<span className="font-normal normal-case tracking-normal">
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
value={schema}
|
||||
onChange={(e) =>
|
||||
setSchema(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"
|
||||
>
|
||||
<option value="">All schemas</option>
|
||||
{availableSchemas.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{(isPg || isMysql) && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Schema{" "}
|
||||
<span className="font-normal normal-case tracking-normal">
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
value={schema}
|
||||
onChange={(e) =>
|
||||
setSchema(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"
|
||||
>
|
||||
<option value="">All schemas</option>
|
||||
{availableSchemas.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clean toggle */}
|
||||
<label
|
||||
className={`flex items-center gap-2.5 cursor-pointer group ${
|
||||
format === "plain"
|
||||
? "opacity-40 pointer-events-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={clean}
|
||||
onChange={(e) =>
|
||||
setClean(e.target.checked)
|
||||
}
|
||||
disabled={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">
|
||||
Clean{" "}
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
||||
DROP before CREATE
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
{format === "plain" && (
|
||||
{(isPg || isMysql || isSqlite) && (
|
||||
<label
|
||||
className={`flex items-center gap-2.5 cursor-pointer group ${
|
||||
isPg && format === "plain"
|
||||
? "opacity-40 pointer-events-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={clean}
|
||||
onChange={(e) =>
|
||||
setClean(e.target.checked)
|
||||
}
|
||||
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">
|
||||
Clean{" "}
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
||||
DROP before CREATE
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
{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
|
||||
@@ -324,5 +446,4 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
</div>
|
||||
</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,23 +58,44 @@ 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);
|
||||
detectPgTools()
|
||||
.then((status) => setToolStatus(status))
|
||||
.catch(() =>
|
||||
setToolStatus({
|
||||
pg_dump_found: false,
|
||||
pg_restore_found: false,
|
||||
pg_dump_version: null,
|
||||
pg_restore_version: null,
|
||||
pg_dump_source: null,
|
||||
pg_restore_source: null,
|
||||
}),
|
||||
)
|
||||
.finally(() => setCheckingTools(false));
|
||||
}, []);
|
||||
|
||||
if (isPg) {
|
||||
setCheckingTools(true);
|
||||
detectPgTools()
|
||||
.then((status) => setPgToolStatus(status))
|
||||
.catch(() =>
|
||||
setPgToolStatus({
|
||||
pg_dump_found: false,
|
||||
pg_restore_found: false,
|
||||
pg_dump_version: null,
|
||||
pg_restore_version: null,
|
||||
pg_dump_source: null,
|
||||
pg_restore_source: null,
|
||||
}),
|
||||
)
|
||||
.finally(() => setCheckingTools(false));
|
||||
} 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;
|
||||
|
||||
try {
|
||||
await dbSync({
|
||||
sourceConnectionId,
|
||||
targetConnectionId,
|
||||
schema: schema || undefined,
|
||||
tables: undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
useBackupStore.getState().failJob(jobId, msg);
|
||||
if (!dbType) {
|
||||
notify("Unable to determine database type for sync", "error");
|
||||
return;
|
||||
}
|
||||
}, [sourceConnectionId, targetConnectionId, schema, startJob, notify]);
|
||||
|
||||
const toolsMissing =
|
||||
toolStatus &&
|
||||
(!toolStatus.pg_dump_found || !toolStatus.pg_restore_found);
|
||||
const toolsBundled =
|
||||
toolStatus?.pg_dump_source === "bundled" &&
|
||||
toolStatus?.pg_restore_source === "bundled";
|
||||
const options = {
|
||||
sourceConnectionId,
|
||||
targetConnectionId,
|
||||
schema: schema || undefined,
|
||||
tables: undefined,
|
||||
dbType,
|
||||
};
|
||||
|
||||
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,39 +316,41 @@ export function SyncPage() {
|
||||
</div>
|
||||
|
||||
{/* Schema (optional) */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Schema{" "}
|
||||
<span className="font-normal normal-case tracking-normal">
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
value={schema}
|
||||
onChange={(e) =>
|
||||
setSchema(e.target.value)
|
||||
}
|
||||
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="">
|
||||
{sourceConnectionId
|
||||
? "All schemas"
|
||||
: "Select a source first"}
|
||||
</option>
|
||||
{availableSchemas.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
{(isPg || isMysql) && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Schema{" "}
|
||||
<span className="font-normal normal-case tracking-normal">
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
value={schema}
|
||||
onChange={(e) =>
|
||||
setSchema(e.target.value)
|
||||
}
|
||||
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="">
|
||||
{sourceConnectionId
|
||||
? "All schemas"
|
||||
: "Select a source first"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{availableSchemas.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</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}
|
||||
@@ -328,5 +420,4 @@ export function SyncPage() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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) => {
|
||||
exportData(rows, columns, format, table);
|
||||
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);
|
||||
};
|
||||
|
||||
|
||||
@@ -56,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 () => {
|
||||
@@ -117,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,14 +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 { initialCrudParams } from "../../lib/objectCrud";
|
||||
import type { ColumnInfo, DependencyInfo } from "../../lib/types";
|
||||
import type { ColumnInfo, DependencyInfo, MaintenanceResult } from "../../lib/types";
|
||||
|
||||
interface TableOverflowMenuProps {
|
||||
schema: string;
|
||||
@@ -17,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({
|
||||
@@ -32,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(() => {
|
||||
@@ -63,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":
|
||||
@@ -85,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-", "");
|
||||
if (rows && rows.length > 0 && columns && columns.length > 0) {
|
||||
exportData(rows, columns, format, `${schema}.${table}`);
|
||||
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;
|
||||
@@ -97,6 +182,34 @@ 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({
|
||||
@@ -123,6 +236,12 @@ export function TableOverflowMenu({
|
||||
});
|
||||
setOpen(false);
|
||||
break;
|
||||
case "vacuum":
|
||||
case "analyze":
|
||||
case "reindex":
|
||||
setMaintenance({ action: id, lockCopy: maintenanceLockCopy[id] });
|
||||
setOpen(false);
|
||||
break;
|
||||
case "empty":
|
||||
setConfirmAction("empty");
|
||||
setOpen(false);
|
||||
@@ -151,9 +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 },
|
||||
];
|
||||
@@ -169,22 +298,69 @@ 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) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => handleAction(item.id)}
|
||||
className={[
|
||||
"flex items-center justify-between px-3 py-2 text-sm w-full text-left transition-colors cursor-pointer",
|
||||
item.danger ? "text-red-400 hover:bg-red-500/10 hover:text-red-300" : "text-text-muted hover:text-text hover:bg-surface-raised",
|
||||
].join(" ")}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
{items.map((item) =>
|
||||
item.divider ? (
|
||||
<div key={item.id} className="border-t border-border my-1" />
|
||||
) : (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => handleAction(item.id)}
|
||||
className={[
|
||||
"flex items-center justify-between px-3 py-2 text-sm w-full text-left transition-colors cursor-pointer",
|
||||
item.danger ? "text-red-400 hover:bg-red-500/10 hover:text-red-300" : "text-text-muted hover:text-text hover:bg-surface-raised",
|
||||
].join(" ")}
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
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";
|
||||
|
||||
vi.mock("../../../lib/commands", () => ({ getSchemaGraph: vi.fn() }));
|
||||
beforeEach(() => {
|
||||
vi.spyOn(cmd, "getSchemaGraph").mockReset().mockResolvedValue({ tables: [], relationships: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const graph = {
|
||||
tables: [
|
||||
@@ -44,12 +50,8 @@ const graph = {
|
||||
};
|
||||
|
||||
describe("ConstraintForm", () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(cmd.getSchemaGraph).mockReset();
|
||||
});
|
||||
|
||||
it("check: emits the expression", () => {
|
||||
vi.mocked(cmd.getSchemaGraph).mockResolvedValue(graph as any);
|
||||
(cmd.getSchemaGraph as any).mockResolvedValue(graph as any);
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<ConstraintForm
|
||||
@@ -74,7 +76,7 @@ describe("ConstraintForm", () => {
|
||||
});
|
||||
|
||||
it("foreign_key: picks referenced table + column", async () => {
|
||||
vi.mocked(cmd.getSchemaGraph).mockResolvedValue(graph as any);
|
||||
(cmd.getSchemaGraph as any).mockResolvedValue(graph as any);
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<ConstraintForm
|
||||
@@ -97,8 +99,8 @@ describe("ConstraintForm", () => {
|
||||
const kindSelect = screen.getByLabelText("Kind");
|
||||
fireEvent.change(kindSelect, { target: { value: "foreign_key" } });
|
||||
await screen.findByText("user_id");
|
||||
const refTable = await screen.findByPlaceholderText("Referenced table");
|
||||
fireEvent.change(refTable, { target: { value: "users" } });
|
||||
const refTable = await screen.findByLabelText("Referenced table");
|
||||
fireEvent.change(refTable, { target: { value: "public.users" } });
|
||||
expect(onChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
action: expect.objectContaining({
|
||||
@@ -108,4 +110,59 @@ describe("ConstraintForm", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,12 +13,23 @@ interface Props {
|
||||
}
|
||||
|
||||
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>;
|
||||
@@ -28,10 +39,10 @@ export function ConstraintForm({ connectionId, params, schemas, onChange }: Prop
|
||||
|
||||
useEffect(() => {
|
||||
cmd
|
||||
.getSchemaGraph(connectionId, (p.schema as string) || undefined)
|
||||
.getSchemaGraph(connectionId, undefined)
|
||||
.then((g: SchemaGraph) => setGraph(g))
|
||||
.catch(() => setGraph({ tables: [], relationships: [] }));
|
||||
}, [connectionId, p.schema]);
|
||||
}, [connectionId]);
|
||||
|
||||
const setAction = (patch: Record<string, unknown>) => onChange(patchAction(params, patch));
|
||||
|
||||
@@ -147,20 +158,43 @@ export function ConstraintForm({ connectionId, params, schemas, onChange }: Prop
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormRow label="Referenced table">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Referenced table"
|
||||
value={(action.ref_table as string) ?? ""}
|
||||
<select
|
||||
aria-label="Referenced table"
|
||||
value={refKey(
|
||||
(action.ref_schema as string) ?? "",
|
||||
(action.ref_table as string) ?? "",
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const t = graph.tables.find((t) => t.name === e.target.value);
|
||||
const { schema, table } = parseRefKey(e.target.value);
|
||||
const t = graph.tables.find(
|
||||
(tbl) => tbl.name === table && tbl.schema === schema,
|
||||
);
|
||||
setAction({
|
||||
ref_table: e.target.value,
|
||||
ref_schema: t?.schema ?? "",
|
||||
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={inputClass}
|
||||
/>
|
||||
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">
|
||||
@@ -171,6 +205,65 @@ export function ConstraintForm({ connectionId, params, schemas, onChange }: Prop
|
||||
/>
|
||||
</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,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>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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 {
|
||||
@@ -64,5 +65,11 @@ export function KindForm({ connectionId, kind, params, schemas, onChange }: Prop
|
||||
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} />;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,20 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { ObjectContextMenu } from "./ObjectContextMenu";
|
||||
import { ObjectContextMenu, DROP_TITLE } from "./ObjectContextMenu";
|
||||
import * as objectCrud from "../../../lib/objectCrud";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
|
||||
vi.mock("../../../lib/objectCrud", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../lib/objectCrud")>();
|
||||
return {
|
||||
...actual,
|
||||
buildObjectDdl: vi.fn(),
|
||||
getAvailableExtensions: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe("ObjectContextMenu", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useDbViewerStore.getState().reset();
|
||||
});
|
||||
|
||||
it("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
|
||||
@@ -59,7 +55,7 @@ describe("ObjectContextMenu", () => {
|
||||
});
|
||||
|
||||
it("Drop fetches dependencies then stages the drop", async () => {
|
||||
vi.mocked(objectCrud.buildObjectDdl).mockResolvedValue([
|
||||
vi.spyOn(objectCrud, "buildObjectDdl").mockResolvedValue([
|
||||
'DROP SEQUENCE "public"."s"',
|
||||
]);
|
||||
const addChange = vi.spyOn(useDbViewerStore.getState(), "addChange");
|
||||
|
||||
@@ -23,7 +23,7 @@ interface Props {
|
||||
extraItems?: { id: string; label: string; danger?: boolean; onClick: () => void }[];
|
||||
}
|
||||
|
||||
const DROP_TITLE: Record<ObjectKind, string> = {
|
||||
export const DROP_TITLE: Record<ObjectKind, string> = {
|
||||
sequence: "sequence",
|
||||
enum: "type",
|
||||
view: "view",
|
||||
@@ -33,6 +33,8 @@ const DROP_TITLE: Record<ObjectKind, string> = {
|
||||
function: "function",
|
||||
procedure: "procedure",
|
||||
trigger: "trigger",
|
||||
table: "table",
|
||||
role: "role",
|
||||
};
|
||||
|
||||
export function ObjectContextMenu({
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ObjectDetail } from "./ObjectDetail";
|
||||
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", () => {
|
||||
@@ -36,4 +42,34 @@ describe("ObjectDetail", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ListChecks,
|
||||
ListOrdered,
|
||||
Puzzle,
|
||||
ShieldCheck,
|
||||
SquareFunction,
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
@@ -18,7 +19,9 @@ import type {
|
||||
ExtensionInfo,
|
||||
IndexInfo,
|
||||
ConstraintInfo,
|
||||
RoleInfo,
|
||||
} from "../../../lib/types";
|
||||
import { RoleDetail } from "./RoleDetail";
|
||||
|
||||
export const TYPE_LABELS: Record<ObjectType, string> = {
|
||||
functions: "Functions",
|
||||
@@ -29,6 +32,7 @@ export const TYPE_LABELS: Record<ObjectType, string> = {
|
||||
indexes: "Indexes",
|
||||
constraints: "Constraints",
|
||||
procedures: "Procedures",
|
||||
roles: "Roles",
|
||||
};
|
||||
|
||||
export const SINGULAR_LABELS: Record<ObjectType, string> = {
|
||||
@@ -40,6 +44,7 @@ export const SINGULAR_LABELS: Record<ObjectType, string> = {
|
||||
indexes: "index",
|
||||
constraints: "constraint",
|
||||
procedures: "procedure",
|
||||
roles: "role",
|
||||
};
|
||||
|
||||
export const OBJECT_ICONS: Record<ObjectType, React.ReactNode> = {
|
||||
@@ -53,6 +58,7 @@ export const OBJECT_ICONS: Record<ObjectType, React.ReactNode> = {
|
||||
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 =
|
||||
@@ -62,7 +68,8 @@ export type AnyObject =
|
||||
| EnumInfo
|
||||
| ExtensionInfo
|
||||
| IndexInfo
|
||||
| ConstraintInfo;
|
||||
| ConstraintInfo
|
||||
| RoleInfo;
|
||||
|
||||
// ─── syntax highlighting for PL/pgSQL / SQL ──────────────
|
||||
|
||||
@@ -562,8 +569,10 @@ function renderFunctionDetail(f: FunctionInfo) {
|
||||
);
|
||||
}
|
||||
|
||||
function renderDetail(type: ObjectType, item: AnyObject) {
|
||||
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":
|
||||
@@ -969,6 +978,6 @@ interface ObjectDetailProps {
|
||||
item: AnyObject;
|
||||
}
|
||||
|
||||
export function ObjectDetail({ connectionId: _connectionId, type, item }: ObjectDetailProps) {
|
||||
return <>{renderDetail(type, item)}</>;
|
||||
export function ObjectDetail({ connectionId, type, item }: ObjectDetailProps) {
|
||||
return <>{renderDetail(connectionId, type, item)}</>;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { KindForm } from "./KindForm";
|
||||
import { TableForm } from "./TableForm";
|
||||
import {
|
||||
buildObjectDdl,
|
||||
type DdlParams,
|
||||
@@ -19,6 +20,10 @@ 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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -75,7 +75,7 @@ describe("SequenceForm", () => {
|
||||
const valueArea = input.parentElement;
|
||||
expect(valueArea).not.toBeNull();
|
||||
expect(valueArea!.className).toContain("focus-within:outline");
|
||||
expect(valueArea!.className).toContain("focus-within:outline-amber-400");
|
||||
expect(valueArea!.className).toContain("focus-within:outline-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
|
||||
|
||||
@@ -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
@@ -32,7 +32,7 @@ export function FormRow({ label, children, className, outline = true }: FormRowP
|
||||
className={[
|
||||
"flex-1 min-w-0 flex flex-row items-center",
|
||||
outline
|
||||
? "focus-within:outline focus-within:outline-2 focus-within:outline-amber-400 focus-within:outline-offset-[-2px]"
|
||||
? "focus-within:outline focus-within:outline-2 focus-within:outline-amber-400/20 focus-within:outline-offset-[-2px]"
|
||||
: "",
|
||||
].join(" ")}
|
||||
>
|
||||
|
||||
@@ -78,6 +78,32 @@ describe("QueryToolbar", () => {
|
||||
useDbViewerStore.getState().reset();
|
||||
});
|
||||
|
||||
const baseProps = {
|
||||
onRun: () => {},
|
||||
onFormat: () => {},
|
||||
connectionId: "conn-1",
|
||||
onRestore: () => {},
|
||||
onRunFromHistory: () => {},
|
||||
};
|
||||
|
||||
it("shows a Cancel button when isRunning is true", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<QueryToolbar {...baseProps} isRunning={true} onCancel={() => {}} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /cancel/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides the Cancel button when not running", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<QueryToolbar {...baseProps} isRunning={false} onCancel={() => {}} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: /cancel/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("renders Run Query and the format icon button", () => {
|
||||
renderToolbar({});
|
||||
expect(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Play, Wand2, Save } from "lucide-react";
|
||||
import { Play, Square, Wand2, Save } from "lucide-react";
|
||||
import { Tooltip } from "../ui/Tooltip";
|
||||
import { QueryHistoryDropdown } from "./QueryHistoryDropdown";
|
||||
import { SaveQueryDialog } from "./SaveQueryDialog";
|
||||
@@ -36,6 +36,8 @@ interface QueryToolbarProps {
|
||||
onRunFromHistory: (sql: string) => void;
|
||||
dbType?: DbType;
|
||||
readOnly?: boolean;
|
||||
isRunning?: boolean;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export function QueryToolbar({
|
||||
@@ -46,10 +48,13 @@ export function QueryToolbar({
|
||||
onRunFromHistory,
|
||||
dbType,
|
||||
readOnly = false,
|
||||
isRunning: isRunningProp,
|
||||
onCancel,
|
||||
}: QueryToolbarProps) {
|
||||
const tabs = useDbViewerStore((s) => s.tabs);
|
||||
const activeTabId = useDbViewerStore((s) => s.activeTabId);
|
||||
const isRunning = tabs.find((t) => t.id === activeTabId)?.loading ?? false;
|
||||
const isRunning =
|
||||
isRunningProp ?? tabs.find((t) => t.id === activeTabId)?.loading ?? false;
|
||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId);
|
||||
@@ -100,6 +105,21 @@ export function QueryToolbar({
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Cancel Query */}
|
||||
{isRunning && onCancel && (
|
||||
<Tooltip content="Cancel running query" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex items-center gap-1.5 rounded-md border border-red-500/50 bg-red-500/10 px-2.5 py-1 font-medium text-red-400 transition-colors hover:bg-red-500/20 cursor-pointer"
|
||||
aria-label="Cancel query"
|
||||
>
|
||||
<Square className="h-3 w-3 fill-current" />
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* History dropdown */}
|
||||
<QueryHistoryDropdown
|
||||
connectionId={connectionId}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { GeneralSettingsTab } from "./GeneralSettingsTab";
|
||||
import { useSettingsStore } from "../../stores/settingsStore";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import * as commands from "../../lib/commands";
|
||||
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
save: vi.fn(),
|
||||
open: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-fs", () => ({
|
||||
readTextFile: vi.fn(),
|
||||
writeTextFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/commands", () => ({
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
theme: "system",
|
||||
font_size: "medium",
|
||||
default_folder_id: null,
|
||||
confirm_before_delete: true,
|
||||
default_ports: { postgresql: 5432, mysql: 3306, sqlite: null, redis: 6379 },
|
||||
tag_order: null,
|
||||
table_refresh_rate: 0,
|
||||
table_page_size: 50,
|
||||
shortcuts: {},
|
||||
accent_color: "#2563EB",
|
||||
editor_font_size: 13,
|
||||
editor_font_family: "Space Mono",
|
||||
editor_word_wrap: "off",
|
||||
editor_minimap: false,
|
||||
editor_tab_size: 4,
|
||||
}),
|
||||
updateSetting: vi.fn().mockResolvedValue(undefined),
|
||||
exportSettings: vi.fn().mockResolvedValue('{"schemaVersion":1,"settings":{}}'),
|
||||
importSettings: vi.fn().mockResolvedValue(undefined),
|
||||
recreateDemoDb: vi.fn().mockResolvedValue("Demo re-added"),
|
||||
regenerateDemoDb: vi.fn().mockResolvedValue("Demo regenerated"),
|
||||
}));
|
||||
|
||||
const baseSettings = {
|
||||
theme: "system" as const,
|
||||
font_size: "medium" as const,
|
||||
default_folder_id: null,
|
||||
confirm_before_delete: true,
|
||||
default_ports: { postgresql: 5432, mysql: 3306, sqlite: null as number | null, redis: 6379 },
|
||||
tag_order: null,
|
||||
table_refresh_rate: 0,
|
||||
table_page_size: 50,
|
||||
shortcuts: {} as Record<string, string>,
|
||||
accent_color: "#2563EB",
|
||||
editor_font_size: 13,
|
||||
editor_font_family: "Space Mono",
|
||||
editor_word_wrap: "off" as const,
|
||||
editor_minimap: false,
|
||||
editor_tab_size: 4,
|
||||
};
|
||||
|
||||
describe("GeneralSettingsTab settings export/import", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useSettingsStore.setState({ settings: baseSettings, loading: false, error: null });
|
||||
useConnectionStore.setState({
|
||||
connections: [],
|
||||
folders: [],
|
||||
tags: [],
|
||||
tagOrder: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
useNotificationStore.setState({ notifications: [] });
|
||||
});
|
||||
|
||||
it("renders Export Settings and Import Settings buttons", () => {
|
||||
render(<GeneralSettingsTab />);
|
||||
expect(screen.getByRole("button", { name: /export settings/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /import settings/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("exports settings to the chosen file and shows a success toast", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const exportedJson = JSON.stringify({ schemaVersion: 1, settings: baseSettings });
|
||||
(commands.exportSettings as ReturnType<typeof vi.fn>).mockResolvedValue(exportedJson);
|
||||
(save as ReturnType<typeof vi.fn>).mockResolvedValue("/tmp/gridline-settings.json");
|
||||
(writeTextFile as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
|
||||
render(<GeneralSettingsTab />);
|
||||
await user.click(screen.getByRole("button", { name: /export settings/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(writeTextFile).toHaveBeenCalledWith("/tmp/gridline-settings.json", exportedJson);
|
||||
});
|
||||
expect(useNotificationStore.getState().notifications).toContainEqual(
|
||||
expect.objectContaining({ type: "success", message: expect.stringMatching(/exported/i) })
|
||||
);
|
||||
});
|
||||
|
||||
it("imports settings from the chosen file, reloads settings, and shows a success toast", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const { readTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const importedJson = JSON.stringify({ schemaVersion: 1, settings: baseSettings });
|
||||
(open as ReturnType<typeof vi.fn>).mockResolvedValue("/tmp/gridline-settings.json");
|
||||
(readTextFile as ReturnType<typeof vi.fn>).mockResolvedValue(importedJson);
|
||||
|
||||
render(<GeneralSettingsTab />);
|
||||
await user.click(screen.getByRole("button", { name: /import settings/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(commands.importSettings).toHaveBeenCalledWith(importedJson);
|
||||
});
|
||||
expect(useNotificationStore.getState().notifications).toContainEqual(
|
||||
expect.objectContaining({ type: "success", message: expect.stringMatching(/imported/i) })
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces an error toast when importing an invalid settings file", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const { readTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const invalidJson = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
settings: { ...baseSettings, theme: "purple" },
|
||||
});
|
||||
(open as ReturnType<typeof vi.fn>).mockResolvedValue("/tmp/bad.json");
|
||||
(readTextFile as ReturnType<typeof vi.fn>).mockResolvedValue(invalidJson);
|
||||
|
||||
render(<GeneralSettingsTab />);
|
||||
await user.click(screen.getByRole("button", { name: /import settings/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useNotificationStore.getState().notifications).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "error",
|
||||
message: expect.stringMatching(/invalid settings/i),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces an error toast when importing malformed JSON", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const { readTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
(open as ReturnType<typeof vi.fn>).mockResolvedValue("/tmp/bad.json");
|
||||
(readTextFile as ReturnType<typeof vi.fn>).mockResolvedValue("not-json");
|
||||
|
||||
render(<GeneralSettingsTab />);
|
||||
await user.click(screen.getByRole("button", { name: /import settings/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useNotificationStore.getState().notifications).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "error",
|
||||
message: expect.stringMatching(/invalid settings/i),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,10 @@ import { AccentPicker } from "../ui/AccentPicker";
|
||||
import { SettingsRow } from "../ui/SettingsRow";
|
||||
import { ConfirmDialog } from "../ui/ConfirmDialog";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import { validateSettingsExport } from "../../lib/settingsImport";
|
||||
import type { FontSize } from "../../lib/types";
|
||||
import { useState } from "react";
|
||||
import { save, open } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
const FONT_SIZE_OPTIONS: { value: FontSize; label: string }[] = [
|
||||
{ value: "small", label: "Small" },
|
||||
@@ -72,6 +74,53 @@ export function GeneralSettingsTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const json = await cmd.exportSettings();
|
||||
const path = await save({
|
||||
defaultPath: "gridline-settings.json",
|
||||
filters: [{ name: "JSON", extensions: ["json"] }],
|
||||
});
|
||||
if (!path) return;
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
await writeTextFile(path, json);
|
||||
notify("Settings exported", "success");
|
||||
} catch (e) {
|
||||
notify(e instanceof Error ? e.message : String(e), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
try {
|
||||
const p = await open({
|
||||
filters: [{ name: "JSON", extensions: ["json"] }],
|
||||
});
|
||||
if (!p || Array.isArray(p)) return;
|
||||
const { readTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const text = await readTextFile(p);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
notify("Invalid settings: file is not valid JSON", "error");
|
||||
return;
|
||||
}
|
||||
const r = validateSettingsExport(parsed);
|
||||
if (!r.ok) {
|
||||
notify(
|
||||
`Invalid settings: ${r.errors.map((e) => e.field).join(", ")}`,
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
await cmd.importSettings(text);
|
||||
await load();
|
||||
notify("Settings imported", "success");
|
||||
} catch (e) {
|
||||
notify(e instanceof Error ? e.message : String(e), "error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
@@ -148,6 +197,36 @@ export function GeneralSettingsTab() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-sm font-medium text-text mb-3">Data</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
<SettingsRow
|
||||
title="Export settings"
|
||||
description="Save your settings to a JSON file."
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
className="rounded-lg border border-border px-3 py-1.5 text-sm text-text hover:bg-surface-raised transition-colors"
|
||||
>
|
||||
Export settings
|
||||
</button>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title="Import settings"
|
||||
description="Restore settings from a previously exported JSON file."
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImport}
|
||||
className="rounded-lg border border-border px-3 py-1.5 text-sm text-text hover:bg-surface-raised transition-colors"
|
||||
>
|
||||
Import settings
|
||||
</button>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-sm font-medium text-text mb-3">Demo</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import tauriConf from "../../src-tauri/tauri.conf.json";
|
||||
|
||||
describe("tauri bundle config (v0.7.6)", () => {
|
||||
describe("tauri bundle config (v0.7.9)", () => {
|
||||
it("declares bundled pg_tools resources", () => {
|
||||
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
|
||||
});
|
||||
it("version is 0.7.6", () => {
|
||||
expect(tauriConf.version).toBe("0.7.6");
|
||||
it("version is 0.7.9", () => {
|
||||
expect(tauriConf.version).toBe("0.7.9");
|
||||
});
|
||||
});
|
||||
@@ -63,6 +63,23 @@ describe("buildChangeSql", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function rebuildItem(sql: string): QueueItem {
|
||||
return {
|
||||
id: "ch-rb1", type: "rebuild_table", sql, status: "pending",
|
||||
createdAt: 0,
|
||||
} as unknown as QueueItem;
|
||||
}
|
||||
|
||||
describe("rebuild_table payload", () => {
|
||||
it("builds a rebuild_table payload with the script", () => {
|
||||
const p = buildChangePayload(rebuildItem("CREATE TABLE _t();"));
|
||||
expect(p).toEqual({ id: "ch-rb1", type: "rebuild_table", sql: "CREATE TABLE _t();" });
|
||||
});
|
||||
it("buildChangeSql returns the script verbatim", () => {
|
||||
expect(buildChangeSql(rebuildItem("SELECT 1;"))).toBe("SELECT 1;");
|
||||
});
|
||||
});
|
||||
|
||||
const ddlItem: QueueItem = {
|
||||
id: "ch-1", type: "ddl",
|
||||
sql: "CREATE TYPE public.role AS ENUM ('admin')",
|
||||
|
||||
@@ -59,6 +59,8 @@ export function buildChangeSql(item: QueueItem): string {
|
||||
return `DELETE FROM ${t}`;
|
||||
case "ddl":
|
||||
return item.sql ?? "";
|
||||
case "rebuild_table":
|
||||
return item.sql ?? "";
|
||||
case "drop_table":
|
||||
return `DROP TABLE ${t}`;
|
||||
default:
|
||||
@@ -88,6 +90,8 @@ export function buildChangePayload(item: QueueItem): ChangePayload {
|
||||
return { id: item.id, type: "empty_table", schema, table };
|
||||
case "ddl":
|
||||
return { id: item.id, type: "ddl", sql: item.sql };
|
||||
case "rebuild_table":
|
||||
return { id: item.id, type: "rebuild_table", sql: item.sql };
|
||||
default:
|
||||
return { id: item.id, type: item.type, sql: item.sql };
|
||||
}
|
||||
|
||||
@@ -7,6 +7,13 @@ vi.mock("@tauri-apps/api/core", () => ({
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
testConnection,
|
||||
getRoles,
|
||||
getRolePrivileges,
|
||||
getTableColumns,
|
||||
getTableRebuildReadiness,
|
||||
runMaintenance,
|
||||
getTablespaces,
|
||||
buildRebuildScript,
|
||||
dbConnect,
|
||||
dbDisconnect,
|
||||
getDatabases,
|
||||
@@ -37,6 +44,7 @@ import {
|
||||
getObjectDdl,
|
||||
getObjectDependencies,
|
||||
} from "./commands";
|
||||
import * as cmd from "./commands";
|
||||
import type { SchemaGraph } from "./types";
|
||||
import type { QueryHistoryEntry } from "./commands";
|
||||
|
||||
@@ -332,4 +340,96 @@ describe("object management commands (v0.7.5)", () => {
|
||||
await getObjectDependencies("c1", "public", "table", "orders");
|
||||
expect(invoke).toHaveBeenCalledWith("get_object_dependencies", { connectionId: "c1", schema: "public", objectType: "table", name: "orders" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("v0.7.7 command wrappers", () => {
|
||||
it("getRoles calls get_roles", async () => {
|
||||
const mockRoles = [
|
||||
{
|
||||
name: "postgres",
|
||||
superuser: true,
|
||||
inherit: true,
|
||||
create_db: true,
|
||||
create_role: true,
|
||||
can_login: true,
|
||||
replication: true,
|
||||
bypass_rls: false,
|
||||
connection_limit: -1,
|
||||
valid_until: null,
|
||||
memberships: [],
|
||||
},
|
||||
];
|
||||
vi.mocked(invoke).mockResolvedValueOnce(mockRoles);
|
||||
|
||||
const result = await getRoles("c1");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("get_roles", { connectionId: "c1" });
|
||||
expect(result).toEqual(mockRoles);
|
||||
});
|
||||
|
||||
it("getRolePrivileges calls get_role_privileges with role", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await getRolePrivileges("c1", "app");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("get_role_privileges", { connectionId: "c1", role: "app" });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("getTableColumns calls get_table_columns", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await getTableColumns("c1", "public", "users");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("get_table_columns", { connectionId: "c1", schema: "public", table: "users" });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("getTableRebuildReadiness calls get_table_rebuild_readiness", async () => {
|
||||
const mockReadiness = { ok: true, reasons: [] };
|
||||
vi.mocked(invoke).mockResolvedValueOnce(mockReadiness);
|
||||
|
||||
const result = await getTableRebuildReadiness("c1", "public", "users");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("get_table_rebuild_readiness", { connectionId: "c1", schema: "public", table: "users" });
|
||||
expect(result).toEqual(mockReadiness);
|
||||
});
|
||||
|
||||
it("runMaintenance calls run_maintenance with action", async () => {
|
||||
const mockResult = { duration_ms: 5, message: "ok" };
|
||||
vi.mocked(invoke).mockResolvedValueOnce(mockResult);
|
||||
|
||||
const result = await runMaintenance("c1", "public", "users", "vacuum");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("run_maintenance", { connectionId: "c1", schema: "public", table: "users", action: "vacuum" });
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it("getTablespaces calls get_tablespaces", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await getTablespaces("c1");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("get_tablespaces", { connectionId: "c1" });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("buildRebuildScript calls build_rebuild_script with newColumns", async () => {
|
||||
const mockScript = "CREATE TABLE _t();";
|
||||
const cols = [{ name: "id", type: "int", nullable: false, default: null, is_pk: true }];
|
||||
vi.mocked(invoke).mockResolvedValueOnce(mockScript);
|
||||
|
||||
const result = await buildRebuildScript("c1", "public", "users", cols);
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("build_rebuild_script", { connectionId: "c1", schema: "public", table: "users", newColumns: cols });
|
||||
expect(result).toEqual(mockScript);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v0.7.8 command wrappers exist", () => {
|
||||
it("exports the backup/cancel/settings wrappers", () => {
|
||||
for (const name of ["cancelQuery","mysqlDump","mysqlRestore","mysqlSync","detectMysqlTools","sqliteDump","sqliteRestore","sqliteSync","exportSettings","importSettings"]) {
|
||||
expect(typeof (cmd as Record<string, unknown>)[name]).toBe("function");
|
||||
}
|
||||
});
|
||||
});
|
||||
+75
-1
@@ -1,7 +1,15 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo } from "./types";
|
||||
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, MySqlToolStatus, MySqlBackupOptions, MySqlRestoreOptions, SqliteBackupOptions, SqliteRestoreOptions, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo, RoleInfo, PrivilegeEntry, RebuildReadiness, MaintenanceResult, TablespaceInfo, ColumnInfo } from "./types";
|
||||
import type { FilterRule, SortRule } from "../stores/dbViewerStore";
|
||||
import type { ChangePayload } from "./changePayload";
|
||||
import { buildObjectDdl as buildObjectDdlImpl, type ObjectKind, type DdlParams } from "./objectCrud";
|
||||
|
||||
export { type ObjectKind, type DdlParams };
|
||||
|
||||
/** Build one or more SQL statements for an object CRUD operation. */
|
||||
export function buildObjectDdl(connectionId: string, kind: ObjectKind, params: DdlParams): Promise<string[]> {
|
||||
return buildObjectDdlImpl(connectionId, kind, params);
|
||||
}
|
||||
|
||||
// NOTE on argument key naming:
|
||||
// Tauri v2's #[tauri::command] macro converts Rust snake_case parameter names
|
||||
@@ -154,6 +162,48 @@ export async function dbSync(options: SyncOptions): Promise<string> {
|
||||
return invoke<string>("db_sync", { options });
|
||||
}
|
||||
|
||||
// ─── v0.7.8: Cancel / MySQL / SQLite / Settings export-import ────
|
||||
|
||||
export async function cancelQuery(connectionId: string): Promise<void> {
|
||||
return invoke<void>("cancel_query", { connectionId });
|
||||
}
|
||||
|
||||
export async function detectMysqlTools(): Promise<MySqlToolStatus> {
|
||||
return invoke<MySqlToolStatus>("detect_mysql_tools");
|
||||
}
|
||||
|
||||
export async function mysqlDump(connectionId: string, options: MySqlBackupOptions): Promise<string> {
|
||||
return invoke<string>("mysql_dump", { connectionId, options });
|
||||
}
|
||||
|
||||
export async function mysqlRestore(connectionId: string, options: MySqlRestoreOptions): Promise<string> {
|
||||
return invoke<string>("mysql_restore", { connectionId, options });
|
||||
}
|
||||
|
||||
export async function mysqlSync(options: SyncOptions): Promise<string> {
|
||||
return invoke<string>("mysql_sync", { options });
|
||||
}
|
||||
|
||||
export async function sqliteDump(connectionId: string, options: SqliteBackupOptions): Promise<string> {
|
||||
return invoke<string>("sqlite_dump", { connectionId, options });
|
||||
}
|
||||
|
||||
export async function sqliteRestore(connectionId: string, options: SqliteRestoreOptions): Promise<string> {
|
||||
return invoke<string>("sqlite_restore", { connectionId, options });
|
||||
}
|
||||
|
||||
export async function sqliteSync(options: SyncOptions): Promise<string> {
|
||||
return invoke<string>("sqlite_sync", { options });
|
||||
}
|
||||
|
||||
export async function exportSettings(): Promise<string> {
|
||||
return invoke<string>("export_settings");
|
||||
}
|
||||
|
||||
export async function importSettings(json: string): Promise<void> {
|
||||
return invoke<void>("import_settings", { json });
|
||||
}
|
||||
|
||||
// ─── Object Explorer (Functions, Triggers, Sequences, Enums, Extensions) ────
|
||||
|
||||
export async function getFunctions(connectionId: string, schema?: string): Promise<FunctionInfo[]> {
|
||||
@@ -319,4 +369,28 @@ export async function getObjectDdl(connectionId: string, schema: string, objectT
|
||||
}
|
||||
export async function getObjectDependencies(connectionId: string, schema: string, objectType: string, name: string): Promise<DependencyInfo[]> {
|
||||
return invoke<DependencyInfo[]>("get_object_dependencies", { connectionId, schema, objectType, name });
|
||||
}
|
||||
|
||||
// ─── v0.7.7: Roles / privileges / rebuild / maintenance ──────────
|
||||
|
||||
export async function getRoles(connectionId: string): Promise<RoleInfo[]> {
|
||||
return invoke<RoleInfo[]>("get_roles", { connectionId });
|
||||
}
|
||||
export async function getRolePrivileges(connectionId: string, role: string): Promise<PrivilegeEntry[]> {
|
||||
return invoke<PrivilegeEntry[]>("get_role_privileges", { connectionId, role });
|
||||
}
|
||||
export async function getTableColumns(connectionId: string, schema: string, table: string): Promise<ColumnInfo[]> {
|
||||
return invoke<ColumnInfo[]>("get_table_columns", { connectionId, schema, table });
|
||||
}
|
||||
export async function getTableRebuildReadiness(connectionId: string, schema: string, table: string): Promise<RebuildReadiness> {
|
||||
return invoke<RebuildReadiness>("get_table_rebuild_readiness", { connectionId, schema, table });
|
||||
}
|
||||
export async function runMaintenance(connectionId: string, schema: string, table: string, action: "vacuum" | "analyze" | "reindex"): Promise<MaintenanceResult> {
|
||||
return invoke<MaintenanceResult>("run_maintenance", { connectionId, schema, table, action });
|
||||
}
|
||||
export async function getTablespaces(connectionId: string): Promise<TablespaceInfo[]> {
|
||||
return invoke<TablespaceInfo[]>("get_tablespaces", { connectionId });
|
||||
}
|
||||
export async function buildRebuildScript<C extends { name: string; type: string; nullable: boolean; default: string | null; is_pk: boolean }[]>(connectionId: string, schema: string, table: string, newColumns: C): Promise<string> {
|
||||
return invoke<string>("build_rebuild_script", { connectionId, schema, table, newColumns });
|
||||
}
|
||||
@@ -7,22 +7,23 @@ describe("dbCapabilities", () => {
|
||||
expect(c).toEqual<DbCapabilities>({
|
||||
explorer: true, queries: true, objects: true, visualizer: true,
|
||||
tools: true, editing: true, import: true, ddl: true, objectCrud: true,
|
||||
maintenance: true, roles: true, tableManagement: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("gives MySQL explorer/queries/editing/import/ddl but not objects/visualizer/tools", () => {
|
||||
it("gives MySQL explorer/queries/editing/import/ddl/tools but not objects/visualizer", () => {
|
||||
const c = DB_CAPABILITIES.mysql;
|
||||
expect(c.explorer).toBe(true);
|
||||
expect(c.queries).toBe(true);
|
||||
expect(c.editing).toBe(true);
|
||||
expect(c.import).toBe(true);
|
||||
expect(c.ddl).toBe(true);
|
||||
expect(c.tools).toBe(true);
|
||||
expect(c.objects).toBe(false);
|
||||
expect(c.visualizer).toBe(false);
|
||||
expect(c.tools).toBe(false);
|
||||
});
|
||||
|
||||
it("gives SQLite explorer/queries/visualizer/editing/import/ddl but not objects/tools", () => {
|
||||
it("gives SQLite explorer/queries/visualizer/editing/import/ddl/tools/tableManagement but not objects", () => {
|
||||
const c = DB_CAPABILITIES.sqlite;
|
||||
expect(c.explorer).toBe(true);
|
||||
expect(c.queries).toBe(true);
|
||||
@@ -30,14 +31,16 @@ describe("dbCapabilities", () => {
|
||||
expect(c.editing).toBe(true);
|
||||
expect(c.import).toBe(true);
|
||||
expect(c.ddl).toBe(true);
|
||||
expect(c.tools).toBe(true);
|
||||
expect(c.tableManagement).toBe(true);
|
||||
expect(c.objects).toBe(false);
|
||||
expect(c.tools).toBe(false);
|
||||
});
|
||||
|
||||
it("gives Redis nothing (connection+test only)", () => {
|
||||
expect(DB_CAPABILITIES.redis).toEqual<DbCapabilities>({
|
||||
explorer: false, queries: false, objects: false, visualizer: false,
|
||||
tools: false, editing: false, import: false, ddl: false, objectCrud: false,
|
||||
maintenance: false, roles: false, tableManagement: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,4 +72,45 @@ describe("objectCrud capability", () => {
|
||||
it("unknown types get objectCrud false", () => {
|
||||
expect(getCapabilities("oracle").objectCrud).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v0.7.7 capabilities", () => {
|
||||
it("postgresql has maintenance, roles, tableManagement", () => {
|
||||
expect(DB_CAPABILITIES.postgresql.maintenance).toBe(true);
|
||||
expect(DB_CAPABILITIES.postgresql.roles).toBe(true);
|
||||
expect(DB_CAPABILITIES.postgresql.tableManagement).toBe(true);
|
||||
});
|
||||
it("mysql/sqlite/redis disable maintenance and roles; only sqlite also gets tableManagement", () => {
|
||||
for (const t of ["mysql", "sqlite", "redis"] as const) {
|
||||
expect(DB_CAPABILITIES[t].maintenance).toBe(false);
|
||||
expect(DB_CAPABILITIES[t].roles).toBe(false);
|
||||
}
|
||||
expect(DB_CAPABILITIES.sqlite.tableManagement).toBe(true);
|
||||
expect(DB_CAPABILITIES.mysql.tableManagement).toBe(false);
|
||||
expect(DB_CAPABILITIES.redis.tableManagement).toBe(false);
|
||||
});
|
||||
it("getCapabilities is safe for unknown types", () => {
|
||||
expect(getCapabilities("bogus").maintenance).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dbCapabilities v0.7.8", () => {
|
||||
it("enables tools for mysql and sqlite", () => {
|
||||
expect(DB_CAPABILITIES.mysql.tools).toBe(true);
|
||||
expect(DB_CAPABILITIES.sqlite.tools).toBe(true);
|
||||
expect(DB_CAPABILITIES.postgresql.tools).toBe(true);
|
||||
});
|
||||
|
||||
it("enables tableManagement for sqlite", () => {
|
||||
expect(DB_CAPABILITIES.sqlite.tableManagement).toBe(true);
|
||||
expect(DB_CAPABILITIES.mysql.tableManagement).toBe(false);
|
||||
});
|
||||
|
||||
it("still reports editing/import/ddl for mysql and sqlite", () => {
|
||||
expect(DB_CAPABILITIES.mysql.editing).toBe(true);
|
||||
expect(DB_CAPABILITIES.mysql.import).toBe(true);
|
||||
expect(DB_CAPABILITIES.mysql.ddl).toBe(true);
|
||||
expect(DB_CAPABILITIES.sqlite.editing).toBe(true);
|
||||
expect(DB_CAPABILITIES.sqlite.objects).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -19,17 +19,24 @@ export interface DbCapabilities {
|
||||
ddl: boolean;
|
||||
/** Create / edit / drop PostgreSQL objects via the changes queue. */
|
||||
objectCrud: boolean;
|
||||
/** Table maintenance (VACUUM/ANALYZE/REINDEX) + rebuild-table. */
|
||||
maintenance: boolean;
|
||||
/** Role browsing/management. */
|
||||
roles: boolean;
|
||||
/** Rebuild-table (rewrite in place) support. */
|
||||
tableManagement: boolean;
|
||||
}
|
||||
|
||||
const ALL_FALSE: DbCapabilities = {
|
||||
explorer: false, queries: false, objects: false, visualizer: false,
|
||||
tools: false, editing: false, import: false, ddl: false, objectCrud: false,
|
||||
maintenance: false, roles: false, tableManagement: false,
|
||||
};
|
||||
|
||||
export const DB_CAPABILITIES: Record<DbType, DbCapabilities> = {
|
||||
postgresql: { ...ALL_FALSE, explorer: true, queries: true, objects: true, visualizer: true, tools: true, editing: true, import: true, ddl: true, objectCrud: true },
|
||||
mysql: { ...ALL_FALSE, explorer: true, queries: true, editing: true, import: true, ddl: true },
|
||||
sqlite: { ...ALL_FALSE, explorer: true, queries: true, visualizer: true, editing: true, import: true, ddl: true },
|
||||
postgresql: { ...ALL_FALSE, explorer: true, queries: true, objects: true, visualizer: true, tools: true, editing: true, import: true, ddl: true, objectCrud: true, maintenance: true, roles: true, tableManagement: true },
|
||||
mysql: { ...ALL_FALSE, explorer: true, queries: true, editing: true, import: true, ddl: true, tools: true },
|
||||
sqlite: { ...ALL_FALSE, explorer: true, queries: true, visualizer: true, editing: true, import: true, ddl: true, tools: true, tableManagement: true },
|
||||
redis: { ...ALL_FALSE },
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, it, expect } from "vitest";
|
||||
import agents from "../../AGENTS.md?raw";
|
||||
import readme from "../../README.md?raw";
|
||||
|
||||
describe("v0.7.6 docs coverage", () => {
|
||||
describe("v0.7.9 docs coverage", () => {
|
||||
it("AGENTS.md marks inline cell editing complete", () => {
|
||||
expect(agents).toContain("Inline cell editing");
|
||||
expect(agents).toMatch(/Inline cell editing \| ✅/);
|
||||
@@ -24,8 +24,8 @@ describe("v0.7.6 docs coverage", () => {
|
||||
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
|
||||
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
|
||||
});
|
||||
it("README declares v0.7.6", () => {
|
||||
expect(readme).toContain("0.7.6");
|
||||
it("README declares v0.7.9", () => {
|
||||
expect(readme).toContain("0.7.9");
|
||||
});
|
||||
it("AGENTS.md marks schema CRUD complete", () => {
|
||||
expect(agents).toMatch(/Schema CRUD \| ✅/);
|
||||
@@ -59,9 +59,14 @@ describe("v0.7.6 docs coverage", () => {
|
||||
it("AGENTS.md marks the Objects view tabbed workspace complete", () => {
|
||||
expect(agents).toMatch(/Objects view tabbed workspace \| ✅/);
|
||||
});
|
||||
it("README links to v0.7.6 assets in both download tables", () => {
|
||||
expect(readme).toContain("releases/download/v0.7.6/");
|
||||
expect(readme).toContain("Gridline_0.7.6_aarch64.dmg");
|
||||
expect(readme).toContain("Gridline-0.7.6-1.x86_64.rpm");
|
||||
it("AGENTS.md marks xlsx/SQLite-dump/cancel/settings-import complete", () => {
|
||||
expect(agents).toMatch(/Excel \(\.xlsx\) export \| ✅/);
|
||||
expect(agents).toMatch(/Cancel long-running queries \| ✅/);
|
||||
expect(agents).toMatch(/Settings export\/import \| ✅/);
|
||||
});
|
||||
it("README links to v0.7.9 assets in both download tables", () => {
|
||||
expect(readme).toContain("releases/download/v0.7.9/");
|
||||
expect(readme).toContain("Gridline_0.7.9_aarch64.dmg");
|
||||
expect(readme).toContain("Gridline-0.7.9-1.x86_64.rpm");
|
||||
});
|
||||
});
|
||||
@@ -26,4 +26,26 @@ describe("exportData", () => {
|
||||
exportData([[1, "a"]], columns, "json", "t");
|
||||
expect(click).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("xlsx produces a Blob with the xlsx MIME and extension download", () => {
|
||||
const origCreateObjectURL = URL.createObjectURL;
|
||||
const origRevokeObjectURL = URL.revokeObjectURL;
|
||||
globalThis.URL.createObjectURL = vi.fn(() => "blob:x") as any;
|
||||
globalThis.URL.revokeObjectURL = vi.fn() as any;
|
||||
const a = { click: vi.fn(), href: "", download: "" };
|
||||
vi.spyOn(document, "createElement").mockReturnValue(a as any);
|
||||
const rows = [[1]];
|
||||
const cols: ColumnInfo[] = [
|
||||
{ name: "id", data_type: "integer", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
||||
];
|
||||
try {
|
||||
exportData(rows, cols, "xlsx", "t");
|
||||
expect(a.download).toBe("t.xlsx");
|
||||
expect((URL.createObjectURL as any).mock.calls[0][0] instanceof Blob).toBe(true);
|
||||
expect((URL.createObjectURL as any).mock.calls[0][0].type).toBe("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
} finally {
|
||||
globalThis.URL.createObjectURL = origCreateObjectURL;
|
||||
globalThis.URL.revokeObjectURL = origRevokeObjectURL;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { buildXlsx } from "./xlsx";
|
||||
import type { ColumnInfo } from "./types";
|
||||
|
||||
export function exportData(
|
||||
@@ -11,6 +12,17 @@ export function exportData(
|
||||
let mime: string;
|
||||
|
||||
switch (format) {
|
||||
case "xlsx": {
|
||||
const bytes = buildXlsx(rows, columns);
|
||||
const blob = new Blob([bytes], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${tableName}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
case "json": {
|
||||
const jsonRows = rows.map((row) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getAvailableExtensions,
|
||||
initialCrudParams,
|
||||
} from "./objectCrud";
|
||||
import type { CrudItem } from "./objectCrud";
|
||||
|
||||
describe("buildObjectDdl", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
@@ -460,4 +461,31 @@ describe("dropCrudParams", () => {
|
||||
action: { op: "drop", arg_types: ["int"] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("table/role crud params", () => {
|
||||
it("initialCrudParams(table, create) starts empty with columns", () => {
|
||||
const p = initialCrudParams("table", { schema: "public", name: "" }, "create");
|
||||
expect(p).toEqual({ schema: "public", name: "", action: { op: "create", columns: [] } });
|
||||
});
|
||||
it("initialCrudParams(table, edit) prefills columns", () => {
|
||||
const item: CrudItem = { schema: "public", name: "users", columns: ["id"], columnMeta: [
|
||||
{ name: "id", type: "integer", nullable: false, is_pk: true, default: null },
|
||||
] };
|
||||
const p = initialCrudParams("table", item, "edit");
|
||||
expect((p as any).action.op).toBe("edit");
|
||||
expect((p as any).action.old_columns).toEqual(item.columnMeta);
|
||||
});
|
||||
it("initialCrudParams(role, create) starts empty with options", () => {
|
||||
const p = initialCrudParams("role", { schema: "", name: "" }, "create");
|
||||
expect(p).toEqual({ schema: "", name: "", action: { op: "create", login: false, superuser: false, createdb: false, createrole: false, inherit: true, replication: false, bypassrls: false, connection_limit: -1, valid_until: "", password: "", members: [] } });
|
||||
});
|
||||
it("dropCrudParams(role) drops by name", () => {
|
||||
const p = dropCrudParams("role", { schema: "", name: "app" });
|
||||
expect(p).toEqual({ schema: "", name: "app", action: { op: "drop" } });
|
||||
});
|
||||
it("dropCrudParams(table) drops the table", () => {
|
||||
const p = dropCrudParams("table", { schema: "public", name: "users" });
|
||||
expect(p).toEqual({ schema: "public", name: "users", action: { op: "drop" } });
|
||||
});
|
||||
});
|
||||
+52
-1
@@ -14,7 +14,9 @@ export type ObjectKind =
|
||||
| "constraint"
|
||||
| "function"
|
||||
| "procedure"
|
||||
| "trigger";
|
||||
| "trigger"
|
||||
| "table"
|
||||
| "role";
|
||||
|
||||
/// Opaque payload for a build: `{ schema, name, action: { op, ... } }`.
|
||||
/// The concrete shape is validated server-side by each kind's params struct.
|
||||
@@ -60,6 +62,18 @@ export interface CrudItem {
|
||||
columns?: string[];
|
||||
// constraint (ConstraintInfo)
|
||||
contype?: "CHECK" | "UNIQUE" | "EXCLUSION";
|
||||
// table (columnMeta carries the snapshot for edit diffs)
|
||||
columnMeta?: { name: string; type: string; nullable: boolean; is_pk: boolean; default: string | null }[];
|
||||
// role (RoleInfo shape used for prefill)
|
||||
superuser?: boolean;
|
||||
inherit?: boolean;
|
||||
create_db?: boolean;
|
||||
create_role?: boolean;
|
||||
can_login?: boolean;
|
||||
replication?: boolean;
|
||||
bypass_rls?: boolean;
|
||||
connection_limit?: number;
|
||||
valid_until?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,6 +208,40 @@ export function initialCrudParams(
|
||||
when: null,
|
||||
},
|
||||
};
|
||||
case "table":
|
||||
return {
|
||||
schema,
|
||||
name: mode === "edit" ? item.name : "",
|
||||
action: {
|
||||
op: mode === "edit" ? "edit" : "create",
|
||||
columns: mode === "edit"
|
||||
? (item.columnMeta ?? []).map((c) => ({
|
||||
name: c.name, type: c.type, nullable: c.nullable,
|
||||
default: c.default ?? null, is_pk: c.is_pk,
|
||||
}))
|
||||
: [],
|
||||
old_columns: mode === "edit" ? (item.columnMeta ?? []) : undefined,
|
||||
},
|
||||
};
|
||||
case "role":
|
||||
return {
|
||||
schema,
|
||||
name: mode === "edit" ? item.name : "",
|
||||
action: {
|
||||
op: "create",
|
||||
login: mode === "edit" ? (item.can_login ?? false) : false,
|
||||
superuser: mode === "edit" ? (item.superuser ?? false) : false,
|
||||
createdb: mode === "edit" ? (item.create_db ?? false) : false,
|
||||
createrole: mode === "edit" ? (item.create_role ?? false) : false,
|
||||
inherit: mode === "edit" ? (item.inherit ?? true) : true,
|
||||
replication: mode === "edit" ? (item.replication ?? false) : false,
|
||||
bypassrls: mode === "edit" ? (item.bypass_rls ?? false) : false,
|
||||
connection_limit: mode === "edit" ? (item.connection_limit ?? -1) : -1,
|
||||
valid_until: mode === "edit" ? (item.valid_until ?? "") : "",
|
||||
password: "",
|
||||
members: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +251,9 @@ export function initialCrudParams(
|
||||
*/
|
||||
export function dropCrudParams(kind: ObjectKind, item: CrudItem): DdlParams {
|
||||
const base = { schema: item.schema, name: item.name };
|
||||
if (kind === "table" || kind === "role") {
|
||||
return { ...base, action: { op: "drop" } };
|
||||
}
|
||||
if (kind === "trigger") {
|
||||
return {
|
||||
...base,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { isMacOS } from "./platform";
|
||||
|
||||
describe("isMacOS", () => {
|
||||
afterEach(() => {
|
||||
vi.stubGlobal("navigator", undefined);
|
||||
});
|
||||
|
||||
it("true on MacIntel/Mac platform", () => {
|
||||
vi.stubGlobal("navigator", { platform: "MacIntel", userAgent: "Mozilla/5.0 (Macintosh; X)" });
|
||||
expect(isMacOS()).toBe(true);
|
||||
});
|
||||
|
||||
it("false on Win32", () => {
|
||||
vi.stubGlobal("navigator", { platform: "Win32", userAgent: "Mozilla/5.0 (Windows NT 10.0)" });
|
||||
expect(isMacOS()).toBe(false);
|
||||
});
|
||||
|
||||
it("false on Linux", () => {
|
||||
vi.stubGlobal("navigator", { platform: "Linux x86_64", userAgent: "Mozilla/5.0 (X11; Linux)" });
|
||||
expect(isMacOS()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
/** True only on macOS (the macOS "Overlay" drag strip is gated on this). Uses
|
||||
* navigator.platform/userAgent (the established pattern in BackupPage.tsx);
|
||||
* @tauri-apps/plugin-os is not installed and getCurrentWindow() has no osLabel(). */
|
||||
export function isMacOS(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
const p = (navigator.platform || "").toLowerCase();
|
||||
const u = (navigator.userAgent || "").toLowerCase();
|
||||
return p.includes("mac") || u.includes("macintosh") || u.includes("mac os");
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { validateSettingsExport } from "./settingsImport";
|
||||
import type { Settings } from "./types";
|
||||
|
||||
const good: Settings = {
|
||||
confirm_before_delete: true, default_folder_id: null, theme: "dark", font_size: "medium",
|
||||
default_ports: { postgresql: 5432 }, tag_order: null, table_refresh_rate: 5, table_page_size: 50,
|
||||
shortcuts: { open_command_palette: "Cmd+K" }, accent_color: "#2563EB",
|
||||
editor_font_size: 14, editor_font_family: "Menlo", editor_word_wrap: "off", editor_minimap: true, editor_tab_size: 2,
|
||||
};
|
||||
|
||||
describe("validateSettingsExport", () => {
|
||||
it("accepts a well-formed object", () => {
|
||||
const r = validateSettingsExport({ schemaVersion: 1, settings: good });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an invalid theme", () => {
|
||||
const r = validateSettingsExport({ schemaVersion: 1, settings: { ...good, theme: "purple" as never } });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.errors.some((e) => e.field === "theme")).toBe(true);
|
||||
});
|
||||
|
||||
it("clamps editor_font_size to [8,24]", () => {
|
||||
const r = validateSettingsExport({ schemaVersion: 1, settings: { ...good, editor_font_size: 999 } });
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.settings.editor_font_size).toBe(24);
|
||||
});
|
||||
|
||||
it("tolerates unknown keys (version skew)", () => {
|
||||
const r = validateSettingsExport({ schemaVersion: 99, settings: { ...good, futureField: true } } as never);
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("requires a non-negative table_refresh_rate", () => {
|
||||
const r = validateSettingsExport({ schemaVersion: 1, settings: { ...good, table_refresh_rate: -1 } });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Settings } from "./types";
|
||||
|
||||
export interface SettingsExport { schemaVersion: number; settings: Settings }
|
||||
export type ValidationOk = { ok: true; settings: Settings };
|
||||
export type ValidationErr = { ok: false; errors: { field: string; message: string }[] };
|
||||
export type ValidationResult = ValidationOk | ValidationErr;
|
||||
|
||||
const THEMES = ["dark", "light", "system"];
|
||||
const FONT_SIZES = ["small", "medium", "large"];
|
||||
const FONT_FAMILIES = ["Space Mono", "Fira Code", "Menlo", "Monaco", "Consolas", "JetBrains Mono", "monospace"];
|
||||
const WORD_WRAPS = ["off", "on"];
|
||||
|
||||
function clamp(n: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, n)); }
|
||||
|
||||
export function validateSettingsExport(input: unknown): ValidationResult {
|
||||
const errors: ValidationErr["errors"] = [];
|
||||
if (typeof input !== "object" || input === null || !("settings" in input)) {
|
||||
return { ok: false, errors: [{ field: "settings", message: "missing settings object" }] };
|
||||
}
|
||||
const s = (input as { settings: Record<string, unknown> }).settings;
|
||||
const out: Record<string, unknown> = {};
|
||||
|
||||
const enumCheck = (field: keyof Settings, allow: readonly string[], val: unknown) => {
|
||||
if (typeof val === "string" && allow.includes(val)) out[field] = val;
|
||||
else errors.push({ field, message: `invalid ${field}` });
|
||||
};
|
||||
enumCheck("theme", THEMES, s.theme);
|
||||
enumCheck("font_size", FONT_SIZES, s.font_size);
|
||||
enumCheck("editor_word_wrap", WORD_WRAPS, s.editor_word_wrap);
|
||||
|
||||
if (typeof s.accent_color === "string" && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(s.accent_color)) out.accent_color = s.accent_color;
|
||||
else errors.push({ field: "accent_color", message: "invalid hex color" });
|
||||
|
||||
if (typeof s.editor_font_size === "number") out.editor_font_size = clamp(Math.trunc(s.editor_font_size), 8, 24);
|
||||
else errors.push({ field: "editor_font_size", message: "must be a number" });
|
||||
if (typeof s.editor_tab_size === "number") out.editor_tab_size = clamp(Math.trunc(s.editor_tab_size), 2, 8);
|
||||
else errors.push({ field: "editor_tab_size", message: "must be a number" });
|
||||
enumCheck("editor_font_family", FONT_FAMILIES, s.editor_font_family);
|
||||
if (typeof s.editor_minimap === "boolean") out.editor_minimap = s.editor_minimap;
|
||||
else errors.push({ field: "editor_minimap", message: "must be boolean" });
|
||||
|
||||
if (typeof s.table_page_size === "number" && s.table_page_size > 0) out.table_page_size = Math.trunc(s.table_page_size);
|
||||
else errors.push({ field: "table_page_size", message: "must be positive" });
|
||||
if (typeof s.table_refresh_rate === "number" && s.table_refresh_rate >= 0) out.table_refresh_rate = s.table_refresh_rate;
|
||||
else errors.push({ field: "table_refresh_rate", message: "must be non-negative" });
|
||||
|
||||
out.confirm_before_delete = typeof s.confirm_before_delete === "boolean" ? s.confirm_before_delete : true;
|
||||
out.default_folder_id = typeof s.default_folder_id === "string" || s.default_folder_id === null ? s.default_folder_id : null;
|
||||
out.tag_order = typeof s.tag_order === "string" || s.tag_order === null ? s.tag_order : null;
|
||||
out.default_ports = s.default_ports && typeof s.default_ports === "object" ? s.default_ports : {};
|
||||
out.shortcuts = s.shortcuts && typeof s.shortcuts === "object" ? s.shortcuts : {};
|
||||
|
||||
if (errors.length) return { ok: false, errors };
|
||||
return { ok: true, settings: out as unknown as Settings };
|
||||
}
|
||||
@@ -19,6 +19,11 @@ import type {
|
||||
IndexInfo,
|
||||
ConstraintInfo,
|
||||
RecentConnection,
|
||||
RoleInfo,
|
||||
PrivilegeEntry,
|
||||
RebuildReadiness,
|
||||
MaintenanceResult,
|
||||
ObjectType,
|
||||
} from "./types";
|
||||
|
||||
describe("ActiveView", () => {
|
||||
@@ -495,4 +500,31 @@ describe("v0.5.0 types", () => {
|
||||
const c = { favorite: true } as Connection;
|
||||
expectTypeOf(c.favorite).toEqualTypeOf<boolean>();
|
||||
});
|
||||
});
|
||||
|
||||
describe("v0.7.7 types", () => {
|
||||
it("ObjectType includes roles", () => {
|
||||
const t: ObjectType = "roles";
|
||||
expect(t).toBe("roles");
|
||||
});
|
||||
it("ChangeItemType includes rebuild_table", () => {
|
||||
const t: ChangeItemType = "rebuild_table";
|
||||
expect(t).toBe("rebuild_table");
|
||||
});
|
||||
it("RoleInfo shape", () => {
|
||||
const r: 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: [],
|
||||
};
|
||||
expect(r.name).toBe("app");
|
||||
});
|
||||
it("PrivilegeEntry/RebuildReadiness/MaintenanceResult shapes", () => {
|
||||
const pe: PrivilegeEntry = { object_class: "table", schema: "public", name: "users", privileges: ["SELECT"], grantable: false };
|
||||
const rr: RebuildReadiness = { ok: true, reasons: [] };
|
||||
const mr: MaintenanceResult = { duration_ms: 1, message: "ok" };
|
||||
expect(pe.object_class).toBe("table");
|
||||
expect(rr.ok).toBe(true);
|
||||
expect(mr.duration_ms).toBe(1);
|
||||
});
|
||||
});
|
||||
+92
-1
@@ -202,6 +202,7 @@ export type ChangeItemType =
|
||||
| "drop_index"
|
||||
| "bulk_insert"
|
||||
| "empty_table"
|
||||
| "rebuild_table"
|
||||
| "ddl";
|
||||
|
||||
export interface ChangeItem {
|
||||
@@ -301,6 +302,7 @@ export interface SyncOptions {
|
||||
targetConnectionId: string;
|
||||
schema?: string;
|
||||
tables?: string[];
|
||||
dbType: DbType;
|
||||
}
|
||||
|
||||
export interface PgToolStatus {
|
||||
@@ -312,6 +314,49 @@ export interface PgToolStatus {
|
||||
pg_restore_source: string | null;
|
||||
}
|
||||
|
||||
// ─── Backup Types: MySQL / SQLite / Settings (v0.7.8) ───────────
|
||||
// NOTE: the Rust `MySqlToolStatus` model is `#[serde(rename_all = "camelCase")]`,
|
||||
// so these interfaces use camelCase keys to match the actual IPC payloads.
|
||||
|
||||
export interface MySqlToolStatus {
|
||||
mysqldumpFound: boolean;
|
||||
mysqlFound: boolean;
|
||||
mysqldumpVersion: string | null;
|
||||
mysqlVersion: string | null;
|
||||
mysqldumpSource: string | null;
|
||||
mysqlSource: string | null;
|
||||
}
|
||||
|
||||
export interface MySqlBackupOptions {
|
||||
database: string;
|
||||
filePath: string;
|
||||
singleTransaction: boolean;
|
||||
noData: boolean;
|
||||
routines: boolean;
|
||||
triggers: boolean;
|
||||
events: boolean;
|
||||
}
|
||||
|
||||
export interface MySqlRestoreOptions {
|
||||
database: string;
|
||||
filePath: string;
|
||||
clean: boolean;
|
||||
}
|
||||
|
||||
export interface SqliteBackupOptions {
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
export interface SqliteRestoreOptions {
|
||||
filePath: string;
|
||||
clean: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsExport {
|
||||
schemaVersion: number;
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
export type PgObjectType =
|
||||
| "table" | "view" | "materialized view" | "function" | "procedure"
|
||||
| "trigger" | "sequence" | "enum" | "extension" | "index" | "constraint";
|
||||
@@ -324,7 +369,8 @@ export type ObjectType =
|
||||
| "extensions"
|
||||
| "indexes"
|
||||
| "constraints"
|
||||
| "procedures";
|
||||
| "procedures"
|
||||
| "roles";
|
||||
|
||||
export interface ObjectSearchHit {
|
||||
name: string;
|
||||
@@ -352,6 +398,51 @@ export interface BackupJob {
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
// ─── Roles / Privileges / Maintenance (v0.7.7) ──────────────────
|
||||
|
||||
export interface RoleMembership {
|
||||
role: string;
|
||||
member: string;
|
||||
grantor: string;
|
||||
admin_option: boolean;
|
||||
}
|
||||
|
||||
export interface RoleInfo {
|
||||
name: string;
|
||||
superuser: boolean;
|
||||
inherit: boolean;
|
||||
create_db: boolean;
|
||||
create_role: boolean;
|
||||
can_login: boolean;
|
||||
replication: boolean;
|
||||
bypass_rls: boolean;
|
||||
connection_limit: number;
|
||||
valid_until: string | null;
|
||||
memberships: RoleMembership[];
|
||||
}
|
||||
|
||||
export interface PrivilegeEntry {
|
||||
object_class: "table" | "sequence" | "routine" | "schema" | "database";
|
||||
schema: string | null;
|
||||
name: string;
|
||||
privileges: string[];
|
||||
grantable: boolean;
|
||||
}
|
||||
|
||||
export interface RebuildReadiness {
|
||||
ok: boolean;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export interface MaintenanceResult {
|
||||
duration_ms: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TablespaceInfo {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ─── Schema Visualizer Types ────────────────────────────────────
|
||||
|
||||
export interface GraphColumn {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import pkg from "../../package.json";
|
||||
|
||||
describe("version", () => {
|
||||
it("declares v0.7.6 across the app shell", () => {
|
||||
expect(pkg.version).toBe("0.7.6");
|
||||
it("declares v0.7.9 across the app shell", () => {
|
||||
expect(pkg.version).toBe("0.7.9");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { unzipSync } from "fflate";
|
||||
import { buildXlsx } from "./xlsx";
|
||||
import type { ColumnInfo } from "./types";
|
||||
|
||||
const cols: ColumnInfo[] = [
|
||||
{ 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 },
|
||||
{ name: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
||||
];
|
||||
|
||||
function sheetXML(out: Uint8Array): string {
|
||||
const files = unzipSync(out);
|
||||
return new TextDecoder().decode(files["xl/worksheets/sheet1.xml"]);
|
||||
}
|
||||
|
||||
describe("buildXlsx", () => {
|
||||
it("emits headers + rows as inline strings", () => {
|
||||
const out = buildXlsx([[1, "Alice"], [2, "Bob"]], cols);
|
||||
const xml = sheetXML(out);
|
||||
expect(xml).toContain('t="inlineStr"');
|
||||
expect(xml).toContain("id");
|
||||
expect(xml).toContain("Alice");
|
||||
expect(xml).toContain("Bob");
|
||||
});
|
||||
|
||||
it("escapes XML-special characters in cell text", () => {
|
||||
const out = buildXlsx([[1, "a<b>&c"]], cols);
|
||||
const xml = sheetXML(out);
|
||||
expect(xml).toContain("a<b>&c");
|
||||
expect(xml).not.toContain("a<b>&c");
|
||||
});
|
||||
|
||||
it("emits formula-triggering values as inline strings (no formula evaluation)", () => {
|
||||
const out = buildXlsx([[1, "=1+1"], [2, "+5"], [3, "@SUM"]], cols);
|
||||
const xml = sheetXML(out);
|
||||
expect(xml).toContain("=1+1");
|
||||
expect(xml).not.toMatch(/<c[^>]*?><f>/);
|
||||
});
|
||||
|
||||
it("declares a column width per header", () => {
|
||||
const out = buildXlsx([[1, "x"]], cols);
|
||||
const xml = sheetXML(out);
|
||||
expect(xml).toContain("<cols>");
|
||||
expect(xml).toContain("width=");
|
||||
});
|
||||
|
||||
it("renders null cells as empty inline strings", () => {
|
||||
const out = buildXlsx([[1, null]], cols);
|
||||
const xml = sheetXML(out);
|
||||
expect(xml).toContain("inlineStr");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { zipSync, strToU8 } from "fflate";
|
||||
import type { ColumnInfo } from "./types";
|
||||
|
||||
/** Minimal OOXML spreadsheet: one sheet, every cell as an inline string
|
||||
* (t="inlineStr") so Excel never evaluates a cell as a formula — the
|
||||
* formula-injection mitigation required by the spec. */
|
||||
const XML_ESCAPES: [RegExp, string][] = [
|
||||
[/&/g, "&"],
|
||||
[/</g, "<"],
|
||||
[/>/g, ">"],
|
||||
[/"/g, """],
|
||||
];
|
||||
function esc(s: string): string {
|
||||
for (const [re, w] of XML_ESCAPES) s = s.replace(re, w);
|
||||
return s;
|
||||
}
|
||||
function colLetter(n: number): string {
|
||||
let s = "";
|
||||
for (let i = n; i > 0; i = Math.floor((i - 1) / 26)) s = String.fromCharCode(65 + ((i - 1) % 26)) + s;
|
||||
return s;
|
||||
}
|
||||
|
||||
function buildColsXML(columns: ColumnInfo[]): string {
|
||||
const maxW = columns.map((c) => Math.min(60, Math.max(8, c.name.length + 2)));
|
||||
return `<cols>${maxW.map((w, i) => `\n <col min="${i + 1}" max="${i + 1}" width="${w}" customWidth="1"/>`).join("")}\n</cols>`;
|
||||
}
|
||||
|
||||
function buildCellsXML(rows: unknown[][], columns: ColumnInfo[]): string {
|
||||
let cells = "";
|
||||
cells += `<row r="1">` + columns
|
||||
.map((c, i) => `<c r="${colLetter(i + 1)}1" t="inlineStr"><is><t>${esc(c.name)}</t></is></c>`)
|
||||
.join("") + `</row>`;
|
||||
rows.forEach((row, rIdx) => {
|
||||
const r = rIdx + 2;
|
||||
cells += `<row r="${r}">` + row
|
||||
.map((v, i) => {
|
||||
const ref = `${colLetter(i + 1)}${r}`;
|
||||
const t = v === null || v === undefined ? "" : String(v);
|
||||
return `<c r="${ref}" t="inlineStr"><is><t>${esc(t)}</t></is></c>`;
|
||||
})
|
||||
.join("") + `</row>`;
|
||||
});
|
||||
return cells;
|
||||
}
|
||||
|
||||
export function buildXlsx(rows: unknown[][], columns: ColumnInfo[]): Uint8Array {
|
||||
const colsXML = buildColsXML(columns);
|
||||
const cells = buildCellsXML(rows, columns);
|
||||
|
||||
const sheet1 = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">${colsXML}<sheetData>${cells}</sheetData></worksheet>`;
|
||||
const workbook = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>`;
|
||||
const ct = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>`;
|
||||
const rels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>`;
|
||||
const rootRels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>`;
|
||||
|
||||
return zipSync({
|
||||
"[Content_Types].xml": strToU8(ct),
|
||||
"_rels/.rels": strToU8(rootRels),
|
||||
"xl/workbook.xml": strToU8(workbook),
|
||||
"xl/_rels/workbook.xml.rels": strToU8(rels),
|
||||
"xl/worksheets/sheet1.xml": strToU8(sheet1),
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { useDbViewerStore } from "./dbViewerStore";
|
||||
import type { QueryResult, TableInfo } from "../lib/types";
|
||||
import type { QueryResult, TableInfo, RoleInfo } from "../lib/types";
|
||||
import * as commands from "../lib/commands";
|
||||
|
||||
vi.mock("../lib/commands", () => ({
|
||||
@@ -598,3 +598,33 @@ describe("openFormTab", () => {
|
||||
expect(after?.kind).toBe(before?.kind);
|
||||
});
|
||||
});
|
||||
|
||||
describe("roles slice", () => {
|
||||
it("setRoles stores roles", () => {
|
||||
useDbViewerStore.getState().reset();
|
||||
const r: 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: [],
|
||||
};
|
||||
useDbViewerStore.getState().setRoles([r]);
|
||||
expect(useDbViewerStore.getState().roles).toEqual([r]);
|
||||
});
|
||||
|
||||
it("openFormTab accepts kind table and role", () => {
|
||||
useDbViewerStore.getState().reset();
|
||||
useDbViewerStore.getState().openFormTab({
|
||||
kind: "table", schema: "public", name: "", title: "Create Table",
|
||||
description: "Create Table", mode: "create", params: { schema: "public", name: "", action: { op: "create", columns: [] } },
|
||||
});
|
||||
const t = useDbViewerStore.getState().tabs[0];
|
||||
expect(t.tabType).toBe("objectForm");
|
||||
expect(t.form?.kind).toBe("table");
|
||||
// dedup: second create of same kind focuses existing
|
||||
useDbViewerStore.getState().openFormTab({
|
||||
kind: "table", schema: "public", name: "", title: "Create Table",
|
||||
description: "Create Table", mode: "create", params: { schema: "public", name: "", action: { op: "create", columns: [] } },
|
||||
});
|
||||
expect(useDbViewerStore.getState().tabs.filter((x) => x.form?.kind === "table").length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, IndexInfo, ConstraintInfo, ObjectType } from "../lib/types";
|
||||
import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, IndexInfo, ConstraintInfo, ObjectType, RoleInfo } from "../lib/types";
|
||||
import { getDatabases, getSchemas, getTables } from "../lib/commands";
|
||||
import type { ObjectKind, DdlParams } from "../lib/objectCrud";
|
||||
|
||||
@@ -120,6 +120,7 @@ interface DbViewerState {
|
||||
indexes: IndexInfo[] | null;
|
||||
setSchemaTreeLoading: (loading: boolean) => void;
|
||||
constraints: ConstraintInfo[] | null;
|
||||
roles: RoleInfo[];
|
||||
|
||||
// Actions
|
||||
openTab: (schema: string, table: string, forceNew?: boolean) => void;
|
||||
@@ -182,6 +183,7 @@ interface DbViewerState {
|
||||
setExtensions: (extensions: ExtensionInfo[]) => void;
|
||||
setIndexes: (indexes: IndexInfo[]) => void;
|
||||
setConstraints: (constraints: ConstraintInfo[]) => void;
|
||||
setRoles: (roles: RoleInfo[]) => void;
|
||||
stageCellEdit: (input: {
|
||||
tabId: string;
|
||||
schema: string;
|
||||
@@ -223,6 +225,7 @@ const initialState = {
|
||||
extensions: null as ExtensionInfo[] | null,
|
||||
indexes: null as IndexInfo[] | null,
|
||||
constraints: null as ConstraintInfo[] | null,
|
||||
roles: [] as RoleInfo[],
|
||||
schemaTreeLoading: false,
|
||||
};
|
||||
|
||||
@@ -577,6 +580,7 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
|
||||
setExtensions: (extensions) => set({ extensions }),
|
||||
setIndexes: (indexes) => set({ indexes }),
|
||||
setConstraints: (constraints) => set({ constraints }),
|
||||
setRoles: (roles) => set({ roles }),
|
||||
setSchemaTreeLoading: (loading) => set({ schemaTreeLoading: loading }),
|
||||
|
||||
stageCellEdit: (input) => {
|
||||
|
||||
Reference in New Issue
Block a user