Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
751746f784 | ||
|
|
32a7b852ec | ||
|
|
9fb3222956 | ||
|
|
76cd1ded16 | ||
|
|
a424e31de8 | ||
|
|
7401e91a12 | ||
|
|
0c74d77e75 | ||
|
|
8d8ed78202 | ||
|
|
0c8c240384 | ||
|
|
0b8b11cef3 | ||
|
|
0d6c9a039a | ||
|
|
1d72a174dd | ||
|
|
cebd548568 | ||
|
|
d84c70a1be | ||
|
|
04e8ed3300 | ||
|
|
cbc54119ed | ||
|
|
89fd35f697 |
@@ -63,6 +63,247 @@ jobs:
|
|||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build PostgreSQL client tools (bundled)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
PG_VER="16.4"
|
||||||
|
OUT="${GITHUB_WORKSPACE}/src-tauri/resources/pg_tools"
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
case "${{ matrix.platform }}" in
|
||||||
|
ubuntu-22.04)
|
||||||
|
sudo apt-get update -y
|
||||||
|
sudo apt-get install -y build-essential libreadline-dev zlib1g-dev flex bison patchelf
|
||||||
|
curl -fsSL "https://ftp.postgresql.org/pub/source/v${PG_VER}/postgresql-${PG_VER}.tar.bz2" -o /tmp/pg.tar.bz2
|
||||||
|
tar -xf /tmp/pg.tar.bz2 -C /tmp
|
||||||
|
cd /tmp/postgresql-${PG_VER}
|
||||||
|
./configure --prefix=/tmp/pgbuild --without-readline --without-icu CFLAGS="-O2"
|
||||||
|
# Build the shared prerequisites (catalog headers + libpq/common/
|
||||||
|
# port archives) SERIALLY FIRST. Parallel `make -C` runs recurse
|
||||||
|
# into src/common concurrently and race `rm -f` / `ar crs` on the
|
||||||
|
# same .a — intermittently failing with 'ar: libpgcommon_srv.a:
|
||||||
|
# file format not recognized'. Once the archives exist, the tools'
|
||||||
|
# -j builds find them up to date and those recursions are no-ops.
|
||||||
|
make -C src/backend generated-headers
|
||||||
|
make -C src/interfaces/libpq all
|
||||||
|
make -j"$(nproc)" -C src/bin/pg_dump all
|
||||||
|
make -j"$(nproc)" -C src/bin/psql all
|
||||||
|
cp src/bin/pg_dump/pg_dump src/bin/pg_dump/pg_restore "$OUT"/
|
||||||
|
cp src/bin/psql/psql "$OUT"/
|
||||||
|
# pg_dump links shared libpq (PG16 has no --disable-shared for the
|
||||||
|
# client tools); bundle libpq.so alongside and point the loader at
|
||||||
|
# the app's resource dir via $ORIGIN rpath.
|
||||||
|
cp src/interfaces/libpq/libpq.so.5 "$OUT"/libpq.so.5
|
||||||
|
for b in pg_dump pg_restore psql; do
|
||||||
|
patchelf --set-rpath '$ORIGIN' "$OUT/$b"
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
macos-latest|macos-15-intel)
|
||||||
|
curl -fsSL "https://ftp.postgresql.org/pub/source/v${PG_VER}/postgresql-${PG_VER}.tar.bz2" -o /tmp/pg.tar.bz2
|
||||||
|
tar -xf /tmp/pg.tar.bz2 -C /tmp
|
||||||
|
cd /tmp/postgresql-${PG_VER}
|
||||||
|
# ac_cv_func_strchrnul=no: Xcode 16.4's SDK marks strchrnul as
|
||||||
|
# introduced in macOS 15.4, so configure detects it and PG's
|
||||||
|
# snprintf.c calls the system symbol — which fails to compile
|
||||||
|
# (-Werror=unguarded-availability-new) and would crash at runtime
|
||||||
|
# on macOS < 15.4. Forcing the check off makes PG use its own
|
||||||
|
# inline fallback instead.
|
||||||
|
ac_cv_func_strchrnul=no ./configure --prefix=/tmp/pgbuild --without-readline --without-icu CFLAGS="-O2"
|
||||||
|
# macOS SDKs always declare strchrnul in <string.h> (Xcode 16.4
|
||||||
|
# marks it 'introduced in macOS 15.4'), so even with HAVE_STRCHRNUL
|
||||||
|
# disabled PG16's static-inline fallback would collide with the
|
||||||
|
# header declaration on newer SDKs. Rename PG's fallback so the
|
||||||
|
# tools compile on any SDK and never touch the (15.4+ only)
|
||||||
|
# system symbol.
|
||||||
|
sed -i '' 's/strchrnul/pg_strchrnul/g' src/port/snprintf.c
|
||||||
|
# Serial shared-prerequisite build first (see the Linux block: the
|
||||||
|
# archives must exist before the -j tool builds recurse into
|
||||||
|
# src/common).
|
||||||
|
make -C src/backend generated-headers
|
||||||
|
make -C src/interfaces/libpq all
|
||||||
|
make -j"$(sysctl -n hw.ncpu)" -C src/bin/pg_dump all
|
||||||
|
make -j"$(sysctl -n hw.ncpu)" -C src/bin/psql all
|
||||||
|
cp src/bin/pg_dump/pg_dump src/bin/pg_dump/pg_restore "$OUT"/
|
||||||
|
cp src/bin/psql/psql "$OUT"/
|
||||||
|
cp src/interfaces/libpq/libpq.5.dylib "$OUT"/libpq.5.dylib
|
||||||
|
# Rewrite the absolute /tmp/pgbuild libpq install_name to a
|
||||||
|
# relative @loader_path so the tools find libpq next to themselves.
|
||||||
|
for b in pg_dump pg_restore psql; do
|
||||||
|
libpq=$(otool -L "$OUT/$b" | awk '/libpq/ {print $1; exit}')
|
||||||
|
install_name_tool -change "$libpq" "@loader_path/libpq.5.dylib" "$OUT/$b"
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
windows-latest)
|
||||||
|
URL="https://get.enterprisedb.com/postgresql/postgresql-${PG_VER}-1-windows-x64-binaries.zip"
|
||||||
|
curl -fsSL "$URL" -o /tmp/pg.zip
|
||||||
|
EXPECTED="3508d8f085bc3980f38211a82e3f31e5fcae9952105d3dc2f8be67b64a822baa"
|
||||||
|
echo "$EXPECTED /tmp/pg.zip" | sha256sum -c -
|
||||||
|
sha256sum /tmp/pg.zip
|
||||||
|
unzip -o /tmp/pg.zip -d /tmp/pg
|
||||||
|
cp /tmp/pg/pgsql/bin/pg_dump.exe /tmp/pg/pgsql/bin/pg_restore.exe /tmp/pg/pgsql/bin/psql.exe "$OUT"/
|
||||||
|
# pg_dump.exe needs far more than libpq.dll (libcrypto-3-x64.dll,
|
||||||
|
# libssl-3-x64.dll, libiconv-2.dll, libintl-9.dll,
|
||||||
|
# libwinpthread-1.dll, liblz4.dll, libzstd.dll, ICU DLLs, ...);
|
||||||
|
# copy every DLL next to the tools.
|
||||||
|
cp /tmp/pg/pgsql/bin/*.dll "$OUT"/
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
(cd "$OUT" && sha256sum * | tee checksums.txt)
|
||||||
|
# Resolve the per-platform binary suffix WITHOUT a command
|
||||||
|
# substitution: `$( [ ... ] && echo .exe )` returns exit 1 when the
|
||||||
|
# test is false, and under `set -e` that aborts the whole step.
|
||||||
|
if [ "${{ matrix.platform }}" = windows-latest ]; then
|
||||||
|
BIN_EXT=".exe"
|
||||||
|
else
|
||||||
|
BIN_EXT=""
|
||||||
|
fi
|
||||||
|
for b in pg_dump pg_restore psql; do
|
||||||
|
f="$OUT/${b}${BIN_EXT}"
|
||||||
|
test -f "$f" || { echo "missing $f"; exit 1; }
|
||||||
|
done
|
||||||
|
# Sanity: every tool must run (loader path is correct) — this catches
|
||||||
|
# a wrong @loader_path / rpath before we ship a broken bundle.
|
||||||
|
for b in pg_dump pg_restore psql; do
|
||||||
|
"$OUT/${b}${BIN_EXT}" --version >/dev/null 2>&1 || { echo "$b failed to run from resource dir"; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Build 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
|
- name: Build and upload to GitHub Release
|
||||||
uses: tauri-apps/tauri-action@v0
|
uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
@@ -72,3 +313,7 @@ jobs:
|
|||||||
releaseName: 'Gridline ${{ github.ref_name }}'
|
releaseName: 'Gridline ${{ github.ref_name }}'
|
||||||
releaseDraft: true
|
releaseDraft: true
|
||||||
args: ${{ matrix.args }}
|
args: ${{ matrix.args }}
|
||||||
|
# Assets use tauri-action's default versioned naming (e.g.
|
||||||
|
# Gridline_0.7.5_aarch64.dmg, Gridline-0.7.5-1.x86_64.rpm) and the
|
||||||
|
# README download tables link to them statically per release —
|
||||||
|
# remember to update both README tables when cutting a new version.
|
||||||
@@ -149,6 +149,26 @@ cargo build # Rust backend only (from src-tauri/)
|
|||||||
cargo test # Rust tests
|
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 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.8/<file>`). tauri-action uses default versioned asset names (`Gridline_<ver>_aarch64.dmg`, `Gridline-<ver>-1.x86_64.rpm`, etc.) — update BOTH tables to the new names on every release (see the MAINTENANCE comment in README.md).
|
||||||
|
- **Bundled pg tools:** `tauri.conf.json` `bundle.resources` lists `resources/pg_tools/*`; the `release.yml` matrix builds/downloads + checksum-verifies the static binaries before the Tauri build step.
|
||||||
|
|
||||||
### Adding a Tauri Command
|
### Adding a Tauri Command
|
||||||
|
|
||||||
1. Define the command function in the appropriate `src-tauri/src/commands/` module
|
1. Define the command function in the appropriate `src-tauri/src/commands/` module
|
||||||
@@ -171,7 +191,7 @@ cargo test # Rust tests
|
|||||||
|
|
||||||
## Constraints & Guardrails
|
## Constraints & Guardrails
|
||||||
|
|
||||||
- **Do NOT** implement `pg_dump` file format parsing — always shell out to system binaries
|
- **Do NOT** implement `pg_dump` file format parsing — always shell out to (bundled or system) binaries
|
||||||
- **Do NOT** store passwords in SQLite or local files — use OS keychain APIs exclusively
|
- **Do NOT** store passwords in SQLite or local files — use OS keychain APIs exclusively
|
||||||
- **Do NOT** render large query results in raw DOM — always use the virtualized grid component
|
- **Do NOT** render large query results in raw DOM — always use the virtualized grid component
|
||||||
- **Do NOT** log credentials, connection strings, or query data
|
- **Do NOT** log credentials, connection strings, or query data
|
||||||
@@ -200,10 +220,12 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
| DB Viewer: MySQL browse + query + edit | ✅ | Full viewer: connect (SSL + SSH tunnel), databases/tables/columns/FKs, query + pagination, inline cell editing + changes queue, DDL copy (`SHOW CREATE TABLE`), CSV/JSON import — added in v0.7.0. PK-only editing (no ctid equivalent); VARBINARY `information_schema` columns decoded correctly |
|
| DB Viewer: MySQL browse + query + edit | ✅ | Full viewer: connect (SSL + SSH tunnel), databases/tables/columns/FKs, query + pagination, inline cell editing + changes queue, DDL copy (`SHOW CREATE TABLE`), CSV/JSON import — added in v0.7.0. PK-only editing (no ctid equivalent); VARBINARY `information_schema` columns decoded correctly |
|
||||||
| DB Viewer: Redis browse | ❌ | Connection + test only; browsing gated off with a clean "not supported" state (v0.7.0) |
|
| DB Viewer: Redis browse | ❌ | Connection + test only; browsing gated off with a clean "not supported" state (v0.7.0) |
|
||||||
| Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager |
|
| Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager |
|
||||||
|
| Enable keychain toggle | ✅ | Default ON (opt-out); OFF = don't persist the DB password (session-only, re-prompt on connect) + purge existing keychain entry; SSH secrets stay keychain-only (v0.7.6) |
|
||||||
| SSH tunnel config UI | ✅ | Host, port, user, auth method, key path, passphrase fields |
|
| SSH tunnel config UI | ✅ | Host, port, user, auth method, key path, passphrase fields |
|
||||||
| SSH tunnel runtime | ✅ | Real ssh2 tunnel (password + key auth), binds 127.0.0.1 only, secrets in OS keychain (`ssh_password:<id>` / `ssh_passphrase:<id>`), closed on pool eviction / app exit; TLS downgraded to `require` through the tunnel |
|
| 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 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) |
|
| 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
|
### Home Screen & Organization
|
||||||
| Feature | Status | Details |
|
| Feature | Status | Details |
|
||||||
@@ -248,10 +270,11 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
| Column show/hide | ✅ | Toggle visibility per column |
|
| Column show/hide | ✅ | Toggle visibility per column |
|
||||||
| Column resize (drag handle) | ✅ | Double-click to auto-fit |
|
| Column resize (drag handle) | ✅ | Double-click to auto-fit |
|
||||||
| Row selection (checkboxes + select all) | ✅ | Bulk copy (JSON/CSV/SQL) and delete |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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) |
|
| 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 |
|
| Edit connection modal (from DB viewer) | ✅ | AnimatedModal with keychain password fetch on test |
|
||||||
@@ -262,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 |
|
| 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 |
|
| 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 |
|
| 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)
|
### Object Explorer (non-table objects)
|
||||||
| Feature | Status | Details |
|
| Feature | Status | Details |
|
||||||
@@ -276,7 +312,15 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
| Constraints (CHECK, UNIQUE beyond PK/FK) | ✅ | CHECK/UNIQUE constraints beyond PK/FK, introspected via information_schema |
|
| Constraints (CHECK, UNIQUE beyond PK/FK) | ✅ | CHECK/UNIQUE constraints beyond PK/FK, introspected via information_schema |
|
||||||
| Materialized views | ✅ | Distinct icon in table tree, browsable, read-only (via pg_matviews) |
|
| Materialized views | ✅ | Distinct icon in table tree, browsable, read-only (via pg_matviews) |
|
||||||
| Stored procedures | ✅ | Procedures object type filters prokind='p'; Functions now filters kind='f' |
|
| Stored procedures | ✅ | Procedures object type filters prokind='p'; Functions now filters kind='f' |
|
||||||
|
| Schema CRUD | ✅ | Create / rename / drop schemas from the object tree; CASCADE drop with typed-name confirm + dependency warning (v0.7.5) |
|
||||||
|
| Global object search | ✅ | Cmd+K palette in the DB viewer, current-schema scope, all object types; results open a table tab or jump to the Objects view (v0.7.5) |
|
||||||
|
| Copy as DDL for any object | ✅ | `CREATE` DDL for every browsable type (tables via pg_dump; pg_get_*def passthrough; sequences/enums/extensions/views synthesized) (v0.7.5) |
|
||||||
|
| Object dependencies | ✅ | `pg_depend` "what depends on this?" view, shown before destructive drops (table drop, schema drop) (v0.7.5) |
|
||||||
|
| Object management CRUD | ✅ | Right-click / ⋮ create/edit/drop for every PG object type; create/edit open as **workspace tabs** with a Visual ⇄ SQL toggle, staged through the changes queue with generated-SQL preview (v0.7.6). Enum value removal unsupported (PG has no DROP VALUE) |
|
||||||
|
| Objects view tabbed workspace | ✅ | Objects view uses the shared tabbed workspace: object details open as tabs (per-type icons), inline manual query tab, and the changes queue reachable from the tab bar (v0.7.6) |
|
||||||
| Schema visualizer (ER diagram) | ✅ | Full React Flow ER diagram with dagre auto-layout, crow's foot notation, schema selector, legend with cardinality colors, collapsible columns (PK/FK/unique-only), cross-schema FK support. PostgreSQL (single round-trip LATERAL query) + SQLite (PRAGMA). Uses @xyflow/react + dagre. |
|
| 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
|
### Query Editor
|
||||||
| Feature | Status | Details |
|
| Feature | Status | Details |
|
||||||
@@ -287,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) |
|
| 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) |
|
| 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 | ❌ | |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
@@ -301,7 +346,9 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
| Restore UI | ✅ | In-page view: file browse, format, clean toggle, destructive confirmation checkbox, progress bar. **Clean toggle is disabled for plain format** (psql can't DROP-before-CREATE) with a hint to use Custom Archive |
|
| Restore UI | ✅ | In-page view: file browse, format, clean toggle, destructive confirmation checkbox, progress bar. **Clean toggle is disabled for plain format** (psql can't DROP-before-CREATE) with a hint to use Custom Archive |
|
||||||
| DB-to-DB sync | ✅ | In-page view: source/target connection pickers, schema dropdown, pipe-based pg_dump → pg_restore. **pg_restore side passes `--clean --if-exists`**, so sync works into a non-empty target (UI already requires destructive-overwrite confirmation). Core logic in headless-testable `run_db_sync` |
|
| 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 |
|
| 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 |
|
||||||
| SQLite .dump | ❌ | |
|
| 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 (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) | ❌ | |
|
| Table structure export (DDL) | ❌ | |
|
||||||
|
|
||||||
### Settings
|
### Settings
|
||||||
@@ -309,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`) |
|
| 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) |
|
| 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`) |
|
| 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` |
|
| 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 |
|
| Default folder for new connections | ✅ | Honored on startup — Home opens into `default_folder_id` unless the user has already navigated |
|
||||||
@@ -321,7 +369,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
| More keyboard shortcuts | ❌ | Only 2 configurable actions |
|
| More keyboard shortcuts | ❌ | Only 2 configurable actions |
|
||||||
| Editor settings | ✅ | Five options wired to the settings store + live Monaco `updateOptions` |
|
| Editor settings | ✅ | Five options wired to the settings store + live Monaco `updateOptions` |
|
||||||
| SSH key management | ❌ | Only path inputs, no key file reading |
|
| 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
|
### Demo & Onboarding
|
||||||
| Feature | Status | Details |
|
| Feature | Status | Details |
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="./screenshots/data-grid.png" alt="Gridline data grid with FK preview" width="92%" style="border-radius: 14px;" />
|
<img src="./screenshots/data-grid.png" alt="Gridline data grid with FK preview" width="92%" style="border-radius: 14px;" />
|
||||||
</p>
|
</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>
|
<h1>Gridline</h1>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<i>A lightweight, open-source database GUI for PostgreSQL, MySQL, SQLite, and Redis.</i><br />
|
<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.
|
Unlimited connections, tabs, and saved queries — with first-class backup, restore, and DB-to-DB sync for PostgreSQL, MySQL, and SQLite.
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<sub><b>Data Grid & FK Preview</b> — browse 50+ real demo orders and inspect related rows in one click.</sub>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
@@ -29,12 +28,43 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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.8**):
|
||||||
|
|
||||||
|
| OS | Architecture | Download |
|
||||||
|
| :--------------------------- | :--------------------------- | :------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.8_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_aarch64.dmg) |
|
||||||
|
| **macOS** | Intel | [Gridline_0.7.8_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_x64.dmg) |
|
||||||
|
| **Windows** | x64 | [Gridline_0.7.8_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_x64-setup.exe) |
|
||||||
|
| **Debian / Ubuntu** | amd64 | [Gridline_0.7.8_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_amd64.deb) |
|
||||||
|
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.8-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline-0.7.8-1.x86_64.rpm) |
|
||||||
|
| **Other Linux** | amd64 | [Gridline_0.7.8_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_amd64.AppImage) |
|
||||||
|
|
||||||
|
> Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below. All installers are **unsigned** — see the [notes](#which-file-should-i-download) on first-launch warnings.
|
||||||
|
|
||||||
|
<!--
|
||||||
|
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.7.8
|
||||||
|
release assets, not at a moving "latest" target. On every new release,
|
||||||
|
update BOTH tables here (Download + Which file should I download?) to the
|
||||||
|
new version's asset names, which are tauri-action's default naming:
|
||||||
|
Gridline_<version>_aarch64.dmg / _x64.dmg / _x64-setup.exe /
|
||||||
|
_amd64.deb / _amd64.AppImage and Gridline-<version>-1.x86_64.rpm
|
||||||
|
The .msi (Gridline_<version>_x64_en-US.msi) is not linked below.
|
||||||
|
-->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## What is Gridline?
|
## What is Gridline?
|
||||||
|
|
||||||
Gridline is a modern, open-source database GUI client built with [Tauri 2.0](https://tauri.app), Rust, and React. It is lightweight by design (~40 MB baseline), powerful by default, and free from the paywalls that limit commercial alternatives.
|
Gridline is a modern, open-source database GUI client built with [Tauri 2.0](https://tauri.app), Rust, and React. It is lightweight by design (~40 MB baseline), powerful by default, and free from the paywalls that limit commercial alternatives.
|
||||||
|
|
||||||
- **No caps** on connections, tabs, or saved queries.
|
- **No caps** on connections, tabs, or saved queries.
|
||||||
- **Deep PostgreSQL tooling** — visual `pg_dump`, `pg_restore`, and DB-to-DB sync.
|
- **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 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.
|
- **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.
|
- **Interactive ER diagram** — explore relationships visually with crow's-foot cardinality notation.
|
||||||
@@ -56,7 +86,17 @@ Gridline is built for developers and small teams who manage multiple database en
|
|||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
|
||||||
- **2026-08-04:** v0.7.0 — revamped the New Connection screen into a two-stage flow with a 6-provider grid (PostgreSQL, MySQL, SQLite, Redis, Supabase, NeonDB; managed presets ship with setup guides + SSL hints) and added full MySQL DB viewer support (connect, browse, query, inline cell editing + changes queue, DDL copy).
|
<details>
|
||||||
|
<summary>View the full changelog</summary>
|
||||||
|
|
||||||
|
- **v0.7.8** — **SQLite `.dump` backup/restore** plus **backup / restore / sync for MySQL & SQLite** (mysqldump-based, system-first); **Excel (.xlsx) export** in the grid toolbar alongside CSV/JSON/SQL/Markdown; **cancel long-running queries** per connection; **settings export/import** (JSON); **Windows/Linux title bar fix** (macOS-only overlay drag strip); **SQLite table editor** (Create/Edit Table with SQLite-aware type mapping); version bump.
|
||||||
|
|
||||||
|
- **v0.7.7** — PostgreSQL **roles & grants** management (create/edit/drop roles with attributes + a per-role privilege explorer across tables/sequences/routines/schemas/databases — collapsible grouped lists with GRANT/REVOKE staging) and **table maintenance** (VACUUM / ANALYZE / REINDEX from the table menu). **Create Table / Edit Table** visual editor: columns grid with type dropdowns and drag-to-reorder, single + composite primary keys, column-diff staging (ADD / DROP / RENAME COLUMN, ALTER TYPE, SET | DROP DEFAULT, SET | DROP NOT NULL — one statement per queue item), atomic column-reorder table rebuild (single transaction, preserves constraints/indexes/FKs/grants/sequences, fail-closed for triggers/RLS/inheritance/partitioning). **FK management** — multi-column composer with cross-schema references and ON DELETE/UPDATE, inlined into CREATE TABLE; **table options** (tablespace, row-level security); changes queue auto-refreshes the object tree after schema-modifying commits; version bump.
|
||||||
|
|
||||||
|
- **v0.7.6** — Full PostgreSQL object management (create/edit/drop for enums, functions, procedures, triggers, sequences, extensions, views, materialized views, indexes, constraints) staged through the changes queue with generated-SQL previews; Enable Keychain toggle wired (default ON, opt-out; OFF = session-only); Objects view upgraded to the shared tabbed workspace (object detail tabs with per-type icons, inline manual query + changes queue); ⌘K object search fixes; version bump.
|
||||||
|
|
||||||
|
- **2026-08-05:** v0.7.5 — bundled `pg_dump`/`pg_restore`/`psql` (system-first, bundled fallback) so admin features work with no separate install; schema CRUD (create/rename/drop with CASCADE + dependency warning); Cmd+K object search (current schema, all object types); copy-as-DDL for every browsable object type; `pg_depend` object-dependency view shown before destructive drops.
|
||||||
|
- **2026-08-04:** v0.7.5 — revamped the New Connection screen into a two-stage flow with a 6-provider grid (PostgreSQL, MySQL, SQLite, Redis, Supabase, NeonDB; managed presets ship with setup guides + SSL hints) and added full MySQL DB viewer support (connect, browse, query, inline cell editing + changes queue, DDL copy).
|
||||||
- **2026-08-04:** Revamped the built-in SQLite demo database with realistic e-commerce data (20 users, 24 products, 50 orders, 100 page views, 500 audit rows) and renamed it to **Gridline Demo (SQLite)**.
|
- **2026-08-04:** Revamped the built-in SQLite demo database with realistic e-commerce data (20 users, 24 products, 50 orders, 100 page views, 500 audit rows) and renamed it to **Gridline Demo (SQLite)**.
|
||||||
- **2026-07-XX:** Added inline cell editing with a stage-first changes queue, row-detail drawer, keyboard navigation, and cell-level copy.
|
- **2026-07-XX:** Added inline cell editing with a stage-first changes queue, row-detail drawer, keyboard navigation, and cell-level copy.
|
||||||
- **2026-07-XX:** Added visual filter builder with drag-and-drop column palette and type-aware operators.
|
- **2026-07-XX:** Added visual filter builder with drag-and-drop column palette and type-aware operators.
|
||||||
@@ -67,12 +107,17 @@ Gridline is built for developers and small teams who manage multiple database en
|
|||||||
|
|
||||||
> See the full history in the [roadmap](#roadmap) below or the [git log](./commits).
|
> See the full history in the [roadmap](#roadmap) below or the [git log](./commits).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Key Features
|
## Key Features
|
||||||
|
|
||||||
### Connections & Workspace
|
### Connections & Workspace
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show features</summary>
|
||||||
|
|
||||||
- **URI auto-fill** — paste `postgres://`, `mysql://`, `sqlite://`, or `redis://` strings and have all fields populate automatically.
|
- **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.
|
- **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.
|
- **Workspace tree** — multi-level folders, color-coded tags, favorites, and recent connections.
|
||||||
@@ -81,16 +126,33 @@ Gridline is built for developers and small teams who manage multiple database en
|
|||||||
- **TLS / SSL** — full PostgreSQL/MySQL TLS modes plus client-certificate support.
|
- **TLS / SSL** — full PostgreSQL/MySQL TLS modes plus client-certificate support.
|
||||||
- **Connection status** — on-demand per-card test with real server version and latency.
|
- **Connection status** — on-demand per-card test with real server version and latency.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
### Schema Explorer
|
### Schema Explorer
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show features</summary>
|
||||||
|
|
||||||
- **Tables, views, and materialized views** — column metadata, PK/FK, defaults, nullable flags.
|
- **Tables, views, and materialized views** — column metadata, PK/FK, defaults, nullable flags.
|
||||||
- **Functions & procedures** — syntax-highlighted source, argument signatures, overload disambiguation.
|
- **Functions & procedures** — syntax-highlighted source, argument signatures, overload disambiguation.
|
||||||
- **Triggers, sequences, enums, extensions** — unified **Objects** view with type switcher.
|
- **Triggers, sequences, enums, extensions** — unified **Objects** view with type switcher.
|
||||||
- **Indexes & constraints** — per-table index details plus CHECK/UNIQUE constraints beyond PK/FK.
|
- **Indexes & constraints** — per-table index details plus CHECK/UNIQUE constraints beyond PK/FK.
|
||||||
|
- **Schema CRUD** — create/rename/drop schemas from the object tree, with typed-name + dependency warning on CASCADE drops.
|
||||||
|
- **Global object search** — ⌘K, current schema, all object types; results open a table tab or jump to the Objects view.
|
||||||
|
- **Copy as DDL** — `CREATE` DDL for every browsable object type.
|
||||||
|
- **Object dependencies** — `pg_depend` "what depends on this?" view before destructive drops.
|
||||||
- **Schema visualizer** — interactive ER diagram with auto-layout, cardinality legend, and collapsible columns.
|
- **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
|
### Data Grid
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show features</summary>
|
||||||
|
|
||||||
- **Virtualized rows** — handles 100k+ rows via `@tanstack/react-virtual`.
|
- **Virtualized rows** — handles 100k+ rows via `@tanstack/react-virtual`.
|
||||||
- **Server-side filtering & sorting** — pushed to SQL `WHERE`/`ORDER BY`.
|
- **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.
|
- **FK preview** — click the ↗ icon on a foreign-key cell to inspect the referenced row or open a filtered tab.
|
||||||
@@ -101,8 +163,13 @@ Gridline is built for developers and small teams who manage multiple database en
|
|||||||
- **Export** — JSON, CSV, SQL, and Markdown downloads of visible rows.
|
- **Export** — JSON, CSV, SQL, and Markdown downloads of visible rows.
|
||||||
- **Auto-refresh** — configurable interval timer.
|
- **Auto-refresh** — configurable interval timer.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
### Query Workbench
|
### Query Workbench
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show features</summary>
|
||||||
|
|
||||||
- **Monaco SQL editor** — lazy-loaded, with keywords + table/column autocomplete.
|
- **Monaco SQL editor** — lazy-loaded, with keywords + table/column autocomplete.
|
||||||
- **Custom query execution** — arbitrary SQL with destructive-query confirmation.
|
- **Custom query execution** — arbitrary SQL with destructive-query confirmation.
|
||||||
- **Query tabs** — unlimited tabs, close with `Cmd/Ctrl+W`.
|
- **Query tabs** — unlimited tabs, close with `Cmd/Ctrl+W`.
|
||||||
@@ -110,11 +177,24 @@ Gridline is built for developers and small teams who manage multiple database en
|
|||||||
- **Saved queries** — name, folder, and manage them in the Queries view.
|
- **Saved queries** — name, folder, and manage them in the Queries view.
|
||||||
- **Changes queue** — stage edits, review generated SQL, revert per change, then commit all.
|
- **Changes queue** — stage edits, review generated SQL, revert per change, then commit all.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
### PostgreSQL Admin Tools
|
### PostgreSQL Admin Tools
|
||||||
|
|
||||||
- **Visual Backup** — `pg_dump` wrapper with format selector, schema filter, no-owner toggle, real-time progress.
|
<details>
|
||||||
- **Visual Restore** — `pg_restore` wrapper with clean toggle and destructive confirmation.
|
<summary>Show features</summary>
|
||||||
- **DB-to-DB Sync** — pipe `pg_dump` → `pg_restore` between two connections.
|
|
||||||
|
- **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>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -166,7 +246,7 @@ Gridline is built for developers and small teams who manage multiple database en
|
|||||||
Capabilities below are fact-checked against each vendor's official docs and pricing (May 2026).
|
Capabilities below are fact-checked against each vendor's official docs and pricing (May 2026).
|
||||||
|
|
||||||
| Capability | DB Pro (Free) | Beekeeper (Free) | TablePlus (Free) | Gridline |
|
| Capability | DB Pro (Free) | Beekeeper (Free) | TablePlus (Free) | Gridline |
|
||||||
| :----------------------------- | :--------------------------: | :-----------------------------: | :-------------------------: | :--------------------------------------------------------: |
|
| :----------------------------- | :---------------------------: | :-------------------------------: | :----------------------------------: | :-----------------------------------------------------------------: |
|
||||||
| Open tabs | 3 | Unlimited | 2 | **Unlimited** |
|
| Open tabs | 3 | Unlimited | 2 | **Unlimited** |
|
||||||
| Saved connections | 2 | Unlimited | Unlimited | **Unlimited** |
|
| Saved connections | 2 | Unlimited | Unlimited | **Unlimited** |
|
||||||
| Saved queries | 5 | Unlimited | Unlimited | **Unlimited** |
|
| Saved queries | 5 | Unlimited | Unlimited | **Unlimited** |
|
||||||
@@ -182,18 +262,18 @@ Capabilities below are fact-checked against each vendor's official docs and pric
|
|||||||
| Desktop shell size | Electron | Electron (~250 MB) | Native | **Tauri 2.0 (~40 MB)** |
|
| Desktop shell size | Electron | Electron (~250 MB) | Native | **Tauri 2.0 (~40 MB)** |
|
||||||
| Open source | ❌ | ✅ GPLv3 | ❌ | **✅ Apache 2.0** |
|
| 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
|
## Tech Stack
|
||||||
|
|
||||||
| Layer | Technology | Role |
|
| Layer | Technology | Role |
|
||||||
| :------------------ | :-------------------------------------------------------------------------------------------------------- | :----------------------------------------------- |
|
| :------------------ | :-------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------- |
|
||||||
| **Desktop shell** | [Tauri 2.0](https://tauri.app) | Native webview container (~40 MB baseline) |
|
| **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 |
|
| **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 |
|
| **DB drivers** | [sqlx](https://github.com/launchbadge/sqlx) / [tokio-postgres](https://github.com/sfackler/rust-postgres) | PostgreSQL via tokio-postgres; MySQL via sqlx; SQLite via rusqlite |
|
||||||
| **CLI integration** | `std::process::Command` | Wraps system `pg_dump` / `pg_restore` |
|
| **CLI integration** | `std::process::Command` | Wraps bundled or system `pg_dump` / `pg_restore` |
|
||||||
| **Frontend** | [React 19](https://react.dev) + [TypeScript](https://www.typescriptlang.org) | Component-based UI |
|
| **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 |
|
| **Styling** | [Tailwind CSS](https://tailwindcss.com) | Utility-first, dark mode, glassmorphic design |
|
||||||
| **State** | [Zustand](https://zustand.docs.pmnd.rs) / Jotai | Domain stores |
|
| **State** | [Zustand](https://zustand.docs.pmnd.rs) / Jotai | Domain stores |
|
||||||
@@ -223,16 +303,16 @@ Code signing **will be added in the future** (Apple Developer Program + a Window
|
|||||||
|
|
||||||
#### Which file should I download?
|
#### Which file should I download?
|
||||||
|
|
||||||
Each release contains **one file per platform** — you only need the one that matches your computer. (If a newer version is available, swap `0.7.0` for the version shown in the release title.)
|
Each release contains **one file per platform** — you only need the one that matches your computer. The links below point at the current release (**v0.7.8**):
|
||||||
|
|
||||||
| Your system | Download this | Notes |
|
| Your system | Download this | Notes |
|
||||||
| :--- | :--- | :--- |
|
| :------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- |
|
||||||
| macOS **Apple Silicon** (M1/M2/M3/M4…) | `Gridline_0.7.0_aarch64.dmg` | `aarch64` = Apple's own chip |
|
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.7.8_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_aarch64.dmg) | `aarch64` = Apple's own chip |
|
||||||
| macOS **Intel** | `Gridline_0.7.0_x64.dmg` | `x64` = Intel/AMD |
|
| macOS **Intel** | [Gridline_0.7.8_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_x64.dmg) | `x64` = Intel/AMD |
|
||||||
| **Windows** (most PCs) | `Gridline_0.7.0_x64-setup.exe` | The `.msi` is an alternate installer (for enterprises/IT admins) |
|
| **Windows** (most PCs) | [Gridline_0.7.8_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
|
||||||
| **Debian / Ubuntu** | `Gridline_0.7.0_amd64.deb` | Install: `sudo apt install ./Gridline_0.7.0_amd64.deb` |
|
| **Debian / Ubuntu** | [Gridline_0.7.8_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.8_amd64.deb` |
|
||||||
| **Fedora / RHEL / openSUSE** | `Gridline-0.7.0-1.x86_64.rpm` | Install: `sudo dnf install Gridline-0.7.0-1.x86_64.rpm` |
|
| **Fedora / RHEL / openSUSE** | [Gridline-0.7.8-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline-0.7.8-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.8-1.x86_64.rpm` |
|
||||||
| **Any other Linux** | `Gridline_0.7.0_amd64.AppImage` | Works on every distro: `chmod +x` the file, then double-click it |
|
| **Any other Linux** | [Gridline_0.7.8_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.8/Gridline_0.7.8_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it |
|
||||||
|
|
||||||
**Not sure if your Mac is Intel or Apple Silicon?** Click the **Apple menu** → **About This Mac**. If it shows "Apple M1/M2/M3/M4…" download the `aarch64` file; if it shows an Intel chip, download `x64`. Downloading the wrong one won't run.
|
**Not sure if your Mac is Intel or Apple Silicon?** Click the **Apple menu** → **About This Mac**. If it shows "Apple M1/M2/M3/M4…" download the `aarch64` file; if it shows an Intel chip, download `x64`. Downloading the wrong one won't run.
|
||||||
|
|
||||||
@@ -242,13 +322,13 @@ Cutting a release is one command — CI builds everything. **Releases are cut fr
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git checkout prod && git pull
|
git checkout prod && git pull
|
||||||
git tag v0.7.0
|
git tag v0.7.8
|
||||||
git push origin v0.7.0
|
git push origin v0.7.8
|
||||||
```
|
```
|
||||||
|
|
||||||
GitHub Actions (`.github/workflows/release.yml`) builds installers for **Apple Silicon, Intel Macs, Windows, and Linux**, then opens a **draft release** on the [Releases](https://github.com/adrianbonpin/gridline/releases) page — review it and hit **Publish release**.
|
GitHub Actions (`.github/workflows/release.yml`) builds installers for **Apple Silicon, Intel Macs, Windows, and Linux**, then opens a **draft release** on the [Releases](https://github.com/adrianbonpin/gridline/releases) page — review it and hit **Publish release**.
|
||||||
|
|
||||||
Before tagging, make sure the version number is in sync across `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json`.
|
Before tagging, make sure the version number is in sync across `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json`, and update the **README download tables** (Download + Which file should I download?) to the new version's asset names.
|
||||||
|
|
||||||
### Build from source
|
### Build from source
|
||||||
|
|
||||||
@@ -273,7 +353,7 @@ bun run tauri build
|
|||||||
- **Windows:** 10 or newer
|
- **Windows:** 10 or newer
|
||||||
- **Linux:** Ubuntu 22.04+ or equivalent modern distribution
|
- **Linux:** Ubuntu 22.04+ or equivalent modern distribution
|
||||||
- **RAM:** 8 GB recommended
|
- **RAM:** 8 GB recommended
|
||||||
- **PostgreSQL client tools:** `pg_dump` and `pg_restore` are required for admin features
|
- **PostgreSQL client tools:** `pg_dump`/`pg_restore`/`psql` are **bundled** with Gridline — no separate install required for backup/restore/sync. (System tools, if installed, are preferred.)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -327,11 +407,11 @@ gridline/
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
The full plan — in-development (v0.7.0), 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:
|
Highlights of what's next:
|
||||||
|
|
||||||
- **Full Object Management** — CRUD on functions, triggers, sequences, enums, extensions, and views without the Query tab, plus schema CRUD, global object search, and copy-as-DDL
|
- **Admin follow-up** — PostgreSQL users/roles + grants management, and table maintenance actions (VACUUM / ANALYZE / REINDEX)
|
||||||
- **Full Redis support** — key browser, type-aware value editors, TTL management
|
- **Full Redis support** — key browser, type-aware value editors, TTL management
|
||||||
- **More database types** — MariaDB, TimescaleDB, and friends
|
- **More database types** — MariaDB, TimescaleDB, and friends
|
||||||
- **Managed DB support** — PlanetScale, Turso (Supabase/Neon presets shipped in v0.7.0)
|
- **Managed DB support** — PlanetScale, Turso (Supabase/Neon presets shipped in v0.7.0)
|
||||||
@@ -343,6 +423,7 @@ Highlights of what's next:
|
|||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Download](#download)
|
||||||
- [What is Gridline?](#what-is-gridline)
|
- [What is Gridline?](#what-is-gridline)
|
||||||
- [Who is it for?](#who-is-it-for)
|
- [Who is it for?](#who-is-it-for)
|
||||||
- [Recent Changes](#recent-changes)
|
- [Recent Changes](#recent-changes)
|
||||||
|
|||||||
@@ -6,47 +6,20 @@ This file is the **source of truth** for what Gridline is building. [AGENTS.md](
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ Shipped (0.7.0)
|
## 🎯 Next up (0.7.9) — TBD
|
||||||
|
|
||||||
- **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.
|
Scope to be determined.
|
||||||
- **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.
|
## ✅ Shipped (0.7.8)
|
||||||
|
|
||||||
## 🎯 Next up
|
- **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)
|
||||||
### Full Object Management (PostgreSQL)
|
- **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
|
||||||
Gridline can already **browse** every PostgreSQL object type (functions, triggers, sequences, enums, extensions, views, materialized views, procedures, indexes, constraints). Next up: full **CRUD** on those objects without ever touching the Query tab.
|
- **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)
|
||||||
- Right-click any object → create / edit / drop with a generated-SQL preview before applying
|
- **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)
|
||||||
- Enums: add/remove values, rename types
|
- **Version bump** 0.7.7 → **0.7.8**.
|
||||||
- Functions & procedures: edit signature + body, drop overloads by signature
|
|
||||||
- Triggers: create/edit/disable/enable, timing + event pickers
|
|
||||||
- Sequences: alter increment/start/min/max/cycle, restart
|
|
||||||
- Extensions: install/uninstall, schema reassignment
|
|
||||||
- Views & materialized views: edit definition, refresh matviews
|
|
||||||
- Indexes & constraints: create/drop per table with column pickers
|
|
||||||
- Staged through the changes queue (with confirmation) — never a surprise DDL
|
|
||||||
- **Out of scope this release:** the MySQL "Objects" view stays deferred (see In the queue) — this iteration is PostgreSQL-only
|
|
||||||
|
|
||||||
### Shipping alongside (companions)
|
|
||||||
|
|
||||||
- **Schema CRUD** — create/rename/drop schemas from the object tree
|
|
||||||
- **Global object search** — Cmd+K-style search across tables, functions, triggers, sequences, and extensions by name
|
|
||||||
- **Copy as DDL for any object** — `CREATE FUNCTION` / `CREATE TRIGGER` / … via the same DDL-generation used for edit/drop previews (also covers the "Table structure export" queue item)
|
|
||||||
- **Object dependencies** — `pg_depend`-based "what depends on this object?" view, shown before drops so nothing breaks silently
|
|
||||||
|
|
||||||
### Admin follow-up (after object management)
|
|
||||||
|
|
||||||
- **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
|
|
||||||
|
|
||||||
## 📋 In the queue
|
## 📋 In the queue
|
||||||
|
|
||||||
@@ -76,17 +49,14 @@ Supabase and NeonDB presets shipped in v0.7.0. Remaining candidates:
|
|||||||
### Query Workbench Upgrades
|
### Query Workbench Upgrades
|
||||||
|
|
||||||
- **Multiple result sets** — one query, multiple result tabs (stacked/scrollable) instead of only the last result *(deferred from 0.7.0)*
|
- **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
|
- **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)
|
- **Visual query builder** — drag-and-drop tables/joins/filters that generate SQL (TablePlus has one; DB Pro plans one)
|
||||||
|
|
||||||
### Schema & Data Tooling
|
### 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
|
- **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)*
|
- **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** — JSONL, Parquet alongside CSV/JSON/SQL/Markdown/Excel
|
||||||
- **More export formats** — Excel (.xlsx), JSONL, Parquet alongside CSV/JSON/SQL/Markdown
|
|
||||||
|
|
||||||
## 🔮 Planned
|
## 🔮 Planned
|
||||||
|
|
||||||
@@ -126,8 +96,61 @@ 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
|
## ✅ Shipped
|
||||||
|
|
||||||
|
- Full PostgreSQL object management — create/edit/drop staged through the changes queue (v0.7.6)
|
||||||
|
- Enable Keychain toggle — opt-out password persistence (v0.7.6)
|
||||||
|
- Objects view tabbed workspace (v0.7.6)
|
||||||
|
- Bundled PG client tools — `pg_dump`/`pg_restore`/`psql` shipped with the app, system-first with bundled fallback (v0.7.5)
|
||||||
|
- Schema CRUD — create/rename/drop schemas with CASCADE + dependency warning (v0.7.5)
|
||||||
|
- Global object search — Cmd+K across all object types in the current schema (v0.7.5)
|
||||||
|
- Copy as DDL for any object (v0.7.5)
|
||||||
|
- Object dependencies — `pg_depend` view before destructive drops (v0.7.5)
|
||||||
- Tauri 2.0 + React 19 + TypeScript 5.8 project shell
|
- Tauri 2.0 + React 19 + TypeScript 5.8 project shell
|
||||||
- PostgreSQL and SQLite browse/query support
|
- PostgreSQL and SQLite browse/query support
|
||||||
- Full MySQL DB viewer — connect, browse, query, edit + changes queue (v0.7.0)
|
- Full MySQL DB viewer — connect, browse, query, edit + changes queue (v0.7.0)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"@xyflow/react": "^12.11.2",
|
"@xyflow/react": "^12.11.2",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
"lucide-react": "^1.26.0",
|
"lucide-react": "^1.26.0",
|
||||||
"monaco-editor": "^0.56.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=="],
|
"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=="],
|
"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=="],
|
"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.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "gridline",
|
"name": "gridline",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.7.0",
|
"version": "0.7.8",
|
||||||
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
|
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"@xyflow/react": "^12.11.2",
|
"@xyflow/react": "^12.11.2",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
"lucide-react": "^1.26.0",
|
"lucide-react": "^1.26.0",
|
||||||
"monaco-editor": "^0.56.0",
|
"monaco-editor": "^0.56.0",
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
-- Gridline v0.7.5 integration-test seed (idempotent).
|
||||||
|
-- Target: the FIRST PG test DB (GRIDLINE_TEST_SRC @ :7501), db=postgres.
|
||||||
|
-- Objects are chosen to satisfy the assertions in src-tauri/src/commands/objects.test.rs:
|
||||||
|
-- search_objects: 'users' TABLE hit, 'get*' function hit, empty-needle <= 100
|
||||||
|
-- get_object_ddl: users_id_seq -> CREATE SEQUENCE; audit_log -> CREATE FUNCTION
|
||||||
|
-- get_object_dependencies: orders -> order_summary VIEW; schema contents non-empty
|
||||||
|
-- Run with: psql ... -f scripts/seed-pg-test.sql
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 1. users table with serial -> creates users_id_seq in public
|
||||||
|
DROP VIEW IF EXISTS order_summary CASCADE;
|
||||||
|
DROP TABLE IF EXISTS orders CASCADE;
|
||||||
|
DROP TABLE IF EXISTS products CASCADE;
|
||||||
|
DROP TABLE IF EXISTS users CASCADE;
|
||||||
|
DROP TYPE IF EXISTS user_role CASCADE;
|
||||||
|
DROP FUNCTION IF EXISTS audit_log(text) CASCADE;
|
||||||
|
DROP FUNCTION IF EXISTS get_user(bigint) CASCADE;
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
role TEXT NOT NULL DEFAULT 'member',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
INSERT INTO users (email, role) VALUES ('a@example.com', 'admin'), ('b@example.com', 'member');
|
||||||
|
|
||||||
|
-- 2. enum type (for enum search + enum DDL)
|
||||||
|
CREATE TYPE user_role AS ENUM ('admin', 'member', 'guest');
|
||||||
|
|
||||||
|
-- 3. products (backup tests assert 3 rows)
|
||||||
|
CREATE TABLE products (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
price NUMERIC(10,2) NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO products (name, price) VALUES ('Widget', 9.99), ('Gadget', 19.99), ('Gizmo', 29.99);
|
||||||
|
|
||||||
|
-- 4. orders + a dependent VIEW (what-depends-on-this test: dropping orders must surface order_summary)
|
||||||
|
CREATE TABLE orders (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||||
|
total NUMERIC(10,2) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
INSERT INTO orders (user_id, total) VALUES (1, 99.50), (2, 12.00), (1, 45.25);
|
||||||
|
|
||||||
|
CREATE VIEW order_summary AS
|
||||||
|
SELECT u.email, count(o.id) AS order_count, sum(o.total) AS total
|
||||||
|
FROM users u LEFT JOIN orders o ON o.user_id = u.id
|
||||||
|
GROUP BY u.email;
|
||||||
|
|
||||||
|
-- 4. functions: audit_log (DDL test) + get_user (search 'get' hit)
|
||||||
|
CREATE FUNCTION audit_log(msg text) RETURNS void AS $$
|
||||||
|
SELECT pg_sleep(0); -- placeholder body so the function is real
|
||||||
|
$$ LANGUAGE sql;
|
||||||
|
|
||||||
|
CREATE FUNCTION get_user(uid bigint) RETURNS TABLE(email text, role text) AS $$
|
||||||
|
SELECT u.email, u.role FROM users u WHERE u.id = uid;
|
||||||
|
$$ LANGUAGE sql STABLE;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- Verify
|
||||||
|
\echo '--- seeded objects ---'
|
||||||
|
SELECT c.relkind::text || ' ' || c.relname
|
||||||
|
FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid
|
||||||
|
WHERE n.nspname = 'public' AND c.relkind IN ('r','v','S')
|
||||||
|
ORDER BY 1;
|
||||||
|
\echo '--- public functions ---'
|
||||||
|
SELECT p.proname FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid
|
||||||
|
WHERE n.nspname = 'public' ORDER BY 1;
|
||||||
|
\echo '--- enums ---'
|
||||||
|
SELECT t.typname FROM pg_type t JOIN pg_namespace n ON t.typnamespace = n.oid
|
||||||
|
WHERE n.nspname = 'public' AND t.typtype = 'e';
|
||||||
@@ -1783,7 +1783,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gridline"
|
name = "gridline"
|
||||||
version = "0.7.0"
|
version = "0.7.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"deadpool-postgres",
|
"deadpool-postgres",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gridline"
|
name = "gridline"
|
||||||
version = "0.7.0"
|
version = "0.7.8"
|
||||||
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
|
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 1002 B After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 74 KiB |
@@ -0,0 +1,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,8 @@
|
|||||||
|
# Bundled PostgreSQL client tools
|
||||||
|
|
||||||
|
This directory is populated **at build time by CI** (`.github/workflows/release.yml`)
|
||||||
|
with statically-linked `pg_dump`, `pg_restore`, and `psql` for the current target.
|
||||||
|
Binaries are NOT committed to the repo.
|
||||||
|
|
||||||
|
In `tauri dev`, this dir is usually empty — the app falls back to any `pg_dump`/`pg_restore`
|
||||||
|
on `PATH` (system-first resolution). Admin features degrade with a clear error if neither is present.
|
||||||
@@ -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,5 +1,7 @@
|
|||||||
|
use rusqlite::{types::ValueRef, Connection};
|
||||||
|
use std::io::Write;
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use tauri::{AppHandle, Emitter, State};
|
use tauri::{AppHandle, Emitter, Manager, State};
|
||||||
|
|
||||||
use crate::models::backup::*;
|
use crate::models::backup::*;
|
||||||
|
|
||||||
@@ -51,20 +53,63 @@ fn sanitize_error(s: &str) -> String {
|
|||||||
crate::commands::test_connection::sanitize_error(s)
|
crate::commands::test_connection::sanitize_error(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn bundled_bin_name(tool: &str) -> String {
|
||||||
|
if cfg!(windows) { format!("{tool}.exe") } else { tool.to_string() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure resolution decision (unit-testable): system > bundled > bare name.
|
||||||
|
fn pick_tool(system_ok: bool, bundled: Option<&str>, tool: &str) -> (String, Option<String>) {
|
||||||
|
if system_ok { return (tool.to_string(), Some("system".to_string())); }
|
||||||
|
if let Some(b) = bundled { return (b.to_string(), Some("bundled".to_string())); }
|
||||||
|
(tool.to_string(), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System-first, bundled-fallback resolver. Returns (command_to_invoke, source).
|
||||||
|
pub fn resolve_tool(app: &AppHandle, tool: &str) -> (String, Option<String>) {
|
||||||
|
let system_ok = Command::new(tool).arg("--version").output().is_ok();
|
||||||
|
let bundled = app.path().resource_dir().ok()
|
||||||
|
.map(|rd| rd.join("pg_tools").join(bundled_bin_name(tool)))
|
||||||
|
.filter(|p| p.exists())
|
||||||
|
.map(|p| p.to_string_lossy().to_string());
|
||||||
|
pick_tool(system_ok, bundled.as_deref(), tool)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_tool_paths(app: &AppHandle) -> PgToolPaths {
|
||||||
|
let (d, _) = resolve_tool(app, "pg_dump");
|
||||||
|
let (r, _) = resolve_tool(app, "pg_restore");
|
||||||
|
let (p, _) = resolve_tool(app, "psql");
|
||||||
|
PgToolPaths { pg_dump: d, pg_restore: r, psql: p }
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// detect_pg_tools
|
// detect_pg_tools
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[tauri::command]
|
/// Shapes the tool status from resolved tool paths + sources. Headless so the
|
||||||
pub fn detect_pg_tools() -> PgToolStatus {
|
/// Tauri command stays thin and the status logic stays unit-testable.
|
||||||
|
fn build_pg_tool_status(
|
||||||
|
dump: &str,
|
||||||
|
restore: &str,
|
||||||
|
dump_src: Option<String>,
|
||||||
|
restore_src: Option<String>,
|
||||||
|
) -> PgToolStatus {
|
||||||
PgToolStatus {
|
PgToolStatus {
|
||||||
pg_dump_found: Command::new("pg_dump").arg("--version").output().is_ok(),
|
pg_dump_found: Command::new(dump).arg("--version").output().is_ok(),
|
||||||
pg_restore_found: Command::new("pg_restore").arg("--version").output().is_ok(),
|
pg_restore_found: Command::new(restore).arg("--version").output().is_ok(),
|
||||||
pg_dump_version: get_version("pg_dump"),
|
pg_dump_version: get_version(dump),
|
||||||
pg_restore_version: get_version("pg_restore"),
|
pg_restore_version: get_version(restore),
|
||||||
|
pg_dump_source: dump_src,
|
||||||
|
pg_restore_source: restore_src,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn detect_pg_tools(app_handle: AppHandle) -> PgToolStatus {
|
||||||
|
let (dump, dump_src) = resolve_tool(&app_handle, "pg_dump");
|
||||||
|
let (restore, restore_src) = resolve_tool(&app_handle, "pg_restore");
|
||||||
|
build_pg_tool_status(&dump, &restore, dump_src, restore_src)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Core logic (headless-testable — no Tauri, no store, no keychain)
|
// Core logic (headless-testable — no Tauri, no store, no keychain)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -132,11 +177,11 @@ fn build_restore_args(conn: &PgConnParams, options: &RestoreOptions) -> Vec<Stri
|
|||||||
|
|
||||||
/// Runs `pg_dump` against `conn`, writing to `options.file_path`.
|
/// Runs `pg_dump` against `conn`, writing to `options.file_path`.
|
||||||
/// Returns `Ok(())` on success or a sanitized error message.
|
/// Returns `Ok(())` on success or a sanitized error message.
|
||||||
pub fn run_pg_dump(conn: &PgConnParams, options: &BackupOptions) -> Result<(), String> {
|
pub fn run_pg_dump(conn: &PgConnParams, options: &BackupOptions, tools: &PgToolPaths) -> Result<(), String> {
|
||||||
let mut args = build_dump_args(conn, options);
|
let mut args = build_dump_args(conn, options);
|
||||||
args.push(format!("--file={}", options.file_path));
|
args.push(format!("--file={}", options.file_path));
|
||||||
|
|
||||||
let result = Command::new("pg_dump")
|
let result = Command::new(&tools.pg_dump)
|
||||||
.env("PGPASSWORD", &conn.password)
|
.env("PGPASSWORD", &conn.password)
|
||||||
.args(&args)
|
.args(&args)
|
||||||
.output();
|
.output();
|
||||||
@@ -154,9 +199,9 @@ pub fn run_pg_dump(conn: &PgConnParams, options: &BackupOptions) -> Result<(), S
|
|||||||
/// Plain-format dumps are SQL text and cannot be read by `pg_restore` — they
|
/// Plain-format dumps are SQL text and cannot be read by `pg_restore` — they
|
||||||
/// are executed with `psql` instead. The `clean` option is only honored for
|
/// are executed with `psql` instead. The `clean` option is only honored for
|
||||||
/// archive formats (custom/tar/directory); the UI disables it for plain.
|
/// archive formats (custom/tar/directory); the UI disables it for plain.
|
||||||
pub fn run_pg_restore(conn: &PgConnParams, options: &RestoreOptions) -> Result<(), String> {
|
pub fn run_pg_restore(conn: &PgConnParams, options: &RestoreOptions, tools: &PgToolPaths) -> Result<(), String> {
|
||||||
if options.format == "plain" {
|
if options.format == "plain" {
|
||||||
let result = Command::new("psql")
|
let result = Command::new(&tools.psql)
|
||||||
.env("PGPASSWORD", &conn.password)
|
.env("PGPASSWORD", &conn.password)
|
||||||
.args([
|
.args([
|
||||||
format!("--host={}", conn.host),
|
format!("--host={}", conn.host),
|
||||||
@@ -177,7 +222,7 @@ pub fn run_pg_restore(conn: &PgConnParams, options: &RestoreOptions) -> Result<(
|
|||||||
let mut args = build_restore_args(conn, options);
|
let mut args = build_restore_args(conn, options);
|
||||||
args.push(options.file_path.clone());
|
args.push(options.file_path.clone());
|
||||||
|
|
||||||
let result = Command::new("pg_restore")
|
let result = Command::new(&tools.pg_restore)
|
||||||
.env("PGPASSWORD", &conn.password)
|
.env("PGPASSWORD", &conn.password)
|
||||||
.args(&args)
|
.args(&args)
|
||||||
.output();
|
.output();
|
||||||
@@ -196,6 +241,7 @@ pub fn run_db_sync(
|
|||||||
target: &PgConnParams,
|
target: &PgConnParams,
|
||||||
schema: Option<&str>,
|
schema: Option<&str>,
|
||||||
tables: Option<&[String]>,
|
tables: Option<&[String]>,
|
||||||
|
tools: &PgToolPaths,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// --- Build pg_dump args ---
|
// --- Build pg_dump args ---
|
||||||
let mut dump_args = base_conn_args(source);
|
let mut dump_args = base_conn_args(source);
|
||||||
@@ -221,7 +267,7 @@ pub fn run_db_sync(
|
|||||||
restore_args.push("--if-exists".into());
|
restore_args.push("--if-exists".into());
|
||||||
|
|
||||||
// --- Spawn pg_dump with piped stdout ---
|
// --- Spawn pg_dump with piped stdout ---
|
||||||
let mut dump_child = Command::new("pg_dump")
|
let mut dump_child = Command::new(&tools.pg_dump)
|
||||||
.env("PGPASSWORD", &source.password)
|
.env("PGPASSWORD", &source.password)
|
||||||
.args(&dump_args)
|
.args(&dump_args)
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
@@ -243,7 +289,7 @@ pub fn run_db_sync(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// --- Run pg_restore with pg_dump stdout as stdin ---
|
// --- Run pg_restore with pg_dump stdout as stdin ---
|
||||||
let restore_result = Command::new("pg_restore")
|
let restore_result = Command::new(&tools.pg_restore)
|
||||||
.env("PGPASSWORD", &target.password)
|
.env("PGPASSWORD", &target.password)
|
||||||
.args(&restore_args)
|
.args(&restore_args)
|
||||||
.stdin(dump_stdout)
|
.stdin(dump_stdout)
|
||||||
@@ -273,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)
|
// Tauri commands (thin wrappers: store lookup + keychain + event emission)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -332,9 +657,10 @@ pub async fn pg_dump(
|
|||||||
|
|
||||||
let job_id_clone = job_id.clone();
|
let job_id_clone = job_id.clone();
|
||||||
let app_handle_clone = app_handle.clone();
|
let app_handle_clone = app_handle.clone();
|
||||||
|
let tools = resolve_tool_paths(&app_handle);
|
||||||
|
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let result = run_pg_dump(¶ms, &options);
|
let result = run_pg_dump(¶ms, &options, &tools);
|
||||||
emit_result(&app_handle_clone, &job_id_clone, result);
|
emit_result(&app_handle_clone, &job_id_clone, result);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -374,9 +700,10 @@ pub async fn pg_restore(
|
|||||||
|
|
||||||
let job_id_clone = job_id.clone();
|
let job_id_clone = job_id.clone();
|
||||||
let app_handle_clone = app_handle.clone();
|
let app_handle_clone = app_handle.clone();
|
||||||
|
let tools = resolve_tool_paths(&app_handle);
|
||||||
|
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let result = run_pg_restore(¶ms, &options);
|
let result = run_pg_restore(¶ms, &options, &tools);
|
||||||
emit_result(&app_handle_clone, &job_id_clone, result);
|
emit_result(&app_handle_clone, &job_id_clone, result);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -464,9 +791,350 @@ pub async fn db_sync(
|
|||||||
let tables = options.tables.clone();
|
let tables = options.tables.clone();
|
||||||
let job_id_clone = job_id.clone();
|
let job_id_clone = job_id.clone();
|
||||||
let app_handle_clone = app_handle.clone();
|
let app_handle_clone = app_handle.clone();
|
||||||
|
let tools = resolve_tool_paths(&app_handle);
|
||||||
|
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let result = run_db_sync(&source, &target, schema.as_deref(), tables.as_deref());
|
let result = run_db_sync(&source, &target, schema.as_deref(), tables.as_deref(), &tools);
|
||||||
|
emit_result(&app_handle_clone, &job_id_clone, result);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
emit_result(&app_handle_clone, &job_id_clone, result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,9 @@ fn build_pg_restore_args_with_schema() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detect_pg_tools_does_not_panic() {
|
fn detect_pg_tools_does_not_panic() {
|
||||||
let status = detect_pg_tools();
|
// detect_pg_tools needs a Tauri AppHandle; the headless core keeps the
|
||||||
|
// status-shaping logic testable without one.
|
||||||
|
let status = build_pg_tool_status("pg_dump", "pg_restore", None, None);
|
||||||
// May or may not find tools, but the call itself must not panic
|
// May or may not find tools, but the call itself must not panic
|
||||||
let _ = status.pg_dump_found;
|
let _ = status.pg_dump_found;
|
||||||
let _ = status.pg_restore_found;
|
let _ = status.pg_restore_found;
|
||||||
@@ -148,6 +150,8 @@ fn pg_tool_status_serialization() {
|
|||||||
pg_restore_found: false,
|
pg_restore_found: false,
|
||||||
pg_dump_version: Some("pg_dump (PostgreSQL) 16.0".into()),
|
pg_dump_version: Some("pg_dump (PostgreSQL) 16.0".into()),
|
||||||
pg_restore_version: None,
|
pg_restore_version: None,
|
||||||
|
pg_dump_source: None,
|
||||||
|
pg_restore_source: None,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&status).unwrap();
|
let json = serde_json::to_string(&status).unwrap();
|
||||||
assert!(json.contains("pg_dump_found"));
|
assert!(json.contains("pg_dump_found"));
|
||||||
@@ -294,7 +298,8 @@ fn integration_dump_restore_sync() {
|
|||||||
tables: None,
|
tables: None,
|
||||||
no_owner: true,
|
no_owner: true,
|
||||||
};
|
};
|
||||||
run_pg_dump(&src, &dump_opts).expect("pg_dump should succeed");
|
run_pg_dump(&src, &dump_opts, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
|
||||||
|
.expect("pg_dump should succeed");
|
||||||
|
|
||||||
// --- 2. Restore into target ---
|
// --- 2. Restore into target ---
|
||||||
let restore_opts = RestoreOptions {
|
let restore_opts = RestoreOptions {
|
||||||
@@ -303,7 +308,8 @@ fn integration_dump_restore_sync() {
|
|||||||
clean: true,
|
clean: true,
|
||||||
schema: None,
|
schema: None,
|
||||||
};
|
};
|
||||||
run_pg_restore(&tgt, &restore_opts).expect("pg_restore should succeed");
|
run_pg_restore(&tgt, &restore_opts, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
|
||||||
|
.expect("pg_restore should succeed");
|
||||||
|
|
||||||
// --- 3. Verify data landed in target ---
|
// --- 3. Verify data landed in target ---
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -320,7 +326,8 @@ fn integration_dump_restore_sync() {
|
|||||||
// --- 4. Sync source -> target (target already has tables from the restore
|
// --- 4. Sync source -> target (target already has tables from the restore
|
||||||
// above — db_sync now passes --clean --if-exists, so it must succeed into a
|
// above — db_sync now passes --clean --if-exists, so it must succeed into a
|
||||||
// non-empty target). ---
|
// non-empty target). ---
|
||||||
run_db_sync(&src, &tgt, None, None).expect("db_sync should succeed");
|
run_db_sync(&src, &tgt, None, None, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
|
||||||
|
.expect("db_sync should succeed");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
psql_count(&tgt, "SELECT count(*) FROM public.products;"),
|
psql_count(&tgt, "SELECT count(*) FROM public.products;"),
|
||||||
3,
|
3,
|
||||||
@@ -367,7 +374,8 @@ fn integration_plain_dump_restore() {
|
|||||||
tables: None,
|
tables: None,
|
||||||
no_owner: true,
|
no_owner: true,
|
||||||
};
|
};
|
||||||
run_pg_dump(&src, &dump_opts).expect("pg_dump (plain) should succeed");
|
run_pg_dump(&src, &dump_opts, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
|
||||||
|
.expect("pg_dump (plain) should succeed");
|
||||||
|
|
||||||
// --- 2. Restore into target (plain -> psql path) ---
|
// --- 2. Restore into target (plain -> psql path) ---
|
||||||
let restore_opts = RestoreOptions {
|
let restore_opts = RestoreOptions {
|
||||||
@@ -376,7 +384,8 @@ fn integration_plain_dump_restore() {
|
|||||||
clean: false,
|
clean: false,
|
||||||
schema: None,
|
schema: None,
|
||||||
};
|
};
|
||||||
run_pg_restore(&tgt, &restore_opts).expect("pg_restore (plain/psql) should succeed");
|
run_pg_restore(&tgt, &restore_opts, &PgToolPaths { pg_dump: "pg_dump".into(), pg_restore: "pg_restore".into(), psql: "psql".into() })
|
||||||
|
.expect("pg_restore (plain/psql) should succeed");
|
||||||
|
|
||||||
// --- 3. Verify data landed in target ---
|
// --- 3. Verify data landed in target ---
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -392,3 +401,134 @@ fn integration_plain_dump_restore() {
|
|||||||
|
|
||||||
let _ = std::fs::remove_file(&dump_path);
|
let _ = std::fs::remove_file(&dump_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Tool resolution (Task 2.1: system-first, bundled-fallback)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pick_tool_prefers_system_then_bundled_then_bare() {
|
||||||
|
assert_eq!(pick_tool(true, None, "pg_dump"), ("pg_dump".to_string(), Some("system".into())));
|
||||||
|
assert_eq!(pick_tool(false, Some("/r/pg_dump"), "pg_dump"), ("/r/pg_dump".to_string(), Some("bundled".into())));
|
||||||
|
assert_eq!(pick_tool(false, None, "pg_dump"), ("pg_dump".to_string(), None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bundled_bin_name_appends_exe_on_windows() {
|
||||||
|
let name = bundled_bin_name("pg_dump");
|
||||||
|
if cfg!(windows) { assert_eq!(name, "pg_dump.exe"); } else { assert_eq!(name, "pg_dump"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// SQLite .dump / restore / sync core (Task 2.2)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
fn seed_sqlite() -> Connection {
|
||||||
|
let c = Connection::open_in_memory().unwrap();
|
||||||
|
c.execute_batch(
|
||||||
|
"CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);
|
||||||
|
CREATE INDEX users_name ON users(name);
|
||||||
|
INSERT INTO users (name) VALUES ('Alice'),('Bob');
|
||||||
|
CREATE TABLE blobs (id INTEGER PRIMARY KEY, data BLOB);
|
||||||
|
INSERT INTO blobs VALUES (1, x'010203');",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_dump_and_restore_roundtrip() {
|
||||||
|
let src = seed_sqlite();
|
||||||
|
let mut buf: Vec<u8> = Vec::new();
|
||||||
|
let mut cur = std::io::Cursor::new(&mut buf);
|
||||||
|
dump_sqlite_to(&src, &mut cur, |_| {}).unwrap();
|
||||||
|
let text = String::from_utf8(buf).unwrap();
|
||||||
|
assert!(text.contains("PRAGMA foreign_keys=OFF"));
|
||||||
|
assert!(text.contains("BEGIN TRANSACTION"));
|
||||||
|
assert!(text.contains("CREATE TABLE users"));
|
||||||
|
assert!(text.contains("INSERT INTO \"users\""));
|
||||||
|
assert!(text.contains("X'010203'"), "BLOB must be hex-literal");
|
||||||
|
assert!(text.contains("CREATE INDEX users_name"));
|
||||||
|
|
||||||
|
let dst = Connection::open_in_memory().unwrap();
|
||||||
|
restore_sqlite(&dst, &text, false).unwrap();
|
||||||
|
let n: i64 = dst.query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0)).unwrap();
|
||||||
|
assert_eq!(n, 2);
|
||||||
|
let blobs: i64 = dst.query_row("SELECT COUNT(*) FROM blobs", [], |r| r.get(0)).unwrap();
|
||||||
|
assert_eq!(blobs, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_dump_preserves_autoincrement_sequence() {
|
||||||
|
let src = Connection::open_in_memory().unwrap();
|
||||||
|
src.execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, v TEXT); INSERT INTO t(v) VALUES ('a'),('b');").unwrap();
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
dump_sqlite_to(&src, &mut Cursor::new(&mut buf), |_| {}).unwrap();
|
||||||
|
let text = String::from_utf8(buf).unwrap();
|
||||||
|
let dst = Connection::open_in_memory().unwrap();
|
||||||
|
restore_sqlite(&dst, &text, false).unwrap();
|
||||||
|
dst.execute("INSERT INTO t(v) VALUES ('c')", []).unwrap();
|
||||||
|
let id: i64 = dst.query_row("SELECT id FROM t WHERE v='c'", [], |r| r.get(0)).unwrap();
|
||||||
|
assert_eq!(id, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_dump_fail_closed_for_virtual_tables() {
|
||||||
|
let src = Connection::open_in_memory().unwrap();
|
||||||
|
src.execute_batch("CREATE VIRTUAL TABLE ft USING fts4(content)").unwrap();
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let err = dump_sqlite_to(&src, &mut Cursor::new(&mut buf), |_| {}).unwrap_err();
|
||||||
|
assert!(err.to_lowercase().contains("virtual table"), "got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_restore_clean_drops_existing() {
|
||||||
|
let src = seed_sqlite();
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
dump_sqlite_to(&src, &mut Cursor::new(&mut buf), |_| {}).unwrap();
|
||||||
|
let text = String::from_utf8(buf).unwrap();
|
||||||
|
let dst = Connection::open_in_memory().unwrap();
|
||||||
|
dst.execute_batch("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT); INSERT INTO users VALUES (99,'old');").unwrap();
|
||||||
|
restore_sqlite(&dst, &text, true).unwrap();
|
||||||
|
let names: Vec<String> = dst.prepare("SELECT name FROM users ORDER BY id").unwrap().query_map([], |r| r.get::<_, String>(0)).unwrap().filter_map(|r| r.ok()).collect();
|
||||||
|
assert_eq!(names, vec!["Alice".to_string(), "Bob".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// MySQL dump / restore / sync arg building (Task 2.3)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mysql_dump_args_single_transaction_no_data_routines() {
|
||||||
|
let opts = MySqlBackupOptions {
|
||||||
|
database: "shop".into(), file_path: "/tmp/d.sql".into(),
|
||||||
|
single_transaction: true, no_data: true, routines: true, triggers: false, events: false,
|
||||||
|
};
|
||||||
|
let args = build_mysql_dump_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into()), &opts);
|
||||||
|
assert!(args.iter().any(|a| a == "--single-transaction"));
|
||||||
|
assert!(args.iter().any(|a| a == "--no-data"));
|
||||||
|
assert!(args.iter().any(|a| a == "--routines"));
|
||||||
|
assert!(args.iter().any(|a| a == "--databases=shop"));
|
||||||
|
assert!(args.iter().any(|a| a == "--result-file=/tmp/d.sql"));
|
||||||
|
// no --password on the command line (uses MYSQL_PWD env)
|
||||||
|
assert!(args.iter().all(|a| !a.starts_with("--password")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mysql_restore_args_no_clean_flags() {
|
||||||
|
let opts = MySqlRestoreOptions { database: "shop".into(), file_path: "/tmp/d.sql".into(), clean: false };
|
||||||
|
let args = build_mysql_restore_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into()), &opts);
|
||||||
|
assert!(args.iter().any(|a| a == "--database=shop"));
|
||||||
|
assert!(args.iter().any(|a| a == "--host=h"));
|
||||||
|
assert!(args.iter().all(|a| a != "--force"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mysql_env_uses_mysql_pwd_not_password_arg() {
|
||||||
|
let opts = MySqlBackupOptions { database: "db".into(), file_path: "/tmp/x.sql".into(), single_transaction: false, no_data: false, routines: false, triggers: false, events: false };
|
||||||
|
let args = build_mysql_dump_args(&MySqlConnParams::new("h".into(), 3306, "u".into(), "db".into(), "p".into()), &opts);
|
||||||
|
assert!(args.iter().all(|a| !a.starts_with("--password")));
|
||||||
|
}
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ mod tests {
|
|||||||
ssl_cert_path: None,
|
ssl_cert_path: None,
|
||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
};
|
};
|
||||||
let result = create_connection_inner(&st, input.clone()).unwrap();
|
let result = create_connection_inner(&st, input.clone()).unwrap();
|
||||||
assert_eq!(result.name, "Prod");
|
assert_eq!(result.name, "Prod");
|
||||||
@@ -254,6 +255,7 @@ mod tests {
|
|||||||
ssl_cert_path: None,
|
ssl_cert_path: None,
|
||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
};
|
};
|
||||||
assert!(create_connection_inner(&st, input).is_err());
|
assert!(create_connection_inner(&st, input).is_err());
|
||||||
}
|
}
|
||||||
@@ -283,6 +285,7 @@ mod tests {
|
|||||||
ssl_cert_path: None,
|
ssl_cert_path: None,
|
||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
};
|
};
|
||||||
let conn = create_connection_inner(&st, input).unwrap();
|
let conn = create_connection_inner(&st, input).unwrap();
|
||||||
delete_connection_inner(&st, &conn.id).unwrap();
|
delete_connection_inner(&st, &conn.id).unwrap();
|
||||||
@@ -300,6 +303,7 @@ mod tests {
|
|||||||
username: None,
|
username: None,
|
||||||
folder_id: None,
|
folder_id: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
password: None,
|
password: None,
|
||||||
database: None,
|
database: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
@@ -331,6 +335,7 @@ mod tests {
|
|||||||
username: None,
|
username: None,
|
||||||
folder_id: None,
|
folder_id: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
password: None,
|
password: None,
|
||||||
database: None,
|
database: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
|
|||||||
@@ -149,19 +149,21 @@ pub fn build_pg_dump_ddl_args(schema: &str, table: &str) -> Vec<String> {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check whether the system `pg_dump` binary is on PATH.
|
/// Check whether the `pg_dump` binary at the given path (bare name or
|
||||||
pub fn pg_dump_available() -> bool {
|
/// absolute path) is executable and reports a version.
|
||||||
std::process::Command::new("pg_dump")
|
pub fn pg_dump_available_at(path: &str) -> bool {
|
||||||
|
std::process::Command::new(path)
|
||||||
.arg("--version")
|
.arg("--version")
|
||||||
.output()
|
.output()
|
||||||
.is_ok()
|
.is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract a single table's DDL from a PostgreSQL database by shelling out to
|
/// Extract a single table's DDL from a PostgreSQL database by shelling out to
|
||||||
/// the system `pg_dump` with `--schema-only`. Credentials are supplied via the
|
/// `pg_dump` (system-first, bundled-fallback) with `--schema-only`.
|
||||||
/// `PGPASSWORD` environment variable only — never as argv — and are never
|
/// Credentials are supplied via the `PGPASSWORD` environment variable only —
|
||||||
/// logged. Execution requires a reachable PostgreSQL server plus an installed
|
/// never as argv — and are never logged. Execution requires a reachable
|
||||||
/// `pg_dump`; unit tests cover the argument construction instead.
|
/// PostgreSQL server plus an installed `pg_dump`; unit tests cover the
|
||||||
|
/// argument construction instead.
|
||||||
pub fn get_pg_ddl_via_dump(
|
pub fn get_pg_ddl_via_dump(
|
||||||
schema: &str,
|
schema: &str,
|
||||||
table: &str,
|
table: &str,
|
||||||
@@ -170,13 +172,14 @@ pub fn get_pg_ddl_via_dump(
|
|||||||
user: &str,
|
user: &str,
|
||||||
db: &str,
|
db: &str,
|
||||||
password: &str,
|
password: &str,
|
||||||
|
pg_dump_path: &str,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
if !pg_dump_available() {
|
if !pg_dump_available_at(pg_dump_path) {
|
||||||
return Err(
|
return Err(
|
||||||
"pg_dump not found. Install PostgreSQL client tools to copy table schema.".into(),
|
"pg_dump not found. Install PostgreSQL client tools or use the bundled tools to copy table schema.".into(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let mut cmd = std::process::Command::new("pg_dump");
|
let mut cmd = std::process::Command::new(pg_dump_path);
|
||||||
cmd.args([
|
cmd.args([
|
||||||
format!("--host={host}"),
|
format!("--host={host}"),
|
||||||
format!("--port={port}"),
|
format!("--port={port}"),
|
||||||
@@ -888,6 +891,7 @@ pub(crate) async fn run_mysql_connect(
|
|||||||
config: &crate::db::pool::DbConfig,
|
config: &crate::db::pool::DbConfig,
|
||||||
ssh_manager: &std::sync::Mutex<crate::commands::ssh::SshTunnelManager>,
|
ssh_manager: &std::sync::Mutex<crate::commands::ssh::SshTunnelManager>,
|
||||||
pool_manager: &tokio::sync::Mutex<crate::db::pool::ConnectionPoolManager>,
|
pool_manager: &tokio::sync::Mutex<crate::db::pool::ConnectionPoolManager>,
|
||||||
|
cancel_registry: &crate::cancel::CancelRegistry,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions, MySqlSslMode};
|
use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions, MySqlSslMode};
|
||||||
if config.host.trim().is_empty() {
|
if config.host.trim().is_empty() {
|
||||||
@@ -965,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()
|
match MySqlPoolOptions::new()
|
||||||
.max_connections(5)
|
.max_connections(5)
|
||||||
.acquire_timeout(std::time::Duration::from_secs(10))
|
.acquire_timeout(std::time::Duration::from_secs(10))
|
||||||
@@ -976,6 +984,13 @@ pub(crate) async fn run_mysql_connect(
|
|||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.register(connection_id, crate::db::pool::DbHandle::MySql(pool));
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1020,6 +1035,8 @@ pub async fn db_connect(
|
|||||||
config.ssl_key_path.as_deref(),
|
config.ssl_key_path.as_deref(),
|
||||||
)
|
)
|
||||||
.map_err(|e| sanitize_error(&e))?;
|
.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
|
// SSH tunnel: if configured, open a loopback tunnel to the remote DB
|
||||||
// and connect through it. The blocking ssh2 handshake runs in
|
// and connect through it. The blocking ssh2 handshake runs in
|
||||||
@@ -1077,6 +1094,18 @@ pub async fn db_connect(
|
|||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok((client, handle)) => {
|
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;
|
let mut pm = state.pool_manager.lock().await;
|
||||||
pm.register(
|
pm.register(
|
||||||
&connection_id,
|
&connection_id,
|
||||||
@@ -1098,8 +1127,14 @@ pub async fn db_connect(
|
|||||||
} else if config.db_type == "sqlite" {
|
} else if config.db_type == "sqlite" {
|
||||||
match rusqlite::Connection::open(&config.host) {
|
match rusqlite::Connection::open(&config.host) {
|
||||||
Ok(conn) => {
|
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;
|
let mut pm = state.pool_manager.lock().await;
|
||||||
pm.register(&connection_id, crate::db::pool::DbHandle::Sqlite(conn));
|
pm.register(&connection_id, crate::db::pool::DbHandle::Sqlite(conn));
|
||||||
|
state
|
||||||
|
.cancel_registry
|
||||||
|
.set_sqlite(&connection_id, crate::cancel::SqliteCancel::new(interrupt));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => Err(format!("Connection failed: {}", e)),
|
Err(e) => Err(format!("Connection failed: {}", e)),
|
||||||
@@ -1110,6 +1145,7 @@ pub async fn db_connect(
|
|||||||
&config,
|
&config,
|
||||||
&state.ssh_manager,
|
&state.ssh_manager,
|
||||||
&state.pool_manager,
|
&state.pool_manager,
|
||||||
|
&state.cancel_registry,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
} else {
|
} else {
|
||||||
@@ -1127,6 +1163,7 @@ pub async fn db_disconnect(
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut pm = state.pool_manager.lock().await;
|
let mut pm = state.pool_manager.lock().await;
|
||||||
pm.remove(&connection_id);
|
pm.remove(&connection_id);
|
||||||
|
state.cancel_registry.remove(&connection_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1861,6 +1898,60 @@ pub async fn get_table_data(
|
|||||||
.await
|
.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]
|
#[tauri::command]
|
||||||
pub async fn get_fk_preview(
|
pub async fn get_fk_preview(
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
@@ -2107,14 +2198,15 @@ pub(crate) fn affected_count_error(n: u64) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
/// Headless change-application shared by the `execute_change` command and
|
||||||
pub async fn execute_change(
|
/// integration tests (thin-command principle — no Tauri `State`).
|
||||||
connection_id: String,
|
pub(crate) async fn execute_change_inner(
|
||||||
|
pm: &tokio::sync::Mutex<ConnectionPoolManager>,
|
||||||
|
connection_id: &str,
|
||||||
change: Change,
|
change: Change,
|
||||||
state: State<'_, crate::AppState>,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut pm = state.pool_manager.lock().await;
|
let mut pm = pm.lock().await;
|
||||||
match pm.get(&connection_id) {
|
match pm.get(connection_id) {
|
||||||
Some(crate::db::pool::DbHandle::Postgresql(client, _)) => {
|
Some(crate::db::pool::DbHandle::Postgresql(client, _)) => {
|
||||||
// Build the parameterized SQL + bound values from the change.
|
// Build the parameterized SQL + bound values from the change.
|
||||||
let (sql, params): (String, Vec<serde_json::Value>) = match &change {
|
let (sql, params): (String, Vec<serde_json::Value>) = match &change {
|
||||||
@@ -2152,6 +2244,27 @@ pub async fn execute_change(
|
|||||||
client.execute(sql, &[]).await.map_err(|e| e.to_string())?;
|
client.execute(sql, &[]).await.map_err(|e| e.to_string())?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
Change::Ddl { sql, .. } => {
|
||||||
|
client.execute(sql, &[]).await.map_err(|e| e.to_string())?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Change::RebuildTable { sql, .. } => {
|
||||||
|
// Single transaction: all-or-nothing rebuild (multi-statement
|
||||||
|
// DDL only; VACUUM never reaches here). Rolls back on any
|
||||||
|
// statement failure.
|
||||||
|
let tx = client
|
||||||
|
.build_transaction()
|
||||||
|
.start()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
tx.batch_execute(sql)
|
||||||
|
.await
|
||||||
|
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
Change::BulkInsert {
|
Change::BulkInsert {
|
||||||
schema,
|
schema,
|
||||||
table,
|
table,
|
||||||
@@ -2244,6 +2357,16 @@ pub async fn execute_change(
|
|||||||
conn.execute(sql, []).map_err(|e| e.to_string())?;
|
conn.execute(sql, []).map_err(|e| e.to_string())?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
Change::Ddl { sql, .. } => {
|
||||||
|
// Object DDL can be multi-statement; run as a batch.
|
||||||
|
conn.execute_batch(sql).map_err(|e| e.to_string())?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Change::RebuildTable { sql, .. } => {
|
||||||
|
// Rebuild script (create tmp / copy / drop / rename).
|
||||||
|
conn.execute_batch(sql).map_err(|e| e.to_string())?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
Change::BulkInsert {
|
Change::BulkInsert {
|
||||||
table,
|
table,
|
||||||
columns,
|
columns,
|
||||||
@@ -2316,6 +2439,12 @@ pub async fn execute_change(
|
|||||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
Change::Ddl { .. } => {
|
||||||
|
return Err("Object management is PostgreSQL-only".to_string());
|
||||||
|
}
|
||||||
|
Change::RebuildTable { .. } => {
|
||||||
|
return Err("Object management is PostgreSQL-only".to_string());
|
||||||
|
}
|
||||||
Change::BulkInsert {
|
Change::BulkInsert {
|
||||||
schema,
|
schema,
|
||||||
table,
|
table,
|
||||||
@@ -2371,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]
|
#[tauri::command]
|
||||||
pub async fn refresh_connection(
|
pub async fn refresh_connection(
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
@@ -2715,10 +2853,12 @@ pub async fn get_table_ddl(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// pg_dump is blocking I/O; run it off the async runtime. Credentials
|
// pg_dump is blocking I/O; run it off the async runtime. Credentials
|
||||||
// travel via PGPASSWORD, never argv.
|
// travel via PGPASSWORD, never argv. Resolve system-first,
|
||||||
|
// bundled-fallback, before moving into the closure.
|
||||||
|
let pg_dump_path = crate::commands::backup::resolve_tool(&app, "pg_dump").0;
|
||||||
let ddl = tokio::task::spawn_blocking(move || {
|
let ddl = tokio::task::spawn_blocking(move || {
|
||||||
get_pg_ddl_via_dump(
|
get_pg_ddl_via_dump(
|
||||||
&schema, &table, &dump_host, dump_port, &user, &db, &password,
|
&schema, &table, &dump_host, dump_port, &user, &db, &password, &pg_dump_path,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -2815,6 +2955,33 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Verify that a `Change::Ddl` serializes with the snake_case `ddl` tag.
|
||||||
|
#[test]
|
||||||
|
fn change_ddl_serialization_uses_snake_case_tag() {
|
||||||
|
let change = Change::Ddl {
|
||||||
|
id: "chg-ddl-1".to_string(),
|
||||||
|
sql: "CREATE TYPE public.role AS ENUM ('admin')".to_string(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&change).unwrap();
|
||||||
|
assert!(
|
||||||
|
json.contains(r#""type":"ddl""#),
|
||||||
|
"serialized Change::Ddl should use snake_case tag 'ddl'; got: {}",
|
||||||
|
json
|
||||||
|
);
|
||||||
|
assert!(json.contains(r#""id":"chg-ddl-1""#));
|
||||||
|
assert!(json.contains(r#""sql":"CREATE TYPE public.role AS ENUM ('admin')""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify that `Change::id()` returns the identifier of a `Change::Ddl`.
|
||||||
|
#[test]
|
||||||
|
fn change_ddl_id_is_accessible() {
|
||||||
|
let change = Change::Ddl {
|
||||||
|
id: "chg-ddl-42".to_string(),
|
||||||
|
sql: "DROP INDEX public.i".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(change.id(), "chg-ddl-42");
|
||||||
|
}
|
||||||
|
|
||||||
/// Verify that `build_update_sql` produces valid SQL with all required
|
/// Verify that `build_update_sql` produces valid SQL with all required
|
||||||
/// clauses.
|
/// clauses.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3180,8 +3347,9 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend)));
|
let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend)));
|
||||||
let pm = fresh_pool_manager().await;
|
let pm = fresh_pool_manager().await;
|
||||||
|
let reg = crate::cancel::CancelRegistry::new();
|
||||||
let id = "mysql-empty-host";
|
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");
|
assert!(res.is_err(), "empty host must fail before any network call");
|
||||||
let mut pmg = pm.lock().await;
|
let mut pmg = pm.lock().await;
|
||||||
assert!(pmg.get(id).is_none(), "no handle registered on failure");
|
assert!(pmg.get(id).is_none(), "no handle registered on failure");
|
||||||
@@ -3240,8 +3408,9 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend)));
|
let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend)));
|
||||||
let pm = fresh_pool_manager().await;
|
let pm = fresh_pool_manager().await;
|
||||||
|
let reg = crate::cancel::CancelRegistry::new();
|
||||||
let id = "mysql-unreachable";
|
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!(
|
assert!(
|
||||||
res.is_err(),
|
res.is_err(),
|
||||||
"port 1 should refuse; must be a clean Err, not panic"
|
"port 1 should refuse; must be a clean Err, not panic"
|
||||||
@@ -3290,8 +3459,9 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend)));
|
let ssh = StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend)));
|
||||||
let pm = fresh_pool_manager().await;
|
let pm = fresh_pool_manager().await;
|
||||||
|
let reg = crate::cancel::CancelRegistry::new();
|
||||||
let id = format!("mysql-it-{}", uuid::Uuid::new_v4());
|
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");
|
let tables = get_tables_inner(&pm, &id, Some(&db)).await.expect("tables");
|
||||||
assert!(!tables.is_empty(), "test DB must contain at least one table");
|
assert!(!tables.is_empty(), "test DB must contain at least one table");
|
||||||
let first = &tables[0];
|
let first = &tables[0];
|
||||||
@@ -3319,4 +3489,11 @@ mod tests {
|
|||||||
.expect("columns");
|
.expect("columns");
|
||||||
assert_eq!(cols.len(), data.columns.len());
|
assert_eq!(cols.len(), data.columns.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_dump_available_at_checks_given_path() {
|
||||||
|
// A bare name that resolves on PATH passes; a bogus path fails.
|
||||||
|
assert!(pg_dump_available_at("pg_dump") || !pg_dump_available_at("pg_dump"));
|
||||||
|
assert!(!pg_dump_available_at("/nonexistent/pg_dump_999999"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ fn demo_connection_input(db_path: &Path) -> ConnectionInput {
|
|||||||
database: None,
|
database: None,
|
||||||
folder_id: None,
|
folder_id: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
environment: Some("development".to_string()),
|
environment: Some("development".to_string()),
|
||||||
ssh_host: None,
|
ssh_host: None,
|
||||||
ssh_port: None,
|
ssh_port: None,
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ pub fn import_connections_inner(
|
|||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
tag_ids: rec.tag_ids.clone().unwrap_or_default(),
|
tag_ids: rec.tag_ids.clone().unwrap_or_default(),
|
||||||
|
use_keychain: true,
|
||||||
};
|
};
|
||||||
match store.create_connection(input) {
|
match store.create_connection(input) {
|
||||||
Ok(_) => imported += 1,
|
Ok(_) => imported += 1,
|
||||||
@@ -214,6 +215,7 @@ mod tests {
|
|||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
});
|
});
|
||||||
let json = export_connections_inner(&st).unwrap();
|
let json = export_connections_inner(&st).unwrap();
|
||||||
assert!(json.contains("\"name\""));
|
assert!(json.contains("\"name\""));
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
use tauri::State;
|
||||||
|
use crate::db::pool::{ConnectionPoolManager, DbHandle};
|
||||||
|
use crate::models::MaintenanceResult;
|
||||||
|
|
||||||
|
/// Run a table-scoped maintenance command via the SIMPLE query protocol
|
||||||
|
/// (never inside a transaction — VACUUM cannot run in a transaction block).
|
||||||
|
pub(crate) async fn run_maintenance_inner(
|
||||||
|
pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, table: &str, action: &str,
|
||||||
|
) -> Result<MaintenanceResult, String> {
|
||||||
|
let mut pm = pm.lock().await;
|
||||||
|
let client = match pm.get(connection_id) {
|
||||||
|
Some(DbHandle::Postgresql(c, _)) => c,
|
||||||
|
Some(_) => return Err("Maintenance is PostgreSQL-only".into()),
|
||||||
|
None => return Err("Connection not found".into()),
|
||||||
|
};
|
||||||
|
crate::db::object_ddl::validate_object_name(&schema)?;
|
||||||
|
crate::db::object_ddl::validate_object_name(&table)?;
|
||||||
|
let verb = match action {
|
||||||
|
"vacuum" => "VACUUM",
|
||||||
|
"analyze" => "ANALYZE",
|
||||||
|
"reindex" => "REINDEX TABLE",
|
||||||
|
_ => return Err(format!("Unknown maintenance action: {action}")),
|
||||||
|
};
|
||||||
|
let sql = format!("{} {}.{}", verb,
|
||||||
|
crate::db::object_ddl::quote_ident(&schema), crate::db::object_ddl::quote_ident(&table));
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
client.simple_query(&sql).await
|
||||||
|
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
|
||||||
|
Ok(MaintenanceResult { duration_ms: start.elapsed().as_millis() as i64, message: format!("{} completed on {}.{}", verb, schema, table) })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn run_maintenance(connection_id: String, schema: String, table: String, action: String, state: State<'_, crate::AppState>) -> Result<MaintenanceResult, String> {
|
||||||
|
run_maintenance_inner(&state.pool_manager, &connection_id, &schema, &table, &action).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "maintenance.test.rs"]
|
||||||
|
mod maintenance_test;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
use crate::db::pool::{ConnectionPoolManager, DbHandle};
|
||||||
|
|
||||||
|
/// Connect directly via tokio-postgres (no tunnel) so tests are Tauri-free.
|
||||||
|
async fn pool() -> (tokio::sync::Mutex<ConnectionPoolManager>, String) {
|
||||||
|
let h = std::env::var("GRIDLINE_TEST_PG_HOST").expect("set GRIDLINE_TEST_PG_HOST");
|
||||||
|
let p: u16 = std::env::var("GRIDLINE_TEST_PG_PORT").unwrap_or_else(|_| "5432".into()).parse().unwrap();
|
||||||
|
let u = std::env::var("GRIDLINE_TEST_PG_USER").expect("set GRIDLINE_TEST_PG_USER");
|
||||||
|
let d = std::env::var("GRIDLINE_TEST_PG_DB").expect("set GRIDLINE_TEST_PG_DB");
|
||||||
|
let pw = std::env::var("GRIDLINE_TEST_PG_PASSWORD").unwrap_or_default();
|
||||||
|
let (client, conn) = tokio_postgres::connect(
|
||||||
|
&format!("host={h} port={p} user={u} dbname={d} password={pw}"),
|
||||||
|
tokio_postgres::NoTls,
|
||||||
|
).await.expect("connect to test PG");
|
||||||
|
let handle = tokio::spawn(async move { let _ = conn.await; });
|
||||||
|
let mut pm = ConnectionPoolManager::new();
|
||||||
|
let id = "test-conn".to_string();
|
||||||
|
pm.register(&id, DbHandle::Postgresql(client, handle));
|
||||||
|
(tokio::sync::Mutex::new(pm), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn run_maintenance_analyze_succeeds() {
|
||||||
|
let (pm, id) = pool().await;
|
||||||
|
let res = crate::commands::maintenance::run_maintenance_inner(&pm, &id, "public", "pg_type", "analyze").await.unwrap();
|
||||||
|
assert!(res.duration_ms >= 0);
|
||||||
|
assert!(res.message.to_lowercase().contains("analyze"));
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ pub mod demo;
|
|||||||
pub mod folders;
|
pub mod folders;
|
||||||
pub mod import_export;
|
pub mod import_export;
|
||||||
pub mod keychain;
|
pub mod keychain;
|
||||||
|
pub mod maintenance;
|
||||||
|
pub mod objects;
|
||||||
pub mod query;
|
pub mod query;
|
||||||
pub mod schema_graph;
|
pub mod schema_graph;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
|||||||
@@ -0,0 +1,587 @@
|
|||||||
|
use tauri::State;
|
||||||
|
use crate::db::pool::{ConnectionPoolManager, DbHandle};
|
||||||
|
use crate::db::object_crud::{build_ddl, rebuild_script, RebuildConstraint, RebuildFk, RebuildFkIn, RebuildGrant, RebuildIndex, RebuildInput, RebuildOwnedSequence, TableColumn};
|
||||||
|
use crate::db::object_ddl::*;
|
||||||
|
use crate::models::db_viewer::{ObjectSearchHit, DependencyInfo, ExtensionInfo};
|
||||||
|
|
||||||
|
fn sanitize(e: &str) -> String { crate::commands::db_viewer::sanitize_error(e) }
|
||||||
|
|
||||||
|
async fn exec_sql(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, sql: String) -> Result<(), String> {
|
||||||
|
let mut pm = pm.lock().await;
|
||||||
|
match pm.get(connection_id) {
|
||||||
|
Some(DbHandle::Postgresql(client, _)) => client.execute(&sql, &[]).await.map(|_| ()).map_err(|e| sanitize(&e.to_string())),
|
||||||
|
Some(_) => Err("Schema CRUD is PostgreSQL-only".into()),
|
||||||
|
None => Err("Connection not found".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn create_schema_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, name: &str) -> Result<(), String> {
|
||||||
|
exec_sql(pm, connection_id, create_schema_sql(name)?).await
|
||||||
|
}
|
||||||
|
pub(crate) async fn rename_schema_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, old: &str, new: &str) -> Result<(), String> {
|
||||||
|
exec_sql(pm, connection_id, rename_schema_sql(old, new)?).await
|
||||||
|
}
|
||||||
|
pub(crate) async fn drop_schema_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, name: &str, cascade: bool) -> Result<(), String> {
|
||||||
|
exec_sql(pm, connection_id, drop_schema_sql(name, cascade)?).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_schema(connection_id: String, name: String, state: State<'_, crate::AppState>) -> Result<(), String> {
|
||||||
|
create_schema_inner(&state.pool_manager, &connection_id, &name).await
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn rename_schema(connection_id: String, old_name: String, new_name: String, state: State<'_, crate::AppState>) -> Result<(), String> {
|
||||||
|
rename_schema_inner(&state.pool_manager, &connection_id, &old_name, &new_name).await
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn drop_schema(connection_id: String, name: String, cascade: bool, state: State<'_, crate::AppState>) -> Result<(), String> {
|
||||||
|
drop_schema_inner(&state.pool_manager, &connection_id, &name, cascade).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn search_objects_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, needle: &str) -> Result<Vec<ObjectSearchHit>, String> {
|
||||||
|
let sql = pg_object_search_query();
|
||||||
|
let mut pm = pm.lock().await;
|
||||||
|
match pm.get(connection_id) {
|
||||||
|
Some(DbHandle::Postgresql(client, _)) => {
|
||||||
|
let rows = client.query(&sql, &[&needle, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||||
|
Ok(rows.iter().map(|r| ObjectSearchHit {
|
||||||
|
name: r.get(0), schema: r.get(1), object_type: r.get(2),
|
||||||
|
}).collect())
|
||||||
|
}
|
||||||
|
Some(DbHandle::Sqlite(_)) | Some(DbHandle::MySql(_)) => Ok(vec![]),
|
||||||
|
None => Err("Connection not found".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn search_objects(connection_id: String, schema: String, query: String, state: State<'_, crate::AppState>) -> Result<Vec<ObjectSearchHit>, String> {
|
||||||
|
search_objects_inner(&state.pool_manager, &connection_id, &schema, &query).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn get_object_ddl_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, object_type: &str, name: &str) -> Result<String, String> {
|
||||||
|
let mut pm = pm.lock().await;
|
||||||
|
let client = match pm.get(connection_id) {
|
||||||
|
Some(DbHandle::Postgresql(c, _)) => c,
|
||||||
|
Some(_) => return Err("Copy-as-DDL is PostgreSQL-only".into()),
|
||||||
|
None => return Err("Connection not found".into()),
|
||||||
|
};
|
||||||
|
match object_type {
|
||||||
|
"function" | "procedure" => {
|
||||||
|
let row = client.query_one("SELECT pg_get_functiondef(p.oid) FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE p.proname=$1 AND n.nspname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||||
|
Ok(row.get::<_, String>(0))
|
||||||
|
}
|
||||||
|
"trigger" => {
|
||||||
|
let row = client.query_one("SELECT pg_get_triggerdef(t.oid) FROM pg_trigger t JOIN pg_class c ON t.tgrelid=c.oid JOIN pg_namespace n ON c.relnamespace=n.oid WHERE t.tgname=$1 AND n.nspname=$2 AND NOT t.tgisinternal LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||||
|
Ok(row.get::<_, String>(0))
|
||||||
|
}
|
||||||
|
"index" => {
|
||||||
|
let row = client.query_one("SELECT pg_get_indexdef(ix.indexrelid) FROM pg_index ix JOIN pg_class i ON i.oid=ix.indexrelid JOIN pg_class t ON t.oid=ix.indrelid JOIN pg_namespace n ON t.relnamespace=n.oid WHERE i.relname=$1 AND n.nspname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||||
|
Ok(row.get::<_, String>(0))
|
||||||
|
}
|
||||||
|
"constraint" => {
|
||||||
|
let row = client.query_one("SELECT c.conname, ns.nspname, cl.relname, pg_get_constraintdef(c.oid) FROM pg_constraint c JOIN pg_class cl ON c.conrelid=cl.oid JOIN pg_namespace ns ON cl.relnamespace=ns.oid WHERE c.conname=$1 AND ns.nspname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||||
|
Ok(constraint_ddl(&crate::models::db_viewer::ConstraintInfo {
|
||||||
|
name: row.get(0), schema: row.get(1), table: row.get(2), contype: "CHECK".into(),
|
||||||
|
definition: row.get(3), deferrable: false, validated: true, columns: vec![] }))
|
||||||
|
}
|
||||||
|
"view" => {
|
||||||
|
let row = client.query_one("SELECT pg_get_viewdef(c.oid, true) FROM pg_class c JOIN pg_namespace n ON c.relnamespace=n.oid WHERE c.relname=$1 AND n.nspname=$2 AND c.relkind='v' LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||||
|
Ok(view_ddl(schema, name, &row.get::<_, String>(0)))
|
||||||
|
}
|
||||||
|
"materialized view" => {
|
||||||
|
let row = client.query_one("SELECT definition FROM pg_matviews WHERE matviewname=$1 AND schemaname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||||
|
Ok(matview_ddl(schema, name, &row.get::<_, String>(0)))
|
||||||
|
}
|
||||||
|
"sequence" => {
|
||||||
|
let row = client.query_one("SELECT sequence_name, sequence_schema, start_value::text, minimum_value::text, maximum_value::text, increment::text, COALESCE(pg_catalog.pg_sequence_last_value(sequence_name::regclass)::text,'0'), cycle_option::text FROM information_schema.sequences WHERE sequence_name=$1 AND sequence_schema=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||||
|
Ok(sequence_ddl(&crate::models::db_viewer::SequenceInfo { name: row.get(0), schema: row.get(1), start_value: row.get(2), min_value: row.get(3), max_value: row.get(4), increment: row.get(5), current_value: row.get(6), cycle: row.get::<_, String>(7).eq_ignore_ascii_case("YES") }))
|
||||||
|
}
|
||||||
|
"enum" => {
|
||||||
|
let row = client.query_one("SELECT t.typname, n.nspname, ARRAY(SELECT e.enumlabel FROM pg_enum e WHERE e.enumtypid=t.oid ORDER BY e.enumsortorder) FROM pg_type t JOIN pg_namespace n ON t.typnamespace=n.oid WHERE t.typname=$1 AND n.nspname=$2 AND t.typtype='e' LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||||
|
Ok(enum_ddl(&crate::models::db_viewer::EnumInfo { name: row.get(0), schema: row.get(1), labels: row.get::<_, Vec<String>>(2) }))
|
||||||
|
}
|
||||||
|
"extension" => {
|
||||||
|
let row = client.query_one("SELECT e.extname, n.nspname, e.extversion::text FROM pg_extension e JOIN pg_namespace n ON e.extnamespace=n.oid WHERE e.extname=$1 AND n.nspname=$2 LIMIT 1", &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||||
|
Ok(extension_ddl(&crate::models::db_viewer::ExtensionInfo { name: row.get(0), schema: row.get(1), version: row.get(2), comment: None }))
|
||||||
|
}
|
||||||
|
"table" => {
|
||||||
|
// Tables reuse the pg_dump path (bundled-aware); the command wrapper resolves the tool path via AppHandle.
|
||||||
|
Err("table DDL uses get_table_ddl".into())
|
||||||
|
}
|
||||||
|
other => Err(format!("Unsupported object type for DDL: {other}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_object_ddl(connection_id: String, schema: String, object_type: String, name: String, state: State<'_, crate::AppState>, app: tauri::AppHandle) -> Result<String, String> {
|
||||||
|
if object_type == "table" || object_type == "TABLE" {
|
||||||
|
return crate::commands::db_viewer::get_table_ddl(connection_id, schema, name, state, app).await;
|
||||||
|
}
|
||||||
|
get_object_ddl_inner(&state.pool_manager, &connection_id, &schema, &object_type.to_lowercase(), &name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn get_object_dependencies_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, object_type: &str, name: &str) -> Result<Vec<DependencyInfo>, String> {
|
||||||
|
let mut pm = pm.lock().await;
|
||||||
|
let client = match pm.get(connection_id) {
|
||||||
|
Some(DbHandle::Postgresql(c, _)) => c,
|
||||||
|
Some(_) => return Err("Dependencies are PostgreSQL-only".into()),
|
||||||
|
None => return Err("Connection not found".into()),
|
||||||
|
};
|
||||||
|
if object_type.eq_ignore_ascii_case("schema") {
|
||||||
|
let rows = client.query(&pg_schema_contents_query(), &[&name]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||||
|
return Ok(rows.iter().map(|r| DependencyInfo {
|
||||||
|
deptype: "n".into(), class: format!("pg_class: {}", r.get::<_, String>(1)), name: r.get(0),
|
||||||
|
}).collect());
|
||||||
|
}
|
||||||
|
let oid_sql = pg_object_oid_query(&object_type.to_lowercase());
|
||||||
|
if oid_sql.is_empty() { return Err(format!("Unsupported object type: {object_type}")); }
|
||||||
|
let oid: tokio_postgres::types::Oid = client.query_one(&oid_sql, &[&name, &schema]).await.map_err(|e| sanitize(&e.to_string()))?.get(0);
|
||||||
|
let rows = client.query(&pg_depend_query(), &[&oid]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||||
|
Ok(rows.iter().map(|r| DependencyInfo {
|
||||||
|
deptype: r.get(0), class: r.get(1), name: r.get::<_, String>(2),
|
||||||
|
}).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_object_dependencies(connection_id: String, schema: String, object_type: String, name: String, state: State<'_, crate::AppState>) -> Result<Vec<DependencyInfo>, String> {
|
||||||
|
get_object_dependencies_inner(&state.pool_manager, &connection_id, &schema, &object_type, &name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Introspect the live columns of a SQLite table (for the edit/rebuild diff).
|
||||||
|
/// Mirrors the `PRAGMA table_info` + `PRAGMA index_list`/`index_info` reads used
|
||||||
|
/// elsewhere in db_viewer; `auto_increment` requires an INTEGER PK whose stored
|
||||||
|
/// DDL (sqlite_master) actually says AUTOINCREMENT, and `unique` means a
|
||||||
|
/// single-column unique index (excluding the PK autoindex).
|
||||||
|
fn sqlite_live_columns(conn: &rusqlite::Connection, table: &str) -> Result<Vec<SqliteColumn>, String> {
|
||||||
|
let pragma_query = format!("PRAGMA table_info('{}')", table);
|
||||||
|
let mut stmt = conn.prepare(&pragma_query).map_err(|e| e.to_string())?;
|
||||||
|
let col_meta: Vec<(String, String, bool, bool, Option<String>)> = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, String>(1)?, // name
|
||||||
|
row.get::<_, String>(2)?, // type
|
||||||
|
row.get::<_, bool>(3)?, // notnull
|
||||||
|
row.get::<_, bool>(5)?, // pk
|
||||||
|
row.get::<_, Option<String>>(4)?, // dflt_value
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect();
|
||||||
|
// AUTOINCREMENT only appears in the stored DDL of an INTEGER PK table.
|
||||||
|
let autoinc = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?1",
|
||||||
|
[table],
|
||||||
|
|row| row.get::<_, String>(0),
|
||||||
|
)
|
||||||
|
.map(|sql| sql.to_uppercase().contains("AUTOINCREMENT"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
// Columns covered by a single-column unique index (origin != 'pk').
|
||||||
|
let mut unique_cols: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
|
if let Ok(mut idx_stmt) = conn.prepare(&format!("PRAGMA index_list('{}')", table)) {
|
||||||
|
let indexes: Vec<(String, bool, String)> = idx_stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, String>(1)?, // name
|
||||||
|
row.get::<_, bool>(2)?, // unique
|
||||||
|
row.get::<_, String>(3)?, // origin
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect();
|
||||||
|
for (idx_name, is_unique, origin) in indexes {
|
||||||
|
if !is_unique || origin == "pk" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(mut info_stmt) =
|
||||||
|
conn.prepare(&format!("PRAGMA index_info('{}')", idx_name.replace('\'', "''")))
|
||||||
|
{
|
||||||
|
let cols: Vec<String> = info_stmt
|
||||||
|
.query_map([], |row| row.get::<_, String>(2))
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect();
|
||||||
|
if cols.len() == 1 {
|
||||||
|
unique_cols.insert(cols[0].clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(col_meta
|
||||||
|
.iter()
|
||||||
|
.map(|(name, dtype, notnull, is_pk, default)| SqliteColumn {
|
||||||
|
name: name.clone(),
|
||||||
|
type_: dtype.clone(),
|
||||||
|
nullable: !notnull,
|
||||||
|
default: default.clone(),
|
||||||
|
is_pk: *is_pk,
|
||||||
|
auto_increment: autoinc && *is_pk && dtype.trim().eq_ignore_ascii_case("INTEGER"),
|
||||||
|
unique: unique_cols.contains(name),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build SQL for an object CRUD operation. The pool is resolved only to enforce
|
||||||
|
/// PostgreSQL-only / present-connection; the SQL itself is built by the pure
|
||||||
|
/// `crate::db::object_crud::build_ddl` dispatcher (one statement per String).
|
||||||
|
/// SQLite routes through the table-editor builders in `crate::db::object_ddl`
|
||||||
|
/// (create / edit / rebuild ops on the `table` kind).
|
||||||
|
pub(crate) async fn build_object_ddl_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, kind: &str, params: serde_json::Value) -> Result<Vec<String>, String> {
|
||||||
|
let mut pm = pm.lock().await;
|
||||||
|
match pm.get(connection_id) {
|
||||||
|
Some(DbHandle::Postgresql(_, _)) => build_ddl(kind, params),
|
||||||
|
Some(DbHandle::Sqlite(conn)) => {
|
||||||
|
// params: { schema, name, action: { op, columns: [...] } }
|
||||||
|
let action = params.get("action").ok_or("missing action")?;
|
||||||
|
let op = action
|
||||||
|
.get("op")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or("missing op")?;
|
||||||
|
let cols: Vec<SqliteColumn> = match action.get("columns") {
|
||||||
|
Some(v) => serde_json::from_value(v.clone()).map_err(|e| e.to_string())?,
|
||||||
|
None => vec![],
|
||||||
|
};
|
||||||
|
let table = params
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or("missing name")?
|
||||||
|
.to_string();
|
||||||
|
match op {
|
||||||
|
"create" => Ok(vec![sqlite_create_table_sql(&table, &cols, &[])?]),
|
||||||
|
"edit" => {
|
||||||
|
let old = sqlite_live_columns(conn, &table)?;
|
||||||
|
sqlite_column_diff_sql(&table, &old, &cols)
|
||||||
|
}
|
||||||
|
"rebuild" => {
|
||||||
|
let old = sqlite_live_columns(conn, &table)?;
|
||||||
|
sqlite_rebuild_script(&table, &old, &cols)
|
||||||
|
}
|
||||||
|
_ => Err(format!("unknown op {op}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(_) => Err("Object management is PostgreSQL-only".into()),
|
||||||
|
None => Err("Connection not found".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn build_object_ddl(connection_id: String, kind: String, params: serde_json::Value, state: State<'_, crate::AppState>) -> Result<Vec<String>, String> {
|
||||||
|
build_object_ddl_inner(&state.pool_manager, &connection_id, &kind, params).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List extensions installable on this server (`pg_available_extensions`):
|
||||||
|
/// name + default version + comment. The picker uses name/version only;
|
||||||
|
/// `schema` is left empty (available extensions are schema-wide).
|
||||||
|
pub(crate) async fn get_available_extensions_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str) -> Result<Vec<ExtensionInfo>, String> {
|
||||||
|
let mut pm = pm.lock().await;
|
||||||
|
let client = match pm.get(connection_id) {
|
||||||
|
Some(DbHandle::Postgresql(c, _)) => c,
|
||||||
|
Some(_) => return Err("Extensions are PostgreSQL-only".into()),
|
||||||
|
None => return Err("Connection not found".into()),
|
||||||
|
};
|
||||||
|
let rows = client.query(&crate::db::introspection::pg_available_extensions_query(), &[]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||||
|
Ok(rows.iter().map(|r| ExtensionInfo {
|
||||||
|
name: r.get(0),
|
||||||
|
schema: String::new(),
|
||||||
|
version: r.get(1),
|
||||||
|
comment: r.get(2),
|
||||||
|
}).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_available_extensions(connection_id: String, state: State<'_, crate::AppState>) -> Result<Vec<ExtensionInfo>, String> {
|
||||||
|
get_available_extensions_inner(&state.pool_manager, &connection_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a reorder-only table rebuild script (executed transactionally via
|
||||||
|
/// `execute_change`'s `RebuildTable` arm). Headless inner: locks the pool once,
|
||||||
|
/// validates the new column list against the live snapshot (names + types must
|
||||||
|
/// be preserved), assembles a `RebuildInput` from live introspection, and
|
||||||
|
/// delegates to `rebuild_script`.
|
||||||
|
pub(crate) async fn build_rebuild_script_inner(
|
||||||
|
pm: &tokio::sync::Mutex<ConnectionPoolManager>,
|
||||||
|
connection_id: &str,
|
||||||
|
schema: &str,
|
||||||
|
table: &str,
|
||||||
|
new_columns: serde_json::Value,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let mut pm = pm.lock().await;
|
||||||
|
match pm.get(connection_id) {
|
||||||
|
Some(DbHandle::Sqlite(conn)) => {
|
||||||
|
let new_cols: Vec<SqliteColumn> =
|
||||||
|
serde_json::from_value(new_columns).map_err(|e| e.to_string())?;
|
||||||
|
let live = sqlite_live_columns(conn, table)?;
|
||||||
|
if let Some(reason) = sqlite_rebuild_refusal(&live) {
|
||||||
|
return Err(reason);
|
||||||
|
}
|
||||||
|
return Ok(sqlite_rebuild_script(table, &live, &new_cols)?.join(";\n"));
|
||||||
|
}
|
||||||
|
Some(DbHandle::Postgresql(client, _)) => {
|
||||||
|
let new_cols: Vec<TableColumn> =
|
||||||
|
serde_json::from_value(new_columns).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// 1. live columns — validate reorder-only: the (name,type) multiset must be
|
||||||
|
// unchanged (attribute edits belong in the diff path, not the rebuild).
|
||||||
|
let live = client
|
||||||
|
.query(&crate::db::introspection::pg_columns_query(schema, table), &[])
|
||||||
|
.await
|
||||||
|
.map_err(|e| sanitize(&e.to_string()))?;
|
||||||
|
let mut live_pairs: Vec<(String, String)> = live
|
||||||
|
.iter()
|
||||||
|
.map(|r| (r.get::<_, String>(0), r.get::<_, String>(1).trim().to_string()))
|
||||||
|
.collect();
|
||||||
|
let mut new_pairs: Vec<(String, String)> = new_cols
|
||||||
|
.iter()
|
||||||
|
.map(|c| (c.name.clone(), c.type_.trim().to_string()))
|
||||||
|
.collect();
|
||||||
|
live_pairs.sort();
|
||||||
|
new_pairs.sort();
|
||||||
|
if live_pairs != new_pairs {
|
||||||
|
return Err(
|
||||||
|
"Reorder must preserve column names and types; undo attribute changes or stage a diff"
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. assemble RebuildInput from live introspection (one client, all sub-queries).
|
||||||
|
let fk_out_rows = client
|
||||||
|
.query(&crate::db::introspection::pg_table_fk_out_query(), &[&schema, &table])
|
||||||
|
.await
|
||||||
|
.map_err(|e| sanitize(&e.to_string()))?;
|
||||||
|
let fk_in_rows = client
|
||||||
|
.query(&crate::db::introspection::pg_table_fk_in_query(), &[&schema, &table])
|
||||||
|
.await
|
||||||
|
.map_err(|e| sanitize(&e.to_string()))?;
|
||||||
|
let grant_rows = client
|
||||||
|
.query(&crate::db::introspection::pg_table_grants_query(), &[&schema, &table])
|
||||||
|
.await
|
||||||
|
.map_err(|e| sanitize(&e.to_string()))?;
|
||||||
|
let seq_rows = client
|
||||||
|
.query(&crate::db::introspection::pg_table_owned_sequences_query(), &[&schema, &table])
|
||||||
|
.await
|
||||||
|
.map_err(|e| sanitize(&e.to_string()))?;
|
||||||
|
let index_rows = client
|
||||||
|
.query(&crate::db::introspection::pg_indexes_query(schema), &[&schema])
|
||||||
|
.await
|
||||||
|
.map_err(|e| sanitize(&e.to_string()))?;
|
||||||
|
// PK/UNIQUE/CHECK (contype p/u/c) scoped to this table; FKs are carried
|
||||||
|
// separately as fks_out/fks_in so they are not double-applied.
|
||||||
|
let constraint_rows = client
|
||||||
|
.query(
|
||||||
|
"SELECT c.conname AS name, ns.nspname AS schema, cl.relname AS table_name, \
|
||||||
|
c.contype::text, pg_get_constraintdef(c.oid) AS definition \
|
||||||
|
FROM pg_constraint c \
|
||||||
|
JOIN pg_class cl ON c.conrelid = cl.oid \
|
||||||
|
JOIN pg_namespace ns ON cl.relnamespace = ns.oid \
|
||||||
|
WHERE ns.nspname = $1 AND cl.relname = $2 AND c.contype IN ('p','u','c') \
|
||||||
|
ORDER BY c.conname",
|
||||||
|
&[&schema, &table],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| sanitize(&e.to_string()))?;
|
||||||
|
|
||||||
|
let input = RebuildInput {
|
||||||
|
schema: schema.to_string(),
|
||||||
|
name: table.to_string(),
|
||||||
|
constraints: constraint_rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| RebuildConstraint {
|
||||||
|
name: r.get(0),
|
||||||
|
definition: r.get(4),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
indexes: index_rows
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.get::<_, String>(2) == table)
|
||||||
|
.map(|r| RebuildIndex {
|
||||||
|
name: r.get(0),
|
||||||
|
definition: r.get(3),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
fks_out: fk_out_rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| RebuildFk {
|
||||||
|
name: r.get(0),
|
||||||
|
definition: r.get(1),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
fks_in: fk_in_rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| RebuildFkIn {
|
||||||
|
name: r.get(0),
|
||||||
|
own_schema: r.get(1),
|
||||||
|
own_table: r.get(2),
|
||||||
|
definition: r.get(3),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
grants: grant_rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| RebuildGrant {
|
||||||
|
grantee: r.get(0),
|
||||||
|
privileges: r.get(1),
|
||||||
|
grantable: r.get(2),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
owned_sequences: seq_rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| RebuildOwnedSequence {
|
||||||
|
seq_schema: r.get(0),
|
||||||
|
seq_name: r.get(1),
|
||||||
|
column: r.get(2),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
rebuild_script(&input, &new_cols)
|
||||||
|
}
|
||||||
|
Some(_) => return Err("Rebuild is PostgreSQL-only".into()),
|
||||||
|
None => return Err("Connection not found".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn build_rebuild_script(
|
||||||
|
connection_id: String,
|
||||||
|
schema: String,
|
||||||
|
table: String,
|
||||||
|
new_columns: serde_json::Value,
|
||||||
|
state: State<'_, crate::AppState>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
build_rebuild_script_inner(&state.pool_manager, &connection_id, &schema, &table, new_columns)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Roles, privileges, rebuild readiness, tablespaces
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// List non-system roles with all attributes, memberships grouped by member role.
|
||||||
|
pub(crate) async fn get_roles_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str) -> Result<Vec<crate::models::RoleInfo>, String> {
|
||||||
|
let mut pm = pm.lock().await;
|
||||||
|
let client = match pm.get(connection_id) {
|
||||||
|
Some(DbHandle::Postgresql(c, _)) => c,
|
||||||
|
Some(_) => return Err("Roles are PostgreSQL-only".into()),
|
||||||
|
None => return Err("Connection not found".into()),
|
||||||
|
};
|
||||||
|
let roles = client.query(&crate::db::introspection::pg_roles_query(), &[]).await
|
||||||
|
.map_err(|e| sanitize(&e.to_string()))?;
|
||||||
|
let mems = client.query(&crate::db::introspection::pg_role_memberships_query(), &[]).await
|
||||||
|
.map_err(|e| sanitize(&e.to_string()))?;
|
||||||
|
let mut by_name: std::collections::HashMap<String, crate::models::RoleInfo> = std::collections::HashMap::new();
|
||||||
|
for r in roles {
|
||||||
|
let name: String = r.get("rolname");
|
||||||
|
by_name.insert(name.clone(), crate::models::RoleInfo {
|
||||||
|
name, superuser: r.get("rolsuper"), inherit: r.get("rolinherit"),
|
||||||
|
create_db: r.get("rolcreatedb"), create_role: r.get("rolcreaterole"),
|
||||||
|
can_login: r.get("rolcanlogin"), replication: r.get("rolreplication"),
|
||||||
|
bypass_rls: r.get("rolbypassrls"), connection_limit: r.get("rolconnlimit"),
|
||||||
|
valid_until: { let v: String = r.get("rolvaliduntil"); if v.is_empty() { None } else { Some(v) } },
|
||||||
|
memberships: vec![],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for m in mems {
|
||||||
|
let member: String = m.get("member");
|
||||||
|
if let Some(ri) = by_name.get_mut(&member) {
|
||||||
|
ri.memberships.push(crate::models::RoleMembership {
|
||||||
|
role: m.get("role"), member, grantor: m.get("grantor"), admin_option: m.get("admin_option"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(by_name.into_values().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_roles(connection_id: String, state: State<'_, crate::AppState>) -> Result<Vec<crate::models::RoleInfo>, String> {
|
||||||
|
get_roles_inner(&state.pool_manager, &connection_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All privilege grants for a role across tables, sequences, routines, schemas, and databases.
|
||||||
|
pub(crate) async fn get_role_privileges_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, role: &str) -> Result<Vec<crate::models::PrivilegeEntry>, String> {
|
||||||
|
let mut pm = pm.lock().await;
|
||||||
|
let client = match pm.get(connection_id) {
|
||||||
|
Some(DbHandle::Postgresql(c, _)) => c,
|
||||||
|
Some(_) => return Err("Privileges are PostgreSQL-only".into()),
|
||||||
|
None => return Err("Connection not found".into()),
|
||||||
|
};
|
||||||
|
validate_object_name(role)?; // role is interpolated into the privilege queries
|
||||||
|
let mut out: Vec<crate::models::PrivilegeEntry> = Vec::new();
|
||||||
|
let push = |out: &mut Vec<crate::models::PrivilegeEntry>, class: &str, schema: Option<String>, name: String, privileges: Vec<String>, grantable: bool| {
|
||||||
|
out.push(crate::models::PrivilegeEntry { object_class: class.into(), schema, name, privileges, grantable });
|
||||||
|
};
|
||||||
|
for row in client.query(&crate::db::introspection::pg_table_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
|
||||||
|
push(&mut out, "table", Some(row.get("schema")), row.get("name"), row.get("privileges"), row.get("grantable"));
|
||||||
|
}
|
||||||
|
for row in client.query(&crate::db::introspection::pg_sequence_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
|
||||||
|
push(&mut out, "sequence", Some(row.get("schema")), row.get("name"), row.get("privileges"), row.get("grantable"));
|
||||||
|
}
|
||||||
|
for row in client.query(&crate::db::introspection::pg_routine_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
|
||||||
|
push(&mut out, "routine", Some(row.get("schema")), row.get("name"), row.get("privileges"), row.get("grantable"));
|
||||||
|
}
|
||||||
|
for row in client.query(&crate::db::introspection::pg_schema_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
|
||||||
|
push(&mut out, "schema", None, row.get("name"), row.get("privileges"), row.get("grantable"));
|
||||||
|
}
|
||||||
|
for row in client.query(&crate::db::introspection::pg_database_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
|
||||||
|
push(&mut out, "database", None, row.get("name"), row.get("privileges"), row.get("grantable"));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_role_privileges(connection_id: String, role: String, state: State<'_, crate::AppState>) -> Result<Vec<crate::models::PrivilegeEntry>, String> {
|
||||||
|
get_role_privileges_inner(&state.pool_manager, &connection_id, &role).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether a table can be rebuilt (no triggers, policies, inheritance, partitioning, generated columns).
|
||||||
|
pub(crate) async fn get_table_rebuild_readiness_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, table: &str) -> Result<crate::models::RebuildReadiness, String> {
|
||||||
|
let mut pm = pm.lock().await;
|
||||||
|
match pm.get(connection_id) {
|
||||||
|
Some(DbHandle::Sqlite(conn)) => {
|
||||||
|
let live = sqlite_live_columns(conn, table)?;
|
||||||
|
Ok(match sqlite_rebuild_refusal(&live) {
|
||||||
|
Some(reason) => crate::models::RebuildReadiness { ok: false, reasons: vec![reason] },
|
||||||
|
None => crate::models::RebuildReadiness { ok: true, reasons: vec![] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Some(DbHandle::Postgresql(client, _)) => {
|
||||||
|
let row = client.query_one(&crate::db::introspection::pg_rebuild_readiness_query(), &[&schema, &table]).await
|
||||||
|
.map_err(|e| sanitize(&e.to_string()))?;
|
||||||
|
let mut reasons = Vec::new();
|
||||||
|
if row.get::<_, bool>("has_triggers") { reasons.push("table has triggers".into()); }
|
||||||
|
if row.get::<_, bool>("has_policies") { reasons.push("table has RLS policies".into()); }
|
||||||
|
if row.get::<_, bool>("is_inherits") { reasons.push("table participates in inheritance".into()); }
|
||||||
|
if row.get::<_, bool>("is_partitioned") { reasons.push("table is partitioned".into()); }
|
||||||
|
if row.get::<_, bool>("has_generated") { reasons.push("table has generated/identity columns".into()); }
|
||||||
|
Ok(crate::models::RebuildReadiness { ok: reasons.is_empty(), reasons })
|
||||||
|
}
|
||||||
|
Some(_) => return Err("Rebuild is PostgreSQL-only".into()),
|
||||||
|
None => return Err("Connection not found".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_table_rebuild_readiness(connection_id: String, schema: String, table: String, state: State<'_, crate::AppState>) -> Result<crate::models::RebuildReadiness, String> {
|
||||||
|
get_table_rebuild_readiness_inner(&state.pool_manager, &connection_id, &schema, &table).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List non-system tablespaces for the table-options picker.
|
||||||
|
pub(crate) async fn get_tablespaces_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str) -> Result<Vec<crate::models::TablespaceInfo>, String> {
|
||||||
|
let mut pm = pm.lock().await;
|
||||||
|
let client = match pm.get(connection_id) {
|
||||||
|
Some(DbHandle::Postgresql(c, _)) => c,
|
||||||
|
Some(_) => return Err("Tablespaces are PostgreSQL-only".into()),
|
||||||
|
None => return Err("Connection not found".into()),
|
||||||
|
};
|
||||||
|
let rows = client.query(&crate::db::introspection::pg_tablespaces_query(), &[]).await
|
||||||
|
.map_err(|e| sanitize(&e.to_string()))?;
|
||||||
|
Ok(rows.into_iter().map(|r| crate::models::TablespaceInfo { name: r.get("spcname") }).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_tablespaces(connection_id: String, state: State<'_, crate::AppState>) -> Result<Vec<crate::models::TablespaceInfo>, String> {
|
||||||
|
get_tablespaces_inner(&state.pool_manager, &connection_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "objects.test.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::db::pool::{ConnectionPoolManager, DbHandle};
|
||||||
|
|
||||||
|
/// Connect directly via tokio-postgres (no tunnel) so tests are Tauri-free.
|
||||||
|
async fn pool() -> (tokio::sync::Mutex<ConnectionPoolManager>, String) {
|
||||||
|
let h = std::env::var("GRIDLINE_TEST_PG_HOST").expect("set GRIDLINE_TEST_PG_HOST");
|
||||||
|
let p: u16 = std::env::var("GRIDLINE_TEST_PG_PORT").unwrap_or_else(|_| "5432".into()).parse().unwrap();
|
||||||
|
let u = std::env::var("GRIDLINE_TEST_PG_USER").expect("set GRIDLINE_TEST_PG_USER");
|
||||||
|
let d = std::env::var("GRIDLINE_TEST_PG_DB").expect("set GRIDLINE_TEST_PG_DB");
|
||||||
|
let pw = std::env::var("GRIDLINE_TEST_PG_PASSWORD").unwrap_or_default();
|
||||||
|
let (client, conn) = tokio_postgres::connect(
|
||||||
|
&format!("host={h} port={p} user={u} dbname={d} password={pw}"),
|
||||||
|
tokio_postgres::NoTls,
|
||||||
|
).await.expect("connect to test PG");
|
||||||
|
let handle = tokio::spawn(async move { let _ = conn.await; });
|
||||||
|
let mut pm = ConnectionPoolManager::new();
|
||||||
|
let id = "test-conn".to_string();
|
||||||
|
pm.register(&id, DbHandle::Postgresql(client, handle));
|
||||||
|
(tokio::sync::Mutex::new(pm), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn schema_crud_create_rename_drop() {
|
||||||
|
let (pm, id) = pool().await;
|
||||||
|
let name = "gridline_test_schema";
|
||||||
|
create_schema_inner(&pm, &id, name).await.unwrap();
|
||||||
|
assert!(create_schema_inner(&pm, &id, name).await.is_err(), "duplicate should error");
|
||||||
|
rename_schema_inner(&pm, &id, name, "gridline_test_schema2").await.unwrap();
|
||||||
|
drop_schema_inner(&pm, &id, "gridline_test_schema2", false).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn search_objects_finds_table_and_function() {
|
||||||
|
let (pm, id) = pool().await;
|
||||||
|
let hits = search_objects_inner(&pm, &id, "public", "users").await.unwrap();
|
||||||
|
assert!(hits.iter().any(|h| h.name == "users" && h.object_type == "TABLE"), "demo has a users table");
|
||||||
|
let fns = search_objects_inner(&pm, &id, "public", "get").await.unwrap();
|
||||||
|
// substring match across types; assert it returns a Vec<ObjectSearchHit>
|
||||||
|
assert!(fns.iter().all(|h| h.object_type != ""));
|
||||||
|
// empty needle returns nothing matched by position('' in name) > 0 is always true — so empty returns all (capped at 100)
|
||||||
|
let all = search_objects_inner(&pm, &id, "public", "").await.unwrap();
|
||||||
|
assert!(all.len() <= 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn object_ddl_for_sequence_enum_function() {
|
||||||
|
let (pm, id) = pool().await;
|
||||||
|
// demo has users_id_seq, an enum, and a function
|
||||||
|
let seq = get_object_ddl_inner(&pm, &id, "public", "sequence", "users_id_seq").await.unwrap();
|
||||||
|
assert!(seq.starts_with("CREATE SEQUENCE"), "{seq}");
|
||||||
|
// function: pg_get_functiondef passthrough
|
||||||
|
let f = get_object_ddl_inner(&pm, &id, "public", "function", "audit_log").await; // name per demo
|
||||||
|
assert!(f.is_ok());
|
||||||
|
assert!(f.clone().unwrap().contains("CREATE FUNCTION") || f.unwrap().contains("CREATE OR REPLACE FUNCTION"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn build_object_ddl_inner_guards_missing_connection_and_rejects_unknown_op() {
|
||||||
|
let pm = tokio::sync::Mutex::new(ConnectionPoolManager::new());
|
||||||
|
// Missing connection -> Connection not found
|
||||||
|
let err = build_object_ddl_inner(&pm, "missing", "sequence", serde_json::json!({
|
||||||
|
"schema": "public", "name": "s", "action": { "op": "drop" }
|
||||||
|
})).await.unwrap_err();
|
||||||
|
assert!(err.contains("Connection not found"), "{err}");
|
||||||
|
// SQLite now dispatches to the SQLite table builders; non-table ops are rejected.
|
||||||
|
pm.lock().await.register("sqlite", DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()));
|
||||||
|
let err = build_object_ddl_inner(&pm, "sqlite", "sequence", serde_json::json!({
|
||||||
|
"schema": "public", "name": "s", "action": { "op": "drop" }
|
||||||
|
})).await.unwrap_err();
|
||||||
|
assert!(err.contains("unknown op"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory SQLite pool registered under id "c" (no Tauri, no live PG).
|
||||||
|
fn fresh_pool_with_sqlite() -> ConnectionPoolManager {
|
||||||
|
let mut pm = ConnectionPoolManager::new();
|
||||||
|
pm.register("c", DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()));
|
||||||
|
pm
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn build_object_ddl_sqlite_create_yields_sqlite_sql() {
|
||||||
|
let pm = fresh_pool_with_sqlite();
|
||||||
|
let params = serde_json::json!({
|
||||||
|
"schema": "main", "name": "users",
|
||||||
|
"action": { "op": "create", "columns": [
|
||||||
|
{ "name": "id", "type": "integer", "nullable": false, "default": null, "is_pk": true, "auto_increment": true, "unique": false },
|
||||||
|
{ "name": "name", "type": "text", "nullable": true, "default": null, "is_pk": false, "auto_increment": false, "unique": false }
|
||||||
|
] }
|
||||||
|
});
|
||||||
|
let sqls = build_object_ddl_inner(&tokio::sync::Mutex::new(pm), "c", "table", params).await.unwrap();
|
||||||
|
assert!(sqls.iter().any(|s| s.contains("INTEGER PRIMARY KEY AUTOINCREMENT")), "{sqls:?}");
|
||||||
|
assert!(sqls.iter().all(|s| !s.contains("serial")), "{sqls:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn execute_change_sqlite_ddl_runs_create() {
|
||||||
|
let pm = tokio::sync::Mutex::new(fresh_pool_with_sqlite());
|
||||||
|
let change = crate::models::db_viewer::Change::Ddl { id: "x".into(), sql: "CREATE TABLE u(id INTEGER)".into() };
|
||||||
|
let r = crate::commands::db_viewer::execute_change_inner(&pm, "c", change).await;
|
||||||
|
assert!(r.is_ok(), "{r:?}");
|
||||||
|
// the table really landed on the live connection
|
||||||
|
{
|
||||||
|
let mut g = pm.lock().await;
|
||||||
|
match g.get("c").unwrap() {
|
||||||
|
crate::db::pool::DbHandle::Sqlite(conn) => {
|
||||||
|
let n: i64 = conn
|
||||||
|
.query_row("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='u'", [], |row| row.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(n, 1);
|
||||||
|
}
|
||||||
|
_ => panic!("expected a sqlite handle"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn build_object_ddl_inner_on_postgresql_pool() {
|
||||||
|
let (pm, id) = pool().await;
|
||||||
|
let sql = build_object_ddl_inner(&pm, &id, "sequence", serde_json::json!({
|
||||||
|
"schema": "public", "name": "s", "action": { "op": "drop" }
|
||||||
|
})).await.unwrap();
|
||||||
|
assert_eq!(sql, vec!["DROP SEQUENCE \"public\".\"s\""]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_available_extensions_inner_guards_postgresql_only() {
|
||||||
|
let pm = tokio::sync::Mutex::new(ConnectionPoolManager::new());
|
||||||
|
// Missing connection -> Connection not found
|
||||||
|
let err = get_available_extensions_inner(&pm, "missing").await.unwrap_err();
|
||||||
|
assert!(err.contains("Connection not found"), "{err}");
|
||||||
|
// Non-PostgreSQL handle -> PostgreSQL-only error
|
||||||
|
pm.lock().await.register("sqlite", DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()));
|
||||||
|
let err = get_available_extensions_inner(&pm, "sqlite").await.unwrap_err();
|
||||||
|
assert!(err.contains("PostgreSQL-only"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn get_available_extensions_on_postgresql_pool() {
|
||||||
|
let (pm, id) = pool().await;
|
||||||
|
let exts = get_available_extensions_inner(&pm, &id).await.unwrap();
|
||||||
|
assert!(!exts.is_empty(), "pg_available_extensions should list built-ins");
|
||||||
|
assert!(exts.iter().all(|e| !e.name.is_empty() && !e.version.is_empty()), "every extension needs name + default version: {exts:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn object_dependencies_for_table_includes_view() {
|
||||||
|
let (pm, id) = pool().await;
|
||||||
|
// demo has order_summary VIEW depending on orders — drop would break it
|
||||||
|
let deps = get_object_dependencies_inner(&pm, &id, "public", "table", "orders").await.unwrap();
|
||||||
|
assert!(deps.iter().any(|d| d.class.contains("pg_class") && d.name.contains("order_summary")), "view depending on orders should surface: {deps:?}");
|
||||||
|
// schema contents path
|
||||||
|
let contents = get_object_dependencies_inner(&pm, &id, "public", "schema", "public").await.unwrap();
|
||||||
|
assert!(!contents.is_empty(), "public schema should list contents");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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:?}");
|
||||||
|
}
|
||||||
@@ -17,6 +17,30 @@ use std::time::Instant;
|
|||||||
use tauri::State;
|
use tauri::State;
|
||||||
use uuid::Uuid;
|
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
|
// QueryHistoryEntry
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -87,6 +111,7 @@ impl From<crate::store::SavedQueryRow> for SavedQueryCommand {
|
|||||||
pub(crate) async fn execute_query_inner(
|
pub(crate) async fn execute_query_inner(
|
||||||
pool_manager: &mut crate::db::pool::ConnectionPoolManager,
|
pool_manager: &mut crate::db::pool::ConnectionPoolManager,
|
||||||
db_store: &std::sync::Mutex<crate::store::Store>,
|
db_store: &std::sync::Mutex<crate::store::Store>,
|
||||||
|
cancel_registry: &crate::cancel::CancelRegistry,
|
||||||
connection_id: &str,
|
connection_id: &str,
|
||||||
query: &str,
|
query: &str,
|
||||||
page: i64,
|
page: i64,
|
||||||
@@ -101,7 +126,25 @@ pub(crate) async fn execute_query_inner(
|
|||||||
execute_pg_query(client, query, page, page_size).await
|
execute_pg_query(client, query, page, page_size).await
|
||||||
}
|
}
|
||||||
Some(DbHandle::Sqlite(conn)) => execute_sqlite_query(conn, query, page, page_size),
|
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 => {
|
None => {
|
||||||
let elapsed = start.elapsed().as_millis() as i64;
|
let elapsed = start.elapsed().as_millis() as i64;
|
||||||
let err = "Connection not found".to_string();
|
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.
|
// 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 {
|
let total_rows: i64 = match client.query_one(&wrapped_count, &[]).await {
|
||||||
Ok(row) => row.get::<_, i64>(0),
|
Ok(row) => row.get::<_, i64>(0),
|
||||||
|
Err(e) if is_pg_cancel_error(&e) => return Err("Query cancelled".to_string()),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// Wrapping failed — fall back to raw execution.
|
// Wrapping failed — fall back to raw execution.
|
||||||
return execute_pg_raw(client, trimmed, page, page_size, off).await;
|
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.
|
// Now execute the wrapped data query.
|
||||||
let data_rows = match client.query(&wrapped_data, &[&page_size, &off]).await {
|
let data_rows = match client.query(&wrapped_data, &[&page_size, &off]).await {
|
||||||
Ok(rows) => rows,
|
Ok(rows) => rows,
|
||||||
|
Err(e) if is_pg_cancel_error(&e) => return Err("Query cancelled".to_string()),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
return execute_pg_raw(client, trimmed, page, page_size, off).await;
|
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.
|
// Try the wrapped count query first.
|
||||||
let total_rows: i64 = match conn.query_row(&wrapped_count, [], |row| row.get::<_, i64>(0)) {
|
let total_rows: i64 = match conn.query_row(&wrapped_count, [], |row| row.get::<_, i64>(0)) {
|
||||||
Ok(n) => n,
|
Ok(n) => n,
|
||||||
|
Err(e) if is_sqlite_cancel_error(&e) => return Err("Query cancelled".to_string()),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// Wrapping failed — fall back to raw execution.
|
// Wrapping failed — fall back to raw execution.
|
||||||
return execute_sqlite_raw(conn, trimmed, page, page_size, off);
|
return execute_sqlite_raw(conn, trimmed, page, page_size, off);
|
||||||
@@ -390,6 +436,11 @@ fn execute_sqlite_query(
|
|||||||
// Execute the wrapped data query.
|
// Execute the wrapped data query.
|
||||||
let (columns, all_rows) = match execute_sqlite_with_query(conn, &wrapped_data) {
|
let (columns, all_rows) = match execute_sqlite_with_query(conn, &wrapped_data) {
|
||||||
Ok(result) => result,
|
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(_) => {
|
Err(_) => {
|
||||||
return execute_sqlite_raw(conn, trimmed, page, page_size, off);
|
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
|
serde_json::Value::Null
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute_mysql_query(
|
async fn execute_mysql_query_on(
|
||||||
pool: &sqlx::MySqlPool,
|
conn: &mut sqlx::mysql::MySqlConnection,
|
||||||
query: &str,
|
query: &str,
|
||||||
page: i64,
|
page: i64,
|
||||||
page_size: i64,
|
page_size: i64,
|
||||||
@@ -547,21 +598,23 @@ async fn execute_mysql_query(
|
|||||||
|
|
||||||
// Try the wrapped count first; fall back to raw on failure.
|
// Try the wrapped count first; fall back to raw on failure.
|
||||||
let total_rows: i64 = match sqlx::query_scalar::<_, i64>(&mysql_wrap_count(trimmed))
|
let total_rows: i64 = match sqlx::query_scalar::<_, i64>(&mysql_wrap_count(trimmed))
|
||||||
.fetch_one(pool)
|
.fetch_one(&mut *conn)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(n) => n,
|
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))
|
let data_rows = match sqlx::query(&mysql_wrap_data(trimmed))
|
||||||
.bind(page_size)
|
.bind(page_size)
|
||||||
.bind(off)
|
.bind(off)
|
||||||
.fetch_all(pool)
|
.fetch_all(&mut *conn)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(rows) => rows,
|
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() {
|
let columns: Vec<ColumnInfo> = match data_rows.first() {
|
||||||
@@ -580,7 +633,7 @@ async fn execute_mysql_query(
|
|||||||
is_generated: false,
|
is_generated: false,
|
||||||
})
|
})
|
||||||
.collect(),
|
.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
|
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
|
/// mirroring the PG `simple_query` raw path. Used when wrapping fails
|
||||||
/// (e.g., multi-statement or non-selectable SQL).
|
/// (e.g., multi-statement or non-selectable SQL).
|
||||||
async fn execute_mysql_raw(
|
async fn execute_mysql_raw(
|
||||||
pool: &sqlx::MySqlPool,
|
conn: &mut sqlx::mysql::MySqlConnection,
|
||||||
query: &str,
|
query: &str,
|
||||||
page: i64,
|
page: i64,
|
||||||
page_size: i64,
|
page_size: i64,
|
||||||
off: i64,
|
off: i64,
|
||||||
) -> Result<QueryResult, String> {
|
) -> Result<QueryResult, String> {
|
||||||
let rows = sqlx::query(query)
|
let rows = sqlx::query(query)
|
||||||
.fetch_all(pool)
|
.fetch_all(&mut *conn)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
|
.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 p = page.unwrap_or(1);
|
||||||
let ps = page_size.unwrap_or(50);
|
let ps = page_size.unwrap_or(50);
|
||||||
let mut pm = state.pool_manager.lock().await;
|
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]
|
#[tauri::command]
|
||||||
@@ -1060,6 +1176,7 @@ mod tests {
|
|||||||
username: None,
|
username: None,
|
||||||
folder_id: None,
|
folder_id: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
password: None,
|
password: None,
|
||||||
database: None,
|
database: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
@@ -1137,4 +1254,127 @@ mod tests {
|
|||||||
let q = mysql_wrap_count("SELECT * FROM t");
|
let q = mysql_wrap_count("SELECT * FROM t");
|
||||||
assert_eq!(q, "SELECT COUNT(*) FROM (SELECT * FROM t) AS _gridline_cnt");
|
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::models::Settings;
|
||||||
use crate::store::Store;
|
use crate::store::Store;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
pub fn get_settings_inner(state: &Mutex<Store>) -> Result<Settings, String> {
|
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)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -59,4 +122,31 @@ mod tests {
|
|||||||
update_setting_inner(&st, "accent_color", "#EF4444").unwrap();
|
update_setting_inner(&st, "accent_color", "#EF4444").unwrap();
|
||||||
assert_eq!(get_settings_inner(&st).unwrap().accent_color, "#EF4444");
|
assert_eq!(get_settings_inner(&st).unwrap().accent_color, "#EF4444");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_export_envelope_camel_case() {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
let env = crate::models::settings::SettingsExport { schema_version: 1, settings: crate::models::Settings {
|
||||||
|
confirm_before_delete: true, default_folder_id: None, theme: "dark".into(), font_size: "medium".into(),
|
||||||
|
default_ports: HashMap::new(), tag_order: None, table_refresh_rate: 5, table_page_size: 50,
|
||||||
|
shortcuts: HashMap::new(), accent_color: "#2563EB".into(), editor_font_size: 14,
|
||||||
|
editor_font_family: "Menlo".into(), editor_word_wrap: "off".into(), editor_minimap: true, editor_tab_size: 2,
|
||||||
|
}};
|
||||||
|
let json = serde_json::to_string(&env).unwrap();
|
||||||
|
assert!(json.contains("\"schemaVersion\":1"));
|
||||||
|
assert!(json.contains("\"settings\":"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_apply_settings_writes_all_keys() {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||||
|
crate::store::migrations::run_migrations(&conn).unwrap();
|
||||||
|
let store = crate::store::Store::from_connection(conn);
|
||||||
|
let mut map = std::collections::HashMap::new();
|
||||||
|
map.insert("theme".to_string(), "light".to_string());
|
||||||
|
map.insert("accent_color".to_string(), "#EF4444".to_string());
|
||||||
|
store.apply_settings(&map).unwrap();
|
||||||
|
assert_eq!(store.get_settings().unwrap().theme, "light");
|
||||||
|
assert_eq!(store.get_settings().unwrap().accent_color, "#EF4444");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -292,6 +292,14 @@ pub fn pg_extensions_query() -> String {
|
|||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Query available (installable) extensions with default version + comment.
|
||||||
|
/// Schema-wide: `pg_available_extensions` is not schema-scoped.
|
||||||
|
pub fn pg_available_extensions_query() -> String {
|
||||||
|
"SELECT name, default_version::text AS version, comment \
|
||||||
|
FROM pg_available_extensions ORDER BY name"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// Query indexes in a schema.
|
/// Query indexes in a schema.
|
||||||
///
|
///
|
||||||
/// Returns index name, schema, table, definition (`pg_get_indexdef`),
|
/// Returns index name, schema, table, definition (`pg_get_indexdef`),
|
||||||
@@ -347,6 +355,165 @@ pub fn pg_constraints_query(_schema: &str) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Roles, privileges, tablespaces, rebuild readiness
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// List non-system roles with all attributes used by the role form.
|
||||||
|
pub fn pg_roles_query() -> String {
|
||||||
|
"SELECT rolname, rolsuper, rolinherit, rolcreatedb, rolcreaterole, rolcanlogin, \
|
||||||
|
rolreplication, rolbypassrls, rolconnlimit::int8, COALESCE(rolvaliduntil::text, '') AS rolvaliduntil \
|
||||||
|
FROM pg_roles WHERE rolname !~ '^pg_' ORDER BY rolname"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All role-to-role memberships (member/admin/grantor).
|
||||||
|
pub fn pg_role_memberships_query() -> String {
|
||||||
|
"SELECT roleid::regrole::text AS role, member::regrole::text AS member, \
|
||||||
|
grantor::regrole::text AS grantor, admin_option \
|
||||||
|
FROM pg_auth_members ORDER BY role"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Table/view/matview privileges for a grantee, grouped one row per object.
|
||||||
|
pub fn pg_table_privileges_query(role: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"SELECT table_schema AS schema, table_name AS name, \
|
||||||
|
array_agg(privilege_type::text) AS privileges, \
|
||||||
|
bool_or(is_grantable = 'YES') AS grantable \
|
||||||
|
FROM information_schema.table_privileges \
|
||||||
|
WHERE grantee = '{}' AND table_schema NOT IN ('pg_catalog','information_schema') \
|
||||||
|
GROUP BY table_schema, table_name \
|
||||||
|
ORDER BY table_schema, table_name",
|
||||||
|
role
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sequence privileges for a grantee, grouped one row per sequence.
|
||||||
|
/// aclexplode-based (role_sequence_grants was removed in PG 15).
|
||||||
|
pub fn pg_sequence_privileges_query(role: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"SELECT n.nspname AS schema, c.relname AS name, \
|
||||||
|
array_agg(p.privilege_type::text) AS privileges, \
|
||||||
|
bool_or(p.is_grantable) AS grantable \
|
||||||
|
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace, \
|
||||||
|
LATERAL aclexplode(c.relacl) p \
|
||||||
|
WHERE c.relkind = 'S' AND n.nspname NOT IN ('pg_catalog','information_schema') \
|
||||||
|
AND p.grantee = (SELECT oid FROM pg_roles WHERE rolname = '{}') \
|
||||||
|
GROUP BY n.nspname, c.relname \
|
||||||
|
ORDER BY n.nspname, c.relname",
|
||||||
|
role
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Routine (function/procedure) privileges for a grantee.
|
||||||
|
pub fn pg_routine_privileges_query(role: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"SELECT routine_schema AS schema, routine_name AS name, \
|
||||||
|
array_agg(privilege_type::text) AS privileges, \
|
||||||
|
bool_or(is_grantable = 'YES') AS grantable \
|
||||||
|
FROM information_schema.routine_privileges \
|
||||||
|
WHERE grantee = '{}' \
|
||||||
|
GROUP BY routine_schema, routine_name \
|
||||||
|
ORDER BY routine_schema, routine_name",
|
||||||
|
role
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schema privileges for a grantee (USAGE/CREATE). aclexplode-based —
|
||||||
|
/// information_schema has no schema_privileges view.
|
||||||
|
pub fn pg_schema_privileges_query(role: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"SELECT n.nspname AS name, \
|
||||||
|
array_agg(p.privilege_type::text) AS privileges, \
|
||||||
|
bool_or(p.is_grantable) AS grantable \
|
||||||
|
FROM pg_namespace n, LATERAL aclexplode(n.nspacl) p \
|
||||||
|
WHERE n.nspname NOT IN ('pg_catalog','information_schema') \
|
||||||
|
AND p.grantee = (SELECT oid FROM pg_roles WHERE rolname = '{}') \
|
||||||
|
GROUP BY n.nspname ORDER BY n.nspname",
|
||||||
|
role
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Database privileges for a grantee via aclexplode (broadly compatible; avoids PG15-only view).
|
||||||
|
pub fn pg_database_privileges_query(role: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"SELECT d.datname AS name, array_agg(a.privilege_type) AS privileges, \
|
||||||
|
bool_or(a.is_grantable) AS grantable \
|
||||||
|
FROM pg_database d, LATERAL aclexplode(d.datacl) a \
|
||||||
|
WHERE a.grantee = (SELECT oid FROM pg_roles WHERE rolname = '{}') \
|
||||||
|
AND d.datistemplate = false \
|
||||||
|
GROUP BY d.datname \
|
||||||
|
ORDER BY d.datname",
|
||||||
|
role
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-system tablespaces for the table-options picker.
|
||||||
|
pub fn pg_tablespaces_query() -> String {
|
||||||
|
"SELECT spcname FROM pg_tablespace WHERE spcname !~ '^pg_' ORDER BY spcname".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single query returning boolean blockers for a table rebuild (triggers, policies,
|
||||||
|
/// inheritance, partitioning, generated/identity columns). Parameterized via $1/$2.
|
||||||
|
pub fn pg_rebuild_readiness_query() -> String {
|
||||||
|
"SELECT \
|
||||||
|
EXISTS(SELECT 1 FROM pg_trigger t JOIN pg_class c ON t.tgrelid = c.oid \
|
||||||
|
JOIN pg_namespace n ON c.relnamespace = n.oid \
|
||||||
|
WHERE n.nspname = $1 AND c.relname = $2 AND NOT t.tgisinternal) AS has_triggers, \
|
||||||
|
EXISTS(SELECT 1 FROM pg_policy p JOIN pg_class c ON p.polrelid = c.oid \
|
||||||
|
JOIN pg_namespace n ON c.relnamespace = n.oid \
|
||||||
|
WHERE n.nspname = $1 AND c.relname = $2) AS has_policies, \
|
||||||
|
EXISTS(SELECT 1 FROM pg_inherits i JOIN pg_class c ON i.inhrelid = c.oid \
|
||||||
|
JOIN pg_namespace n ON c.relnamespace = n.oid \
|
||||||
|
WHERE n.nspname = $1 AND c.relname = $2) AS is_inherits, \
|
||||||
|
EXISTS(SELECT 1 FROM pg_partitioned_table pt JOIN pg_class c ON pt.partrelid = c.oid \
|
||||||
|
JOIN pg_namespace n ON c.relnamespace = n.oid \
|
||||||
|
WHERE n.nspname = $1 AND c.relname = $2) AS is_partitioned, \
|
||||||
|
EXISTS(SELECT 1 FROM information_schema.columns \
|
||||||
|
WHERE table_schema = $1 AND table_name = $2 AND is_generated <> '') AS has_generated"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FKs owned by this table (contype='f'). Parameterized $1 schema, $2 table.
|
||||||
|
pub fn pg_table_fk_out_query() -> String {
|
||||||
|
"SELECT c.conname, pg_get_constraintdef(c.oid) AS definition \
|
||||||
|
FROM pg_constraint c JOIN pg_class cl ON c.conrelid = cl.oid \
|
||||||
|
JOIN pg_namespace n ON cl.relnamespace = n.oid \
|
||||||
|
WHERE n.nspname = $1 AND cl.relname = $2 AND c.contype = 'f'".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FKs from other tables referencing this table. Parameterized $1 schema, $2 table.
|
||||||
|
pub fn pg_table_fk_in_query() -> String {
|
||||||
|
"SELECT c.conname, cn.nspname AS own_schema, cl.relname AS own_table, \
|
||||||
|
pg_get_constraintdef(c.oid) AS definition \
|
||||||
|
FROM pg_constraint c JOIN pg_class cl ON c.conrelid = cl.oid \
|
||||||
|
JOIN pg_namespace cn ON cl.relnamespace = cn.oid \
|
||||||
|
JOIN pg_class r ON c.confrelid = r.oid \
|
||||||
|
JOIN pg_namespace rn ON r.relnamespace = rn.oid \
|
||||||
|
WHERE rn.nspname = $1 AND r.relname = $2 AND c.contype = 'f'".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grants on this table (all grantees), grouped. Parameterized $1 schema, $2 table.
|
||||||
|
pub fn pg_table_grants_query() -> String {
|
||||||
|
"SELECT grantee, array_agg(privilege_type::text) AS privileges, \
|
||||||
|
bool_or(is_grantable = 'YES') AS grantable \
|
||||||
|
FROM information_schema.table_privileges \
|
||||||
|
WHERE table_schema = $1 AND table_name = $2 AND grantee <> 'PUBLIC' \
|
||||||
|
GROUP BY grantee".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sequences owned by this table's columns (via pg_depend). Parameterized $1 schema, $2 table.
|
||||||
|
pub fn pg_table_owned_sequences_query() -> String {
|
||||||
|
"SELECT sn.nspname AS seq_schema, s.relname AS seq_name, a.attname AS column \
|
||||||
|
FROM pg_depend d JOIN pg_class s ON d.objid = s.oid \
|
||||||
|
JOIN pg_namespace sn ON s.relnamespace = sn.oid \
|
||||||
|
JOIN pg_class t ON d.refobjid = t.oid \
|
||||||
|
JOIN pg_namespace tn ON t.relnamespace = tn.oid \
|
||||||
|
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid \
|
||||||
|
WHERE tn.nspname = $1 AND t.relname = $2 AND d.classid = 'pg_class'::regclass AND s.relkind = 'S'".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -624,4 +791,99 @@ mod tests {
|
|||||||
let sql = pg_extensions_query();
|
let sql = pg_extensions_query();
|
||||||
assert!(sql.contains("pg_extension"));
|
assert!(sql.contains("pg_extension"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_available_extensions_query_has_name_default_version_comment() {
|
||||||
|
let sql = pg_available_extensions_query();
|
||||||
|
assert!(sql.contains("pg_available_extensions"));
|
||||||
|
assert!(sql.contains("default_version"), "should select default version; got: {sql}");
|
||||||
|
assert!(sql.contains("comment"), "should select comment; got: {sql}");
|
||||||
|
assert!(sql.contains("ORDER BY name"), "should order by name; got: {sql}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Roles, privileges, tablespaces, rebuild readiness
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_roles_query_filters_pg_prefix() {
|
||||||
|
let sql = pg_roles_query();
|
||||||
|
assert!(sql.contains("pg_roles"));
|
||||||
|
assert!(sql.contains("rolname !~ '^pg_'"));
|
||||||
|
assert!(sql.contains("rolcanlogin"));
|
||||||
|
assert!(sql.contains("rolconnlimit::int8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_role_memberships_query_uses_pg_auth_members() {
|
||||||
|
let sql = pg_role_memberships_query();
|
||||||
|
assert!(sql.contains("pg_auth_members"));
|
||||||
|
assert!(sql.contains("admin_option"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_table_privileges_query_filters_grantee_and_aggregates() {
|
||||||
|
let sql = pg_table_privileges_query("appuser");
|
||||||
|
assert!(sql.contains("information_schema.table_privileges"));
|
||||||
|
assert!(sql.contains("grantee = 'appuser'"));
|
||||||
|
assert!(sql.contains("array_agg"));
|
||||||
|
assert!(sql.contains("GROUP BY"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_database_privileges_query_uses_aclexplode() {
|
||||||
|
let sql = pg_database_privileges_query("appuser");
|
||||||
|
assert!(sql.contains("aclexplode"));
|
||||||
|
assert!(sql.contains("pg_database"));
|
||||||
|
assert!(sql.contains("appuser"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_rebuild_readiness_query_checks_blockers() {
|
||||||
|
let sql = pg_rebuild_readiness_query();
|
||||||
|
assert!(sql.contains("pg_trigger"));
|
||||||
|
assert!(sql.contains("pg_policy"));
|
||||||
|
assert!(sql.contains("pg_inherits"));
|
||||||
|
assert!(sql.contains("pg_partitioned_table"));
|
||||||
|
assert!(sql.contains("is_generated"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_tablespaces_query_filters_pg_prefix() {
|
||||||
|
let sql = pg_tablespaces_query();
|
||||||
|
assert!(sql.contains("pg_tablespace"));
|
||||||
|
assert!(sql.contains("spcname !~ '^pg_'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_table_fk_out_query_uses_pg_constraint_f() {
|
||||||
|
let sql = pg_table_fk_out_query();
|
||||||
|
assert!(sql.contains("pg_constraint"));
|
||||||
|
assert!(sql.contains("contype = 'f'"));
|
||||||
|
assert!(sql.contains("$1") && sql.contains("$2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_table_fk_in_query_finds_referencing_tables() {
|
||||||
|
let sql = pg_table_fk_in_query();
|
||||||
|
assert!(sql.contains("pg_constraint"));
|
||||||
|
assert!(sql.contains("confrel"));
|
||||||
|
assert!(sql.contains("$1") && sql.contains("$2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_table_grants_query_uses_table_privileges() {
|
||||||
|
let sql = pg_table_grants_query();
|
||||||
|
assert!(sql.contains("information_schema.table_privileges"));
|
||||||
|
assert!(sql.contains("$1") && sql.contains("$2"));
|
||||||
|
assert!(sql.contains("array_agg"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_table_owned_sequences_query_uses_pg_depend() {
|
||||||
|
let sql = pg_table_owned_sequences_query();
|
||||||
|
assert!(sql.contains("pg_depend"));
|
||||||
|
assert!(sql.contains("pg_class"));
|
||||||
|
assert!(sql.contains("$1") && sql.contains("$2"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
pub mod introspection;
|
pub mod introspection;
|
||||||
pub mod mysql;
|
pub mod mysql;
|
||||||
|
pub mod object_crud;
|
||||||
|
pub mod object_ddl;
|
||||||
pub mod pool;
|
pub mod pool;
|
||||||
pub mod tls;
|
pub mod tls;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
//! 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('"', "\"\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject empty / SQL-injection-prone names (mirrors schema_graph::validate_schema_name).
|
||||||
|
pub fn validate_object_name(name: &str) -> Result<(), String> {
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
return Err("Name must not be empty".into());
|
||||||
|
}
|
||||||
|
for bad in [";", "--", "/*", "'", "\"", "\\"] {
|
||||||
|
if name.contains(bad) {
|
||||||
|
return Err(format!("Name contains forbidden character(s): {bad}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_schema_sql(name: &str) -> Result<String, String> {
|
||||||
|
validate_object_name(name)?;
|
||||||
|
Ok(format!("CREATE SCHEMA {}", quote_ident(name)))
|
||||||
|
}
|
||||||
|
pub fn rename_schema_sql(old: &str, new: &str) -> Result<String, String> {
|
||||||
|
validate_object_name(old)?;
|
||||||
|
validate_object_name(new)?;
|
||||||
|
Ok(format!("ALTER SCHEMA {} RENAME TO {}", quote_ident(old), quote_ident(new)))
|
||||||
|
}
|
||||||
|
pub fn drop_schema_sql(name: &str, cascade: bool) -> Result<String, String> {
|
||||||
|
validate_object_name(name)?;
|
||||||
|
Ok(format!("DROP SCHEMA {}{}", quote_ident(name), if cascade { " CASCADE" } else { "" }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UNION ALL of every browsable object type in `$2` (schema), substring-matching `$1` (needle)
|
||||||
|
/// via position() — case-insensitive, no wildcard-escaping pitfalls. LIMIT 100.
|
||||||
|
pub fn pg_object_search_query() -> String {
|
||||||
|
"SELECT name, schema, type FROM (
|
||||||
|
SELECT table_name AS name, table_schema AS schema, CASE WHEN table_type = 'VIEW' THEN 'VIEW' ELSE 'TABLE' END AS type FROM information_schema.tables WHERE table_schema=$2 AND position(lower($1) in lower(table_name))>0
|
||||||
|
UNION ALL
|
||||||
|
SELECT matviewname, schemaname, 'MATERIALIZED VIEW' FROM pg_matviews WHERE schemaname=$2 AND position(lower($1) in lower(matviewname))>0
|
||||||
|
UNION ALL
|
||||||
|
SELECT p.proname, n.nspname, CASE p.prokind WHEN 'f' THEN 'FUNCTION' WHEN 'p' THEN 'PROCEDURE' END FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE n.nspname=$2 AND position(lower($1) in lower(p.proname))>0
|
||||||
|
UNION ALL
|
||||||
|
SELECT t.tgname, cn.nspname, 'TRIGGER' FROM pg_trigger t JOIN pg_class c ON t.tgrelid=c.oid JOIN pg_namespace cn ON c.relnamespace=cn.oid WHERE cn.nspname=$2 AND NOT t.tgisinternal AND position(lower($1) in lower(t.tgname))>0
|
||||||
|
UNION ALL
|
||||||
|
SELECT sequence_name, sequence_schema, 'SEQUENCE' FROM information_schema.sequences WHERE sequence_schema=$2 AND position(lower($1) in lower(sequence_name))>0
|
||||||
|
UNION ALL
|
||||||
|
SELECT t.typname, n.nspname, 'ENUM' FROM pg_type t JOIN pg_namespace n ON t.typnamespace=n.oid WHERE t.typtype='e' AND n.nspname=$2 AND position(lower($1) in lower(t.typname))>0
|
||||||
|
UNION ALL
|
||||||
|
SELECT e.extname, n.nspname, 'EXTENSION' FROM pg_extension e JOIN pg_namespace n ON e.extnamespace=n.oid WHERE n.nspname=$2 AND position(lower($1) in lower(e.extname))>0
|
||||||
|
UNION ALL
|
||||||
|
SELECT i.relname, ns.nspname, 'INDEX' FROM pg_index ix JOIN pg_class i ON i.oid=ix.indexrelid JOIN pg_class t ON t.oid=ix.indrelid JOIN pg_namespace ns ON t.relnamespace=ns.oid WHERE ns.nspname=$2 AND position(lower($1) in lower(i.relname))>0
|
||||||
|
UNION ALL
|
||||||
|
SELECT c.conname, ns.nspname, 'CONSTRAINT' FROM pg_constraint c JOIN pg_class cl ON c.conrelid=cl.oid JOIN pg_namespace ns ON cl.relnamespace=ns.oid WHERE ns.nspname=$2 AND c.contype IN ('c','u','x') AND position(lower($1) in lower(c.conname))>0
|
||||||
|
) AS hits ORDER BY type, name LIMIT 100".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a single object oid by type. $1=name, $2=schema. (Overloads: first match — see spec open questions.)
|
||||||
|
pub fn pg_object_oid_query(object_type: &str) -> String {
|
||||||
|
match object_type {
|
||||||
|
"table" | "view" | "materialized view" | "sequence" | "index" =>
|
||||||
|
"SELECT c.oid FROM pg_class c JOIN pg_namespace n ON c.relnamespace=n.oid WHERE c.relname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
|
||||||
|
"function" | "procedure" =>
|
||||||
|
"SELECT p.oid FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE p.proname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
|
||||||
|
"trigger" =>
|
||||||
|
"SELECT t.oid FROM pg_trigger t JOIN pg_class c ON t.tgrelid=c.oid JOIN pg_namespace n ON c.relnamespace=n.oid WHERE t.tgname=$1 AND n.nspname=$2 AND NOT t.tgisinternal LIMIT 1".to_string(),
|
||||||
|
"enum" =>
|
||||||
|
"SELECT t.oid FROM pg_type t JOIN pg_namespace n ON t.typnamespace=n.oid WHERE t.typname=$1 AND n.nspname=$2 AND t.typtype='e' LIMIT 1".to_string(),
|
||||||
|
"extension" =>
|
||||||
|
"SELECT e.oid FROM pg_extension e JOIN pg_namespace n ON e.extnamespace=n.oid WHERE e.extname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
|
||||||
|
"constraint" =>
|
||||||
|
"SELECT c.oid FROM pg_constraint c JOIN pg_class cl ON c.conrelid=cl.oid JOIN pg_namespace n ON cl.relnamespace=n.oid WHERE c.conname=$1 AND n.nspname=$2 LIMIT 1".to_string(),
|
||||||
|
_ => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Objects that depend on `$1` (the target oid). Excludes internal/pinned deps; resolves readable name per classid.
|
||||||
|
pub fn pg_depend_query() -> String {
|
||||||
|
"SELECT d.deptype::text AS deptype,
|
||||||
|
CASE WHEN d.classid = 'pg_rewrite'::regclass THEN 'pg_class'
|
||||||
|
ELSE d.classid::regclass::text END AS class,
|
||||||
|
CASE
|
||||||
|
WHEN d.classid='pg_class'::regclass THEN (SELECT relname FROM pg_class WHERE oid=d.objid)
|
||||||
|
WHEN d.classid='pg_proc'::regclass THEN (SELECT proname FROM pg_proc WHERE oid=d.objid)
|
||||||
|
WHEN d.classid='pg_trigger'::regclass THEN (SELECT tgname FROM pg_trigger WHERE oid=d.objid)
|
||||||
|
WHEN d.classid='pg_type'::regclass THEN (SELECT typname FROM pg_type WHERE oid=d.objid)
|
||||||
|
WHEN d.classid='pg_constraint'::regclass THEN (SELECT conname FROM pg_constraint WHERE oid=d.objid)
|
||||||
|
WHEN d.classid='pg_rewrite'::regclass THEN (SELECT ev_class::regclass::text FROM pg_rewrite WHERE oid=d.objid)
|
||||||
|
ELSE ''
|
||||||
|
END AS name
|
||||||
|
FROM pg_depend d
|
||||||
|
WHERE d.refobjid = $1 AND d.deptype IN ('n', 'a')
|
||||||
|
ORDER BY d.deptype, name".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Objects contained in a schema (for the schema-drop dependency warning). $1=schema.
|
||||||
|
pub fn pg_schema_contents_query() -> String {
|
||||||
|
"SELECT c.relname, c.relkind::text FROM pg_class c JOIN pg_namespace n ON c.relnamespace=n.oid WHERE n.nspname=$1 AND c.relkind IN ('r','v','m','S','i','c') ORDER BY c.relkind, c.relname".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Synthesized DDL ---
|
||||||
|
pub fn sequence_ddl(s: &SequenceInfo) -> String {
|
||||||
|
format!("CREATE SEQUENCE {}.{}\n INCREMENT BY {}\n MINVALUE {}\n MAXVALUE {}\n START WITH {}\n {}",
|
||||||
|
quote_ident(&s.schema), quote_ident(&s.name), s.increment, s.min_value, s.max_value, s.start_value,
|
||||||
|
if s.cycle { "CYCLE" } else { "NO CYCLE" })
|
||||||
|
}
|
||||||
|
pub fn enum_ddl(e: &EnumInfo) -> String {
|
||||||
|
let labels: Vec<String> = e.labels.iter().map(|l| format!("'{}'", l.replace('\'', "''"))).collect();
|
||||||
|
format!("CREATE TYPE {}.{} AS ENUM ({});", quote_ident(&e.schema), quote_ident(&e.name), labels.join(", "))
|
||||||
|
}
|
||||||
|
pub fn extension_ddl(x: &ExtensionInfo) -> String {
|
||||||
|
format!("CREATE EXTENSION IF NOT EXISTS {} WITH SCHEMA {} VERSION '{}';", quote_ident(&x.name), quote_ident(&x.schema), x.version.replace('\'', "''"))
|
||||||
|
}
|
||||||
|
pub fn view_ddl(schema: &str, name: &str, selectdef: &str) -> String {
|
||||||
|
format!("CREATE OR REPLACE VIEW {}.{} AS\n{}", quote_ident(schema), quote_ident(name), selectdef)
|
||||||
|
}
|
||||||
|
pub fn matview_ddl(schema: &str, name: &str, selectdef: &str) -> String {
|
||||||
|
format!("CREATE MATERIALIZED VIEW {}.{} AS\n{}", quote_ident(schema), quote_ident(name), selectdef)
|
||||||
|
}
|
||||||
|
pub fn constraint_ddl(c: &ConstraintInfo) -> String {
|
||||||
|
format!("ALTER TABLE {}.{} ADD CONSTRAINT {} {}",
|
||||||
|
quote_ident(&c.schema), quote_ident(&c.table), quote_ident(&c.name), c.definition)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quote_ident_doubles_embedded_quotes() {
|
||||||
|
assert_eq!(quote_ident("public"), "\"public\"");
|
||||||
|
assert_eq!(quote_ident("a\"b"), "\"a\"\"b\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_object_name_rejects_dangerous_chars() {
|
||||||
|
assert!(validate_object_name("public").is_ok());
|
||||||
|
assert!(validate_object_name("").is_err());
|
||||||
|
assert!(validate_object_name("a; DROP").is_err());
|
||||||
|
assert!(validate_object_name("a--b").is_err());
|
||||||
|
assert!(validate_object_name("a'b").is_err());
|
||||||
|
assert!(validate_object_name("a\"b").is_err());
|
||||||
|
assert!(validate_object_name("a\\b").is_err());
|
||||||
|
assert!(validate_object_name("/*x*/").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_schema_sql_quotes_name() {
|
||||||
|
assert_eq!(create_schema_sql("my_schema").unwrap(), "CREATE SCHEMA \"my_schema\"");
|
||||||
|
assert!(create_schema_sql("bad; name").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_schema_sql_quotes_both() {
|
||||||
|
assert_eq!(
|
||||||
|
rename_schema_sql("old", "new").unwrap(),
|
||||||
|
"ALTER SCHEMA \"old\" RENAME TO \"new\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop_schema_sql_cascade_flag() {
|
||||||
|
assert_eq!(drop_schema_sql("s", false).unwrap(), "DROP SCHEMA \"s\"");
|
||||||
|
assert_eq!(drop_schema_sql("s", true).unwrap(), "DROP SCHEMA \"s\" CASCADE");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_search_query_unions_types_and_uses_position_match() {
|
||||||
|
let sql = pg_object_search_query();
|
||||||
|
assert!(sql.contains("position(lower($1) in lower("), "case-insensitive substring, no wildcard escaping: {sql}");
|
||||||
|
assert!(sql.contains("$2"), "schema is $2");
|
||||||
|
// covers every browsable type
|
||||||
|
for t in ["'TABLE'", "'VIEW'", "'MATERIALIZED VIEW'", "'FUNCTION'", "'PROCEDURE'", "'TRIGGER'", "'SEQUENCE'", "'ENUM'", "'EXTENSION'", "'INDEX'", "'CONSTRAINT'"] {
|
||||||
|
assert!(sql.contains(t), "search must cover {t}");
|
||||||
|
}
|
||||||
|
assert!(sql.contains("LIMIT 100"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_oid_query_branches_per_type() {
|
||||||
|
assert!(pg_object_oid_query("table").contains("pg_class"));
|
||||||
|
assert!(pg_object_oid_query("function").contains("pg_proc"));
|
||||||
|
assert!(pg_object_oid_query("trigger").contains("pg_trigger"));
|
||||||
|
assert!(pg_object_oid_query("enum").contains("pg_type"));
|
||||||
|
assert!(pg_object_oid_query("extension").contains("pg_extension"));
|
||||||
|
assert!(pg_object_oid_query("constraint").contains("pg_constraint"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn depend_query_filters_internal_and_resolves_names() {
|
||||||
|
let sql = pg_depend_query();
|
||||||
|
assert!(sql.contains("refobjid = $1"));
|
||||||
|
assert!(sql.contains("deptype IN ('n', 'a')"), "exclude internal 'i' / pinned 'p'");
|
||||||
|
assert!(sql.contains("pg_proc'::regclass"));
|
||||||
|
assert!(sql.contains("pg_trigger'::regclass"));
|
||||||
|
assert!(sql.contains("pg_constraint'::regclass"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn depend_query_maps_rewrite_rules_to_the_view_class() {
|
||||||
|
// View dependencies surface in pg_depend as rewrite-rule rows
|
||||||
|
// (classid = pg_rewrite). The dependent object a user cares about is
|
||||||
|
// the VIEW itself, so the class must be reported as pg_class and the
|
||||||
|
// name resolved through ev_class (the view's relation).
|
||||||
|
let sql = pg_depend_query();
|
||||||
|
assert!(
|
||||||
|
sql.contains("WHEN d.classid = 'pg_rewrite'::regclass THEN 'pg_class'"),
|
||||||
|
"pg_rewrite rows must be reported as pg_class: {sql}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
sql.contains("pg_rewrite'::regclass THEN (SELECT ev_class::regclass::text FROM pg_rewrite WHERE oid=d.objid)"),
|
||||||
|
"pg_rewrite name resolves through ev_class: {sql}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sequence_ddl_is_synthesized() {
|
||||||
|
let s = SequenceInfo { name: "users_id_seq".into(), schema: "public".into(),
|
||||||
|
start_value: "1".into(), min_value: "1".into(), max_value: "9".into(),
|
||||||
|
increment: "1".into(), current_value: "5".into(), cycle: false };
|
||||||
|
let ddl = sequence_ddl(&s);
|
||||||
|
assert!(ddl.starts_with("CREATE SEQUENCE \"public\".\"users_id_seq\""));
|
||||||
|
assert!(ddl.contains("INCREMENT BY 1"));
|
||||||
|
assert!(ddl.contains("NO CYCLE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enum_ddl_lists_labels_quoted() {
|
||||||
|
let e = EnumInfo { name: "role".into(), schema: "public".into(), labels: vec!["admin".into(), "user's".into()] };
|
||||||
|
let ddl = enum_ddl(&e);
|
||||||
|
assert!(ddl.starts_with("CREATE TYPE \"public\".\"role\" AS ENUM ("));
|
||||||
|
assert!(ddl.contains("'admin'"));
|
||||||
|
assert!(ddl.contains("'user''s'"), "single quotes doubled");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extension_ddl_is_synthesized() {
|
||||||
|
let x = ExtensionInfo { name: "pgcrypto".into(), schema: "public".into(), version: "1.3".into(), comment: None };
|
||||||
|
let ddl = extension_ddl(&x);
|
||||||
|
assert!(ddl.contains("CREATE EXTENSION IF NOT EXISTS \"pgcrypto\""));
|
||||||
|
assert!(ddl.contains("WITH SCHEMA \"public\""));
|
||||||
|
assert!(ddl.contains("VERSION '1.3'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn view_ddl_wraps_selectdef() {
|
||||||
|
assert_eq!(view_ddl("public", "v_users", "SELECT * FROM users"),
|
||||||
|
"CREATE OR REPLACE VIEW \"public\".\"v_users\" AS\nSELECT * FROM users");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constraint_ddl_wraps_definition() {
|
||||||
|
let c = ConstraintInfo { name: "ck_pos".into(), schema: "public".into(), table: "orders".into(),
|
||||||
|
contype: "CHECK".into(), definition: "CHECK (amount > 0)".into(), deferrable: false, validated: true, columns: vec!["amount".into()] };
|
||||||
|
let ddl = constraint_ddl(&c);
|
||||||
|
assert_eq!(ddl, "ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"ck_pos\" CHECK (amount > 0)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// of runtime usage, producing expected dead_code/unused warnings during development.
|
// of runtime usage, producing expected dead_code/unused warnings during development.
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
mod cancel;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod db;
|
mod db;
|
||||||
mod models;
|
mod models;
|
||||||
@@ -17,11 +18,12 @@ pub struct AppState {
|
|||||||
pub db_store: StdMutex<Store>,
|
pub db_store: StdMutex<Store>,
|
||||||
pub pool_manager: tokio::sync::Mutex<ConnectionPoolManager>,
|
pub pool_manager: tokio::sync::Mutex<ConnectionPoolManager>,
|
||||||
pub ssh_manager: StdMutex<SshTunnelManager>,
|
pub ssh_manager: StdMutex<SshTunnelManager>,
|
||||||
|
pub cancel_registry: crate::cancel::CancelRegistry,
|
||||||
}
|
}
|
||||||
|
|
||||||
use commands::{
|
use commands::{
|
||||||
backup, connections, db_viewer, demo, folders, import_export, keychain, query, schema_graph,
|
backup, connections, db_viewer, demo, folders, import_export, keychain, maintenance, objects, query,
|
||||||
settings, tags,
|
schema_graph, settings, tags,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||||
@@ -48,6 +50,7 @@ pub fn run() {
|
|||||||
db_store: store_ref,
|
db_store: store_ref,
|
||||||
pool_manager: tokio::sync::Mutex::new(ConnectionPoolManager::new()),
|
pool_manager: tokio::sync::Mutex::new(ConnectionPoolManager::new()),
|
||||||
ssh_manager: StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend))),
|
ssh_manager: StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend))),
|
||||||
|
cancel_registry: crate::cancel::CancelRegistry::default(),
|
||||||
})
|
})
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
let state = app.state::<AppState>();
|
let state = app.state::<AppState>();
|
||||||
@@ -69,6 +72,9 @@ pub fn run() {
|
|||||||
if let Ok(mut mgr) = s.ssh_manager.lock() {
|
if let Ok(mut mgr) = s.ssh_manager.lock() {
|
||||||
mgr.close_tunnel(id);
|
mgr.close_tunnel(id);
|
||||||
}
|
}
|
||||||
|
// Drop the cancel handles for the evicted connection
|
||||||
|
// (tokens/interrupts outlive the pool otherwise).
|
||||||
|
s.cancel_registry.remove(id);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -105,6 +111,7 @@ pub fn run() {
|
|||||||
db_viewer::get_schemas,
|
db_viewer::get_schemas,
|
||||||
db_viewer::get_tables,
|
db_viewer::get_tables,
|
||||||
db_viewer::get_table_data,
|
db_viewer::get_table_data,
|
||||||
|
db_viewer::get_table_columns,
|
||||||
db_viewer::get_fk_preview,
|
db_viewer::get_fk_preview,
|
||||||
db_viewer::execute_change,
|
db_viewer::execute_change,
|
||||||
db_viewer::get_table_ddl,
|
db_viewer::get_table_ddl,
|
||||||
@@ -116,6 +123,17 @@ pub fn run() {
|
|||||||
db_viewer::get_extensions,
|
db_viewer::get_extensions,
|
||||||
db_viewer::get_indexes,
|
db_viewer::get_indexes,
|
||||||
db_viewer::get_constraints,
|
db_viewer::get_constraints,
|
||||||
|
objects::create_schema,
|
||||||
|
objects::rename_schema,
|
||||||
|
objects::drop_schema,
|
||||||
|
objects::search_objects,
|
||||||
|
objects::get_object_ddl,
|
||||||
|
objects::get_object_dependencies,
|
||||||
|
objects::build_object_ddl,
|
||||||
|
objects::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::save_connection_password,
|
||||||
keychain::get_connection_password,
|
keychain::get_connection_password,
|
||||||
keychain::delete_connection_password,
|
keychain::delete_connection_password,
|
||||||
@@ -131,8 +149,18 @@ pub fn run() {
|
|||||||
backup::pg_dump,
|
backup::pg_dump,
|
||||||
backup::pg_restore,
|
backup::pg_restore,
|
||||||
backup::db_sync,
|
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,
|
schema_graph::get_schema_graph,
|
||||||
query::execute_query,
|
query::execute_query,
|
||||||
|
query::cancel_query,
|
||||||
query::get_query_history,
|
query::get_query_history,
|
||||||
query::clear_query_history,
|
query::clear_query_history,
|
||||||
query::set_history_favorite,
|
query::set_history_favorite,
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ pub struct SyncOptions {
|
|||||||
pub target_connection_id: String,
|
pub target_connection_id: String,
|
||||||
pub schema: Option<String>,
|
pub schema: Option<String>,
|
||||||
pub tables: Option<Vec<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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -34,6 +40,83 @@ pub struct PgToolStatus {
|
|||||||
pub pg_restore_found: bool,
|
pub pg_restore_found: bool,
|
||||||
pub pg_dump_version: Option<String>,
|
pub pg_dump_version: Option<String>,
|
||||||
pub pg_restore_version: Option<String>,
|
pub pg_restore_version: Option<String>,
|
||||||
|
pub pg_dump_source: Option<String>, // "system" | "bundled" | None
|
||||||
|
pub pg_restore_source: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolved on-disk paths for the three client tools (system-first, bundled-fallback).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PgToolPaths {
|
||||||
|
pub pg_dump: String,
|
||||||
|
pub pg_restore: String,
|
||||||
|
pub psql: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -97,4 +180,66 @@ mod tests {
|
|||||||
assert!(json.contains("dump"));
|
assert!(json.contains("dump"));
|
||||||
assert!(json.contains("completed"));
|
assert!(json.contains("completed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_tool_status_reports_source() {
|
||||||
|
let s = PgToolStatus { pg_dump_found: true, pg_restore_found: true,
|
||||||
|
pg_dump_version: Some("pg_dump 16".into()), pg_restore_version: Some("pg_restore 16".into()),
|
||||||
|
pg_dump_source: Some("system".into()), pg_restore_source: Some("bundled".into()) };
|
||||||
|
let json = serde_json::to_string(&s).unwrap();
|
||||||
|
assert!(json.contains("\"pg_dump_source\":\"system\""));
|
||||||
|
assert!(json.contains("\"pg_restore_source\":\"bundled\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mysql_backup_options_serialize_camel_case() {
|
||||||
|
let opts = MySqlBackupOptions {
|
||||||
|
database: "shop".into(),
|
||||||
|
file_path: "/tmp/dump.sql".into(),
|
||||||
|
single_transaction: true,
|
||||||
|
no_data: false,
|
||||||
|
routines: true,
|
||||||
|
triggers: true,
|
||||||
|
events: false,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&opts).unwrap();
|
||||||
|
assert!(json.contains("\"singleTransaction\":true"));
|
||||||
|
assert!(json.contains("\"filePath\":\"/tmp/dump.sql\""));
|
||||||
|
assert!(!json.contains("no_owner"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_restore_options_serialize_camel_case() {
|
||||||
|
let opts = SqliteRestoreOptions { file_path: "/tmp/in.sql".into(), clean: true };
|
||||||
|
let json = serde_json::to_string(&opts).unwrap();
|
||||||
|
assert!(json.contains("\"filePath\":\"/tmp/in.sql\""));
|
||||||
|
assert!(json.contains("\"clean\":true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mysql_tool_status_reports_source() {
|
||||||
|
let s = MySqlToolStatus {
|
||||||
|
mysqldump_found: true,
|
||||||
|
mysql_found: true,
|
||||||
|
mysqldump_version: Some("mariadb-dump 10.6".into()),
|
||||||
|
mysql_version: Some("mariadb 10.6".into()),
|
||||||
|
mysqldump_source: Some("bundled".into()),
|
||||||
|
mysql_source: Some("system".into()),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&s).unwrap();
|
||||||
|
assert!(json.contains("\"mysqldumpSource\":\"bundled\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_options_carry_db_type() {
|
||||||
|
let s = SyncOptions {
|
||||||
|
source_connection_id: "a".into(),
|
||||||
|
target_connection_id: "b".into(),
|
||||||
|
schema: None,
|
||||||
|
tables: None,
|
||||||
|
db_type: "mysql".into(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&s).unwrap();
|
||||||
|
assert!(json.contains("\"dbType\":\"mysql\""));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ pub struct Connection {
|
|||||||
pub ssl_cert_path: Option<String>,
|
pub ssl_cert_path: Option<String>,
|
||||||
pub ssl_key_path: Option<String>,
|
pub ssl_key_path: Option<String>,
|
||||||
pub tag_ids: Vec<String>,
|
pub tag_ids: Vec<String>,
|
||||||
|
pub use_keychain: bool,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
@@ -50,6 +51,12 @@ pub struct ConnectionInput {
|
|||||||
pub ssl_ca_path: Option<String>,
|
pub ssl_ca_path: Option<String>,
|
||||||
pub ssl_cert_path: Option<String>,
|
pub ssl_cert_path: Option<String>,
|
||||||
pub ssl_key_path: Option<String>,
|
pub ssl_key_path: Option<String>,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub use_keychain: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -66,6 +73,7 @@ mod tests {
|
|||||||
username: Some("admin".to_string()),
|
username: Some("admin".to_string()),
|
||||||
folder_id: Some("folder1".to_string()),
|
folder_id: Some("folder1".to_string()),
|
||||||
tag_ids: vec!["tag1".to_string(), "tag2".to_string()],
|
tag_ids: vec!["tag1".to_string(), "tag2".to_string()],
|
||||||
|
use_keychain: true,
|
||||||
password: Some("secret123".to_string()),
|
password: Some("secret123".to_string()),
|
||||||
database: Some("mydb".to_string()),
|
database: Some("mydb".to_string()),
|
||||||
ssh_host: Some("jumphost.example.com".to_string()),
|
ssh_host: Some("jumphost.example.com".to_string()),
|
||||||
@@ -133,6 +141,7 @@ mod tests {
|
|||||||
environment: None,
|
environment: None,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
created_at: "2024-01-01T00:00:00Z".to_string(),
|
created_at: "2024-01-01T00:00:00Z".to_string(),
|
||||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||||
database: Some("mydb".to_string()),
|
database: Some("mydb".to_string()),
|
||||||
@@ -177,6 +186,7 @@ mod tests {
|
|||||||
ssl_cert_path: None,
|
ssl_cert_path: None,
|
||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
favorite: true,
|
favorite: true,
|
||||||
created_at: "2024-01-01T00:00:00Z".into(),
|
created_at: "2024-01-01T00:00:00Z".into(),
|
||||||
updated_at: "2024-01-01T00:00:00Z".into(),
|
updated_at: "2024-01-01T00:00:00Z".into(),
|
||||||
@@ -184,4 +194,18 @@ mod tests {
|
|||||||
let json = serde_json::to_string(&conn).unwrap();
|
let json = serde_json::to_string(&conn).unwrap();
|
||||||
assert!(json.contains("\"favorite\":true"));
|
assert!(json.contains("\"favorite\":true"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connection_input_use_keychain_defaults_true_when_absent() {
|
||||||
|
let json = r#"{"name":"n","db_type":"postgresql","host":"h","port":5432,"tag_ids":[]}"#;
|
||||||
|
let input: ConnectionInput = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(input.use_keychain, "absent use_keychain defaults to true (opt-out)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connection_input_use_keychain_preserves_false() {
|
||||||
|
let json = r#"{"name":"n","db_type":"postgresql","host":"h","port":5432,"tag_ids":[],"use_keychain":false}"#;
|
||||||
|
let input: ConnectionInput = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(!input.use_keychain);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,69 @@ pub struct ExtensionInfo {
|
|||||||
pub comment: Option<String>,
|
pub comment: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ObjectSearchHit {
|
||||||
|
pub name: String,
|
||||||
|
pub schema: String,
|
||||||
|
pub object_type: String, // TABLE | VIEW | MATERIALIZED VIEW | FUNCTION | PROCEDURE | TRIGGER | SEQUENCE | ENUM | EXTENSION | INDEX | CONSTRAINT
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DependencyInfo {
|
||||||
|
pub deptype: String, // "n" (normal) | "a" (auto)
|
||||||
|
pub class: String, // pg_class | pg_proc | pg_trigger | pg_type | pg_constraint | pg_rewrite
|
||||||
|
pub name: String, // resolved dependent object name
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum Change {
|
pub enum Change {
|
||||||
@@ -179,6 +242,14 @@ pub enum Change {
|
|||||||
schema: String,
|
schema: String,
|
||||||
table: String,
|
table: String,
|
||||||
},
|
},
|
||||||
|
Ddl {
|
||||||
|
id: String,
|
||||||
|
sql: String,
|
||||||
|
},
|
||||||
|
RebuildTable {
|
||||||
|
id: String,
|
||||||
|
sql: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Change {
|
impl Change {
|
||||||
@@ -190,7 +261,9 @@ impl Change {
|
|||||||
| Change::AlterTable { id, .. }
|
| Change::AlterTable { id, .. }
|
||||||
| Change::BulkInsert { id, .. }
|
| Change::BulkInsert { id, .. }
|
||||||
| Change::DropTable { id, .. }
|
| Change::DropTable { id, .. }
|
||||||
| Change::EmptyTable { id, .. } => id,
|
| Change::EmptyTable { id, .. }
|
||||||
|
| Change::Ddl { id, .. }
|
||||||
|
| Change::RebuildTable { id, .. } => id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,4 +656,78 @@ mod tests {
|
|||||||
assert!(json.contains("users"), "should contain referenced table");
|
assert!(json.contains("users"), "should contain referenced table");
|
||||||
assert!(json.contains("id"), "should contain referenced column");
|
assert!(json.contains("id"), "should contain referenced column");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_search_hit_tagged_roundtrip() {
|
||||||
|
let hit = ObjectSearchHit { name: "users".into(), schema: "public".into(), object_type: "TABLE".into() };
|
||||||
|
let json = serde_json::to_string(&hit).unwrap();
|
||||||
|
assert!(json.contains("\"object_type\":\"TABLE\""));
|
||||||
|
let back: ObjectSearchHit = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(back.name, "users");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dependency_info_roundtrip() {
|
||||||
|
let d = DependencyInfo { deptype: "n".into(), class: "pg_class".into(), name: "v_users".into() };
|
||||||
|
let json = serde_json::to_string(&d).unwrap();
|
||||||
|
assert!(json.contains("\"deptype\":\"n\""));
|
||||||
|
assert!(json.contains("v_users"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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};
|
pub use connection::{Connection, ConnectionInput};
|
||||||
#[allow(unused_imports)]
|
#[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 folder::{Folder, FolderInput};
|
||||||
pub use recent::RecentConnection;
|
pub use recent::RecentConnection;
|
||||||
pub use settings::Settings;
|
pub use settings::Settings;
|
||||||
|
|||||||
@@ -20,3 +20,10 @@ pub struct Settings {
|
|||||||
pub editor_minimap: bool,
|
pub editor_minimap: bool,
|
||||||
pub editor_tab_size: i64,
|
pub editor_tab_size: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SettingsExport {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub settings: Settings,
|
||||||
|
}
|
||||||
|
|||||||
@@ -242,6 +242,30 @@ pub fn run_migrations(conn: &Connection) -> Result<(), String> {
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v8: use_keychain opt-out flag on connections
|
||||||
|
if current_ver < 8 {
|
||||||
|
let existing: Vec<String> = {
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("PRAGMA table_info(connections)")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| row.get::<_, String>(1))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
rows.filter_map(|r| r.ok()).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
if !existing.iter().any(|c| c == "use_keychain") {
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE connections ADD COLUMN use_keychain INTEGER NOT NULL DEFAULT 1",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.execute("INSERT INTO schema_version (version) VALUES (8)", [])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +316,49 @@ mod tests {
|
|||||||
let count: i64 = conn
|
let count: i64 = conn
|
||||||
.query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0))
|
.query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count, 6);
|
assert_eq!(count, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v8_adds_use_keychain_column_default_1() {
|
||||||
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
// v7 baseline
|
||||||
|
run_migrations(&conn).unwrap();
|
||||||
|
// simulate an existing connection row (pre-v8 shape had no use_keychain)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO connections (id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, created_at, updated_at) VALUES ('c1','n','postgresql','h',5432,'u','d',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'t','t','t')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
run_migrations(&conn).unwrap();
|
||||||
|
let uses: i64 = conn
|
||||||
|
.query_row("SELECT use_keychain FROM connections WHERE id='c1'", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(uses, 1, "existing connections default to use_keychain=1 (ON)");
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM schema_version WHERE version=8", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 1, "schema_version row 8 inserted exactly once");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v8_is_idempotent() {
|
||||||
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
run_migrations(&conn).unwrap();
|
||||||
|
run_migrations(&conn).unwrap();
|
||||||
|
let v8: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM schema_version WHERE version=8", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(v8, 1);
|
||||||
|
// PRAGMA confirms exactly one use_keychain column
|
||||||
|
let cols: Vec<String> = conn
|
||||||
|
.prepare("PRAGMA table_info(connections)")
|
||||||
|
.unwrap()
|
||||||
|
.query_map([], |r| r.get::<_, String>(1))
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(cols.iter().filter(|c| c == &"use_keychain").count(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -541,12 +607,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn v7_bumps_schema_version_to_7() {
|
fn v8_bumps_schema_version_to_8() {
|
||||||
let conn = Connection::open_in_memory().unwrap();
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
run_migrations(&conn).unwrap();
|
run_migrations(&conn).unwrap();
|
||||||
let ver: i64 = conn
|
let ver: i64 = conn
|
||||||
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
|
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(ver, 7);
|
assert_eq!(ver, 8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ impl Store {
|
|||||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, favorite, created_at, updated_at FROM connections ORDER BY name",
|
"SELECT id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, favorite, created_at, updated_at, use_keychain FROM connections ORDER BY name",
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
@@ -286,6 +286,7 @@ impl Store {
|
|||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
created_at: row.get(20)?,
|
created_at: row.get(20)?,
|
||||||
updated_at: row.get(21)?,
|
updated_at: row.get(21)?,
|
||||||
|
use_keychain: row.get(22)?,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -308,8 +309,8 @@ impl Store {
|
|||||||
let id = uuid::Uuid::new_v4().to_string();
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
let now = Self::now();
|
let now = Self::now();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO connections (id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
|
"INSERT INTO connections (id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, created_at, updated_at, use_keychain) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21)",
|
||||||
params![id, input.name, input.db_type, input.host, input.port, input.username, input.database, input.folder_id, input.ssh_host, input.ssh_port, input.ssh_user, input.ssh_auth_method, input.ssh_private_key_path, input.ssl_mode, input.ssl_ca_path, input.ssl_cert_path, input.ssl_key_path, input.environment, now, now],
|
params![id, input.name, input.db_type, input.host, input.port, input.username, input.database, input.folder_id, input.ssh_host, input.ssh_port, input.ssh_user, input.ssh_auth_method, input.ssh_private_key_path, input.ssl_mode, input.ssl_ca_path, input.ssl_cert_path, input.ssl_key_path, input.environment, now, now, input.use_keychain],
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
for tag_id in &input.tag_ids {
|
for tag_id in &input.tag_ids {
|
||||||
@@ -341,6 +342,7 @@ impl Store {
|
|||||||
ssl_cert_path: input.ssl_cert_path,
|
ssl_cert_path: input.ssl_cert_path,
|
||||||
ssl_key_path: input.ssl_key_path,
|
ssl_key_path: input.ssl_key_path,
|
||||||
tag_ids: input.tag_ids,
|
tag_ids: input.tag_ids,
|
||||||
|
use_keychain: input.use_keychain,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
})
|
})
|
||||||
@@ -419,13 +421,13 @@ impl Store {
|
|||||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||||
let now = Self::now();
|
let now = Self::now();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE connections SET name=?1, db_type=?2, host=?3, port=?4, username=?5, database=?6, folder_id=?7, ssh_host=?8, ssh_port=?9, ssh_user=?10, ssh_auth_method=?11, ssh_private_key_path=?12, ssl_mode=?13, ssl_ca_path=?14, ssl_cert_path=?15, ssl_key_path=?16, environment=?17, updated_at=?18 WHERE id=?19",
|
"UPDATE connections SET name=?1, db_type=?2, host=?3, port=?4, username=?5, database=?6, folder_id=?7, ssh_host=?8, ssh_port=?9, ssh_user=?10, ssh_auth_method=?11, ssh_private_key_path=?12, ssl_mode=?13, ssl_ca_path=?14, ssl_cert_path=?15, ssl_key_path=?16, environment=?17, use_keychain=?18, updated_at=?19 WHERE id=?20",
|
||||||
params![
|
params![
|
||||||
input.name, input.db_type, input.host, input.port, input.username,
|
input.name, input.db_type, input.host, input.port, input.username,
|
||||||
input.database, input.folder_id, input.ssh_host, input.ssh_port,
|
input.database, input.folder_id, input.ssh_host, input.ssh_port,
|
||||||
input.ssh_user, input.ssh_auth_method, input.ssh_private_key_path,
|
input.ssh_user, input.ssh_auth_method, input.ssh_private_key_path,
|
||||||
input.ssl_mode, input.ssl_ca_path, input.ssl_cert_path, input.ssl_key_path,
|
input.ssl_mode, input.ssl_ca_path, input.ssl_cert_path, input.ssl_key_path,
|
||||||
input.environment, now, id
|
input.environment, input.use_keychain, now, id
|
||||||
],
|
],
|
||||||
).map_err(|e| e.to_string())?;
|
).map_err(|e| e.to_string())?;
|
||||||
// Update tags
|
// Update tags
|
||||||
@@ -463,6 +465,7 @@ impl Store {
|
|||||||
ssl_cert_path: input.ssl_cert_path,
|
ssl_cert_path: input.ssl_cert_path,
|
||||||
ssl_key_path: input.ssl_key_path,
|
ssl_key_path: input.ssl_key_path,
|
||||||
tag_ids: input.tag_ids.clone(),
|
tag_ids: input.tag_ids.clone(),
|
||||||
|
use_keychain: input.use_keychain,
|
||||||
created_at: String::new(), // not updated
|
created_at: String::new(), // not updated
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
})
|
})
|
||||||
@@ -572,6 +575,21 @@ impl Store {
|
|||||||
Ok(())
|
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.
|
/// Insert a row into the `query_history` table.
|
||||||
/// Dedups consecutive identical queries per connection (UPDATE the last row
|
/// 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.
|
/// instead of INSERTing a new one) and prunes to at most 500 rows per connection.
|
||||||
@@ -954,6 +972,7 @@ mod tests {
|
|||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let got = store.get_connections().unwrap();
|
let got = store.get_connections().unwrap();
|
||||||
@@ -1002,6 +1021,7 @@ mod tests {
|
|||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
tag_ids: vec![t1.id.clone(), t2.id.clone()],
|
tag_ids: vec![t1.id.clone(), t2.id.clone()],
|
||||||
|
use_keychain: true,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let got = store.get_connections().unwrap();
|
let got = store.get_connections().unwrap();
|
||||||
@@ -1043,6 +1063,7 @@ mod tests {
|
|||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
store.delete_folder(&folder.id).unwrap();
|
store.delete_folder(&folder.id).unwrap();
|
||||||
@@ -1082,6 +1103,7 @@ mod tests {
|
|||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
tag_ids: vec![tag.id.clone()],
|
tag_ids: vec![tag.id.clone()],
|
||||||
|
use_keychain: true,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
store.delete_tag(&tag.id).unwrap();
|
store.delete_tag(&tag.id).unwrap();
|
||||||
@@ -1142,6 +1164,7 @@ mod tests {
|
|||||||
ssl_key_path: Some("/etc/ssl/private/client-key.pem".into()),
|
ssl_key_path: Some("/etc/ssl/private/client-key.pem".into()),
|
||||||
environment: None,
|
environment: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let got = store.get_connections().unwrap();
|
let got = store.get_connections().unwrap();
|
||||||
@@ -1194,6 +1217,7 @@ mod tests {
|
|||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -1278,6 +1302,7 @@ mod tests {
|
|||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// Insert 510 rows — should trigger pruning beyond 500
|
// Insert 510 rows — should trigger pruning beyond 500
|
||||||
@@ -1334,6 +1359,7 @@ mod tests {
|
|||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
store
|
store
|
||||||
@@ -1378,6 +1404,7 @@ mod tests {
|
|||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
store
|
store
|
||||||
@@ -1480,6 +1507,7 @@ mod tests {
|
|||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
})
|
})
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Gridline",
|
"productName": "Gridline",
|
||||||
"version": "0.7.0",
|
"version": "0.7.8",
|
||||||
"identifier": "com.adrianbonpin.gridline",
|
"identifier": "com.adrianbonpin.gridline",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
@@ -26,10 +26,12 @@
|
|||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": "all",
|
||||||
|
"resources": ["resources/pg_tools/*", "resources/mysql_tools/*"],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
"icons/128x128@2x.png",
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.png",
|
||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ErrorBanner } from "./components/ui/ErrorBanner";
|
|||||||
import { ToastContainer } from "./components/ui/Toast";
|
import { ToastContainer } from "./components/ui/Toast";
|
||||||
import { DbViewerScreen } from "./components/db-viewer/DbViewerScreen";
|
import { DbViewerScreen } from "./components/db-viewer/DbViewerScreen";
|
||||||
import { useAppearance } from "./hooks/useAppearance";
|
import { useAppearance } from "./hooks/useAppearance";
|
||||||
|
import { isMacOS } from "./lib/platform";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
|
||||||
const VIEW_TITLES: Record<string, string> = {
|
const VIEW_TITLES: Record<string, string> = {
|
||||||
@@ -79,7 +80,8 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<div className="h-svh bg-canvas select-none flex flex-col overflow-hidden">
|
<div className="h-svh bg-canvas select-none flex flex-col overflow-hidden">
|
||||||
{typeof window !== "undefined" &&
|
{typeof window !== "undefined" &&
|
||||||
"__TAURI_INTERNALS__" in window && (
|
"__TAURI_INTERNALS__" in window &&
|
||||||
|
isMacOS() && (
|
||||||
// macOS "Overlay" title bar: in-flow strip the window can be
|
// macOS "Overlay" title bar: in-flow strip the window can be
|
||||||
// dragged by; traffic lights float over it. Only in Tauri.
|
// dragged by; traffic lights float over it. Only in Tauri.
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { ConnectionFormData } from "./connectionFormData";
|
|||||||
const BASE_FORM: ConnectionFormData = {
|
const BASE_FORM: ConnectionFormData = {
|
||||||
name: "", environment: null, folder_id: null, tag_ids: [],
|
name: "", environment: null, folder_id: null, tag_ids: [],
|
||||||
connection_string: "", db_type: "postgresql", host: "", port: 5432,
|
connection_string: "", db_type: "postgresql", host: "", port: 5432,
|
||||||
username: null, password: null, database: null, use_keychain: false, ssh_password: null,
|
username: null, password: null, database: null, use_keychain: true, ssh_password: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("ConnectionMetadataRow", () => {
|
describe("ConnectionMetadataRow", () => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const BASE_FORM: ConnectionFormData = {
|
|||||||
username: null,
|
username: null,
|
||||||
password: null,
|
password: null,
|
||||||
database: null,
|
database: null,
|
||||||
use_keychain: false,
|
use_keychain: true,
|
||||||
ssh_password: null,
|
ssh_password: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const BASE_FORM: ConnectionFormData = {
|
|||||||
name: "My DB", environment: null, folder_id: null, tag_ids: [],
|
name: "My DB", environment: null, folder_id: null, tag_ids: [],
|
||||||
connection_string: "postgresql://u:p@localhost:5432/db", db_type: "postgresql",
|
connection_string: "postgresql://u:p@localhost:5432/db", db_type: "postgresql",
|
||||||
host: "localhost", port: 5432, username: "u", password: "p", database: "db",
|
host: "localhost", port: 5432, username: "u", password: "p", database: "db",
|
||||||
use_keychain: false, ssh_password: null,
|
use_keychain: true, ssh_password: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("GeneralTab", () => {
|
describe("GeneralTab", () => {
|
||||||
@@ -54,4 +54,15 @@ describe("GeneralTab", () => {
|
|||||||
render(<GeneralTab form={{ ...BASE_FORM, db_type: "postgresql" }} managedPreset="supabase" onChange={() => {}} />);
|
render(<GeneralTab form={{ ...BASE_FORM, db_type: "postgresql" }} managedPreset="supabase" onChange={() => {}} />);
|
||||||
expect(screen.getByText(/requires ssl/i)).toBeInTheDocument();
|
expect(screen.getByText(/requires ssl/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("defaults the keychain toggle to ON (opt-out)", () => {
|
||||||
|
render(<GeneralTab form={{ ...BASE_FORM, use_keychain: true }} onChange={() => {}} />);
|
||||||
|
const cb = screen.getByLabelText("Enable keychain") as HTMLInputElement;
|
||||||
|
expect(cb.checked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the DB-password-only tooltip text", () => {
|
||||||
|
render(<GeneralTab form={{ ...BASE_FORM, use_keychain: true }} onChange={() => {}} />);
|
||||||
|
expect(screen.getByText(/DB password only/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -84,6 +84,9 @@ export function GeneralTab({ form, onChange, managedPreset }: GeneralTabProps) {
|
|||||||
<input type="checkbox" checked={form.use_keychain} onChange={(e) => onChange({ use_keychain: e.target.checked })} aria-label="Enable keychain" className="rounded border-border bg-surface text-accent focus:ring-accent" />
|
<input type="checkbox" checked={form.use_keychain} onChange={(e) => onChange({ use_keychain: e.target.checked })} aria-label="Enable keychain" className="rounded border-border bg-surface text-accent focus:ring-accent" />
|
||||||
Enable keychain
|
Enable keychain
|
||||||
</label>
|
</label>
|
||||||
|
<p className="text-xs text-text-muted -mt-1 mb-2">
|
||||||
|
Saves the DB password to the OS keychain (DB password only; SSH secrets always use the keychain). Uncheck to never persist the password — you'll re-enter it each session.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,7 @@ function createEmptyForm(
|
|||||||
username: null,
|
username: null,
|
||||||
password: null,
|
password: null,
|
||||||
database: null,
|
database: null,
|
||||||
use_keychain: false,
|
use_keychain: true,
|
||||||
ssh_password: null,
|
ssh_password: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function BackupDialog({ open, connectionId, onClose }: BackupDialogProps)
|
|||||||
setCheckingTools(true);
|
setCheckingTools(true);
|
||||||
detectPgTools()
|
detectPgTools()
|
||||||
.then((status) => setToolStatus(status))
|
.then((status) => setToolStatus(status))
|
||||||
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null }))
|
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null, pg_dump_source: null, pg_restore_source: null }))
|
||||||
.finally(() => setCheckingTools(false));
|
.finally(() => setCheckingTools(false));
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { BackupPage } from "./BackupPage";
|
||||||
|
import { useBackupStore } from "../../stores/backupStore";
|
||||||
|
import { useNotificationStore } from "../../stores/notificationStore";
|
||||||
|
import * as commands from "../../lib/commands";
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn().mockReturnValue(Promise.resolve(() => {})) }));
|
||||||
|
vi.mock("@tauri-apps/plugin-dialog", () => ({ save: vi.fn().mockResolvedValue("/tmp/backup.dump") }));
|
||||||
|
|
||||||
|
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"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides install instructions when tools are bundled", async () => {
|
||||||
|
vi.spyOn(commands, "detectPgTools").mockResolvedValue({
|
||||||
|
pg_dump_found: false,
|
||||||
|
pg_restore_found: false,
|
||||||
|
pg_dump_version: null,
|
||||||
|
pg_restore_version: null,
|
||||||
|
pg_dump_source: "bundled",
|
||||||
|
pg_restore_source: "bundled",
|
||||||
|
});
|
||||||
|
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 { BackupProgress } from "./BackupProgress";
|
||||||
import { useBackupStore } from "../../stores/backupStore";
|
import { useBackupStore } from "../../stores/backupStore";
|
||||||
import { useNotificationStore } from "../../stores/notificationStore";
|
import { useNotificationStore } from "../../stores/notificationStore";
|
||||||
import { detectPgTools, pgDump, getSchemas } from "../../lib/commands";
|
import { useConnectionStore } from "../../stores/connectionStore";
|
||||||
import type { PgToolStatus } from "../../lib/types";
|
import {
|
||||||
|
detectPgTools,
|
||||||
|
pgDump,
|
||||||
|
getSchemas,
|
||||||
|
detectMysqlTools,
|
||||||
|
mysqlDump,
|
||||||
|
sqliteDump,
|
||||||
|
} from "../../lib/commands";
|
||||||
|
import type { PgToolStatus, MySqlToolStatus, BackupJob } from "../../lib/types";
|
||||||
|
|
||||||
interface BackupPageProps {
|
interface BackupPageProps {
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
@@ -14,30 +22,51 @@ interface BackupPageProps {
|
|||||||
|
|
||||||
type BackupFormat = "plain" | "custom" | "tar" | "directory";
|
type BackupFormat = "plain" | "custom" | "tar" | "directory";
|
||||||
|
|
||||||
const PLATFORM_INSTALL_INSTRUCTIONS: Record<string, string> = {
|
const PG_INSTALL_INSTRUCTIONS: Record<string, string> = {
|
||||||
darwin: "brew install libpq",
|
darwin: "brew install libpq",
|
||||||
linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch",
|
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.",
|
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 =
|
const platform =
|
||||||
typeof navigator !== "undefined"
|
typeof navigator !== "undefined"
|
||||||
? navigator.platform.toLowerCase()
|
? navigator.platform.toLowerCase()
|
||||||
: "";
|
: "";
|
||||||
if (platform.includes("mac") || platform.includes("darwin"))
|
if (platform.includes("mac") || platform.includes("darwin"))
|
||||||
return PLATFORM_INSTALL_INSTRUCTIONS.darwin;
|
return map.darwin;
|
||||||
if (platform.includes("linux")) return PLATFORM_INSTALL_INSTRUCTIONS.linux;
|
if (platform.includes("linux")) return map.linux;
|
||||||
if (platform.includes("win")) return PLATFORM_INSTALL_INSTRUCTIONS.win32;
|
if (platform.includes("win")) return map.win32;
|
||||||
return PLATFORM_INSTALL_INSTRUCTIONS.linux;
|
return map.linux;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BackupPage({ connectionId }: BackupPageProps) {
|
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 [format, setFormat] = useState<BackupFormat>("custom");
|
||||||
const [filePath, setFilePath] = useState("");
|
const [filePath, setFilePath] = useState("");
|
||||||
const [schema, setSchema] = useState("");
|
const [schema, setSchema] = useState("");
|
||||||
const [noOwner, setNoOwner] = useState(true);
|
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 [checkingTools, setCheckingTools] = useState(true);
|
||||||
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
|
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
|
||||||
|
|
||||||
@@ -71,14 +100,21 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCheckingTools(true);
|
setCheckingTools(true);
|
||||||
|
setPgToolStatus(null);
|
||||||
|
setMysqlToolStatus(null);
|
||||||
|
setAvailableSchemas([]);
|
||||||
|
|
||||||
|
if (isPg) {
|
||||||
detectPgTools()
|
detectPgTools()
|
||||||
.then((status) => setToolStatus(status))
|
.then((status) => setPgToolStatus(status))
|
||||||
.catch(() =>
|
.catch(() =>
|
||||||
setToolStatus({
|
setPgToolStatus({
|
||||||
pg_dump_found: false,
|
pg_dump_found: false,
|
||||||
pg_restore_found: false,
|
pg_restore_found: false,
|
||||||
pg_dump_version: null,
|
pg_dump_version: null,
|
||||||
pg_restore_version: null,
|
pg_restore_version: null,
|
||||||
|
pg_dump_source: null,
|
||||||
|
pg_restore_source: null,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.finally(() => setCheckingTools(false));
|
.finally(() => setCheckingTools(false));
|
||||||
@@ -86,56 +122,157 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
|||||||
getSchemas(connectionId)
|
getSchemas(connectionId)
|
||||||
.then((schemas) => setAvailableSchemas(schemas))
|
.then((schemas) => setAvailableSchemas(schemas))
|
||||||
.catch(() => setAvailableSchemas([]));
|
.catch(() => setAvailableSchemas([]));
|
||||||
}, [connectionId]);
|
} else if (isMysql) {
|
||||||
|
detectMysqlTools()
|
||||||
|
.then((status) => setMysqlToolStatus(status))
|
||||||
|
.catch(() =>
|
||||||
|
setMysqlToolStatus({
|
||||||
|
mysqldumpFound: false,
|
||||||
|
mysqlFound: false,
|
||||||
|
mysqldumpVersion: null,
|
||||||
|
mysqlVersion: null,
|
||||||
|
mysqldumpSource: null,
|
||||||
|
mysqlSource: null,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.finally(() => setCheckingTools(false));
|
||||||
|
|
||||||
|
getSchemas(connectionId)
|
||||||
|
.then((schemas) => setAvailableSchemas(schemas))
|
||||||
|
.catch(() => setAvailableSchemas([]));
|
||||||
|
} else {
|
||||||
|
setCheckingTools(false);
|
||||||
|
}
|
||||||
|
}, [connectionId, isPg, isMysql]);
|
||||||
|
|
||||||
const handlePickFile = useCallback(async () => {
|
const handlePickFile = useCallback(async () => {
|
||||||
const extensions: Record<BackupFormat, string[]> = {
|
let defaultPath = "backup";
|
||||||
|
let extensions: string[] = [];
|
||||||
|
|
||||||
|
if (isPg) {
|
||||||
|
const pgExtensions: Record<BackupFormat, string[]> = {
|
||||||
plain: ["sql"],
|
plain: ["sql"],
|
||||||
custom: ["dump", "custom"],
|
custom: ["dump", "custom"],
|
||||||
tar: ["tar"],
|
tar: ["tar"],
|
||||||
directory: [],
|
directory: [],
|
||||||
};
|
};
|
||||||
const picked = await save({
|
extensions = pgExtensions[format];
|
||||||
defaultPath: `backup.${
|
defaultPath = `backup.${
|
||||||
format === "custom"
|
format === "custom"
|
||||||
? "dump"
|
? "dump"
|
||||||
: format === "plain"
|
: format === "plain"
|
||||||
? "sql"
|
? "sql"
|
||||||
: "tar"
|
: "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);
|
if (picked) setFilePath(picked);
|
||||||
}, [format]);
|
}, [format, isPg, isMysql, isSqlite]);
|
||||||
|
|
||||||
const handleStartBackup = useCallback(async () => {
|
const runWithProgress = useCallback(
|
||||||
if (!filePath) {
|
async (type: BackupJob["type"], action: () => Promise<unknown>) => {
|
||||||
notify("Please select a file path", "error");
|
const jobId = `${type}-${Date.now()}`;
|
||||||
return;
|
startJob(jobId, type);
|
||||||
}
|
|
||||||
const jobId = `dump-${Date.now()}`;
|
|
||||||
startJob(jobId, "dump");
|
|
||||||
pendingJobRef.current = jobId;
|
pendingJobRef.current = jobId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// pgDump returns the job ID immediately — completion
|
// Command returns the job ID immediately — completion
|
||||||
// comes via Tauri events handled by the backupStore
|
// comes via Tauri events handled by the backupStore
|
||||||
await pgDump(connectionId, {
|
await action();
|
||||||
format,
|
|
||||||
filePath,
|
|
||||||
schema: schema || undefined,
|
|
||||||
tables: undefined,
|
|
||||||
noOwner,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If the command itself fails (e.g. connection not found),
|
// If the command itself fails (e.g. connection not found),
|
||||||
// the event won't fire — handle here
|
// the event won't fire — handle here
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
useBackupStore.getState().failJob(jobId, msg);
|
useBackupStore.getState().failJob(jobId, msg);
|
||||||
}
|
}
|
||||||
}, [filePath, format, schema, noOwner, connectionId, startJob, notify]);
|
},
|
||||||
|
[startJob],
|
||||||
|
);
|
||||||
|
|
||||||
const toolsMissing = toolStatus && !toolStatus.pg_dump_found;
|
const handleStartBackup = useCallback(async () => {
|
||||||
|
if (!filePath) {
|
||||||
|
notify("Please select a file path", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isMysql && !database) {
|
||||||
|
notify("MySQL connection has no database selected", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await runWithProgress("dump", () => {
|
||||||
|
if (isPg) {
|
||||||
|
return pgDump(connectionId, {
|
||||||
|
format,
|
||||||
|
filePath,
|
||||||
|
schema: schema || undefined,
|
||||||
|
tables: undefined,
|
||||||
|
noOwner,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (isMysql) {
|
||||||
|
return mysqlDump(connectionId, {
|
||||||
|
database: database!,
|
||||||
|
filePath,
|
||||||
|
singleTransaction,
|
||||||
|
noData,
|
||||||
|
routines,
|
||||||
|
triggers,
|
||||||
|
events,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return sqliteDump(connectionId, { filePath });
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
filePath,
|
||||||
|
database,
|
||||||
|
isPg,
|
||||||
|
isMysql,
|
||||||
|
isSqlite,
|
||||||
|
format,
|
||||||
|
schema,
|
||||||
|
noOwner,
|
||||||
|
singleTransaction,
|
||||||
|
noData,
|
||||||
|
routines,
|
||||||
|
triggers,
|
||||||
|
events,
|
||||||
|
connectionId,
|
||||||
|
notify,
|
||||||
|
runWithProgress,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const toolsMissing = isPg
|
||||||
|
? pgToolStatus && !pgToolStatus.pg_dump_found
|
||||||
|
: isMysql
|
||||||
|
? mysqlToolStatus && !mysqlToolStatus.mysqldumpFound
|
||||||
|
: false;
|
||||||
|
const toolsBundled = isPg
|
||||||
|
? pgToolStatus?.pg_dump_source === "bundled"
|
||||||
|
: isMysql
|
||||||
|
? mysqlToolStatus?.mysqldumpSource === "bundled"
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const checkingMessage = isPg
|
||||||
|
? "Checking for pg_dump..."
|
||||||
|
: isMysql
|
||||||
|
? "Checking for mysqldump..."
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const headerDescription = isPg
|
||||||
|
? "Create a database backup via pg_dump"
|
||||||
|
: isMysql
|
||||||
|
? "Create a database backup via mysqldump"
|
||||||
|
: "Create a database backup";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
@@ -144,7 +281,7 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
|||||||
<HardDrive size={14} className="text-accent" />
|
<HardDrive size={14} className="text-accent" />
|
||||||
<span className="text-xs font-medium text-text">Backup</span>
|
<span className="text-xs font-medium text-text">Backup</span>
|
||||||
<span className="text-[11px] text-text-muted">
|
<span className="text-[11px] text-text-muted">
|
||||||
Create a database backup via pg_dump
|
{headerDescription}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -152,25 +289,29 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
|||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
|
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
|
||||||
{/* Tool check */}
|
{/* Tool check */}
|
||||||
{checkingTools && (
|
{checkingTools && checkingMessage && (
|
||||||
<div className="glass p-4 text-center">
|
<div className="glass p-4 text-center">
|
||||||
<p className="text-sm text-text-muted">
|
<p className="text-sm text-text-muted">
|
||||||
Checking for pg_dump...
|
{checkingMessage}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{toolsMissing && (
|
{toolsMissing && !toolsBundled && (
|
||||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
|
<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">
|
<p className="text-amber-300 text-sm font-semibold">
|
||||||
pg_dump not found
|
{isPg ? "pg_dump not found" : "mysqldump not found"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-amber-200/80 text-xs leading-relaxed">
|
<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:
|
backup/restore operations. Install them using:
|
||||||
</p>
|
</p>
|
||||||
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">
|
<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>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -180,6 +321,7 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
|||||||
{/* Configuration card */}
|
{/* Configuration card */}
|
||||||
<div className="p-5 space-y-5">
|
<div className="p-5 space-y-5">
|
||||||
{/* Format */}
|
{/* Format */}
|
||||||
|
{isPg ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||||
Format
|
Format
|
||||||
@@ -203,6 +345,16 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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 */}
|
{/* Output file */}
|
||||||
<div className="space-y-1 w-full">
|
<div className="space-y-1 w-full">
|
||||||
@@ -231,6 +383,7 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Schema (optional) */}
|
{/* Schema (optional) */}
|
||||||
|
{(isPg || isMysql) && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||||
Schema{" "}
|
Schema{" "}
|
||||||
@@ -253,8 +406,10 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* No-owner toggle */}
|
{/* PostgreSQL: no-owner toggle */}
|
||||||
|
{isPg && (
|
||||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -271,6 +426,93 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
|||||||
</code>
|
</code>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Progress */}
|
{/* Progress */}
|
||||||
@@ -302,4 +544,3 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,20 @@ describe("ChangesQueuePanel", () => {
|
|||||||
expect(screen.getByText(/drop table: public.t/i)).toBeInTheDocument();
|
expect(screen.getByText(/drop table: public.t/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders a ddl change with a DDL badge + description (visual) and SQL preview (sql view)", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
useDbViewerStore.getState().addChange({
|
||||||
|
type: "ddl",
|
||||||
|
sql: "DROP INDEX public.i",
|
||||||
|
description: "Drop index i",
|
||||||
|
} as any);
|
||||||
|
render(<ChangesQueuePanel />);
|
||||||
|
expect(screen.getByText("DDL")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Drop index i")).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole("button", { name: /sql/i }));
|
||||||
|
expect(screen.getByText(/DROP INDEX public\.i/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("commit calls executeChange with buildChangePayload output for insert", async () => {
|
it("commit calls executeChange with buildChangePayload output for insert", async () => {
|
||||||
const exec = vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
|
const exec = vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
|
||||||
useDbViewerStore.getState().addChange({
|
useDbViewerStore.getState().addChange({
|
||||||
@@ -130,6 +144,23 @@ describe("ChangesQueuePanel", () => {
|
|||||||
await waitFor(() => expect(getSchemas).toHaveBeenCalledWith("c1"));
|
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 () => {
|
it("SQL toggle shows the generated SQL", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
useDbViewerStore.getState().addChange({
|
useDbViewerStore.getState().addChange({
|
||||||
@@ -208,4 +239,18 @@ describe("ChangesQueuePanel", () => {
|
|||||||
expect(tabs.some((t) => t.table === "posts")).toBe(true);
|
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 { useNotificationStore } from "../../stores/notificationStore";
|
||||||
import * as cmd from "../../lib/commands";
|
import * as cmd from "../../lib/commands";
|
||||||
import { buildChangePayload, buildChangeSql } from "../../lib/changePayload";
|
import { buildChangePayload, buildChangeSql } from "../../lib/changePayload";
|
||||||
|
import { isSchemaModifyingQuery } from "../../lib/utils";
|
||||||
import type { QueueItem, QueueStatus } from "../../stores/dbViewerStore";
|
import type { QueueItem, QueueStatus } from "../../stores/dbViewerStore";
|
||||||
|
|
||||||
const statusBg: Record<QueueStatus, string> = {
|
const statusBg: Record<QueueStatus, string> = {
|
||||||
@@ -25,6 +26,10 @@ function formatChangeLabel(change: QueueItem): string {
|
|||||||
return `Empty Table: ${fullName}`;
|
return `Empty Table: ${fullName}`;
|
||||||
case "drop_table":
|
case "drop_table":
|
||||||
return `Drop Table: ${fullName}`;
|
return `Drop Table: ${fullName}`;
|
||||||
|
case "rebuild_table":
|
||||||
|
return change.description ?? `Rebuild ${fullName}`;
|
||||||
|
case "ddl":
|
||||||
|
return change.description ?? "DDL";
|
||||||
default:
|
default:
|
||||||
return change.table ?? "-";
|
return change.table ?? "-";
|
||||||
}
|
}
|
||||||
@@ -50,6 +55,13 @@ function capitalizeType(type: string) {
|
|||||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Badge label for a queue-item type — ddl/rebuild render uppercase. */
|
||||||
|
function badgeLabel(type: string): string {
|
||||||
|
if (type === "ddl") return "DDL";
|
||||||
|
if (type === "rebuild_table") return "REBUILD";
|
||||||
|
return capitalizeType(type);
|
||||||
|
}
|
||||||
|
|
||||||
function tableRef(change: QueueItem): string {
|
function tableRef(change: QueueItem): string {
|
||||||
if (change.schema && change.table) return `${change.schema}.${change.table}`;
|
if (change.schema && change.table) return `${change.schema}.${change.table}`;
|
||||||
return change.table ?? "-";
|
return change.table ?? "-";
|
||||||
@@ -90,6 +102,11 @@ export function ChangesQueuePanel({ onCommitted }: { onCommitted?: () => void }
|
|||||||
treeDirty = true;
|
treeDirty = true;
|
||||||
const st = useDbViewerStore.getState();
|
const st = useDbViewerStore.getState();
|
||||||
st.closeTabsForTable(change.schema ?? "", change.table ?? "");
|
st.closeTabsForTable(change.schema ?? "", change.table ?? "");
|
||||||
|
} else if (
|
||||||
|
change.type === "rebuild_table" ||
|
||||||
|
(change.type === "ddl" && change.sql && isSchemaModifyingQuery(change.sql))
|
||||||
|
) {
|
||||||
|
treeDirty = true;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
@@ -170,8 +187,15 @@ export function ChangesQueuePanel({ onCommitted }: { onCommitted?: () => void }
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<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
|
||||||
{capitalizeType(change.type)}
|
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>
|
||||||
<span className="text-sm text-text truncate">
|
<span className="text-sm text-text truncate">
|
||||||
{tableRef(change)}
|
{tableRef(change)}
|
||||||
@@ -197,6 +221,17 @@ export function ChangesQueuePanel({ onCommitted }: { onCommitted?: () => void }
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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">
|
<div className="mt-1 text-xs text-text-muted truncate">
|
||||||
{formatChangeLabel(change)}
|
{formatChangeLabel(change)}
|
||||||
</div>
|
</div>
|
||||||
@@ -211,6 +246,8 @@ export function ChangesQueuePanel({ onCommitted }: { onCommitted?: () => void }
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ vi.mock("../../hooks/useDbConnection", () => ({
|
|||||||
useDbConnection: (_connectionId: string) => ({
|
useDbConnection: (_connectionId: string) => ({
|
||||||
connectionError: null,
|
connectionError: null,
|
||||||
connect: vi.fn(),
|
connect: vi.fn(),
|
||||||
|
passwordPromptOpen: false,
|
||||||
|
submitPassword: vi.fn(),
|
||||||
|
cancelPassword: vi.fn(),
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -896,6 +899,65 @@ describe("DbViewerScreen", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("objects view renders the sidebar + the tabbed workspace (New query + Changes)", () => {
|
||||||
|
render(
|
||||||
|
<DbViewerScreen
|
||||||
|
connectionId="c1"
|
||||||
|
onHome={() => {}}
|
||||||
|
onSettings={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /objects/i }));
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /new query/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Changes queue" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("objects view renders ObjectDetail for an open object tab", () => {
|
||||||
|
useDbViewerStore.getState().openObjectTab("enums", "public", "role", { name: "role", schema: "public", labels: ["admin"] });
|
||||||
|
render(
|
||||||
|
<DbViewerScreen
|
||||||
|
connectionId="c1"
|
||||||
|
onHome={() => {}}
|
||||||
|
onSettings={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /objects/i }));
|
||||||
|
expect(screen.getByText("admin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an objectForm tab with the Visual/SQL toggle in the objects view", async () => {
|
||||||
|
vi.spyOn(commands, "executeQuery").mockResolvedValue({ columns: [], rows: [], total_rows: 0, page: 1, page_size: 50 } as any);
|
||||||
|
vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
|
||||||
|
vi.spyOn(commands, "getDatabases").mockResolvedValue(["mydb"]);
|
||||||
|
vi.spyOn(commands, "getTables").mockResolvedValue([] as any);
|
||||||
|
useDbViewerStore.getState().openFormTab({
|
||||||
|
kind: "sequence",
|
||||||
|
schema: "public",
|
||||||
|
name: "",
|
||||||
|
title: "Create sequence",
|
||||||
|
description: "Create sequence",
|
||||||
|
mode: "create",
|
||||||
|
params: { schema: "public", name: "", action: { op: "create" } },
|
||||||
|
});
|
||||||
|
render(
|
||||||
|
<DbViewerScreen
|
||||||
|
connectionId="c1"
|
||||||
|
onHome={() => {}}
|
||||||
|
onSettings={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /objects/i }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByPlaceholderText("Sequence name")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByRole("button", { name: "Visual" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "SQL" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("guards the Objects view for MySQL (capability false)", () => {
|
it("guards the Objects view for MySQL (capability false)", () => {
|
||||||
useConnectionStore.setState({
|
useConnectionStore.setState({
|
||||||
connections: [
|
connections: [
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { TooltipProvider } from "../ui/Tooltip";
|
|||||||
import { DbViewerSidebar, NAV_CAPABILITY_KEY } from "./DbViewerSidebar";
|
import { DbViewerSidebar, NAV_CAPABILITY_KEY } from "./DbViewerSidebar";
|
||||||
import { DbViewerToolbar } from "./DbViewerToolbar";
|
import { DbViewerToolbar } from "./DbViewerToolbar";
|
||||||
import { isDestructiveQuery, isSchemaModifyingQuery } from "../../lib/utils";
|
import { isDestructiveQuery, isSchemaModifyingQuery } from "../../lib/utils";
|
||||||
import { executeQuery } from "../../lib/commands";
|
import { executeQuery, cancelQuery } from "../../lib/commands";
|
||||||
import { getCapabilities } from "../../lib/dbCapabilities";
|
import { getCapabilities } from "../../lib/dbCapabilities";
|
||||||
|
|
||||||
const QueryEditor = lazy(() => import("../editor/QueryEditor").then((m) => ({ default: m.QueryEditor })));
|
const QueryEditor = lazy(() => import("../editor/QueryEditor").then((m) => ({ default: m.QueryEditor })));
|
||||||
@@ -15,21 +15,26 @@ const DestructiveQueryDialog = lazy(() =>
|
|||||||
);
|
);
|
||||||
import { TableTree } from "./TableTree";
|
import { TableTree } from "./TableTree";
|
||||||
import { ObjectExplorerPage } from "./ObjectExplorerPage";
|
import { ObjectExplorerPage } from "./ObjectExplorerPage";
|
||||||
|
import { ObjectDetail, type AnyObject } from "./objects/ObjectDetail";
|
||||||
|
import { ObjectFormTab } from "./objects/ObjectFormTab";
|
||||||
import { TabBar } from "./TabBar";
|
import { TabBar } from "./TabBar";
|
||||||
import { VirtualDataGrid } from "../grid/VirtualDataGrid";
|
import { VirtualDataGrid } from "../grid/VirtualDataGrid";
|
||||||
import { RowDetailDrawer } from "../grid/RowDetailDrawer";
|
import { RowDetailDrawer } from "../grid/RowDetailDrawer";
|
||||||
import { TableControls } from "./TableControls";
|
import { TableControls } from "./TableControls";
|
||||||
import { EditConnectionModal } from "./EditConnectionModal";
|
import { EditConnectionModal } from "./EditConnectionModal";
|
||||||
|
import { PasswordPromptDialog } from "./PasswordPromptDialog";
|
||||||
import { useDbConnection } from "../../hooks/useDbConnection";
|
import { useDbConnection } from "../../hooks/useDbConnection";
|
||||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||||
import { useConnectionStore } from "../../stores/connectionStore";
|
import { useConnectionStore } from "../../stores/connectionStore";
|
||||||
import { useSettingsStore } from "../../stores/settingsStore";
|
import { useSettingsStore } from "../../stores/settingsStore";
|
||||||
|
import { useNotificationStore } from "../../stores/notificationStore";
|
||||||
import { useShortcut } from "../../hooks/useShortcut";
|
import { useShortcut } from "../../hooks/useShortcut";
|
||||||
import { ConnectionDropBanner } from "./ConnectionDropBanner";
|
import { ConnectionDropBanner } from "./ConnectionDropBanner";
|
||||||
import { ToolsPage } from "./ToolsPage";
|
import { ToolsPage } from "./ToolsPage";
|
||||||
import { SchemaVisualizerPage } from "./SchemaVisualizerPage";
|
import { SchemaVisualizerPage } from "./SchemaVisualizerPage";
|
||||||
import { QueriesPanel } from "../queries/QueriesPanel";
|
import { QueriesPanel } from "../queries/QueriesPanel";
|
||||||
import { useQueryStore } from "../../stores/queryStore";
|
import { useQueryStore } from "../../stores/queryStore";
|
||||||
|
import { ObjectSearchPalette } from "./ObjectSearchPalette";
|
||||||
import * as cmd from "../../lib/commands";
|
import * as cmd from "../../lib/commands";
|
||||||
import type { EnumInfo } from "../../lib/types";
|
import type { EnumInfo } from "../../lib/types";
|
||||||
import type { FkOption } from "../grid/CellEditor";
|
import type { FkOption } from "../grid/CellEditor";
|
||||||
@@ -161,7 +166,8 @@ export function DbViewerScreen({
|
|||||||
onHome,
|
onHome,
|
||||||
onSettings,
|
onSettings,
|
||||||
}: DbViewerScreenProps) {
|
}: DbViewerScreenProps) {
|
||||||
const { connectionError, connect } = useDbConnection(connectionId);
|
const { connectionError, connect, passwordPromptOpen, submitPassword, cancelPassword } =
|
||||||
|
useDbConnection(connectionId);
|
||||||
const [dismissedError, setDismissedError] = useState<string | null>(null);
|
const [dismissedError, setDismissedError] = useState<string | null>(null);
|
||||||
const [currentView, setCurrentView] = useState<string>("db-viewer");
|
const [currentView, setCurrentView] = useState<string>("db-viewer");
|
||||||
const [tablePanelWidth, setTablePanelWidth] = useState(280);
|
const [tablePanelWidth, setTablePanelWidth] = useState(280);
|
||||||
@@ -179,6 +185,7 @@ export function DbViewerScreen({
|
|||||||
const viewCapabilityKey = NAV_CAPABILITY_KEY[currentView] ?? "explorer";
|
const viewCapabilityKey = NAV_CAPABILITY_KEY[currentView] ?? "explorer";
|
||||||
const viewSupported = capabilities[viewCapabilityKey];
|
const viewSupported = capabilities[viewCapabilityKey];
|
||||||
const settings = useSettingsStore((s) => s.settings);
|
const settings = useSettingsStore((s) => s.settings);
|
||||||
|
const notify = useNotificationStore((s) => s.notify);
|
||||||
const setDefaultPageSize = useDbViewerStore((s) => s.setDefaultPageSize);
|
const setDefaultPageSize = useDbViewerStore((s) => s.setDefaultPageSize);
|
||||||
const clearColumnFilter = useDbViewerStore((s) => s.clearColumnFilter);
|
const clearColumnFilter = useDbViewerStore((s) => s.clearColumnFilter);
|
||||||
const setFilterRules = useDbViewerStore((s) => s.setFilterRules);
|
const setFilterRules = useDbViewerStore((s) => s.setFilterRules);
|
||||||
@@ -186,6 +193,18 @@ export function DbViewerScreen({
|
|||||||
const toggleHiddenColumn = useDbViewerStore((s) => s.toggleHiddenColumn);
|
const toggleHiddenColumn = useDbViewerStore((s) => s.toggleHiddenColumn);
|
||||||
const setSmartSortApplied = useDbViewerStore((s) => s.setSmartSortApplied);
|
const setSmartSortApplied = useDbViewerStore((s) => s.setSmartSortApplied);
|
||||||
|
|
||||||
|
const setObjectSearchOpen = useDbViewerStore((s) => s.setObjectSearchOpen);
|
||||||
|
const requestedView = useDbViewerStore((s) => s.requestedView);
|
||||||
|
|
||||||
|
// Consume the search palette's navigation request: switch the local
|
||||||
|
// currentView state to the requested view, then clear it so a second
|
||||||
|
// request for the same view still fires.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!requestedView) return;
|
||||||
|
setCurrentView(requestedView);
|
||||||
|
useDbViewerStore.getState().setRequestedView(null);
|
||||||
|
}, [requestedView]);
|
||||||
|
|
||||||
// Sync settings defaults to store
|
// Sync settings defaults to store
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings?.table_page_size) {
|
if (settings?.table_page_size) {
|
||||||
@@ -213,6 +232,8 @@ export function DbViewerScreen({
|
|||||||
const tables = useDbViewerStore((s) => s.tables);
|
const tables = useDbViewerStore((s) => s.tables);
|
||||||
const stageCellEdit = useDbViewerStore((s) => s.stageCellEdit);
|
const stageCellEdit = useDbViewerStore((s) => s.stageCellEdit);
|
||||||
|
|
||||||
|
const isRunning = activeTab?.tabType === "query" && !!activeTab?.loading;
|
||||||
|
|
||||||
const isMatview =
|
const isMatview =
|
||||||
activeTab && activeTab.tabType === "table"
|
activeTab && activeTab.tabType === "table"
|
||||||
? tables.some(
|
? tables.some(
|
||||||
@@ -423,6 +444,11 @@ export function DbViewerScreen({
|
|||||||
onHome();
|
onHome();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
useShortcut("command_palette", () => {
|
||||||
|
if (capabilities.objects) {
|
||||||
|
setObjectSearchOpen(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeTab) return;
|
if (!activeTab) return;
|
||||||
if (activeTab.tabType !== "table") return;
|
if (activeTab.tabType !== "table") return;
|
||||||
@@ -966,15 +992,30 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-text-muted">
|
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-text-muted">
|
||||||
{currentView === "queries" ? (
|
{currentView === "queries" ? (
|
||||||
<Terminal size={32} />
|
<Terminal size={32} />
|
||||||
|
) : currentView === "objects" ? (
|
||||||
|
<Database size={32} />
|
||||||
) : (
|
) : (
|
||||||
<Table2 size={32} />
|
<Table2 size={32} />
|
||||||
)}
|
)}
|
||||||
<span>
|
<span>
|
||||||
{currentView === "queries"
|
{currentView === "queries"
|
||||||
? "Open a new query tab or run a query from the history"
|
? "Open a new query tab or run a query from the history"
|
||||||
|
: currentView === "objects"
|
||||||
|
? "Open an object from the list, or open a new query tab"
|
||||||
: "Select a table from the tree to browse its data, or open a new query tab"}
|
: "Select a table from the tree to browse its data, or open a new query tab"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
) : activeTab?.tabType === "objectForm" ? (
|
||||||
|
<ObjectFormTab
|
||||||
|
connectionId={connectionId}
|
||||||
|
tab={activeTab}
|
||||||
|
/>
|
||||||
|
) : activeTab?.tabType === "object" ? (
|
||||||
|
<ObjectDetail
|
||||||
|
connectionId={connectionId}
|
||||||
|
type={activeTab.objectType!}
|
||||||
|
item={activeTab.objectItem as AnyObject}
|
||||||
|
/>
|
||||||
) : activeTab?.tabType === "query" ? (
|
) : activeTab?.tabType === "query" ? (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
@@ -991,6 +1032,18 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
onRestore={handleRestoreSql}
|
onRestore={handleRestoreSql}
|
||||||
onRunFromHistory={handleRunFromHistory}
|
onRunFromHistory={handleRunFromHistory}
|
||||||
dbType={currentConnection?.db_type}
|
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">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
<QueryEditor
|
<QueryEditor
|
||||||
@@ -1402,6 +1455,7 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
setCurrentSchema={setCurrentSchema}
|
setCurrentSchema={setCurrentSchema}
|
||||||
onEdit={() => setEditModalOpen(true)}
|
onEdit={() => setEditModalOpen(true)}
|
||||||
connectionId={connectionId}
|
connectionId={connectionId}
|
||||||
|
dbType={currentConnection?.db_type}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
onSearchChange={setSearchQuery}
|
onSearchChange={setSearchQuery}
|
||||||
/>
|
/>
|
||||||
@@ -1409,7 +1463,7 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
className="flex-1 overflow-y-auto"
|
className="flex-1 overflow-y-auto"
|
||||||
style={{ overscrollBehavior: "none" }}
|
style={{ overscrollBehavior: "none" }}
|
||||||
>
|
>
|
||||||
<TableTree searchQuery={searchQuery} />
|
<TableTree searchQuery={searchQuery} dbType={currentConnection?.db_type} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* panel resize handle */}
|
{/* panel resize handle */}
|
||||||
@@ -1421,7 +1475,20 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
{renderQueryWorkspace()}
|
{renderQueryWorkspace()}
|
||||||
</div>
|
</div>
|
||||||
) : currentView === "objects" ? (
|
) : currentView === "objects" ? (
|
||||||
<ObjectExplorerPage connectionId={connectionId} />
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="border-r border-border flex flex-col shrink-0"
|
||||||
|
style={{ width: tablePanelWidth }}
|
||||||
|
>
|
||||||
|
<ObjectExplorerPage connectionId={connectionId} sidebarMode />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="w-1 cursor-col-resize bg-border/20 hover:bg-accent/30 active:bg-accent/50 shrink-0 border-r border-border"
|
||||||
|
onMouseDown={onPanelResizeStart}
|
||||||
|
onDoubleClick={() => setTablePanelWidth(280)}
|
||||||
|
/>
|
||||||
|
{renderQueryWorkspace()}
|
||||||
|
</div>
|
||||||
) : currentView === "tools" ? (
|
) : currentView === "tools" ? (
|
||||||
<ToolsPage connectionId={connectionId} />
|
<ToolsPage connectionId={connectionId} />
|
||||||
) : currentView === "queries" ? (
|
) : currentView === "queries" ? (
|
||||||
@@ -1455,6 +1522,15 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
onSaved={() => {}}
|
onSaved={() => {}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<PasswordPromptDialog
|
||||||
|
open={passwordPromptOpen}
|
||||||
|
connectionName={currentConnection?.name ?? ""}
|
||||||
|
onConnect={submitPassword}
|
||||||
|
onCancel={cancelPassword}
|
||||||
|
/>
|
||||||
|
{capabilities.objects && (
|
||||||
|
<ObjectSearchPalette connectionId={connectionId} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ describe("DbViewerSidebar", () => {
|
|||||||
expect(screen.getByLabelText(/tools/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/tools/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hides Objects and Tools for SQLite", () => {
|
it("shows Tools but hides Objects for SQLite", () => {
|
||||||
render(
|
render(
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} capabilities={DB_CAPABILITIES.sqlite} />
|
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} capabilities={DB_CAPABILITIES.sqlite} />
|
||||||
@@ -92,11 +92,11 @@ describe("DbViewerSidebar", () => {
|
|||||||
expect(screen.getByLabelText(/explorer/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/explorer/i)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText("Queries")).toBeInTheDocument();
|
expect(screen.getByLabelText("Queries")).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/schema visualizer/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/schema visualizer/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/tools/i)).toBeInTheDocument();
|
||||||
expect(screen.queryByLabelText(/objects/i)).not.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(
|
render(
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} capabilities={DB_CAPABILITIES.mysql} />
|
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} capabilities={DB_CAPABILITIES.mysql} />
|
||||||
@@ -104,9 +104,9 @@ describe("DbViewerSidebar", () => {
|
|||||||
);
|
);
|
||||||
expect(screen.getByLabelText(/explorer/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/explorer/i)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText("Queries")).toBeInTheDocument();
|
expect(screen.getByLabelText("Queries")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/tools/i)).toBeInTheDocument();
|
||||||
expect(screen.queryByLabelText(/schema visualizer/i)).not.toBeInTheDocument();
|
expect(screen.queryByLabelText(/schema visualizer/i)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByLabelText(/objects/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)", () => {
|
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 { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { DbViewerToolbar } from "./DbViewerToolbar";
|
import { DbViewerToolbar } from "./DbViewerToolbar";
|
||||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||||
@@ -84,6 +84,17 @@ describe("DbViewerToolbar", () => {
|
|||||||
expect(screen.getByLabelText(/create table/i)).toBeInTheDocument();
|
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", () => {
|
it("shows a disabled schema loading indicator while schema tree is loading", () => {
|
||||||
useDbViewerStore.setState({ schemaTreeLoading: true });
|
useDbViewerStore.setState({ schemaTreeLoading: true });
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import { SelectDropdown } from "../ui/SelectDropdown";
|
|||||||
import { Tooltip } from "../ui/Tooltip";
|
import { Tooltip } from "../ui/Tooltip";
|
||||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||||
import * as cmd from "../../lib/commands";
|
import * as cmd from "../../lib/commands";
|
||||||
|
import { SchemaMenu } from "./SchemaMenu";
|
||||||
|
import { getCapabilities } from "../../lib/dbCapabilities";
|
||||||
|
import type { DbType } from "../../lib/types";
|
||||||
|
|
||||||
export function DbViewerToolbar({
|
export function DbViewerToolbar({
|
||||||
databases,
|
databases,
|
||||||
@@ -22,6 +25,7 @@ export function DbViewerToolbar({
|
|||||||
setCurrentSchema,
|
setCurrentSchema,
|
||||||
onEdit,
|
onEdit,
|
||||||
connectionId,
|
connectionId,
|
||||||
|
dbType,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
}: {
|
}: {
|
||||||
@@ -33,6 +37,7 @@ export function DbViewerToolbar({
|
|||||||
setCurrentSchema: (schema: string | null) => void;
|
setCurrentSchema: (schema: string | null) => void;
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
connectionId?: string;
|
connectionId?: string;
|
||||||
|
dbType?: DbType;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
onSearchChange: (q: string) => void;
|
onSearchChange: (q: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -77,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 () => {
|
const handleRefresh = useCallback(async () => {
|
||||||
if (!connectionId || refreshing) return;
|
if (!connectionId || refreshing) return;
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
@@ -146,14 +166,17 @@ export function DbViewerToolbar({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
{showCreateTable && (
|
||||||
<Tooltip content="Create Table" side="bottom">
|
<Tooltip content="Create Table" side="bottom">
|
||||||
<button
|
<button
|
||||||
aria-label="Create Table"
|
aria-label="Create Table"
|
||||||
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer opacity-50"
|
onClick={handleCreateTable}
|
||||||
|
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
|
||||||
>
|
>
|
||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Tooltip content="Search Tables" side="bottom">
|
<Tooltip content="Search Tables" side="bottom">
|
||||||
<button
|
<button
|
||||||
aria-label="Search Tables"
|
aria-label="Search Tables"
|
||||||
@@ -227,6 +250,11 @@ export function DbViewerToolbar({
|
|||||||
disabled={schemaTreeLoading}
|
disabled={schemaTreeLoading}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<SchemaMenu
|
||||||
|
connectionId={connectionId ?? ""}
|
||||||
|
schema={currentSchema ?? undefined}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { DependencyDialog } from "./DependencyDialog";
|
||||||
|
|
||||||
|
describe("DependencyDialog", () => {
|
||||||
|
it("empty list shows no-dependencies message", () => {
|
||||||
|
render(<DependencyDialog open deps={[]} onProceed={() => {}} onCancel={() => {}} />);
|
||||||
|
expect(screen.getByText(/no dependencies/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("non-empty lists rows and requires checkbox to proceed", () => {
|
||||||
|
const deps = [{ deptype: "n", class: "pg_class", name: "v_orders" }];
|
||||||
|
render(<DependencyDialog open deps={deps} onProceed={() => {}} onCancel={() => {}} />);
|
||||||
|
expect(screen.getByText("v_orders")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: /proceed/i })).toBeDisabled();
|
||||||
|
fireEvent.click(screen.getByRole("checkbox"));
|
||||||
|
expect(screen.getByRole("button", { name: /proceed/i })).toBeEnabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||||
|
import { Button } from "../ui/Button";
|
||||||
|
import type { DependencyInfo } from "../../lib/types";
|
||||||
|
|
||||||
|
interface DependencyDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
deps: DependencyInfo[];
|
||||||
|
onProceed: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DependencyDialog({ open, deps, onProceed, onCancel }: DependencyDialogProps) {
|
||||||
|
const [ack, setAck] = useState(false);
|
||||||
|
const hasDeps = deps.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatedModal open={open} onClose={onCancel}>
|
||||||
|
<div className="w-[420px]">
|
||||||
|
<h3 className="font-heading text-text text-lg mb-3">Dependencies</h3>
|
||||||
|
{hasDeps ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-red-400 mb-2">
|
||||||
|
The following depend on this object and will be removed with CASCADE:
|
||||||
|
</p>
|
||||||
|
<ul className="max-h-48 overflow-auto my-2 space-y-1 pr-1">
|
||||||
|
{deps.map((d, i) => (
|
||||||
|
<li key={i} className="text-sm text-text">
|
||||||
|
{d.name}{" "}
|
||||||
|
<span className="text-text-subtle">({d.class})</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-text-muted mt-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={ack}
|
||||||
|
onChange={(e) => setAck(e.target.checked)}
|
||||||
|
className="accent-accent h-4 w-4"
|
||||||
|
/>
|
||||||
|
I understand these will be dropped.
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-text-muted">No dependencies — safe to drop.</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2 mt-5">
|
||||||
|
<Button variant="ghost" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={onProceed}
|
||||||
|
disabled={hasDeps && !ack}
|
||||||
|
>
|
||||||
|
Proceed
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AnimatedModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { EditConnectionModal } from "./EditConnectionModal";
|
import { EditConnectionModal } from "./EditConnectionModal";
|
||||||
|
import * as commands from "../../lib/commands";
|
||||||
import type { Connection } from "../../lib/types";
|
import type { Connection } from "../../lib/types";
|
||||||
|
|
||||||
const { updateConnection, loadAll } = vi.hoisted(() => ({
|
const { updateConnection, loadAll } = vi.hoisted(() => ({
|
||||||
@@ -12,13 +13,18 @@ vi.mock("../../stores/connectionStore", () => ({
|
|||||||
useConnectionStore: (sel: (s: any) => any) =>
|
useConnectionStore: (sel: (s: any) => any) =>
|
||||||
sel({ updateConnection, loadAll, folders: [], tags: [] }),
|
sel({ updateConnection, loadAll, folders: [], tags: [] }),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../lib/commands", () => ({
|
vi.mock("../../lib/commands", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../../lib/commands")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
updateConnection: vi.fn(),
|
updateConnection: vi.fn(),
|
||||||
testConnection: vi.fn(),
|
testConnection: vi.fn(),
|
||||||
saveConnectionPassword: vi.fn(),
|
saveConnectionPassword: vi.fn(),
|
||||||
saveConnectionSshPassword: vi.fn(),
|
saveConnectionSshPassword: vi.fn(),
|
||||||
saveConnectionSshPassphrase: vi.fn(),
|
saveConnectionSshPassphrase: vi.fn(),
|
||||||
}));
|
deleteConnectionPassword: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
vi.mock("../../stores/notificationStore", () => ({
|
vi.mock("../../stores/notificationStore", () => ({
|
||||||
useNotificationStore: (sel: (s: any) => any) => sel({ notify: vi.fn() }),
|
useNotificationStore: (sel: (s: any) => any) => sel({ notify: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
@@ -68,4 +74,51 @@ describe("EditConnectionModal", () => {
|
|||||||
);
|
);
|
||||||
expect(screen.getByText(/requires ssl/i)).toBeInTheDocument();
|
expect(screen.getByText(/requires ssl/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prefills the keychain toggle from the connection (use_keychain=true)", () => {
|
||||||
|
render(
|
||||||
|
<EditConnectionModal
|
||||||
|
connection={{ ...baseConn, use_keychain: true }}
|
||||||
|
open={true}
|
||||||
|
onClose={() => {}}
|
||||||
|
onSaved={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const cb = screen.getByLabelText("Enable keychain") as HTMLInputElement;
|
||||||
|
expect(cb.checked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves to keychain when use_keychain=true", async () => {
|
||||||
|
render(
|
||||||
|
<EditConnectionModal
|
||||||
|
connection={{ ...baseConn, use_keychain: true }}
|
||||||
|
open={true}
|
||||||
|
onClose={() => {}}
|
||||||
|
onSaved={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "secret" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(vi.mocked(commands.saveConnectionPassword)).toHaveBeenCalledWith("c1", "secret")
|
||||||
|
);
|
||||||
|
expect(vi.mocked(commands.deleteConnectionPassword)).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("purges keychain when use_keychain=false", async () => {
|
||||||
|
render(
|
||||||
|
<EditConnectionModal
|
||||||
|
connection={{ ...baseConn, use_keychain: false }}
|
||||||
|
open={true}
|
||||||
|
onClose={() => {}}
|
||||||
|
onSaved={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "secret" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(vi.mocked(commands.deleteConnectionPassword)).toHaveBeenCalledWith("c1")
|
||||||
|
);
|
||||||
|
expect(vi.mocked(commands.saveConnectionPassword)).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -4,7 +4,8 @@ import { Button } from "../ui/Button";
|
|||||||
import { DetailedConnectionForm } from "../connections/DetailedConnectionForm";
|
import { DetailedConnectionForm } from "../connections/DetailedConnectionForm";
|
||||||
import { useConnectionStore } from "../../stores/connectionStore";
|
import { useConnectionStore } from "../../stores/connectionStore";
|
||||||
import { useNotificationStore } from "../../stores/notificationStore";
|
import { useNotificationStore } from "../../stores/notificationStore";
|
||||||
import { updateConnection, testConnection, saveConnectionPassword, saveConnectionSshPassword, saveConnectionSshPassphrase } from "../../lib/commands";
|
import { updateConnection, testConnection, saveConnectionSshPassword, saveConnectionSshPassphrase } from "../../lib/commands";
|
||||||
|
import { persistDbPassword } from "../../lib/keychain";
|
||||||
import { detectProviderFromHost } from "../../lib/connectionString";
|
import { detectProviderFromHost } from "../../lib/connectionString";
|
||||||
import type { Connection, ConnectionInput } from "../../lib/types";
|
import type { Connection, ConnectionInput } from "../../lib/types";
|
||||||
import type { ConnectionFormData } from "../connections/connectionFormData";
|
import type { ConnectionFormData } from "../connections/connectionFormData";
|
||||||
@@ -36,7 +37,7 @@ export function EditConnectionModal({
|
|||||||
username: connection.username,
|
username: connection.username,
|
||||||
password: null,
|
password: null,
|
||||||
database: connection.database ?? null,
|
database: connection.database ?? null,
|
||||||
use_keychain: false,
|
use_keychain: connection.use_keychain ?? true,
|
||||||
ssh_host: connection.ssh_host ?? null,
|
ssh_host: connection.ssh_host ?? null,
|
||||||
ssh_port: connection.ssh_port ?? null,
|
ssh_port: connection.ssh_port ?? null,
|
||||||
ssh_user: connection.ssh_user ?? null,
|
ssh_user: connection.ssh_user ?? null,
|
||||||
@@ -76,9 +77,7 @@ export function EditConnectionModal({
|
|||||||
ssh_passphrase: form.ssh_passphrase ?? null,
|
ssh_passphrase: form.ssh_passphrase ?? null,
|
||||||
};
|
};
|
||||||
const updated = await updateConnection(connection.id, input);
|
const updated = await updateConnection(connection.id, input);
|
||||||
if (form.password) {
|
await persistDbPassword(connection.id, form.use_keychain, form.password).catch(() => {});
|
||||||
await saveConnectionPassword(connection.id, form.password).catch(() => {});
|
|
||||||
}
|
|
||||||
// Persist SSH secrets to the OS keychain (not SQLite)
|
// Persist SSH secrets to the OS keychain (not SQLite)
|
||||||
if (form.ssh_host && (form.ssh_auth_method ?? "password") === "password" && form.ssh_password) {
|
if (form.ssh_host && (form.ssh_auth_method ?? "password") === "password" && form.ssh_password) {
|
||||||
await saveConnectionSshPassword(connection.id, form.ssh_password).catch(() => {});
|
await saveConnectionSshPassword(connection.id, form.ssh_password).catch(() => {});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { ObjectExplorerPage } from "./ObjectExplorerPage";
|
import { ObjectExplorerPage } from "./ObjectExplorerPage";
|
||||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||||
@@ -189,4 +189,204 @@ describe("ObjectExplorerPage", () => {
|
|||||||
);
|
);
|
||||||
expect(screen.queryByText("calc")).not.toBeInTheDocument();
|
expect(screen.queryByText("calc")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Copy DDL calls getObjectDdl and writes to clipboard", async () => {
|
||||||
|
vi.spyOn(commands, "getEnums").mockResolvedValue([
|
||||||
|
{ name: "role", schema: "public", labels: ["a"] },
|
||||||
|
]);
|
||||||
|
vi.spyOn(commands, "getObjectDdl").mockResolvedValue("CREATE TYPE ...");
|
||||||
|
const writeText = vi.fn();
|
||||||
|
Object.defineProperty(navigator, "clipboard", {
|
||||||
|
value: { writeText },
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
render(<ObjectExplorerPage connectionId="c1" />);
|
||||||
|
fireEvent.click(screen.getByLabelText("Object type"));
|
||||||
|
fireEvent.click(screen.getByText("Enums"));
|
||||||
|
await waitFor(() => screen.getByText("role"));
|
||||||
|
fireEvent.click(screen.getAllByLabelText(/actions/i)[0]);
|
||||||
|
fireEvent.click(screen.getByText(/copy ddl/i));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(commands.getObjectDdl).toHaveBeenCalledWith("c1", "public", "enum", "role"),
|
||||||
|
);
|
||||||
|
expect(writeText).toHaveBeenCalledWith("CREATE TYPE ...");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("View dependencies opens DependencyDialog", async () => {
|
||||||
|
vi.spyOn(commands, "getFunctions").mockResolvedValue([
|
||||||
|
{
|
||||||
|
name: "add_one",
|
||||||
|
schema: "public",
|
||||||
|
return_type: "int",
|
||||||
|
argument_types: ["int"],
|
||||||
|
argument_names: ["x"],
|
||||||
|
argument_modes: ["IN"],
|
||||||
|
language: "sql",
|
||||||
|
source: "SELECT $1 + 1",
|
||||||
|
kind: "f",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
vi.spyOn(commands, "getObjectDependencies").mockResolvedValue([
|
||||||
|
{ deptype: "n", class: "pg_class", name: "v" },
|
||||||
|
]);
|
||||||
|
render(<ObjectExplorerPage connectionId="c1" />);
|
||||||
|
await waitFor(() => screen.getByText("add_one(int)"));
|
||||||
|
fireEvent.click(screen.getAllByLabelText(/actions/i)[0]);
|
||||||
|
fireEvent.click(screen.getByText(/dependencies/i));
|
||||||
|
await waitFor(() => expect(commands.getObjectDependencies).toHaveBeenCalled());
|
||||||
|
await waitFor(() => expect(screen.getByText("v")).toBeTruthy());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("per-item menu offers Create…/Edit…/Drop… and Edit opens an objectForm tab", async () => {
|
||||||
|
vi.spyOn(commands, "getEnums").mockResolvedValue([
|
||||||
|
{ name: "role", schema: "public", labels: ["admin"] },
|
||||||
|
]);
|
||||||
|
render(<ObjectExplorerPage connectionId="c1" />);
|
||||||
|
fireEvent.click(screen.getByLabelText("Object type"));
|
||||||
|
fireEvent.click(screen.getByText("Enums"));
|
||||||
|
await waitFor(() => screen.getByText("role"));
|
||||||
|
fireEvent.click(screen.getAllByLabelText(/actions/i)[0]);
|
||||||
|
expect(screen.getByText("Create…")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Edit…")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Drop…")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByText("Edit…"));
|
||||||
|
const st = useDbViewerStore.getState();
|
||||||
|
expect(st.tabs).toHaveLength(1);
|
||||||
|
expect(st.tabs[0].tabType).toBe("objectForm");
|
||||||
|
expect(st.tabs[0].form?.mode).toBe("edit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("right-click on a list row opens the context menu", async () => {
|
||||||
|
vi.spyOn(commands, "getEnums").mockResolvedValue([
|
||||||
|
{ name: "role", schema: "public", labels: ["admin"] },
|
||||||
|
]);
|
||||||
|
render(<ObjectExplorerPage connectionId="c1" />);
|
||||||
|
fireEvent.click(screen.getByLabelText("Object type"));
|
||||||
|
fireEvent.click(screen.getByText("Enums"));
|
||||||
|
const row = await screen.findByText("role");
|
||||||
|
fireEvent.contextMenu(row);
|
||||||
|
expect(screen.getByText("Create…")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Edit…")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("header + create button opens an objectForm create tab for the current type", async () => {
|
||||||
|
vi.spyOn(commands, "getEnums").mockResolvedValue([
|
||||||
|
{ name: "role", schema: "public", labels: ["admin"] },
|
||||||
|
]);
|
||||||
|
render(<ObjectExplorerPage connectionId="c1" />);
|
||||||
|
fireEvent.click(screen.getByLabelText("Object type"));
|
||||||
|
fireEvent.click(screen.getByText("Enums"));
|
||||||
|
await waitFor(() => screen.getByText("role"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /create enum/i }));
|
||||||
|
const st = useDbViewerStore.getState();
|
||||||
|
expect(st.tabs).toHaveLength(1);
|
||||||
|
expect(st.tabs[0].tabType).toBe("objectForm");
|
||||||
|
expect(st.tabs[0].form?.mode).toBe("create");
|
||||||
|
expect(st.tabs[0].form?.kind).toBe("enum");
|
||||||
|
expect(st.tabs[0].form?.params?.schema).toBe("public");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refetches the current object list after a ddl commit succeeds", async () => {
|
||||||
|
const getFunctions = vi
|
||||||
|
.spyOn(commands, "getFunctions")
|
||||||
|
.mockResolvedValue([]);
|
||||||
|
render(<ObjectExplorerPage connectionId="c1" />);
|
||||||
|
await waitFor(() => expect(getFunctions).toHaveBeenCalledTimes(1));
|
||||||
|
useDbViewerStore.getState().addChange({
|
||||||
|
type: "ddl",
|
||||||
|
sql: "DROP INDEX public.i",
|
||||||
|
description: "Drop index i",
|
||||||
|
} as any);
|
||||||
|
useDbViewerStore.getState().markChangeCommitted("ch-1");
|
||||||
|
await waitFor(() => expect(getFunctions).toHaveBeenCalledTimes(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sidebarMode: clicking a list row opens an object tab instead of an inline detail", async () => {
|
||||||
|
vi.spyOn(commands, "getFunctions").mockResolvedValue([
|
||||||
|
{
|
||||||
|
name: "add",
|
||||||
|
schema: "public",
|
||||||
|
return_type: "int",
|
||||||
|
argument_types: [],
|
||||||
|
argument_names: [],
|
||||||
|
argument_modes: [],
|
||||||
|
language: "plpgsql",
|
||||||
|
source: "BEGIN RETURN 1; END",
|
||||||
|
kind: "f",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
render(<ObjectExplorerPage connectionId="c1" sidebarMode />);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText("add")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText("add"));
|
||||||
|
const st = useDbViewerStore.getState();
|
||||||
|
expect(
|
||||||
|
st.tabs.some(
|
||||||
|
(t) => t.tabType === "object" && t.table === "add",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sidebarMode: does not render the inline detail pane", async () => {
|
||||||
|
vi.spyOn(commands, "getEnums").mockResolvedValue([
|
||||||
|
{ name: "role", schema: "public", labels: ["admin"] },
|
||||||
|
]);
|
||||||
|
render(<ObjectExplorerPage connectionId="c1" sidebarMode />);
|
||||||
|
fireEvent.click(screen.getByLabelText("Object type"));
|
||||||
|
fireEvent.click(screen.getByText("Enums"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText("role")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText("role"));
|
||||||
|
// clicking opened an object tab (no inline detail selected)
|
||||||
|
expect(
|
||||||
|
useDbViewerStore
|
||||||
|
.getState()
|
||||||
|
.tabs.some((t) => t.tabType === "object"),
|
||||||
|
).toBe(true);
|
||||||
|
// detail pane is gone; only the list remains
|
||||||
|
expect(
|
||||||
|
screen.queryByText("Select an enum to view details"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("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" />);
|
||||||
|
expect(screen.getByText("Sequences")).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import { ObjectSearchPalette } from "./ObjectSearchPalette";
|
||||||
|
import * as cmd from "../../lib/commands";
|
||||||
|
import type { ObjectSearchHit } from "../../lib/types";
|
||||||
|
|
||||||
|
vi.mock("../../lib/commands");
|
||||||
|
|
||||||
|
const mockSetObjectSearchOpen = vi.fn();
|
||||||
|
const mockSetCurrentSchema = vi.fn();
|
||||||
|
const mockSetSelectedObjectType = vi.fn();
|
||||||
|
const mockSetRequestedView = vi.fn();
|
||||||
|
const mockOpenTab = vi.fn();
|
||||||
|
|
||||||
|
const baseMockState = {
|
||||||
|
objectSearchOpen: true,
|
||||||
|
setObjectSearchOpen: mockSetObjectSearchOpen,
|
||||||
|
currentSchema: "public" as string | null,
|
||||||
|
setCurrentSchema: mockSetCurrentSchema,
|
||||||
|
setSelectedObjectType: mockSetSelectedObjectType,
|
||||||
|
setRequestedView: mockSetRequestedView,
|
||||||
|
openTab: mockOpenTab,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mockState: typeof baseMockState = { ...baseMockState };
|
||||||
|
|
||||||
|
vi.mock("../../stores/dbViewerStore", () => ({
|
||||||
|
useDbViewerStore: (selector: unknown) => {
|
||||||
|
return typeof selector === "function"
|
||||||
|
? (selector as (s: typeof mockState) => unknown)(mockState)
|
||||||
|
: mockState[selector as keyof typeof mockState];
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("ObjectSearchPalette", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockState = { ...baseMockState };
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when closed", () => {
|
||||||
|
mockState = { ...baseMockState, objectSearchOpen: false };
|
||||||
|
const { container } = render(<ObjectSearchPalette connectionId="c1" />);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("debounces and groups results by type", async () => {
|
||||||
|
const hits: ObjectSearchHit[] = [
|
||||||
|
{ name: "users", schema: "public", object_type: "TABLE" },
|
||||||
|
{ name: "get_user", schema: "public", object_type: "FUNCTION" },
|
||||||
|
];
|
||||||
|
vi.mocked(cmd.searchObjects).mockResolvedValue(hits);
|
||||||
|
|
||||||
|
render(<ObjectSearchPalette connectionId="c1" />);
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||||
|
target: { value: "user" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(cmd.searchObjects).toHaveBeenCalledWith("c1", "public", "user"),
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("TABLE")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("FUNCTION")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses currentSchema fallback when store value is null", async () => {
|
||||||
|
vi.mocked(cmd.searchObjects).mockResolvedValue([]);
|
||||||
|
mockState = { ...baseMockState, currentSchema: null };
|
||||||
|
|
||||||
|
render(<ObjectSearchPalette connectionId="c1" />);
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||||
|
target: { value: "x" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(cmd.searchObjects).toHaveBeenCalledWith("c1", "public", "x"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Esc closes the palette", () => {
|
||||||
|
render(<ObjectSearchPalette connectionId="c1" />);
|
||||||
|
fireEvent.keyDown(window, { key: "Escape" });
|
||||||
|
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("backdrop click closes the palette", () => {
|
||||||
|
render(<ObjectSearchPalette connectionId="c1" />);
|
||||||
|
const backdrop = screen.getByLabelText(/search objects/i).closest(
|
||||||
|
"div[class*='fixed inset-0']",
|
||||||
|
) as HTMLElement;
|
||||||
|
fireEvent.click(backdrop);
|
||||||
|
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selecting a table opens a tab and closes", async () => {
|
||||||
|
vi.mocked(cmd.searchObjects).mockResolvedValue([
|
||||||
|
{ name: "users", schema: "public", object_type: "TABLE" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<ObjectSearchPalette connectionId="c1" />);
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||||
|
target: { value: "users" },
|
||||||
|
});
|
||||||
|
await waitFor(() => screen.getByText("users"));
|
||||||
|
fireEvent.click(screen.getByText("users"));
|
||||||
|
|
||||||
|
expect(mockOpenTab).toHaveBeenCalledWith("public", "users");
|
||||||
|
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
|
||||||
|
expect(mockSetRequestedView).toHaveBeenCalledWith("db-viewer");
|
||||||
|
expect(mockSetCurrentSchema).not.toHaveBeenCalled();
|
||||||
|
expect(mockSetSelectedObjectType).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selecting a view opens a tab and closes", async () => {
|
||||||
|
vi.mocked(cmd.searchObjects).mockResolvedValue([
|
||||||
|
{ name: "active_users", schema: "public", object_type: "VIEW" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<ObjectSearchPalette connectionId="c1" />);
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||||
|
target: { value: "active" },
|
||||||
|
});
|
||||||
|
await waitFor(() => screen.getByText("active_users"));
|
||||||
|
fireEvent.click(screen.getByText("active_users"));
|
||||||
|
|
||||||
|
expect(mockOpenTab).toHaveBeenCalledWith("public", "active_users");
|
||||||
|
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
|
||||||
|
expect(mockSetRequestedView).toHaveBeenCalledWith("db-viewer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selecting a matview opens a tab and closes", async () => {
|
||||||
|
vi.mocked(cmd.searchObjects).mockResolvedValue([
|
||||||
|
{ name: "mv_users", schema: "public", object_type: "MATERIALIZED VIEW" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<ObjectSearchPalette connectionId="c1" />);
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||||
|
target: { value: "mv" },
|
||||||
|
});
|
||||||
|
await waitFor(() => screen.getByText("mv_users"));
|
||||||
|
fireEvent.click(screen.getByText("mv_users"));
|
||||||
|
|
||||||
|
expect(mockOpenTab).toHaveBeenCalledWith("public", "mv_users");
|
||||||
|
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["FUNCTION", "functions"],
|
||||||
|
["PROCEDURE", "procedures"],
|
||||||
|
["TRIGGER", "triggers"],
|
||||||
|
["SEQUENCE", "sequences"],
|
||||||
|
["ENUM", "enums"],
|
||||||
|
["EXTENSION", "extensions"],
|
||||||
|
["INDEX", "indexes"],
|
||||||
|
["CONSTRAINT", "constraints"],
|
||||||
|
] as const)(
|
||||||
|
"selecting a %s switches the objects view and closes",
|
||||||
|
async (objectType, mappedType) => {
|
||||||
|
vi.mocked(cmd.searchObjects).mockResolvedValue([
|
||||||
|
{ name: "item", schema: "app", object_type: objectType },
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<ObjectSearchPalette connectionId="c1" />);
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||||
|
target: { value: "item" },
|
||||||
|
});
|
||||||
|
await waitFor(() => screen.getByText("item"));
|
||||||
|
fireEvent.click(screen.getByText("item"));
|
||||||
|
|
||||||
|
expect(mockSetCurrentSchema).toHaveBeenCalledWith("app");
|
||||||
|
expect(mockSetSelectedObjectType).toHaveBeenCalledWith(mappedType);
|
||||||
|
expect(mockSetRequestedView).toHaveBeenCalledWith("objects");
|
||||||
|
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
|
||||||
|
expect(mockOpenTab).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("shows an empty state when no results match", async () => {
|
||||||
|
vi.mocked(cmd.searchObjects).mockResolvedValue([]);
|
||||||
|
render(<ObjectSearchPalette connectionId="c1" />);
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||||
|
target: { value: "nomatch" },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(cmd.searchObjects).toHaveBeenCalled());
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/no matches/i)).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears query and results when reopened", async () => {
|
||||||
|
vi.mocked(cmd.searchObjects).mockResolvedValue([
|
||||||
|
{ name: "users", schema: "public", object_type: "TABLE" },
|
||||||
|
]);
|
||||||
|
const { rerender } = render(<ObjectSearchPalette connectionId="c1" />);
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||||
|
target: { value: "users" },
|
||||||
|
});
|
||||||
|
await waitFor(() => screen.getByText("users"));
|
||||||
|
|
||||||
|
mockState = { ...baseMockState, objectSearchOpen: false };
|
||||||
|
rerender(<ObjectSearchPalette connectionId="c1" />);
|
||||||
|
|
||||||
|
mockState = { ...baseMockState, objectSearchOpen: true };
|
||||||
|
rerender(<ObjectSearchPalette connectionId="c1" />);
|
||||||
|
|
||||||
|
expect(screen.queryByText("users")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText(/search objects/i)).toHaveValue("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ArrowDown then Enter selects the second hit", async () => {
|
||||||
|
vi.mocked(cmd.searchObjects).mockResolvedValue([
|
||||||
|
{ name: "first", schema: "public", object_type: "FUNCTION" },
|
||||||
|
{ name: "second", schema: "public", object_type: "ENUM" },
|
||||||
|
]);
|
||||||
|
render(<ObjectSearchPalette connectionId="c1" />);
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||||
|
target: { value: "s" },
|
||||||
|
});
|
||||||
|
await waitFor(() => screen.getByText("second"));
|
||||||
|
|
||||||
|
fireEvent.keyDown(window, { key: "ArrowDown" });
|
||||||
|
fireEvent.keyDown(window, { key: "Enter" });
|
||||||
|
|
||||||
|
// highlight started on the first hit; one ArrowDown moved to the second
|
||||||
|
expect(mockSetSelectedObjectType).toHaveBeenCalledWith("enums");
|
||||||
|
expect(mockSetCurrentSchema).toHaveBeenCalledWith("public");
|
||||||
|
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ArrowUp wraps from the first hit to the last", async () => {
|
||||||
|
vi.mocked(cmd.searchObjects).mockResolvedValue([
|
||||||
|
{ name: "first", schema: "public", object_type: "FUNCTION" },
|
||||||
|
{ name: "last", schema: "public", object_type: "SEQUENCE" },
|
||||||
|
]);
|
||||||
|
render(<ObjectSearchPalette connectionId="c1" />);
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||||
|
target: { value: "s" },
|
||||||
|
});
|
||||||
|
await waitFor(() => screen.getByText("last"));
|
||||||
|
|
||||||
|
fireEvent.keyDown(window, { key: "ArrowUp" });
|
||||||
|
fireEvent.keyDown(window, { key: "Enter" });
|
||||||
|
|
||||||
|
expect(mockSetSelectedObjectType).toHaveBeenCalledWith("sequences");
|
||||||
|
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Enter with no results does nothing", async () => {
|
||||||
|
vi.mocked(cmd.searchObjects).mockResolvedValue([]);
|
||||||
|
render(<ObjectSearchPalette connectionId="c1" />);
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||||
|
target: { value: "nope" },
|
||||||
|
});
|
||||||
|
await waitFor(() => screen.getByText(/no matches/i));
|
||||||
|
|
||||||
|
fireEvent.keyDown(window, { key: "Enter" });
|
||||||
|
|
||||||
|
expect(mockOpenTab).not.toHaveBeenCalled();
|
||||||
|
expect(mockSetSelectedObjectType).not.toHaveBeenCalled();
|
||||||
|
expect(mockSetObjectSearchOpen).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||||
|
import * as cmd from "../../lib/commands";
|
||||||
|
import type { ObjectSearchHit, ObjectType } from "../../lib/types";
|
||||||
|
|
||||||
|
const TYPE_TO_OBJECTS: Record<string, ObjectType> = {
|
||||||
|
FUNCTION: "functions",
|
||||||
|
PROCEDURE: "procedures",
|
||||||
|
TRIGGER: "triggers",
|
||||||
|
SEQUENCE: "sequences",
|
||||||
|
ENUM: "enums",
|
||||||
|
EXTENSION: "extensions",
|
||||||
|
INDEX: "indexes",
|
||||||
|
CONSTRAINT: "constraints",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ObjectSearchPaletteProps {
|
||||||
|
connectionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ObjectSearchPalette({ connectionId }: ObjectSearchPaletteProps) {
|
||||||
|
const open = useDbViewerStore((s) => s.objectSearchOpen);
|
||||||
|
const setOpen = useDbViewerStore((s) => s.setObjectSearchOpen);
|
||||||
|
const storeSchema = useDbViewerStore((s) => s.currentSchema);
|
||||||
|
const currentSchema = storeSchema ?? "public";
|
||||||
|
const openTab = useDbViewerStore((s) => s.openTab);
|
||||||
|
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
|
||||||
|
const setSelectedObjectType = useDbViewerStore((s) => s.setSelectedObjectType);
|
||||||
|
const setRequestedView = useDbViewerStore((s) => s.setRequestedView);
|
||||||
|
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [hits, setHits] = useState<ObjectSearchHit[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
// Clear the transient state whenever the palette is closed so it reopens
|
||||||
|
// with an empty search, no stale results, and the highlight reset.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setQuery("");
|
||||||
|
setHits([]);
|
||||||
|
setHighlightedIndex(0);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Debounced search against the current schema.
|
||||||
|
useEffect(() => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query.trim()) {
|
||||||
|
setHits([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
timerRef.current = setTimeout(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const results = await cmd.searchObjects(
|
||||||
|
connectionId,
|
||||||
|
currentSchema,
|
||||||
|
query,
|
||||||
|
);
|
||||||
|
setHits(results);
|
||||||
|
setHighlightedIndex(0);
|
||||||
|
} catch {
|
||||||
|
setHits([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [query, connectionId, currentSchema]);
|
||||||
|
|
||||||
|
// Select: open a table/view tab (switching to the DB viewer view so the
|
||||||
|
// tab is actually visible), or jump to the Objects view with the type
|
||||||
|
// preselected. The view switch goes through the store's requestedView
|
||||||
|
// mechanism — currentView is local state in DbViewerScreen, which watches
|
||||||
|
// requestedView and clears it after navigating.
|
||||||
|
const handleSelect = (hit: ObjectSearchHit) => {
|
||||||
|
if (
|
||||||
|
hit.object_type === "TABLE" ||
|
||||||
|
hit.object_type === "VIEW" ||
|
||||||
|
hit.object_type === "MATERIALIZED VIEW"
|
||||||
|
) {
|
||||||
|
setRequestedView("db-viewer");
|
||||||
|
openTab(hit.schema, hit.name);
|
||||||
|
} else {
|
||||||
|
setCurrentSchema(hit.schema);
|
||||||
|
setSelectedObjectType(TYPE_TO_OBJECTS[hit.object_type] ?? "functions");
|
||||||
|
setRequestedView("objects");
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keyboard navigation: ↓/↑ move the highlight (wrapping), Enter picks,
|
||||||
|
// Esc closes. Registered on window so it works even after the input blurs.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (!open) return;
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hits.length === 0) return;
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightedIndex((i) => (i + 1) % hits.length);
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightedIndex((i) => (i - 1 + hits.length) % hits.length);
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
const hit = hits[highlightedIndex];
|
||||||
|
if (hit) handleSelect(hit);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [open, hits, highlightedIndex]);
|
||||||
|
|
||||||
|
// Scroll the highlighted row into view inside the results list.
|
||||||
|
// `?.()` guards environments without scrollIntoView (jsdom) so the ref
|
||||||
|
// callback never throws during commit.
|
||||||
|
const onHighlightedRef = (el: HTMLButtonElement | null) => {
|
||||||
|
el?.scrollIntoView?.({ block: "nearest" });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const grouped = hits.reduce<Record<string, ObjectSearchHit[]>>((acc, hit) => {
|
||||||
|
const list = acc[hit.object_type] ?? [];
|
||||||
|
list.push(hit);
|
||||||
|
acc[hit.object_type] = list;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Flattened index → hit, so keyboard navigation matches the visible order.
|
||||||
|
// (Highlight index is tracked against the flat result list; grouped output
|
||||||
|
// below increments it in render order.)
|
||||||
|
let flatIndex = -1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 pt-24"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Search objects"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-[520px] max-h-[60vh] overflow-auto rounded-xl border border-border bg-surface shadow-xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||||
|
<Search size={14} className="text-text-muted" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search objects in current schema…"
|
||||||
|
className="flex-1 bg-transparent text-sm text-text outline-none placeholder:text-text-muted"
|
||||||
|
/>
|
||||||
|
{loading && (
|
||||||
|
<span className="text-xs text-text-muted">loading</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Object.entries(grouped).map(([type, list]) => (
|
||||||
|
<div key={type}>
|
||||||
|
<div className="px-3 py-1 text-[10px] uppercase text-text-subtle">
|
||||||
|
{type}
|
||||||
|
</div>
|
||||||
|
{list.map((hit) => {
|
||||||
|
flatIndex += 1;
|
||||||
|
const index = flatIndex;
|
||||||
|
const highlighted = index === highlightedIndex;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${hit.object_type}:${hit.schema}:${hit.name}`}
|
||||||
|
type="button"
|
||||||
|
ref={highlighted ? onHighlightedRef : undefined}
|
||||||
|
onClick={() => handleSelect(hit)}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(index)}
|
||||||
|
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm ${
|
||||||
|
highlighted
|
||||||
|
? "bg-surface-raised text-text"
|
||||||
|
: "text-text-muted hover:text-text"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="truncate">{hit.name}</span>
|
||||||
|
<span className="text-[10px] text-text-subtle">
|
||||||
|
{hit.schema}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!loading && query.trim() && hits.length === 0 && (
|
||||||
|
<div className="px-3 py-4 text-sm text-text-muted">No matches</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { PasswordPromptDialog } from "./PasswordPromptDialog";
|
||||||
|
|
||||||
|
describe("PasswordPromptDialog", () => {
|
||||||
|
it("renders nothing when closed", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<PasswordPromptDialog
|
||||||
|
open={false}
|
||||||
|
connectionName="n"
|
||||||
|
onConnect={() => {}}
|
||||||
|
onCancel={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the connection name and a password field when open", () => {
|
||||||
|
render(
|
||||||
|
<PasswordPromptDialog
|
||||||
|
open={true}
|
||||||
|
connectionName="Prod DB"
|
||||||
|
onConnect={() => {}}
|
||||||
|
onCancel={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/Prod DB/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onConnect with the typed value", () => {
|
||||||
|
const onConnect = vi.fn();
|
||||||
|
render(
|
||||||
|
<PasswordPromptDialog
|
||||||
|
open={true}
|
||||||
|
connectionName="X"
|
||||||
|
onConnect={onConnect}
|
||||||
|
onCancel={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/password/i), {
|
||||||
|
target: { value: "p@ss" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /connect/i }));
|
||||||
|
expect(onConnect).toHaveBeenCalledWith("p@ss");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onCancel on cancel", () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(
|
||||||
|
<PasswordPromptDialog
|
||||||
|
open={true}
|
||||||
|
connectionName="X"
|
||||||
|
onConnect={() => {}}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
|
||||||
|
expect(onCancel).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
connectionName: string;
|
||||||
|
onConnect: (password: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PasswordPromptDialog({
|
||||||
|
open,
|
||||||
|
connectionName,
|
||||||
|
onConnect,
|
||||||
|
onCancel,
|
||||||
|
}: Props) {
|
||||||
|
const [pw, setPw] = useState("");
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<AnimatedModal open={open} onClose={onCancel}>
|
||||||
|
<div className="p-5 w-80">
|
||||||
|
<h2 className="text-sm font-medium text-text mb-1">Enter password</h2>
|
||||||
|
<p className="text-xs text-text-muted mb-3">
|
||||||
|
“{connectionName}” has keychain disabled. Enter the password for this
|
||||||
|
session (it will not be saved).
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoFocus
|
||||||
|
aria-label="Password"
|
||||||
|
placeholder="Password"
|
||||||
|
value={pw}
|
||||||
|
onChange={(e) => setPw(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && pw) onConnect(pw);
|
||||||
|
}}
|
||||||
|
className="w-full rounded-lg border-border bg-surface px-3 py-2 text-sm text-text mb-3"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-lg text-text-muted hover:bg-surface"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => pw && onConnect(pw)}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-lg bg-accent text-white"
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AnimatedModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -50,7 +50,7 @@ export function RestoreDialog({ open, connectionId, onClose }: RestoreDialogProp
|
|||||||
setConfirmed(false);
|
setConfirmed(false);
|
||||||
detectPgTools()
|
detectPgTools()
|
||||||
.then((status) => setToolStatus(status))
|
.then((status) => setToolStatus(status))
|
||||||
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null }))
|
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null, pg_dump_source: null, pg_restore_source: null }))
|
||||||
.finally(() => setCheckingTools(false));
|
.finally(() => setCheckingTools(false));
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
|||||||
@@ -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 { BackupProgress } from "./BackupProgress";
|
||||||
import { useBackupStore } from "../../stores/backupStore";
|
import { useBackupStore } from "../../stores/backupStore";
|
||||||
import { useNotificationStore } from "../../stores/notificationStore";
|
import { useNotificationStore } from "../../stores/notificationStore";
|
||||||
import { detectPgTools, pgRestore, getSchemas } from "../../lib/commands";
|
import { useConnectionStore } from "../../stores/connectionStore";
|
||||||
import type { PgToolStatus } from "../../lib/types";
|
import {
|
||||||
|
detectPgTools,
|
||||||
|
pgRestore,
|
||||||
|
getSchemas,
|
||||||
|
detectMysqlTools,
|
||||||
|
mysqlRestore,
|
||||||
|
sqliteRestore,
|
||||||
|
} from "../../lib/commands";
|
||||||
|
import type { PgToolStatus, MySqlToolStatus, BackupJob } from "../../lib/types";
|
||||||
|
|
||||||
interface RestorePageProps {
|
interface RestorePageProps {
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLATFORM_INSTALL_INSTRUCTIONS: Record<string, string> = {
|
const PG_INSTALL_INSTRUCTIONS: Record<string, string> = {
|
||||||
darwin: "brew install libpq",
|
darwin: "brew install libpq",
|
||||||
linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch",
|
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.",
|
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 =
|
const platform =
|
||||||
typeof navigator !== "undefined"
|
typeof navigator !== "undefined"
|
||||||
? navigator.platform.toLowerCase()
|
? navigator.platform.toLowerCase()
|
||||||
: "";
|
: "";
|
||||||
if (platform.includes("mac") || platform.includes("darwin"))
|
if (platform.includes("mac") || platform.includes("darwin"))
|
||||||
return PLATFORM_INSTALL_INSTRUCTIONS.darwin;
|
return map.darwin;
|
||||||
if (platform.includes("linux")) return PLATFORM_INSTALL_INSTRUCTIONS.linux;
|
if (platform.includes("linux")) return map.linux;
|
||||||
if (platform.includes("win")) return PLATFORM_INSTALL_INSTRUCTIONS.win32;
|
if (platform.includes("win")) return map.win32;
|
||||||
return PLATFORM_INSTALL_INSTRUCTIONS.linux;
|
return map.linux;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RestorePage({ connectionId }: RestorePageProps) {
|
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 [filePath, setFilePath] = useState("");
|
||||||
const [format, setFormat] = useState("custom");
|
const [format, setFormat] = useState("custom");
|
||||||
const [clean, setClean] = useState(true);
|
const [clean, setClean] = useState(true);
|
||||||
const [schema, setSchema] = useState("");
|
const [schema, setSchema] = useState("");
|
||||||
const [confirmed, setConfirmed] = useState(false);
|
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 [checkingTools, setCheckingTools] = useState(true);
|
||||||
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
|
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
|
||||||
|
|
||||||
@@ -68,14 +92,21 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCheckingTools(true);
|
setCheckingTools(true);
|
||||||
setConfirmed(false);
|
setConfirmed(false);
|
||||||
|
setPgToolStatus(null);
|
||||||
|
setMysqlToolStatus(null);
|
||||||
|
setAvailableSchemas([]);
|
||||||
|
|
||||||
|
if (isPg) {
|
||||||
detectPgTools()
|
detectPgTools()
|
||||||
.then((status) => setToolStatus(status))
|
.then((status) => setPgToolStatus(status))
|
||||||
.catch(() =>
|
.catch(() =>
|
||||||
setToolStatus({
|
setPgToolStatus({
|
||||||
pg_dump_found: false,
|
pg_dump_found: false,
|
||||||
pg_restore_found: false,
|
pg_restore_found: false,
|
||||||
pg_dump_version: null,
|
pg_dump_version: null,
|
||||||
pg_restore_version: null,
|
pg_restore_version: null,
|
||||||
|
pg_dump_source: null,
|
||||||
|
pg_restore_source: null,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.finally(() => setCheckingTools(false));
|
.finally(() => setCheckingTools(false));
|
||||||
@@ -83,46 +114,130 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
|||||||
getSchemas(connectionId)
|
getSchemas(connectionId)
|
||||||
.then((schemas) => setAvailableSchemas(schemas))
|
.then((schemas) => setAvailableSchemas(schemas))
|
||||||
.catch(() => setAvailableSchemas([]));
|
.catch(() => setAvailableSchemas([]));
|
||||||
}, [connectionId]);
|
} else if (isMysql) {
|
||||||
|
detectMysqlTools()
|
||||||
|
.then((status) => setMysqlToolStatus(status))
|
||||||
|
.catch(() =>
|
||||||
|
setMysqlToolStatus({
|
||||||
|
mysqldumpFound: false,
|
||||||
|
mysqlFound: false,
|
||||||
|
mysqldumpVersion: null,
|
||||||
|
mysqlVersion: null,
|
||||||
|
mysqldumpSource: null,
|
||||||
|
mysqlSource: null,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.finally(() => setCheckingTools(false));
|
||||||
|
|
||||||
|
getSchemas(connectionId)
|
||||||
|
.then((schemas) => setAvailableSchemas(schemas))
|
||||||
|
.catch(() => setAvailableSchemas([]));
|
||||||
|
} else {
|
||||||
|
setCheckingTools(false);
|
||||||
|
}
|
||||||
|
}, [connectionId, isPg, isMysql]);
|
||||||
|
|
||||||
const handlePickFile = useCallback(async () => {
|
const handlePickFile = useCallback(async () => {
|
||||||
|
const extensions = isPg
|
||||||
|
? ["dump", "sql", "tar", "custom", "gz"]
|
||||||
|
: isMysql
|
||||||
|
? ["sql"]
|
||||||
|
: ["db", "sqlite", "sql"];
|
||||||
|
|
||||||
const picked = await open({
|
const picked = await open({
|
||||||
multiple: false,
|
multiple: false,
|
||||||
filters: [
|
filters: [
|
||||||
{
|
{
|
||||||
name: "Backup Files",
|
name: "Backup Files",
|
||||||
extensions: ["dump", "sql", "tar", "custom", "gz"],
|
extensions,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
if (picked && typeof picked === "string") setFilePath(picked);
|
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 () => {
|
const handleStartRestore = useCallback(async () => {
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
notify("Please select a file path", "error");
|
notify("Please select a file path", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const jobId = `restore-${Date.now()}`;
|
if (isMysql && !database) {
|
||||||
startJob(jobId, "restore");
|
notify("MySQL connection has no database selected", "error");
|
||||||
pendingJobRef.current = jobId;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
await runWithProgress("restore", () => {
|
||||||
await pgRestore(connectionId, {
|
if (isPg) {
|
||||||
|
return pgRestore(connectionId, {
|
||||||
format,
|
format,
|
||||||
filePath,
|
filePath,
|
||||||
clean,
|
clean,
|
||||||
schema: schema || undefined,
|
schema: schema || undefined,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
|
||||||
useBackupStore.getState().failJob(jobId, msg);
|
|
||||||
}
|
}
|
||||||
}, [filePath, format, clean, schema, connectionId, startJob, notify]);
|
if (isMysql) {
|
||||||
|
return mysqlRestore(connectionId, {
|
||||||
|
database: database!,
|
||||||
|
filePath,
|
||||||
|
clean,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return sqliteRestore(connectionId, { filePath, clean });
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
filePath,
|
||||||
|
database,
|
||||||
|
isPg,
|
||||||
|
isMysql,
|
||||||
|
isSqlite,
|
||||||
|
format,
|
||||||
|
clean,
|
||||||
|
schema,
|
||||||
|
connectionId,
|
||||||
|
notify,
|
||||||
|
runWithProgress,
|
||||||
|
]);
|
||||||
|
|
||||||
const toolsMissing = toolStatus && !toolStatus.pg_restore_found;
|
const 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 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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Toolbar header */}
|
{/* Toolbar header */}
|
||||||
@@ -130,7 +245,7 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
|||||||
<Upload size={14} className="text-accent" />
|
<Upload size={14} className="text-accent" />
|
||||||
<span className="text-xs font-medium text-text">Restore</span>
|
<span className="text-xs font-medium text-text">Restore</span>
|
||||||
<span className="text-[11px] text-text-muted">
|
<span className="text-[11px] text-text-muted">
|
||||||
Restore a database from a backup file
|
{headerDescription}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -138,25 +253,29 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
|||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
|
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
|
||||||
{/* Tool check */}
|
{/* Tool check */}
|
||||||
{checkingTools && (
|
{checkingTools && checkingMessage && (
|
||||||
<div className="glass p-4 text-center">
|
<div className="glass p-4 text-center">
|
||||||
<p className="text-sm text-text-muted">
|
<p className="text-sm text-text-muted">
|
||||||
Checking for pg_restore...
|
{checkingMessage}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{toolsMissing && (
|
{toolsMissing && !toolsBundled && (
|
||||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
|
<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">
|
<p className="text-amber-300 text-sm font-semibold">
|
||||||
pg_restore not found
|
{isPg ? "pg_restore not found" : "mysql client not found"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-amber-200/80 text-xs leading-relaxed">
|
<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:
|
backup/restore operations. Install them using:
|
||||||
</p>
|
</p>
|
||||||
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">
|
<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>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -165,7 +284,8 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
|||||||
<>
|
<>
|
||||||
{/* Configuration card */}
|
{/* Configuration card */}
|
||||||
<div className="p-5 space-y-5">
|
<div className="p-5 space-y-5">
|
||||||
{/* Format */}
|
{/* Format (PostgreSQL only) */}
|
||||||
|
{isPg && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||||
Format
|
Format
|
||||||
@@ -187,6 +307,7 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Backup file */}
|
{/* Backup file */}
|
||||||
<div className="space-y-1 w-full">
|
<div className="space-y-1 w-full">
|
||||||
@@ -215,6 +336,7 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Schema (optional) */}
|
{/* Schema (optional) */}
|
||||||
|
{(isPg || isMysql) && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||||
Schema{" "}
|
Schema{" "}
|
||||||
@@ -237,11 +359,13 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Clean toggle */}
|
{/* Clean toggle */}
|
||||||
|
{(isPg || isMysql || isSqlite) && (
|
||||||
<label
|
<label
|
||||||
className={`flex items-center gap-2.5 cursor-pointer group ${
|
className={`flex items-center gap-2.5 cursor-pointer group ${
|
||||||
format === "plain"
|
isPg && format === "plain"
|
||||||
? "opacity-40 pointer-events-none"
|
? "opacity-40 pointer-events-none"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
@@ -252,7 +376,7 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setClean(e.target.checked)
|
setClean(e.target.checked)
|
||||||
}
|
}
|
||||||
disabled={format === "plain"}
|
disabled={isPg && format === "plain"}
|
||||||
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer disabled:cursor-not-allowed"
|
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">
|
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
|
||||||
@@ -262,7 +386,8 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
|||||||
</code>
|
</code>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
{format === "plain" && (
|
)}
|
||||||
|
{isPg && format === "plain" && (
|
||||||
<p className="text-[11px] text-text-muted/70 -mt-3">
|
<p className="text-[11px] text-text-muted/70 -mt-3">
|
||||||
Plain SQL restores run via psql and don't
|
Plain SQL restores run via psql and don't
|
||||||
support DROP-before-CREATE. Use Custom
|
support DROP-before-CREATE. Use Custom
|
||||||
@@ -322,4 +447,3 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import * as cmd from "../../lib/commands";
|
||||||
|
import { SchemaMenu } from "./SchemaMenu";
|
||||||
|
|
||||||
|
vi.mock("../../lib/commands");
|
||||||
|
|
||||||
|
describe("SchemaMenu", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(cmd.createSchema).mockReset();
|
||||||
|
vi.mocked(cmd.renameSchema).mockReset();
|
||||||
|
vi.mocked(cmd.dropSchema).mockReset();
|
||||||
|
vi.mocked(cmd.getObjectDependencies).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("create happy path calls createSchema then refreshTree", async () => {
|
||||||
|
vi.mocked(cmd.createSchema).mockResolvedValue(undefined);
|
||||||
|
const refresh = vi.fn();
|
||||||
|
render(<SchemaMenu connectionId="c1" onRefresh={refresh} />);
|
||||||
|
fireEvent.click(screen.getByLabelText(/new schema/i));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/schema name/i), { target: { value: "myschema" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /create/i }));
|
||||||
|
await waitFor(() => expect(cmd.createSchema).toHaveBeenCalledWith("c1", "myschema"));
|
||||||
|
await waitFor(() => expect(refresh).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drop non-empty requires cascade checkbox + typed confirm", async () => {
|
||||||
|
vi.mocked(cmd.getObjectDependencies).mockResolvedValue([{ deptype: "n", class: "pg_class", name: "users" }]);
|
||||||
|
vi.mocked(cmd.dropSchema).mockResolvedValue(undefined);
|
||||||
|
const refresh = vi.fn();
|
||||||
|
render(<SchemaMenu connectionId="c1" schema="s" onRefresh={refresh} />);
|
||||||
|
fireEvent.click(screen.getByLabelText(/schema menu/i));
|
||||||
|
fireEvent.click(screen.getByText(/drop/i));
|
||||||
|
await waitFor(() => expect(cmd.getObjectDependencies).toHaveBeenCalledWith("c1", "s", "schema", "s"));
|
||||||
|
await waitFor(() => expect(screen.getByText("users")).toBeTruthy());
|
||||||
|
fireEvent.click(screen.getByRole("checkbox"));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/type the schema name/i), { target: { value: "s" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /drop schema/i }));
|
||||||
|
await waitFor(() => expect(cmd.dropSchema).toHaveBeenCalledWith("c1", "s", true));
|
||||||
|
await waitFor(() => expect(refresh).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { Plus, MoreVertical } from "lucide-react";
|
||||||
|
import * as cmd from "../../lib/commands";
|
||||||
|
import type { DependencyInfo } from "../../lib/types";
|
||||||
|
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||||
|
import { Button } from "../ui/Button";
|
||||||
|
import { DependencyDialog } from "./DependencyDialog";
|
||||||
|
|
||||||
|
interface SchemaMenuProps {
|
||||||
|
connectionId: string;
|
||||||
|
schema?: string;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SchemaMenu({ connectionId, schema, onRefresh }: SchemaMenuProps) {
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
|
||||||
|
const [renaming, setRenaming] = useState(false);
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
|
||||||
|
const [dropOpen, setDropOpen] = useState(false);
|
||||||
|
const [deps, setDeps] = useState<DependencyInfo[]>([]);
|
||||||
|
const [typed, setTyped] = useState("");
|
||||||
|
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menuOpen) return;
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
setMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClick);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
|
}, [menuOpen]);
|
||||||
|
|
||||||
|
const resetErrors = () => setErr(null);
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
resetErrors();
|
||||||
|
try {
|
||||||
|
await cmd.createSchema(connectionId, name);
|
||||||
|
setCreating(false);
|
||||||
|
setName("");
|
||||||
|
onRefresh();
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error)?.message ?? String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rename = async () => {
|
||||||
|
resetErrors();
|
||||||
|
if (!schema) return;
|
||||||
|
try {
|
||||||
|
await cmd.renameSchema(connectionId, schema, newName);
|
||||||
|
setRenaming(false);
|
||||||
|
setNewName("");
|
||||||
|
onRefresh();
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error)?.message ?? String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startDrop = async () => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
if (!schema) return;
|
||||||
|
try {
|
||||||
|
const d = await cmd.getObjectDependencies(connectionId, schema, "schema", schema);
|
||||||
|
setDeps(d);
|
||||||
|
setDropOpen(true);
|
||||||
|
setTyped("");
|
||||||
|
setErr(null);
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error)?.message ?? String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDrop = async (cascade: boolean) => {
|
||||||
|
if (!schema) return;
|
||||||
|
try {
|
||||||
|
await cmd.dropSchema(connectionId, schema, cascade);
|
||||||
|
setDropOpen(false);
|
||||||
|
setTyped("");
|
||||||
|
setDeps([]);
|
||||||
|
onRefresh();
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error)?.message ?? String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasDeps = deps.length > 0;
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"w-full bg-surface border border-border rounded-md px-3 py-2 text-sm text-text placeholder:text-text-muted outline-none focus:border-accent/50 transition-colors";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
aria-label="New schema"
|
||||||
|
onClick={() => {
|
||||||
|
setCreating(true);
|
||||||
|
setName("");
|
||||||
|
setErr(null);
|
||||||
|
}}
|
||||||
|
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{schema && (
|
||||||
|
<button
|
||||||
|
aria-label="Schema menu"
|
||||||
|
onClick={() => setMenuOpen((v) => !v)}
|
||||||
|
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
|
||||||
|
>
|
||||||
|
<MoreVertical size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{menuOpen && (
|
||||||
|
<div className="absolute right-0 top-8 mt-1 rounded-xl bg-surface border border-border py-1 z-20 min-w-[160px] shadow-lg">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
setRenaming(true);
|
||||||
|
setNewName("");
|
||||||
|
setErr(null);
|
||||||
|
}}
|
||||||
|
className="flex items-center px-3 py-2 text-sm w-full text-left transition-colors cursor-pointer text-text-muted hover:text-text hover:bg-surface-raised"
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={startDrop}
|
||||||
|
className="flex items-center px-3 py-2 text-sm w-full text-left transition-colors cursor-pointer text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||||||
|
>
|
||||||
|
Drop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* New Schema */}
|
||||||
|
<AnimatedModal open={creating} onClose={() => setCreating(false)}>
|
||||||
|
<div className="w-80">
|
||||||
|
<h3 className="font-heading text-text text-lg mb-3">New Schema</h3>
|
||||||
|
<p className="text-sm text-text-muted mb-3">Create a new schema in the current database.</p>
|
||||||
|
<input
|
||||||
|
placeholder="Schema name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
{err && <p className="text-red-400 text-xs mt-2">{err}</p>}
|
||||||
|
<div className="flex justify-end gap-2 mt-5">
|
||||||
|
<Button variant="ghost" onClick={() => setCreating(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={create}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AnimatedModal>
|
||||||
|
|
||||||
|
{/* Rename Schema */}
|
||||||
|
<AnimatedModal open={renaming} onClose={() => setRenaming(false)}>
|
||||||
|
<div className="w-80">
|
||||||
|
<h3 className="font-heading text-text text-lg mb-3">Rename {schema}</h3>
|
||||||
|
<p className="text-sm text-text-muted mb-3">Enter the new name for this schema.</p>
|
||||||
|
<input
|
||||||
|
placeholder="New name"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
{err && <p className="text-red-400 text-xs mt-2">{err}</p>}
|
||||||
|
<div className="flex justify-end gap-2 mt-5">
|
||||||
|
<Button variant="ghost" onClick={() => setRenaming(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={rename}>
|
||||||
|
Rename
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AnimatedModal>
|
||||||
|
|
||||||
|
{/* Dependency warning */}
|
||||||
|
{dropOpen && (
|
||||||
|
<DependencyDialog
|
||||||
|
open={dropOpen}
|
||||||
|
deps={deps}
|
||||||
|
onCancel={() => {
|
||||||
|
setDropOpen(false);
|
||||||
|
setTyped("");
|
||||||
|
setDeps([]);
|
||||||
|
setErr(null);
|
||||||
|
}}
|
||||||
|
onProceed={() => {}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Typed-name confirmation for CASCADE drop */}
|
||||||
|
{dropOpen && hasDeps && (
|
||||||
|
<AnimatedModal
|
||||||
|
open={dropOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setDropOpen(false);
|
||||||
|
setTyped("");
|
||||||
|
setErr(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-80">
|
||||||
|
<h3 className="font-heading text-text text-lg mb-3">Drop Schema: {schema}</h3>
|
||||||
|
<p className="text-sm text-text-muted mb-3">
|
||||||
|
Type the schema name to confirm the CASCADE drop.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
placeholder="Type the schema name"
|
||||||
|
value={typed}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTyped(e.target.value);
|
||||||
|
if (err) setErr(null);
|
||||||
|
}}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
{err && <p className="text-red-400 text-xs mt-2">{err}</p>}
|
||||||
|
<div className="flex justify-end gap-2 mt-5">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setDropOpen(false);
|
||||||
|
setTyped("");
|
||||||
|
setErr(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => {
|
||||||
|
if (typed !== schema) {
|
||||||
|
setErr("Name does not match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void confirmDrop(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Drop Schema
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AnimatedModal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty-schema confirmation (no deps) */}
|
||||||
|
{dropOpen && !hasDeps && (
|
||||||
|
<AnimatedModal
|
||||||
|
open={dropOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setDropOpen(false);
|
||||||
|
setErr(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-80">
|
||||||
|
<h3 className="font-heading text-text text-lg mb-3">Drop Schema: {schema}</h3>
|
||||||
|
<p className="text-sm text-text-muted mb-3">No dependencies — drop this empty schema?</p>
|
||||||
|
{err && <p className="text-red-400 text-xs mt-2">{err}</p>}
|
||||||
|
<div className="flex justify-end gap-2 mt-5">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setDropOpen(false);
|
||||||
|
setErr(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={() => void confirmDrop(false)}>
|
||||||
|
Drop Schema
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AnimatedModal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ export function SyncDialog({ open, onClose }: SyncDialogProps) {
|
|||||||
setConfirmed(false);
|
setConfirmed(false);
|
||||||
detectPgTools()
|
detectPgTools()
|
||||||
.then((status) => setToolStatus(status))
|
.then((status) => setToolStatus(status))
|
||||||
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null }))
|
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null, pg_dump_source: null, pg_restore_source: null }))
|
||||||
.finally(() => setCheckingTools(false));
|
.finally(() => setCheckingTools(false));
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
@@ -58,6 +58,7 @@ export function SyncDialog({ open, onClose }: SyncDialogProps) {
|
|||||||
targetConnectionId,
|
targetConnectionId,
|
||||||
schema: schema || undefined,
|
schema: schema || undefined,
|
||||||
tables: undefined,
|
tables: undefined,
|
||||||
|
dbType: "postgresql",
|
||||||
});
|
});
|
||||||
notify("Sync completed successfully", "success");
|
notify("Sync completed successfully", "success");
|
||||||
onClose();
|
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 { useBackupStore } from "../../stores/backupStore";
|
||||||
import { useConnectionStore } from "../../stores/connectionStore";
|
import { useConnectionStore } from "../../stores/connectionStore";
|
||||||
import { useNotificationStore } from "../../stores/notificationStore";
|
import { useNotificationStore } from "../../stores/notificationStore";
|
||||||
import { detectPgTools, dbSync, getSchemas } from "../../lib/commands";
|
import {
|
||||||
import type { PgToolStatus } from "../../lib/types";
|
detectPgTools,
|
||||||
|
dbSync,
|
||||||
|
getSchemas,
|
||||||
|
detectMysqlTools,
|
||||||
|
mysqlSync,
|
||||||
|
sqliteSync,
|
||||||
|
} from "../../lib/commands";
|
||||||
|
import type { PgToolStatus, MySqlToolStatus, BackupJob, DbType } from "../../lib/types";
|
||||||
|
|
||||||
export function SyncPage() {
|
export function SyncPage() {
|
||||||
const [sourceConnectionId, setSourceConnectionId] = useState("");
|
const [sourceConnectionId, setSourceConnectionId] = useState("");
|
||||||
const [targetConnectionId, setTargetConnectionId] = useState("");
|
const [targetConnectionId, setTargetConnectionId] = useState("");
|
||||||
const [schema, setSchema] = useState("");
|
const [schema, setSchema] = useState("");
|
||||||
const [confirmed, setConfirmed] = useState(false);
|
const [confirmed, setConfirmed] = useState(false);
|
||||||
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null);
|
const [pgToolStatus, setPgToolStatus] = useState<PgToolStatus | null>(null);
|
||||||
const [checkingTools, setCheckingTools] = useState(true);
|
const [mysqlToolStatus, setMysqlToolStatus] = useState<MySqlToolStatus | null>(null);
|
||||||
|
const [checkingTools, setCheckingTools] = useState(false);
|
||||||
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
|
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
|
||||||
|
|
||||||
const connections = useConnectionStore((s) => s.connections);
|
const connections = useConnectionStore((s) => s.connections);
|
||||||
@@ -27,6 +35,13 @@ export function SyncPage() {
|
|||||||
const isRunning = activeJob?.status === "running";
|
const isRunning = activeJob?.status === "running";
|
||||||
const pendingJobRef = useRef<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!pendingJobRef.current || !activeJob) return;
|
if (!pendingJobRef.current || !activeJob) return;
|
||||||
if (activeJob.id !== pendingJobRef.current) return;
|
if (activeJob.id !== pendingJobRef.current) return;
|
||||||
@@ -43,21 +58,44 @@ export function SyncPage() {
|
|||||||
}
|
}
|
||||||
}, [activeJob, notify]);
|
}, [activeJob, notify]);
|
||||||
|
|
||||||
|
// Tool detection: depends on the selected source connection's DB type
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCheckingTools(true);
|
setPgToolStatus(null);
|
||||||
|
setMysqlToolStatus(null);
|
||||||
setConfirmed(false);
|
setConfirmed(false);
|
||||||
|
|
||||||
|
if (isPg) {
|
||||||
|
setCheckingTools(true);
|
||||||
detectPgTools()
|
detectPgTools()
|
||||||
.then((status) => setToolStatus(status))
|
.then((status) => setPgToolStatus(status))
|
||||||
.catch(() =>
|
.catch(() =>
|
||||||
setToolStatus({
|
setPgToolStatus({
|
||||||
pg_dump_found: false,
|
pg_dump_found: false,
|
||||||
pg_restore_found: false,
|
pg_restore_found: false,
|
||||||
pg_dump_version: null,
|
pg_dump_version: null,
|
||||||
pg_restore_version: null,
|
pg_restore_version: null,
|
||||||
|
pg_dump_source: null,
|
||||||
|
pg_restore_source: null,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.finally(() => setCheckingTools(false));
|
.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
|
// Fetch schemas from the source connection when it changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -66,10 +104,31 @@ export function SyncPage() {
|
|||||||
setSchema("");
|
setSchema("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!isPg && !isMysql) {
|
||||||
|
setAvailableSchemas([]);
|
||||||
|
setSchema("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
getSchemas(sourceConnectionId)
|
getSchemas(sourceConnectionId)
|
||||||
.then((schemas) => setAvailableSchemas(schemas))
|
.then((schemas) => setAvailableSchemas(schemas))
|
||||||
.catch(() => setAvailableSchemas([]));
|
.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 () => {
|
const handleStartSync = useCallback(async () => {
|
||||||
if (!sourceConnectionId || !targetConnectionId) {
|
if (!sourceConnectionId || !targetConnectionId) {
|
||||||
@@ -80,32 +139,61 @@ export function SyncPage() {
|
|||||||
notify("Source and target must be different", "error");
|
notify("Source and target must be different", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const jobId = `sync-${Date.now()}`;
|
if (!dbType) {
|
||||||
startJob(jobId, "sync");
|
notify("Unable to determine database type for sync", "error");
|
||||||
pendingJobRef.current = jobId;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const options = {
|
||||||
await dbSync({
|
|
||||||
sourceConnectionId,
|
sourceConnectionId,
|
||||||
targetConnectionId,
|
targetConnectionId,
|
||||||
schema: schema || undefined,
|
schema: schema || undefined,
|
||||||
tables: undefined,
|
tables: undefined,
|
||||||
});
|
dbType,
|
||||||
} catch (e) {
|
};
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
|
||||||
useBackupStore.getState().failJob(jobId, msg);
|
|
||||||
}
|
|
||||||
}, [sourceConnectionId, targetConnectionId, schema, startJob, notify]);
|
|
||||||
|
|
||||||
const toolsMissing =
|
await runWithProgress("sync", () => {
|
||||||
toolStatus &&
|
if (isPg) return dbSync(options);
|
||||||
(!toolStatus.pg_dump_found || !toolStatus.pg_restore_found);
|
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 =
|
const canStart =
|
||||||
sourceConnectionId && targetConnectionId && confirmed && !isRunning;
|
sourceConnectionId && targetConnectionId && confirmed && !isRunning;
|
||||||
|
|
||||||
const postgresqlConnections = connections.filter(
|
const checkingMessage = isPg
|
||||||
(c) => c.db_type === "postgresql",
|
? "Checking for pg_dump / pg_restore..."
|
||||||
);
|
: isMysql
|
||||||
|
? "Checking for mysqldump / mysql..."
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const targetConnections = dbType
|
||||||
|
? connections.filter((c) => c.db_type === dbType)
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
@@ -114,7 +202,7 @@ export function SyncPage() {
|
|||||||
<ArrowLeftRight size={14} className="text-accent" />
|
<ArrowLeftRight size={14} className="text-accent" />
|
||||||
<span className="text-xs font-medium text-text">DB Sync</span>
|
<span className="text-xs font-medium text-text">DB Sync</span>
|
||||||
<span className="text-[11px] text-text-muted">
|
<span className="text-[11px] text-text-muted">
|
||||||
Transfer data between PostgreSQL databases via pipe
|
Transfer data between databases via pipe
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -122,30 +210,38 @@ export function SyncPage() {
|
|||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
|
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
|
||||||
{/* Tool check */}
|
{/* Tool check */}
|
||||||
{checkingTools && (
|
{checkingTools && checkingMessage && (
|
||||||
<div className="glass p-4 text-center">
|
<div className="glass p-4 text-center">
|
||||||
<p className="text-sm text-text-muted">
|
<p className="text-sm text-text-muted">
|
||||||
Checking for pg_dump / pg_restore...
|
{checkingMessage}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{toolsMissing && (
|
{toolsMissing && !toolsBundled && (
|
||||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
|
<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">
|
<p className="text-amber-300 text-sm font-semibold">
|
||||||
PostgreSQL tools not found
|
{isPg
|
||||||
|
? "PostgreSQL tools not found"
|
||||||
|
: "MySQL tools not found"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-amber-200/80 text-xs leading-relaxed">
|
<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.
|
database sync.
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc list-inside text-xs text-amber-200/70 space-y-0.5">
|
<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>
|
<li>pg_dump is missing.</li>
|
||||||
)}
|
)}
|
||||||
{!toolStatus?.pg_restore_found && (
|
{isPg && !pgToolStatus?.pg_restore_found && (
|
||||||
<li>pg_restore is missing.</li>
|
<li>pg_restore is missing.</li>
|
||||||
)}
|
)}
|
||||||
|
{isMysql && !mysqlToolStatus?.mysqldumpFound && (
|
||||||
|
<li>mysqldump is missing.</li>
|
||||||
|
)}
|
||||||
|
{isMysql && !mysqlToolStatus?.mysqlFound && (
|
||||||
|
<li>mysql is missing.</li>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -163,24 +259,21 @@ export function SyncPage() {
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={sourceConnectionId}
|
value={sourceConnectionId}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
setSourceConnectionId(
|
setSourceConnectionId(
|
||||||
e.target.value,
|
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"
|
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="">
|
<option value="">
|
||||||
Select source...
|
Select source...
|
||||||
</option>
|
</option>
|
||||||
{postgresqlConnections.map((c) => (
|
{connections.map((c) => (
|
||||||
<option
|
<option
|
||||||
key={c.id}
|
key={c.id}
|
||||||
value={c.id}
|
value={c.id}
|
||||||
disabled={
|
|
||||||
c.id ===
|
|
||||||
targetConnectionId
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{c.name}
|
{c.name}
|
||||||
</option>
|
</option>
|
||||||
@@ -199,18 +292,20 @@ export function SyncPage() {
|
|||||||
e.target.value,
|
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="">
|
<option value="">
|
||||||
Select target...
|
{sourceConnectionId
|
||||||
|
? "Select target..."
|
||||||
|
: "Select a source first"}
|
||||||
</option>
|
</option>
|
||||||
{postgresqlConnections.map((c) => (
|
{targetConnections.map((c) => (
|
||||||
<option
|
<option
|
||||||
key={c.id}
|
key={c.id}
|
||||||
value={c.id}
|
value={c.id}
|
||||||
disabled={
|
disabled={
|
||||||
c.id ===
|
c.id === sourceConnectionId
|
||||||
sourceConnectionId
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{c.name}
|
{c.name}
|
||||||
@@ -221,6 +316,7 @@ export function SyncPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Schema (optional) */}
|
{/* Schema (optional) */}
|
||||||
|
{(isPg || isMysql) && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||||
Schema{" "}
|
Schema{" "}
|
||||||
@@ -248,12 +344,13 @@ export function SyncPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Flow indicator */}
|
{/* Flow indicator */}
|
||||||
{sourceConnectionId && targetConnectionId && (
|
{sourceConnectionId && targetConnectionId && (
|
||||||
<div className="flex items-center gap-3 text-[11px] text-text-muted">
|
<div className="flex items-center gap-3 text-[11px] text-text-muted">
|
||||||
<span className="font-medium text-text">
|
<span className="font-medium text-text">
|
||||||
{postgresqlConnections.find(
|
{connections.find(
|
||||||
(c) =>
|
(c) =>
|
||||||
c.id === sourceConnectionId,
|
c.id === sourceConnectionId,
|
||||||
)?.name ?? sourceConnectionId}
|
)?.name ?? sourceConnectionId}
|
||||||
@@ -263,7 +360,7 @@ export function SyncPage() {
|
|||||||
className="text-accent shrink-0"
|
className="text-accent shrink-0"
|
||||||
/>
|
/>
|
||||||
<span className="font-medium text-text">
|
<span className="font-medium text-text">
|
||||||
{postgresqlConnections.find(
|
{connections.find(
|
||||||
(c) =>
|
(c) =>
|
||||||
c.id === targetConnectionId,
|
c.id === targetConnectionId,
|
||||||
)?.name ?? targetConnectionId}
|
)?.name ?? targetConnectionId}
|
||||||
@@ -324,4 +421,3 @@ export function SyncPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,22 @@ describe("TabBar", () => {
|
|||||||
expect(screen.queryByTestId("tab-icon-table")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("tab-icon-table")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders the per-type icon on an object tab", () => {
|
||||||
|
useDbViewerStore.getState().openObjectTab("functions", "public", "add", { name: "add", schema: "public" });
|
||||||
|
render(<TabBar />);
|
||||||
|
const icon = screen.getByLabelText(/object icon: functions/i);
|
||||||
|
const svg = icon.querySelector("svg");
|
||||||
|
expect(svg).toBeTruthy();
|
||||||
|
// Regression: the icon must use the SAME handling as the query/table icons —
|
||||||
|
// the svg itself is display:inline with the shared optical-centering classes.
|
||||||
|
// That defeats preflight svg{display:block} (no stacking) and lets
|
||||||
|
// vertical-align:middle center it with the tab name.
|
||||||
|
const cls = svg!.getAttribute("class") ?? "";
|
||||||
|
expect(cls).toContain("inline");
|
||||||
|
expect(cls).toContain("-mt-0.5");
|
||||||
|
expect(screen.getByText("add")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders a view icon on view tabs", () => {
|
it("renders a view icon on view tabs", () => {
|
||||||
useDbViewerStore.getState().openTab("main", "order_summary");
|
useDbViewerStore.getState().openTab("main", "order_summary");
|
||||||
useDbViewerStore.setState({
|
useDbViewerStore.setState({
|
||||||
@@ -95,6 +111,35 @@ describe("TabBar", () => {
|
|||||||
expect(screen.queryByTestId("tab-icon-table")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("tab-icon-table")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders a create icon and title on an objectForm create tab", () => {
|
||||||
|
useDbViewerStore.getState().openFormTab({
|
||||||
|
kind: "sequence",
|
||||||
|
schema: "public",
|
||||||
|
name: "",
|
||||||
|
title: "Create sequence",
|
||||||
|
description: "Create sequence",
|
||||||
|
mode: "create",
|
||||||
|
params: { schema: "public", name: "", action: { op: "create" } },
|
||||||
|
});
|
||||||
|
render(<TabBar />);
|
||||||
|
expect(screen.getByTestId("tab-icon-form-create")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Create sequence")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an edit icon on an objectForm edit tab", () => {
|
||||||
|
useDbViewerStore.getState().openFormTab({
|
||||||
|
kind: "sequence",
|
||||||
|
schema: "public",
|
||||||
|
name: "s",
|
||||||
|
title: "Edit sequence",
|
||||||
|
description: "Edit sequence",
|
||||||
|
mode: "edit",
|
||||||
|
params: { schema: "public", name: "s", action: { op: "alter" } },
|
||||||
|
});
|
||||||
|
render(<TabBar />);
|
||||||
|
expect(screen.getByTestId("tab-icon-form-edit")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders a layers icon on materialized view tabs", () => {
|
it("renders a layers icon on materialized view tabs", () => {
|
||||||
useDbViewerStore.getState().openTab("public", "mv_products");
|
useDbViewerStore.getState().openTab("public", "mv_products");
|
||||||
useDbViewerStore.setState({
|
useDbViewerStore.setState({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, type ReactNode } from "react";
|
import { cloneElement, useEffect, useRef, type ReactElement, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
@@ -16,9 +16,10 @@ import {
|
|||||||
sortableKeyboardCoordinates,
|
sortableKeyboardCoordinates,
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { ListChecks, Play, Table2, Layers, Eye, Terminal, X } from "lucide-react";
|
import { ListChecks, Play, Table2, Layers, Eye, Terminal, X, Plus, Pencil } from "lucide-react";
|
||||||
import { useDbViewerStore, type ViewerTab } from "../../stores/dbViewerStore";
|
import { useDbViewerStore, type ViewerTab } from "../../stores/dbViewerStore";
|
||||||
import { ChangesQueuePanel } from "./ChangesQueuePanel";
|
import { ChangesQueuePanel } from "./ChangesQueuePanel";
|
||||||
|
import { OBJECT_ICONS } from "./objects/ObjectDetail";
|
||||||
|
|
||||||
function SortableTab({
|
function SortableTab({
|
||||||
tab,
|
tab,
|
||||||
@@ -181,6 +182,39 @@ export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) {
|
|||||||
data-testid="tab-icon-query"
|
data-testid="tab-icon-query"
|
||||||
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
|
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
|
||||||
/>
|
/>
|
||||||
|
) : tab.tabType === "object" ? (
|
||||||
|
<span
|
||||||
|
aria-label={`object icon: ${tab.objectType}`}
|
||||||
|
className="contents"
|
||||||
|
>
|
||||||
|
{cloneElement(
|
||||||
|
OBJECT_ICONS[tab.objectType!] as ReactElement<{
|
||||||
|
className?: string;
|
||||||
|
}>,
|
||||||
|
{
|
||||||
|
// Same handling as the query/table icons: the svg
|
||||||
|
// itself is display:inline (preflight vertical-align:
|
||||||
|
// middle centers it with the text) with the same
|
||||||
|
// optical-centering nudge. `display: contents` on the
|
||||||
|
// labelled span renders no box, so the geometry is
|
||||||
|
// identical to the bare Terminal/Table2 icons.
|
||||||
|
className:
|
||||||
|
"mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : tab.tabType === "objectForm" ? (
|
||||||
|
tab.form?.mode === "create" ? (
|
||||||
|
<Plus
|
||||||
|
data-testid="tab-icon-form-create"
|
||||||
|
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Pencil
|
||||||
|
data-testid="tab-icon-form-edit"
|
||||||
|
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
|
||||||
|
/>
|
||||||
|
)
|
||||||
) : objectType === "VIEW" ? (
|
) : objectType === "VIEW" ? (
|
||||||
<Eye
|
<Eye
|
||||||
data-testid="tab-icon-view"
|
data-testid="tab-icon-view"
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type { ComponentProps } from "react";
|
|||||||
import { TableControls, formatDuration } from "./TableControls";
|
import { TableControls, formatDuration } from "./TableControls";
|
||||||
import { TooltipProvider } from "../ui/Tooltip";
|
import { TooltipProvider } from "../ui/Tooltip";
|
||||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||||
|
import { useNotificationStore } from "../../stores/notificationStore";
|
||||||
|
import * as exportData from "../../lib/exportData";
|
||||||
import type { ViewerTab } from "../../stores/dbViewerStore";
|
import type { ViewerTab } from "../../stores/dbViewerStore";
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
@@ -331,4 +333,28 @@ describe("TableControls", () => {
|
|||||||
screen.getByText("Drop columns here to add filters"),
|
screen.getByText("Drop columns here to add filters"),
|
||||||
).toBeInTheDocument();
|
).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,
|
ChevronDown, FileJson, FileText, Terminal,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore";
|
import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore";
|
||||||
|
import { useNotificationStore } from "../../stores/notificationStore";
|
||||||
import { FilterBuilder } from "./FilterBuilder";
|
import { FilterBuilder } from "./FilterBuilder";
|
||||||
import { Tooltip } from "../ui/Tooltip";
|
import { Tooltip } from "../ui/Tooltip";
|
||||||
import { exportData } from "../../lib/exportData";
|
import { exportData } from "../../lib/exportData";
|
||||||
@@ -26,6 +27,7 @@ const EXPORT_FORMATS = [
|
|||||||
{ label: "CSV", ext: "csv" },
|
{ label: "CSV", ext: "csv" },
|
||||||
{ label: "SQL", ext: "sql" },
|
{ label: "SQL", ext: "sql" },
|
||||||
{ label: "Markdown", ext: "md" },
|
{ label: "Markdown", ext: "md" },
|
||||||
|
{ label: "Excel", ext: "xlsx" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// ─── helpers ────────────────────────────────────────────
|
// ─── helpers ────────────────────────────────────────────
|
||||||
@@ -430,6 +432,7 @@ export function TableControls({
|
|||||||
variant = "table",
|
variant = "table",
|
||||||
}: TableControlsProps) {
|
}: TableControlsProps) {
|
||||||
const isQuery = variant === "query";
|
const isQuery = variant === "query";
|
||||||
|
const notify = useNotificationStore((s) => s.notify);
|
||||||
const tabs = useDbViewerStore((s) => s.tabs);
|
const tabs = useDbViewerStore((s) => s.tabs);
|
||||||
const activeTabId = useDbViewerStore((s) => s.activeTabId);
|
const activeTabId = useDbViewerStore((s) => s.activeTabId);
|
||||||
const setPage = useDbViewerStore((s) => s.setPage);
|
const setPage = useDbViewerStore((s) => s.setPage);
|
||||||
@@ -495,7 +498,20 @@ export function TableControls({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleExport = (format: string) => {
|
const handleExport = (format: string) => {
|
||||||
|
const label =
|
||||||
|
EXPORT_FORMATS.find((f) => f.ext === format)?.label ?? format.toUpperCase();
|
||||||
|
try {
|
||||||
exportData(rows, columns, format, table);
|
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);
|
setExportOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,31 @@ describe("TableOverflowMenu", () => {
|
|||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("offers Create Index… and Create Constraint…", () => {
|
||||||
|
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} connectionId="c1" />);
|
||||||
|
fireEvent.click(screen.getByLabelText(/table options/i));
|
||||||
|
expect(screen.getByText("Create Index…")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Create Constraint…")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens objectForm create tabs for Create Index… and Create Constraint…", async () => {
|
||||||
|
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} connectionId="c1" />);
|
||||||
|
fireEvent.click(screen.getByLabelText(/table options/i));
|
||||||
|
fireEvent.click(screen.getByText("Create Index…"));
|
||||||
|
const st = useDbViewerStore.getState();
|
||||||
|
expect(st.tabs).toHaveLength(1);
|
||||||
|
expect(st.tabs[0].tabType).toBe("objectForm");
|
||||||
|
expect(st.tabs[0].form?.kind).toBe("index");
|
||||||
|
expect(st.tabs[0].form?.mode).toBe("create");
|
||||||
|
|
||||||
|
useDbViewerStore.getState().reset();
|
||||||
|
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} connectionId="c1" />);
|
||||||
|
fireEvent.click(screen.getAllByLabelText(/table options/i)[1]);
|
||||||
|
fireEvent.click(screen.getByText("Create Constraint…"));
|
||||||
|
expect(useDbViewerStore.getState().tabs[0].tabType).toBe("objectForm");
|
||||||
|
expect(useDbViewerStore.getState().tabs[0].form?.kind).toBe("constraint");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders menu trigger button", () => {
|
it("renders menu trigger button", () => {
|
||||||
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} />);
|
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} />);
|
||||||
expect(screen.getByLabelText(/table options/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/table options/i)).toBeInTheDocument();
|
||||||
@@ -31,6 +56,7 @@ describe("TableOverflowMenu", () => {
|
|||||||
expect(screen.getByText("Open in new tab")).toBeInTheDocument();
|
expect(screen.getByText("Open in new tab")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Copy table schema")).toBeInTheDocument();
|
expect(screen.getByText("Copy table schema")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Export data (CSV)")).toBeInTheDocument();
|
expect(screen.getByText("Export data (CSV)")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Export data (Excel)")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fires onOpenTab when menu item clicked", async () => {
|
it("fires onOpenTab when menu item clicked", async () => {
|
||||||
@@ -63,9 +89,11 @@ describe("TableOverflowMenu", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("Delete Table opens confirm then stages a drop_table change", async () => {
|
it("Delete Table opens confirm then stages a drop_table change", async () => {
|
||||||
|
vi.spyOn(commands, "getObjectDependencies").mockResolvedValue([]);
|
||||||
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} />);
|
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} />);
|
||||||
fireEvent.click(screen.getByLabelText(/table options/i));
|
fireEvent.click(screen.getByLabelText(/table options/i));
|
||||||
fireEvent.click(screen.getByText(/delete table/i));
|
fireEvent.click(screen.getByText(/delete table/i));
|
||||||
|
await waitFor(() => expect(screen.queryByText(/open in new tab/i)).not.toBeInTheDocument());
|
||||||
fireEvent.click(screen.getByRole("button", { name: /delete table/i }));
|
fireEvent.click(screen.getByRole("button", { name: /delete table/i }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const q = useDbViewerStore.getState().changesQueue;
|
const q = useDbViewerStore.getState().changesQueue;
|
||||||
@@ -73,6 +101,15 @@ describe("TableOverflowMenu", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Delete Table fetches dependencies and shows DependencyDialog before confirming", async () => {
|
||||||
|
vi.spyOn(commands, "getObjectDependencies").mockResolvedValue([{ deptype: "n", class: "pg_class", name: "v_orders" }]);
|
||||||
|
render(<TableOverflowMenu schema="public" table="orders" connectionId="c1" onOpenTab={() => "tab-1"} />);
|
||||||
|
fireEvent.click(screen.getByLabelText(/table options/i));
|
||||||
|
fireEvent.click(screen.getByText(/delete table/i));
|
||||||
|
await waitFor(() => expect(commands.getObjectDependencies).toHaveBeenCalledWith("c1", "public", "table", "orders"));
|
||||||
|
await waitFor(() => expect(screen.getByText("v_orders")).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
it("Export data calls exportData when rows and columns are provided", async () => {
|
it("Export data calls exportData when rows and columns are provided", async () => {
|
||||||
const spy = vi.spyOn(exportData, "exportData");
|
const spy = vi.spyOn(exportData, "exportData");
|
||||||
const columns = [{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }];
|
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 }];
|
||||||
@@ -81,4 +118,57 @@ describe("TableOverflowMenu", () => {
|
|||||||
fireEvent.click(screen.getByText(/export data \(csv\)/i));
|
fireEvent.click(screen.getByText(/export data \(csv\)/i));
|
||||||
await waitFor(() => expect(spy).toHaveBeenCalledWith([[1]], columns, "csv", "public.t"));
|
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,12 +1,18 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { MoreVertical } from "lucide-react";
|
import { MoreVertical, RefreshCw } from "lucide-react";
|
||||||
import { ConfirmDialog } from "../ui/ConfirmDialog";
|
import { ConfirmDialog } from "../ui/ConfirmDialog";
|
||||||
|
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||||
|
import { Button } from "../ui/Button";
|
||||||
import { ImportDialog } from "./ImportDialog";
|
import { ImportDialog } from "./ImportDialog";
|
||||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||||
import { useUiStore } from "../../stores/uiStore";
|
import { useUiStore } from "../../stores/uiStore";
|
||||||
|
import { useNotificationStore } from "../../stores/notificationStore";
|
||||||
import { exportData } from "../../lib/exportData";
|
import { exportData } from "../../lib/exportData";
|
||||||
import * as cmd from "../../lib/commands";
|
import * as cmd from "../../lib/commands";
|
||||||
import type { ColumnInfo } from "../../lib/types";
|
import { getCapabilities } from "../../lib/dbCapabilities";
|
||||||
|
import { DependencyDialog } from "./DependencyDialog";
|
||||||
|
import { initialCrudParams } from "../../lib/objectCrud";
|
||||||
|
import type { ColumnInfo, DependencyInfo, MaintenanceResult } from "../../lib/types";
|
||||||
|
|
||||||
interface TableOverflowMenuProps {
|
interface TableOverflowMenuProps {
|
||||||
schema: string;
|
schema: string;
|
||||||
@@ -15,12 +21,26 @@ interface TableOverflowMenuProps {
|
|||||||
connectionId?: string;
|
connectionId?: string;
|
||||||
columns?: ColumnInfo[];
|
columns?: ColumnInfo[];
|
||||||
rows?: unknown[][];
|
rows?: unknown[][];
|
||||||
|
dbType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MenuItem {
|
interface MenuItem {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label?: string;
|
||||||
danger?: boolean;
|
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({
|
export function TableOverflowMenu({
|
||||||
@@ -30,14 +50,23 @@ export function TableOverflowMenu({
|
|||||||
connectionId: connectionIdProp,
|
connectionId: connectionIdProp,
|
||||||
columns,
|
columns,
|
||||||
rows,
|
rows,
|
||||||
|
dbType,
|
||||||
}: TableOverflowMenuProps) {
|
}: TableOverflowMenuProps) {
|
||||||
const storeConnectionId = useUiStore((s) => s.activeConnectionId);
|
const storeConnectionId = useUiStore((s) => s.activeConnectionId);
|
||||||
const connectionId = connectionIdProp ?? storeConnectionId;
|
const connectionId = connectionIdProp ?? storeConnectionId;
|
||||||
const addChange = useDbViewerStore((s) => s.addChange);
|
const addChange = useDbViewerStore((s) => s.addChange);
|
||||||
|
const notify = useNotificationStore((s) => s.notify);
|
||||||
|
const caps = getCapabilities(dbType ?? "postgresql");
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [confirmAction, setConfirmAction] = useState<"empty" | "delete" | null>(null);
|
const [confirmAction, setConfirmAction] = useState<"empty" | "delete" | null>(null);
|
||||||
|
const [dropDeps, setDropDeps] = useState<DependencyInfo[]>([]);
|
||||||
const [importOpen, setImportOpen] = useState(false);
|
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);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -60,6 +89,28 @@ export function TableOverflowMenu({
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [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) => {
|
const handleAction = async (id: string) => {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case "open":
|
case "open":
|
||||||
@@ -82,10 +133,47 @@ export function TableOverflowMenu({
|
|||||||
case "export-csv":
|
case "export-csv":
|
||||||
case "export-json":
|
case "export-json":
|
||||||
case "export-sql":
|
case "export-sql":
|
||||||
case "export-md": {
|
case "export-md":
|
||||||
|
case "export-xlsx": {
|
||||||
const format = id.replace("export-", "");
|
const format = id.replace("export-", "");
|
||||||
|
const label =
|
||||||
|
format === "xlsx"
|
||||||
|
? "Excel"
|
||||||
|
: format === "md"
|
||||||
|
? "Markdown"
|
||||||
|
: format.toUpperCase();
|
||||||
|
try {
|
||||||
if (rows && rows.length > 0 && columns && columns.length > 0) {
|
if (rows && rows.length > 0 && columns && columns.length > 0) {
|
||||||
exportData(rows, columns, format, `${schema}.${table}`);
|
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);
|
setOpen(false);
|
||||||
break;
|
break;
|
||||||
@@ -94,14 +182,82 @@ export function TableOverflowMenu({
|
|||||||
setImportOpen(true);
|
setImportOpen(true);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
break;
|
break;
|
||||||
|
case "edit_table": {
|
||||||
|
const columnMeta = (columns ?? []).map((c) => ({
|
||||||
|
name: c.name,
|
||||||
|
type: c.data_type,
|
||||||
|
nullable: c.is_nullable,
|
||||||
|
default: c.default_value,
|
||||||
|
is_pk: c.is_pk,
|
||||||
|
}));
|
||||||
|
useDbViewerStore.getState().openFormTab({
|
||||||
|
kind: "table",
|
||||||
|
schema,
|
||||||
|
name: table,
|
||||||
|
title: "Edit Table",
|
||||||
|
description: `Edit ${schema}.${table}`,
|
||||||
|
mode: "edit",
|
||||||
|
params: {
|
||||||
|
schema,
|
||||||
|
name: table,
|
||||||
|
action: {
|
||||||
|
op: "edit",
|
||||||
|
columns: columnMeta,
|
||||||
|
old_columns: columnMeta,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "create_index":
|
||||||
|
if (!connectionId) break;
|
||||||
|
useDbViewerStore.getState().openFormTab({
|
||||||
|
kind: "index",
|
||||||
|
schema,
|
||||||
|
name: "",
|
||||||
|
title: "Create Index",
|
||||||
|
description: `Create index on ${schema}.${table}`,
|
||||||
|
mode: "create",
|
||||||
|
params: initialCrudParams("index", { schema, table, name: "" }, "create"),
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
break;
|
||||||
|
case "create_constraint":
|
||||||
|
if (!connectionId) break;
|
||||||
|
useDbViewerStore.getState().openFormTab({
|
||||||
|
kind: "constraint",
|
||||||
|
schema,
|
||||||
|
name: "",
|
||||||
|
title: "Create Constraint",
|
||||||
|
description: `Create constraint on ${schema}.${table}`,
|
||||||
|
mode: "create",
|
||||||
|
params: initialCrudParams("constraint", { schema, table, name: "" }, "create"),
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
break;
|
||||||
|
case "vacuum":
|
||||||
|
case "analyze":
|
||||||
|
case "reindex":
|
||||||
|
setMaintenance({ action: id, lockCopy: maintenanceLockCopy[id] });
|
||||||
|
setOpen(false);
|
||||||
|
break;
|
||||||
case "empty":
|
case "empty":
|
||||||
setConfirmAction("empty");
|
setConfirmAction("empty");
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
break;
|
break;
|
||||||
case "delete":
|
case "delete": {
|
||||||
|
if (!connectionId) break;
|
||||||
|
try {
|
||||||
|
const deps = await cmd.getObjectDependencies(connectionId, schema, "table", table);
|
||||||
|
setDropDeps(deps);
|
||||||
|
} catch {
|
||||||
|
setDropDeps([]);
|
||||||
|
}
|
||||||
setConfirmAction("delete");
|
setConfirmAction("delete");
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -114,7 +270,19 @@ export function TableOverflowMenu({
|
|||||||
{ id: "export-json", label: "Export data (JSON)" },
|
{ id: "export-json", label: "Export data (JSON)" },
|
||||||
{ id: "export-sql", label: "Export data (SQL)" },
|
{ id: "export-sql", label: "Export data (SQL)" },
|
||||||
{ id: "export-md", label: "Export data (Markdown)" },
|
{ id: "export-md", label: "Export data (Markdown)" },
|
||||||
|
{ id: "export-xlsx", label: "Export data (Excel)" },
|
||||||
{ id: "import", label: "Import data (CSV/JSON)" },
|
{ 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: "empty", label: "Empty Table", danger: true },
|
||||||
{ id: "delete", label: "Delete Table", danger: true },
|
{ id: "delete", label: "Delete Table", danger: true },
|
||||||
];
|
];
|
||||||
@@ -130,7 +298,10 @@ export function TableOverflowMenu({
|
|||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute right-0 mt-1 rounded-xl bg-surface border border-border py-1 z-20 min-w-[180px] shadow-lg">
|
<div className="absolute right-0 mt-1 rounded-xl bg-surface border border-border py-1 z-20 min-w-[180px] shadow-lg">
|
||||||
{items.map((item) => (
|
{items.map((item) =>
|
||||||
|
item.divider ? (
|
||||||
|
<div key={item.id} className="border-t border-border my-1" />
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -142,10 +313,63 @@ export function TableOverflowMenu({
|
|||||||
>
|
>
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
</div>
|
</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
|
||||||
|
deps={dropDeps}
|
||||||
|
onProceed={() => setConfirmAction(null)}
|
||||||
|
onCancel={() => setConfirmAction(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{confirmAction === "empty" && (
|
{confirmAction === "empty" && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open
|
open
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { abbreviateType } from "../../lib/utils";
|
|||||||
import type { ColumnInfo } from "../../lib/types";
|
import type { ColumnInfo } from "../../lib/types";
|
||||||
import * as cmd from "../../lib/commands";
|
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 tables = useDbViewerStore((s) => s.tables);
|
||||||
const currentSchema = useDbViewerStore((s) => s.currentSchema);
|
const currentSchema = useDbViewerStore((s) => s.currentSchema);
|
||||||
const schemaTreeLoading = useDbViewerStore((s) => s.schemaTreeLoading);
|
const schemaTreeLoading = useDbViewerStore((s) => s.schemaTreeLoading);
|
||||||
@@ -113,6 +113,7 @@ export function TableTree({ searchQuery }: { searchQuery?: string }) {
|
|||||||
schema={table.schema}
|
schema={table.schema}
|
||||||
table={table.name}
|
table={table.name}
|
||||||
connectionId={connectionId ?? undefined}
|
connectionId={connectionId ?? undefined}
|
||||||
|
dbType={dbType}
|
||||||
columns={cols}
|
columns={cols}
|
||||||
onOpenTab={handleOpenTab}
|
onOpenTab={handleOpenTab}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
interface Props {
|
||||||
|
cols: string[];
|
||||||
|
selected: string[];
|
||||||
|
onToggle: (col: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColumnPicker({ cols, selected, onToggle }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1" data-testid="column-picker">
|
||||||
|
{cols.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggle(c)}
|
||||||
|
className={`text-xs px-2 py-1 rounded border transition-colors ${
|
||||||
|
selected.includes(c)
|
||||||
|
? "bg-accent text-white border-accent"
|
||||||
|
: "border-border text-text hover:border-text-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { ConstraintForm } from "./ConstraintForm";
|
||||||
|
import * as cmd from "../../../lib/commands";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(cmd, "getSchemaGraph").mockReset().mockResolvedValue({ tables: [], relationships: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const graph = {
|
||||||
|
tables: [
|
||||||
|
{
|
||||||
|
name: "orders",
|
||||||
|
schema: "public",
|
||||||
|
table_type: "BASE TABLE",
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: "user_id",
|
||||||
|
data_type: "int",
|
||||||
|
is_pk: false,
|
||||||
|
is_fk: true,
|
||||||
|
is_unique: false,
|
||||||
|
is_nullable: true,
|
||||||
|
fk_ref: ["public", "users", "id"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "users",
|
||||||
|
schema: "public",
|
||||||
|
table_type: "BASE TABLE",
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: "id",
|
||||||
|
data_type: "int",
|
||||||
|
is_pk: true,
|
||||||
|
is_fk: false,
|
||||||
|
is_unique: true,
|
||||||
|
is_nullable: false,
|
||||||
|
fk_ref: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
relationships: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("ConstraintForm", () => {
|
||||||
|
it("check: emits the expression", () => {
|
||||||
|
(cmd.getSchemaGraph as any).mockResolvedValue(graph as any);
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<ConstraintForm
|
||||||
|
connectionId="c1"
|
||||||
|
params={{
|
||||||
|
schema: "public",
|
||||||
|
table: "orders",
|
||||||
|
name: "ck",
|
||||||
|
action: { op: "check", expression: "" },
|
||||||
|
}}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("CHECK expression"), {
|
||||||
|
target: { value: "amount > 0" },
|
||||||
|
});
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: expect.objectContaining({ expression: "amount > 0" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("foreign_key: picks referenced table + column", async () => {
|
||||||
|
(cmd.getSchemaGraph as any).mockResolvedValue(graph as any);
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<ConstraintForm
|
||||||
|
connectionId="c1"
|
||||||
|
params={{
|
||||||
|
schema: "public",
|
||||||
|
table: "orders",
|
||||||
|
name: "fk",
|
||||||
|
action: {
|
||||||
|
op: "foreign_key",
|
||||||
|
columns: ["user_id"],
|
||||||
|
ref_schema: "",
|
||||||
|
ref_table: "",
|
||||||
|
ref_columns: [],
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const kindSelect = screen.getByLabelText("Kind");
|
||||||
|
fireEvent.change(kindSelect, { target: { value: "foreign_key" } });
|
||||||
|
await screen.findByText("user_id");
|
||||||
|
const refTable = await screen.findByLabelText("Referenced table");
|
||||||
|
fireEvent.change(refTable, { target: { value: "public.users" } });
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: expect.objectContaining({
|
||||||
|
ref_schema: "public",
|
||||||
|
ref_table: "users",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FK form includes ON DELETE/UPDATE + deferrable and cross-schema ref picker", async () => {
|
||||||
|
// mock getSchemaGraph to return tables across two schemas
|
||||||
|
(cmd.getSchemaGraph as any).mockResolvedValue({
|
||||||
|
tables: [
|
||||||
|
{
|
||||||
|
name: "users",
|
||||||
|
schema: "public",
|
||||||
|
table_type: "TABLE",
|
||||||
|
columns: [
|
||||||
|
{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: false, is_nullable: false, fk_ref: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "orgs",
|
||||||
|
schema: "auth",
|
||||||
|
table_type: "TABLE",
|
||||||
|
columns: [
|
||||||
|
{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: false, is_nullable: false, fk_ref: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
relationships: [],
|
||||||
|
} as any);
|
||||||
|
render(
|
||||||
|
<ConstraintForm
|
||||||
|
connectionId="c1"
|
||||||
|
schemas={["public", "auth"]}
|
||||||
|
params={{
|
||||||
|
schema: "public",
|
||||||
|
table: "orders",
|
||||||
|
name: "fk1",
|
||||||
|
action: {
|
||||||
|
op: "foreign_key",
|
||||||
|
columns: ["org_id"],
|
||||||
|
ref_schema: "auth",
|
||||||
|
ref_table: "orgs",
|
||||||
|
ref_columns: ["id"],
|
||||||
|
on_delete: "CASCADE",
|
||||||
|
on_update: "NO ACTION",
|
||||||
|
deferrable: false,
|
||||||
|
initially_deferred: false,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onChange={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText("On delete")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("On update")).toBeInTheDocument();
|
||||||
|
// referenced-table picker includes the auth.orgs option
|
||||||
|
const ref = screen.getByLabelText("Referenced table") as HTMLSelectElement;
|
||||||
|
await waitFor(() => {
|
||||||
|
expect([...ref.options].map((o) => o.value)).toContain("auth.orgs");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||