Compare commits
32
Commits
5a45391d91
..
v0.8.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62e3b96bad | ||
|
|
daa38d163e | ||
|
|
092b3b2831 | ||
|
|
88e7e45818 | ||
|
|
ce4a73b0bb | ||
|
|
8b08ad0e46 | ||
|
|
b17bf95b6d | ||
|
|
9089f313f8 | ||
|
|
3454ce67fe | ||
|
|
f807c22a36 | ||
|
|
e4de489f53 | ||
|
|
fdbfd3d0d2 | ||
|
|
228660044a | ||
|
|
c4dc1c1c83 | ||
|
|
f1d9fdae3a | ||
|
|
18157f453f | ||
|
|
5607773299 | ||
|
|
60d472dde2 | ||
|
|
bdf2bb543b | ||
|
|
67f3532938 | ||
|
|
1d8dfce99a | ||
|
|
8bcdc0888f | ||
|
|
fd451d219d | ||
|
|
a86f5f7199 | ||
|
|
2fccb7b405 | ||
|
|
0efabbdc2e | ||
|
|
294e72e6ff | ||
|
|
15f4dc9bac | ||
|
|
867df98958 | ||
|
|
e66d7ac102 | ||
|
|
84a6f8d41c | ||
|
|
08b0f79f07 |
+210
-20
@@ -7,27 +7,65 @@ name: Release
|
||||
# git checkout prod && git pull
|
||||
# git tag v0.5.0 && git push origin v0.5.0
|
||||
#
|
||||
# SIGNING STATUS: macOS bundles are AD-HOC signed via tauri.conf.json
|
||||
# (bundle.macOS.signingIdentity: "-", hardenedRuntime: false). That replaces
|
||||
# the linker-only signature that made macOS report the app as "damaged" and
|
||||
# refuse Finder launches — users now get the standard "Open Anyway" prompt
|
||||
# (or run `sudo xattr -dr com.apple.quarantine` for the damaged-error case, see
|
||||
# README). Still NOT Developer-ID signed / notarized (no certs yet).
|
||||
# tauri-action automatically signs + notarizes when the signing secrets are
|
||||
# present, so the moment we add APPLE_CERTIFICATE / APPLE_API_KEY /
|
||||
# WINDOWS_CERTIFICATE (or Azure Trusted Signing) to repo secrets, future
|
||||
# builds are properly signed — no changes to this file required.
|
||||
# SIGNING STATUS: this is the ACTIVE release path. macOS is Developer-ID
|
||||
# signed + notarized in CI when the Apple secrets below are configured (see
|
||||
# README "macOS signing & notarization"). When the secrets are absent the mac
|
||||
# jobs still build, but ad-hoc signed / unnotarized. Linux/Windows installers
|
||||
# remain unsigned.
|
||||
#
|
||||
# Required secrets for signed + notarized macOS builds:
|
||||
# APPLE_CERTIFICATE base64 of the Developer ID Application .p12
|
||||
# APPLE_CERTIFICATE_PASSWORD password for that .p12
|
||||
# APPLE_SIGNING_IDENTITY "Developer ID Application: Name (TEAMID)"
|
||||
# KEYCHAIN_PASSWORD any throwaway password for the temp keychain
|
||||
# APPLE_API_KEY App Store Connect Key ID
|
||||
# APPLE_API_ISSUER App Store Connect Issuer ID
|
||||
# APPLE_API_KEY_CONTENT contents of the AuthKey_<KeyID>.p8
|
||||
#
|
||||
# The cask bump runs only after the draft release is PUBLISHED (the GitHub
|
||||
# releases/tags API does not serve drafts), so it is wired to the `release:
|
||||
# published` event rather than the tag push. It pushes with a write-enabled
|
||||
# deploy key scoped to ONLY the tap repo (HOMEBREW_TAP_DEPLOY_KEY), so no
|
||||
# broad personal access token is stored — the default GITHUB_TOKEN cannot
|
||||
# push to a different repository.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
release:
|
||||
types: [published]
|
||||
# Rebuild an EXISTING tag without moving it — e.g. to re-run a release after
|
||||
# a workflow fix. Dispatch from `prod` and pass the tag to build; the tag
|
||||
# itself is never rewritten, so the release ref stays truthful.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag to build or bump the cask for, e.g. v0.8.0'
|
||||
required: true
|
||||
bump_cask:
|
||||
description: 'Only bump the Homebrew cask for `tag` (skip the build)'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
# workflow_dispatch passes the tag as an input; push/release events derive it
|
||||
# from the ref. Drives the checkout ref and tauri-action's tagName.
|
||||
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
# Build + draft release happen on a tag push or a dispatch that isn't a
|
||||
# cask-only run. The `release: published` event exists only to trigger the
|
||||
# cask bump below.
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.bump_cask != true)
|
||||
env:
|
||||
# Empty when the secret is unset — the tool steps below test for a
|
||||
# non-empty value before re-signing.
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -44,6 +82,69 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ env.RELEASE_TAG }}
|
||||
|
||||
# ---- Apple signing setup (macOS only, skipped when secrets absent) ---
|
||||
- name: Detect Apple signing secrets
|
||||
id: apple
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -n "${{ secrets.APPLE_CERTIFICATE }}" ]; then
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::APPLE_CERTIFICATE not set — macOS build will be ad-hoc signed and NOT notarized."
|
||||
fi
|
||||
|
||||
# The bundled pg/mariadb tools are re-signed in their own steps below, so
|
||||
# the identity must be in a keychain BEFORE those steps run. tauri-action
|
||||
# is deliberately NOT given APPLE_CERTIFICATE (only the identity), so it
|
||||
# does not create a second keychain.
|
||||
- name: Import Apple Developer ID certificate
|
||||
if: startsWith(matrix.platform, 'macos') && steps.apple.outputs.enabled == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
KEYCHAIN="gridline-signing.keychain-db"
|
||||
echo "$APPLE_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/cert.p12"
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
|
||||
security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" \
|
||||
-P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: \
|
||||
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null
|
||||
EXISTING="$(security list-keychain -d user | tr -d ' \"')"
|
||||
security list-keychain -d user -s "$KEYCHAIN" $EXISTING
|
||||
rm -f "$RUNNER_TEMP/cert.p12"
|
||||
security find-identity -v -p codesigning
|
||||
|
||||
- name: Prepare notarization credentials
|
||||
if: startsWith(matrix.platform, 'macos') && steps.apple.outputs.enabled == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
KEY_PATH="$RUNNER_TEMP/private_keys/AuthKey_${APPLE_API_KEY}.p8"
|
||||
mkdir -p "$(dirname "$KEY_PATH")"
|
||||
# Apple's PEM parser expects a terminating newline, which
|
||||
# `gh secret set` strips from the stored value — so write one
|
||||
# explicitly. A doubled trailing newline is harmless to PEM parsers,
|
||||
# but a missing one can make notarization fail to load the key.
|
||||
printf '%s\n' "$APPLE_API_KEY_CONTENT" > "$KEY_PATH"
|
||||
{
|
||||
echo "APPLE_API_KEY=${APPLE_API_KEY}"
|
||||
echo "APPLE_API_ISSUER=${APPLE_API_ISSUER}"
|
||||
echo "APPLE_API_KEY_PATH=${KEY_PATH}"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install Linux dependencies
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
@@ -136,6 +237,16 @@ jobs:
|
||||
libpq=$(otool -L "$OUT/$b" | awk '/libpq/ {print $1; exit}')
|
||||
install_name_tool -change "$libpq" "@loader_path/libpq.5.dylib" "$OUT/$b"
|
||||
done
|
||||
# install_name_tool invalidates the linker signature. Re-sign with
|
||||
# the Developer ID identity + hardened runtime + secure timestamp:
|
||||
# Apple rejects ad-hoc-signed nested executables at notarization.
|
||||
# No-op until the Apple secrets are configured.
|
||||
if [ -n "${APPLE_SIGNING_IDENTITY:-}" ]; then
|
||||
for f in "$OUT"/pg_dump "$OUT"/pg_restore "$OUT"/psql "$OUT"/libpq.5.dylib; do
|
||||
codesign --force --options runtime --timestamp \
|
||||
--sign "$APPLE_SIGNING_IDENTITY" "$f"
|
||||
done
|
||||
fi
|
||||
;;
|
||||
windows-latest)
|
||||
URL="https://get.enterprisedb.com/postgresql/postgresql-${PG_VER}-1-windows-x64-binaries.zip"
|
||||
@@ -268,7 +379,12 @@ jobs:
|
||||
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
|
||||
if [ -n "${APPLE_SIGNING_IDENTITY:-}" ]; then
|
||||
codesign --force --options runtime --timestamp \
|
||||
--sign "$APPLE_SIGNING_IDENTITY" "$dylib"
|
||||
else
|
||||
codesign --force --sign - "$dylib" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
done
|
||||
;;
|
||||
@@ -314,8 +430,8 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
projectPath: desktop
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseName: 'Gridline ${{ github.ref_name }}'
|
||||
tagName: ${{ env.RELEASE_TAG }}
|
||||
releaseName: 'Gridline ${{ env.RELEASE_TAG }}'
|
||||
releaseDraft: true
|
||||
args: ${{ matrix.args }}
|
||||
# Assets use tauri-action's default versioned naming (e.g.
|
||||
@@ -323,44 +439,118 @@ jobs:
|
||||
# README download tables link to them statically per release —
|
||||
# remember to update both README tables when cutting a new version.
|
||||
|
||||
# tauri-action notarizes + staples the .app but NOT the .dmg wrapping it,
|
||||
# so Gatekeeper rejects the downloaded DMG as "Unnotarized Developer ID"
|
||||
# even though the app inside is fine. Submit the DMG to the same notary
|
||||
# service, staple the ticket, and replace the uploaded asset.
|
||||
#
|
||||
# Assets are replaced via the release ID rather than `gh release upload`:
|
||||
# the GitHub releases/tags endpoint returns 404 for drafts, so uploading
|
||||
# by tag cannot work while the release is still a draft.
|
||||
- name: Notarize and staple the DMGs
|
||||
if: startsWith(matrix.platform, 'macos') && steps.apple.outputs.enabled == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
dmgs=(desktop/src-tauri/target/*/release/bundle/dmg/*.dmg)
|
||||
if [ ${#dmgs[@]} -eq 0 ]; then
|
||||
echo "no DMG produced" >&2
|
||||
exit 1
|
||||
fi
|
||||
REL_ID=$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
|
||||
--jq ".[] | select(.tag_name == \"${RELEASE_TAG}\") | .id" | head -1)
|
||||
if [ -z "$REL_ID" ]; then
|
||||
echo "could not resolve a release id for ${RELEASE_TAG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
for dmg in "${dmgs[@]}"; do
|
||||
name=$(basename "$dmg")
|
||||
echo ">> notarizing $name (this can take a few minutes)"
|
||||
xcrun notarytool submit "$dmg" \
|
||||
--key "$APPLE_API_KEY_PATH" \
|
||||
--key-id "$APPLE_API_KEY" \
|
||||
--issuer "$APPLE_API_ISSUER" \
|
||||
--wait
|
||||
xcrun stapler staple "$dmg"
|
||||
xcrun stapler validate "$dmg"
|
||||
# Replace the copy tauri-action uploaded, which carries no DMG ticket.
|
||||
old=$(gh api "repos/${GITHUB_REPOSITORY}/releases/${REL_ID}/assets" \
|
||||
--jq ".[] | select(.name == \"${name}\") | .id" | head -1)
|
||||
if [ -n "$old" ]; then
|
||||
gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/${old}"
|
||||
fi
|
||||
echo ">> re-uploading stapled $name"
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token ${GH_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@${dmg}" \
|
||||
"https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/${REL_ID}/assets?name=${name}"
|
||||
echo
|
||||
done
|
||||
echo "all DMGs notarized and stapled"
|
||||
|
||||
# After the installers are uploaded, refresh the Homebrew cask in the
|
||||
# AdrianBonpin/homebrew-gridline tap: hash the freshly-published DMGs,
|
||||
# render Casks/gridline.rb from the template, and push it. Users then get
|
||||
# the new version via `brew upgrade --cask gridline`.
|
||||
#
|
||||
# Requires the HOMEBREW_TAP_TOKEN secret (a PAT with `repo` scope on the tap
|
||||
# repo — the default GITHUB_TOKEN cannot push to a different repo). The job
|
||||
# no-ops until that secret is set, so releases stay green before this is
|
||||
# Requires the HOMEBREW_TAP_DEPLOY_KEY secret — a write-enabled SSH deploy
|
||||
# key on the tap repo (the default GITHUB_TOKEN cannot push to a different
|
||||
# repository, and a deploy key avoids storing a broad PAT). The job skips
|
||||
# cleanly until that secret is set, so releases stay green before this is
|
||||
# configured.
|
||||
bump-cask:
|
||||
needs: publish
|
||||
# Normally fires on `release: published`, which requires the DMGs to be
|
||||
# downloadable — the GitHub releases/tags API returns 404 for drafts, so
|
||||
# this cannot run on the tag push. NOTE: a release whose DRAFT was created
|
||||
# by tauri-action's GITHUB_TOKEN does not reliably emit a `release` event,
|
||||
# so `workflow_dispatch` with bump_cask=true is the dependable path.
|
||||
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.bump_cask == true)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check for tap deploy key
|
||||
id: tap
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -n "${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }}" ]; then
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::HOMEBREW_TAP_DEPLOY_KEY not set - skipping Homebrew cask bump."
|
||||
fi
|
||||
|
||||
- name: Checkout
|
||||
if: steps.tap.outputs.enabled == 'true'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve version and DMG checksums
|
||||
id: meta
|
||||
if: steps.tap.outputs.enabled == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
gh release download "$GITHUB_REF_NAME" -p "Gridline_${VERSION}_aarch64.dmg" -O /tmp/arm.dmg
|
||||
gh release download "$GITHUB_REF_NAME" -p "Gridline_${VERSION}_x64.dmg" -O /tmp/intel.dmg
|
||||
VERSION="${RELEASE_TAG#v}"
|
||||
gh release download "$RELEASE_TAG" -p "Gridline_${VERSION}_aarch64.dmg" -O /tmp/arm.dmg
|
||||
gh release download "$RELEASE_TAG" -p "Gridline_${VERSION}_x64.dmg" -O /tmp/intel.dmg
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "sha_arm=$(shasum -a 256 /tmp/arm.dmg | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
|
||||
echo "sha_intel=$(shasum -a 256 /tmp/intel.dmg | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout tap repo
|
||||
if: steps.tap.outputs.enabled == 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: AdrianBonpin/homebrew-gridline
|
||||
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
ssh-key: ${{ secrets.HOMEBREW_TAP_DEPLOY_KEY }}
|
||||
path: tap
|
||||
|
||||
- name: Render and push cask
|
||||
if: steps.tap.outputs.enabled == 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
SHA_ARM: ${{ steps.meta.outputs.sha_arm }}
|
||||
|
||||
@@ -104,7 +104,7 @@ gridline/
|
||||
├── .github/workflows/ # release.yml (monorepo paths) + auto-assign.yml
|
||||
├── docs/ # Project docs
|
||||
├── screenshots/ # README marketing images
|
||||
├── scripts/ # Release tooling (render-cask.sh)
|
||||
├── scripts/ # Release tooling (render-cask.sh, release-mac.sh) + Gitea mirror (mirror-gitea.sh, hooks/, install-git-hooks.sh)
|
||||
└── AGENTS.md # This file
|
||||
```
|
||||
|
||||
@@ -167,24 +167,35 @@ cargo test # Rust tests (from desktop/sr
|
||||
|
||||
### Branch & PR workflow
|
||||
|
||||
**GitHub is the only remote that matters.** The canonical repo is `github` → [AdrianBonpin/gridline](https://github.com/AdrianBonpin/gridline) (default branch `prod`, issues, PRs, Actions, releases). The `origin` remote (`git.ranio.xyz/adrianbonpin/gridline`) is a **just-in-case mirror** — do not push branches, open PRs, or publish releases/tags there. If you must mirror something manually, push to `github` first.
|
||||
|
||||
**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
|
||||
2. Commit and push the feature branch to `github` (`git push -u github <branch>`)
|
||||
3. Open a PR into `prod` on GitHub (`gh pr create --repo AdrianBonpin/gridline --base 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.
|
||||
|
||||
**The Gitea fallback keeps itself current — run `scripts/install-git-hooks.sh` once per clone.** It sets `core.hooksPath=scripts/hooks`, so:
|
||||
|
||||
- `pre-push` mirrors `prod` + all tags to `origin` (Gitea) whenever you push to the `github` remote,
|
||||
- `post-merge` mirrors again after a `git pull`/merge while on `prod` (a PR squash-merged on GitHub only reaches the mirror when the local `prod` pulls it).
|
||||
|
||||
`scripts/mirror-gitea.sh` does the same by hand (`--branches` also pushes feature branches); it refuses to push to a GitHub remote and exits non-zero if the mirror could not be updated. The hooks downgrade that to a warning — **a Gitea outage never blocks a push to GitHub**.
|
||||
|
||||
### 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).
|
||||
**Releases are GitHub-only.** Tag `prod` and push the tag to the `github` remote — `git tag vN.M.N && git push github 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). The Gitea tag arrives automatically via the `pre-push` hook (or `scripts/mirror-gitea.sh`) — never `git push origin vN.M.N` expecting a build, and never publish a release on Gitea.
|
||||
|
||||
**Before tagging**, keep everything in sync:
|
||||
- Version number across `desktop/package.json`, `desktop/src-tauri/Cargo.toml`, `desktop/src-tauri/tauri.conf.json`, and `www/src/pages/index.astro` (the landing page version string)
|
||||
- `desktop/src/lib/version.test.ts` and `desktop/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.9/<file>`). tauri-action uses default versioned asset names (`Gridline_<ver>_aarch64.dmg`, `Gridline-<ver>-1.x86_64.rpm`, etc.) — update BOTH tables to the new names on every release (see the MAINTENANCE comment in README.md).
|
||||
- Version number across `desktop/package.json`, `desktop/src-tauri/Cargo.toml` (+ the `gridline` entry in `desktop/src-tauri/Cargo.lock` — run any `cargo metadata`/`cargo build` to sync it; do NOT blanket-replace `0.8.x` in the lock, the sqlx family shares that version), `desktop/src-tauri/tauri.conf.json`
|
||||
- Landing page version strings: `www/src/pages/index.astro` and `www/public/landing.js` (both carry a `const version`), plus the new entry at the top of `www/src/data/changelog.ts`
|
||||
- Tests that assert the version: `desktop/src/lib/version.test.ts`, `desktop/src/lib/bundle-config.test.ts`, `desktop/src/lib/docs-coverage.test.ts` (this one also asserts the README's asset names and the landing `const 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.8.3/<file>`). tauri-action uses default versioned asset names (`Gridline_<ver>_aarch64.dmg`, `Gridline-<ver>-1.x86_64.rpm`, etc.) — update BOTH tables, the two "current release (**vX.Y.Z**)" banners, and the MAINTENANCE comment on every release.
|
||||
- **Bundled pg tools:** `tauri.conf.json` `bundle.resources` lists `resources/pg_tools/*`; the `release.yml` matrix builds/downloads + checksum-verifies the static binaries before the Tauri build step.
|
||||
- **Release notes must include the macOS first-launch instructions** — until a Developer ID signing certificate is secured, releases are ad-hoc signed only (not notarized), so every macOS user hits a Gatekeeper prompt. The draft release body must include: right-click → **Open** → **Open** (or System Settings → Privacy & Security → **Open Anyway**) for "developer cannot be verified", and `sudo xattr -dr com.apple.quarantine /Applications/Gridline.app` (with the re-run-after-every-upgrade note) for "damaged and can't be opened".
|
||||
- **macOS is Developer-ID signed + notarized in CI** from the repository secrets (`APPLE_CERTIFICATE`, `APPLE_CERTIFICATE_PASSWORD`, `APPLE_SIGNING_IDENTITY`, `APPLE_API_KEY`, `APPLE_API_ISSUER`, `APPLE_API_KEY_CONTENT`, `KEYCHAIN_PASSWORD`) — the workflow logs a `notice` when they are absent and falls back to ad-hoc, so **check the run output before publishing a draft**. Verify a published DMG with `xcrun stapler validate <dmg>` plus `spctl --assess --type execute` on the `.app` inside (`source=Notarized Developer ID`). `scripts/release-mac.sh` is the **local fallback**: it auto-detects the Developer ID identity from the login keychain, reads notarization credentials from `~/.config/gridline/notarize.env`, signs + notarizes + staples both architectures, verifies the result, then uploads to the **GitHub** release with `gh release upload --clobber`.
|
||||
- **After publishing the draft**, the `release: published` event runs the cask bump (`HOMEBREW_TAP_DEPLOY_KEY` pushes `Casks/gridline.rb` to `AdrianBonpin/homebrew-gridline`). Verify the cask `version` + `sha256` match the published DMGs.
|
||||
|
||||
### Adding a Tauri Command
|
||||
|
||||
@@ -237,19 +248,20 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
||||
### Connection Management
|
||||
| Feature | Status | Details |
|
||||
| :--- | :---: | :--- |
|
||||
| Connections CRUD (PostgreSQL, MySQL, SQLite, Redis) | ✅ | Full create/read/update/delete with form validation |
|
||||
| New Connection screen (revamped) | ✅ | Two-stage entry → configured flow: Connection URI + 6-card provider grid (PostgreSQL / MySQL / SQLite / Redis / Supabase / NeonDB) with an OR divider → expands into label + tags/env/folder + General|SSH·SSL tabs. Supabase & NeonDB are managed-PostgreSQL presets (persist as `postgresql`) with in-app setup guides + SSL hints; SQLite swaps the URI field for a file-path + Browse input (v0.7.0) |
|
||||
| Connections CRUD (PostgreSQL, MySQL, SQLite, Redis, MariaDB) | ✅ | Full create/read/update/delete with form validation |
|
||||
| MariaDB connections | ✅ | First-class db_type on the MySQL driver path (v0.8.0) |
|
||||
| New Connection screen (revamped) | ✅ | Two-stage entry → configured flow: Connection URI + 6-card provider grid (PostgreSQL / MySQL / SQLite / Redis / Supabase / NeonDB) with an OR divider → expands into label + tags/env/folder + General|SSH·SSL tabs. **Pasting a URI fills every setting it can** — type, host, port, user, password, database, `?sslmode=`/`ssl-mode=` (Neon/Supabase → Require, MySQL `VERIFY_IDENTITY` → verify-full) — and offers a URL-derived label (database name, else host) as a placeholder that Save/Test uses when the field is left blank; Supabase & NeonDB are managed-PostgreSQL presets (persist as `postgresql`) with in-app setup guides + SSL hints, PlanetScale is a managed-MySQL (Vitess) preset (persists as `mysql`) with a setup guide + `.psdb.cloud` host detection (v0.8.0); presets pre-select their TLS mode (Supabase/Neon Require, PlanetScale Verify Full); SQLite swaps the URI field for a file-path + Browse input (v0.7.0)
|
||||
| Connection testing (all DB types) | ✅ | PostgreSQL, MySQL, SQLite, Redis all testable |
|
||||
| DB Viewer: PostgreSQL browse + query | ✅ | Schemas, tables, paginated data, FK preview, JSON viewer |
|
||||
| DB Viewer: SQLite browse + query | ✅ | Full support via rusqlite |
|
||||
| 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. **All MySQL column types decode by their declared type** (`mysql_cell_kind` → `mysql_cell_to_json` in `commands/query.rs`, v0.8.2) — `int`/`bigint` (64-bit stringified so the value survives IPC), `decimal` (exact digits, never a float), `date`/`time`/`datetime`/`timestamp` (naive, never an instant — MySQL converts TIMESTAMP via the session time zone), `float`/`double`, `bit`, `year`, `json`, text and binary — where the previous blind `try_get` try-chain rendered every non-string type as NULL (issue #41: 21 of 30 types) |
|
||||
| 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. macOS items get an explicit `SecAccess` ACL pinning them to the app's code-signature requirement on every save (`keychain_acl.rs`) — items survive re-signs/upgrades without keychain permission prompts. **Self-healing ACLs**: the ACL is also (silently) verified *before* reads and repaired when this app isn't in it, so items pinned to an older binary path (pre-monorepo `src-tauri/`, a reinstalled app, an ad-hoc dev build) cost one authorization instead of asking for the keychain password on every access, per secret |
|
||||
| Enable keychain toggle | ✅ | Default ON (opt-out); OFF = don't persist the DB password (session-only, re-prompt on connect) + purge existing keychain entry; SSH secrets stay keychain-only (v0.7.6) |
|
||||
| SSH tunnel config UI | ✅ | Host, port, user, auth method, key path, passphrase fields |
|
||||
| SSH tunnel runtime | ✅ | Real ssh2 tunnel (password + key auth), binds 127.0.0.1 only, secrets in OS keychain (`ssh_password:<id>` / `ssh_passphrase:<id>`), closed on pool eviction / app exit; TLS downgraded to `require` through the tunnel |
|
||||
| SSL/TLS config UI | ✅ | Mode (disable/require/verify-ca/verify-full), cert paths |
|
||||
| SSL/TLS runtime | ✅ | PostgreSQL all modes via rustls (disable/require/verify-ca/verify-full; **v1: `verify-ca` behaves as `verify-full`** — documented refinement), client certs PKCS#1/PKCS#8/EC, encrypted client keys rejected; MySQL test path maps modes (verify-full → VerifyIdentity) |
|
||||
| SSL/TLS config UI | ✅ | Mode (disable/require/verify-ca/verify-full), cert paths. Selections (mode + CA/client cert/key) are persisted on create **and** edit — the form data type carries the `ssl_*` fields and the submit handlers pass them through (previously the SSH/SSL tab's values were dropped, so connections saved with TLS off and managed providers rejected them) |
|
||||
| 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). Managed hosts (`*.neon.tech` / `*.supabase.co` / `*.psdb.cloud`) default to `require` when the connection has no `ssl_mode` (`db::tls::resolve_ssl_mode`), so pre-fix saved connections still connect; an explicit mode always wins |
|
||||
| Roles & grants (PG) | ✅ | Create/edit/drop PostgreSQL roles and GRANT/REVOKE privileges per object class from a UI (v0.7.7) |
|
||||
|
||||
### Home Screen & Organization
|
||||
@@ -290,21 +302,21 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
||||
| Smart default sort | ✅ | 12-tier priority: updated_at → created_at → *_at → *_id → seq/rank/version |
|
||||
| Data grid pagination | ✅ | Page nav, page size selector persisted in settings |
|
||||
| Column filtering (server-side) | ✅ | eq, neq, contains, starts, ends, gt, lt, null, notnull pushed to SQL WHERE |
|
||||
| Visual filter builder | ✅ | Drag-and-drop column palette (@dnd-kit) with type-aware operators (textish → contains, else eq), AND semantics, persists in tab `filterRules` |
|
||||
| Visual filter builder | ✅ | Column Filters popover with a "+ Add filter" row editor (column / operator / value selects, per-rule remove, AND semantics, persists in tab `filterRules`). Free-text values are debounced (400ms) so a query only fires after the user stops typing; column/operator changes apply immediately. The filter button is a plain toggle (open when closed, close when open) |
|
||||
| Column sorting (server-side) | ✅ | Multi-column asc/desc pushed to SQL ORDER BY |
|
||||
| Column show/hide | ✅ | Toggle visibility per column |
|
||||
| Column resize (drag handle) | ✅ | Double-click to auto-fit |
|
||||
| Row selection (checkboxes + select all) | ✅ | Bulk copy (JSON/CSV/SQL) and delete |
|
||||
| Export toolbar (JSON, CSV, SQL, Markdown, Excel (.xlsx)) | ✅ | Client-side Blob download of visible rows; Excel (.xlsx) via client-side workbook generation (v0.7.8) |
|
||||
| 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); plus Rust-side **Export all rows to file…** (full-result CSV/JSONL/JSON streaming with progress + cancel) (v0.8.0) |
|
||||
| Excel (.xlsx) export | ✅ | Client-side .xlsx export of visible rows alongside JSON/CSV/SQL/Markdown (v0.7.8) |
|
||||
| Auto-refresh timer | ✅ | Configurable interval in settings |
|
||||
| Changes queue (INSERT, UPDATE, DELETE, bulk_insert, empty_table, drop_table) | ✅ | Stage → **Commit All**. Tab bar **Changes** button (amber border + count badge when pending) toggles a **popover** anchored to it: header with **Visual/SQL** toggle (cards showing op badge + table + description + per-change **Revert**, or a generated-SQL preview via `buildChangeSql`), footer **Clear All** + **Commit All (N)** with **⌘S/Ctrl+S** shortcut. Committed cards show a green ✓ (failed ✗); committing `drop_table` auto-closes open tabs of that table |
|
||||
| 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. **Insert Row** adds an editable pending row at the top of the grid (accent outline); committing inserts only the filled columns so serial/identity/generated defaults apply |
|
||||
| Auto schema-tree refresh | ✅ | Tree auto-refreshes after a successful schema-modifying query run (`CREATE`/`DROP`/`ALTER`/`TRUNCATE` via `isSchemaModifyingQuery`) and after committing schema-modifying queue changes — `drop_table`, schema-modifying `ddl` (e.g. `CREATE TABLE`), and `rebuild_table` — no manual refresh needed |
|
||||
| Data import (CSV/JSON) | ✅ | Table overflow menu → ImportDialog: file pick, parse, preview (first 100 rows), header→column mapping, caps 100k rows / 100 MB; stages a bulk_insert change through the queue → Commit All |
|
||||
| Table menu actions | ✅ | Copy table schema (DDL via pg_dump / sqlite_master), Empty Table (DELETE) / Delete Table (DROP) through the queue with confirm, export stubs wired (JSON/CSV/SQL/Markdown) |
|
||||
| Edit connection modal (from DB viewer) | ✅ | AnimatedModal with keychain password fetch on test |
|
||||
| Connection drop banner | ✅ | Auto-detects broken connections with reconnect prompt |
|
||||
| Inline cell editing | ✅ | Double-click/Enter edits a cell; commit stages an `update` change in the queue → Commit All. No-PK tables use ctid/rowid locator; PK/generated/identity columns and views/matviews are read-only. Stale-write protection via affected-row-count check. **Optimistic UI**: the changes queue is the single source of truth — `deriveStagedValues` feeds staged values + a pending amber dot back into the grid (dot clears on commit, values survive until refetch); re-editing the same cell replaces the queue entry (original `oldData` kept); Clear All removes dots instantly; queue cards show an old → new value diff. **Smart editors**: PG enum columns render a `<select>` of enum labels; FK columns render a searchable dropdown of referenced rows (one row per option showing the first 4 referenced columns, FK-popover styling, 360px fixed, portal to body); `text`/`json`/`jsonb` use a single-line scrolling textarea. |
|
||||
| Inline cell editing | ✅ | Double-click/Enter edits a cell; commit stages an `update` change in the queue → Commit All. No-PK tables use ctid/rowid locator; PK/generated/identity columns and views/matviews are read-only. Stale-write protection via affected-row-count check. **Optimistic UI**: the changes queue is the single source of truth — `deriveStagedValues` feeds staged values + a pending amber dot back into the grid (dot clears on commit, values survive until refetch); re-editing the same cell replaces the queue entry (original `oldData` kept); Clear All removes dots instantly; queue cards show an old → new value diff. **Smart editors**: PG enum columns render a `<select>` of enum labels; FK columns render a searchable dropdown of referenced rows (one row per option showing the first 4 referenced columns, FK-popover styling, 360px fixed, portal to body); `date`/`time`/`timestamp` columns use native date/time/datetime pickers, `boolean` a true/false select, and numeric types a number input; `text`/`json`/`jsonb` use a single-line scrolling textarea. |
|
||||
| Virtualized data grid | ✅ | Row-level virtualization via @tanstack/react-virtual `useVirtualizer`; handles 100k+ rows |
|
||||
| Row detail / expandable row view | ✅ | RowDetailDrawer: right-drawer per row, opened via the cell context menu **View Row** |
|
||||
| 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 |
|
||||
@@ -346,6 +358,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
||||
| 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) |
|
||||
| TimescaleDB hypertables | ✅ | Objects view type on PG connections with the extension; runtime availability gating (v0.8.0) |
|
||||
|
||||
### Query Editor
|
||||
| Feature | Status | Details |
|
||||
@@ -355,7 +368,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
||||
| Destructive query guard | ✅ | Confirmation dialog for INSERT/UPDATE/DELETE/DROP/ALTER/TRUNCATE/CREATE/REPLACE (`isDestructiveQuery` + `DestructiveQueryDialog`) |
|
||||
| Query history / recent queries | ✅ | v5 `query_history` table + v6 `favorite` column; consecutive-identical dedup + retention pruning (500/connection); `get_query_history`/`clear_query_history`/`set_history_favorite` commands; **QueryHistoryDropdown** in the query toolbar (load / run / favorite / clear, lazy fetch, loading + error states) |
|
||||
| SQL autocomplete (keywords, tables, columns) | ✅ | Completion provider in `src/lib/monacoSetup.ts` backed by `src/lib/sqlCompletion.ts` (pure, unit-tested): keywords (~60) + table names from the active schema; typing `table.` or `schema.table.` suggests that table's columns (introspected via `get_schema_graph`, cached per schema in memory, `incomplete: true` warm-up on first use) |
|
||||
| Multiple result sets | ❌ | |
|
||||
| Multiple result sets | ✅ | Stacked panels per statement via execute_query_multi; DML/DDL notices; stop-at-first-error (v0.8.0) |
|
||||
| Cancel long-running queries | ✅ | Per-connection cancel from the query toolbar (`pg_cancel_backend` / MySQL `KILL` / SQLite interruption) instead of waiting or killing the app (v0.7.8) |
|
||||
| Saved queries (named, organized) | ✅ | v6 `queries` table (nullable `connection_id` for global queries, `folder` field, `ON DELETE CASCADE`); `save_query`/`get_saved_queries`/`update_saved_query`/`delete_saved_query` commands with validation (name ≤200, folder ≤100, text ≤1MB); **SaveQueryDialog** (name + folder, empty-name guard); managed in the Queries view Saved tab |
|
||||
| Query favorites / pinning | ✅ | Star toggle per history entry via `set_history_favorite`; favorites-only filter in the Queries view History tab |
|
||||
@@ -371,9 +384,12 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
||||
| Restore UI | ✅ | In-page view: file browse, format, clean toggle, destructive confirmation checkbox, progress bar. **Clean toggle is disabled for plain format** (psql can't DROP-before-CREATE) with a hint to use Custom Archive |
|
||||
| DB-to-DB sync | ✅ | In-page view: source/target connection pickers, schema dropdown, pipe-based pg_dump → pg_restore. **pg_restore side passes `--clean --if-exists`**, so sync works into a non-empty target (UI already requires destructive-overwrite confirmation). Core logic in headless-testable `run_db_sync` |
|
||||
| Unified Tools view | ✅ | Backup / Restore / DB Sync merged into a single **Tools** nav item; operation-switcher dropdown in the view toolbar, existing forms rendered below |
|
||||
| Bundled PostgreSQL client tools | ✅ | Static pg_dump/pg_restore/psql shipped as Tauri resources; system-first, bundled-fallback resolution via resource_dir (v0.7.5) |
|
||||
| Bundled PostgreSQL client tools | ✅ | Static pg_dump/pg_restore/psql shipped as Tauri resources; system-first, bundled-fallback resolution (v0.7.5). **Both engines now resolve through one headless `db/tool_resolver.rs`** (v0.8.3): trusted absolute dirs (Homebrew, MacPorts, Postgres.app, `/usr/local`) → inherited `PATH` → bundled, with the bundled tools read from the nested `resources/pg_tools` / `resources/mysql_tools` path Tauri actually produces (the pre-0.8.3 join dropped the `resources/` component, so bundled fallback never worked). Candidates must be regular files that run `--version` (1 s per probe, 3 s aggregate). `resources/mysql_tools` (mariadb-dump / mariadb) is required for MySQL **backup** and **DB Sync** only; MySQL restore is native since v0.8.2 |
|
||||
| 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) |
|
||||
| Backup / Restore / Sync for MySQL & SQLite | ✅ | MySQL **backup** via the resolved dump client — system `mariadb-dump`, then system `mysqldump` (this is what Homebrew `mysql-client` installs), then the bundled `mariadb-dump`; DB-to-DB sync extended beyond PostgreSQL (v0.7.8). A **non-blocking** client/server version warning is emitted at job start when the resolved dump client's major version is below the server's (MariaDB's legacy `5.5.5-<real>-MariaDB` prefix is unwrapped). **MySQL restore runs in-process over the sqlx driver** (v0.8.2) — `run_mysql_restore_on` splits the script (`DELIMITER`-aware) and executes it on one connection, **no `mariadb` client and no subprocess**. MySQL backup and DB Sync still shell out to the resolved dump client |
|
||||
| MySQL `clean` restore semantics | ✅ | `Clean` drops **only the objects the dump recreates** (`CREATE TABLE`/`VIEW`/`PROCEDURE`/`FUNCTION`/`TRIGGER`/`EVENT`), matching `pg_restore --clean` — an unrelated table in the target survives (`db/mysql_clean.rs`, v0.8.2). Deliberately not `DROP DATABASE`. The drops run with `FOREIGN_KEY_CHECKS = 0` (restored right after) so ordering cannot fail the restore. **MySQL DDL auto-commits and cannot be rolled back**, so a failure stops at the failing statement (reported with its position) and the Restore form warns that a failed restore "may leave this database partially applied" |
|
||||
| `.sql` file association (open with Gridline) | ✅ | `bundle.fileAssociations` in `tauri.conf.json` registers `.sql` as **SQL Script** (`role: Editor`, **`rank: Alternate`** — Gridline never hijacks the default handler, it only appears under *Open With*, v0.8.2). The OS path is validated (absolute, `.sql` extension, real file, ≤5 MB — `MAX_SQL_FILE_BYTES`, symlinks not followed) and queued in Rust, then drained by the **argument-free** `take_pending_sql_files` command, so the webview can never name a file to read. The text lands in a query tab named after the file and is **never executed** — Run still goes through the destructive-query guard. Re-opening the same file focuses the existing tab without discarding edits. With no connection open the file is stashed and Home shows a banner prompting for one (macOS `RunEvent::Opened`; Windows/Linux argv + single-instance forward) |
|
||||
| Schema diff / compare | ✅ | Tools → Schema Diff; same-engine snapshot diff; safe items stage into the changes queue (v0.8.0) |
|
||||
| Table structure export (DDL) | ❌ | |
|
||||
|
||||
### Settings
|
||||
|
||||
@@ -18,43 +18,46 @@
|
||||
<a href="https://react.dev/"><img src="https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB" alt="React" /></a>
|
||||
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript" /></a>
|
||||
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-D22128.svg?style=for-the-badge" alt="Apache 2.0 License" /></a>
|
||||
<a href="https://git.ranio.xyz/adrianbonpin/gridline"><img src="https://img.shields.io/badge/Open_Source-Gitea-4183C4.svg?style=for-the-badge" alt="Open source on Gitea" /></a>
|
||||
<a href="https://github.com/AdrianBonpin/gridline"><img src="https://img.shields.io/badge/Open_Source-GitHub-181717.svg?style=for-the-badge" alt="Open source on GitHub" /></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://git.ranio.xyz/adrianbonpin/gridline/releases"><img src="https://img.shields.io/badge/Download_Latest_Release-2ea44f?style=for-the-badge" alt="Download Latest" /></a>
|
||||
<a href="https://git.ranio.xyz/adrianbonpin/gridline/issues/new"><img src="https://img.shields.io/badge/Open_an_Issue-%23E4405F.svg?style=for-the-badge" alt="Open an Issue" /></a>
|
||||
<a href="https://github.com/AdrianBonpin/gridline/releases"><img src="https://img.shields.io/badge/Download_Latest_Release-2ea44f?style=for-the-badge" alt="Download Latest" /></a>
|
||||
<a href="https://github.com/AdrianBonpin/gridline/issues/new"><img src="https://img.shields.io/badge/Open_an_Issue-%23E4405F.svg?style=for-the-badge" alt="Open an Issue" /></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Download
|
||||
|
||||
Grab the installer for your OS from the [latest release](https://git.ranio.xyz/adrianbonpin/gridline/releases/latest) — the links below point at the current release (**v0.7.12**):
|
||||
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.8.3**):
|
||||
|
||||
| OS | Architecture | Download |
|
||||
| :--------------------------- | :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.12_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_aarch64.dmg) |
|
||||
| **macOS** | Intel | [Gridline_0.7.12_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_x64.dmg) |
|
||||
| **Windows** | x64 | [Gridline_0.7.12_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_x64-setup.exe) |
|
||||
| **Debian / Ubuntu** | amd64 | [Gridline_0.7.12_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_amd64.deb) |
|
||||
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.12-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline-0.7.12-1.x86_64.rpm) |
|
||||
| **Other Linux** | amd64 | [Gridline_0.7.12_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_amd64.AppImage) |
|
||||
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.8.3_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_aarch64.dmg) |
|
||||
| **macOS** | Intel | [Gridline_0.8.3_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_x64.dmg) |
|
||||
| **Windows** | x64 | [Gridline_0.8.3_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_x64-setup.exe) |
|
||||
| **Debian / Ubuntu** | amd64 | [Gridline_0.8.3_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_amd64.deb) |
|
||||
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.8.3-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline-0.8.3-1.x86_64.rpm) |
|
||||
| **Other Linux** | amd64 | [Gridline_0.8.3_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_amd64.AppImage) |
|
||||
|
||||
**macOS via Homebrew** (recommended for Mac users):
|
||||
|
||||
```bash
|
||||
brew install --cask AdrianBonpin/gridline/gridline
|
||||
# or, after a one-time `brew tap AdrianBonpin/gridline`:
|
||||
# one-time setup (the tap is hosted on GitHub):
|
||||
brew tap AdrianBonpin/gridline https://github.com/AdrianBonpin/homebrew-gridline.git
|
||||
brew trust adrianbonpin/gridline
|
||||
|
||||
# install / upgrade:
|
||||
brew install --cask gridline
|
||||
```
|
||||
|
||||
The cask strips the macOS quarantine flag automatically during install, so **no manual `xattr` step is needed** on the Homebrew path — the app just launches. (Direct-DMG downloads still need the [first-launch instructions](#installers-are-unsigned-for-now) below.)
|
||||
The cask installs the app directly. The DMGs are Developer-ID signed and notarized, so macOS trusts them on first launch — no `xattr` or "Open Anyway" step needed.
|
||||
|
||||
> **macOS first launch:** macOS may say *"Gridline is damaged and can't be opened"* or *"the developer cannot be verified"* — this is expected; the app isn't Developer-ID signed/notarized yet. See the [macOS first-launch instructions](#installers-are-unsigned-for-now) (right-click → Open, or the one-time `xattr` fix). Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below.
|
||||
> **macOS first launch:** Gridline is Developer-ID signed and notarized, so macOS opens it without a Gatekeeper prompt. Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below.
|
||||
|
||||
<!--
|
||||
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.7.12
|
||||
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.8.3
|
||||
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:
|
||||
@@ -99,6 +102,18 @@ Gridline is built for developers and small teams who manage multiple database en
|
||||
<details>
|
||||
<summary>View the full changelog</summary>
|
||||
|
||||
- **v0.8.3** — **Your installed database tools are found again** ([issue #44](https://github.com/AdrianBonpin/gridline/issues/44)): Gridline reported *"mysqldump not found"* even when the tools were installed, for three separate reasons — the bundled binaries were looked up one directory too high (`<resource_dir>/mysql_tools` instead of the `<resource_dir>/resources/mysql_tools` the installer actually bundles, which silently disabled the bundled fallback for PostgreSQL too), a macOS GUI app inherits launchd's minimal `PATH` rather than your shell's, and only `mariadb-dump`/`mariadb` were ever probed — never `mysqldump`/`mysql`, which is what `brew install mysql-client` installs. Both engines now resolve through one ordered chain (trusted absolute directories → your `PATH` → bundled), a candidate counts only if it is a real file that runs `--version`, and an explicit **Check again** re-detects on demand. **The bundled MySQL client now actually runs**: backup and sync were passing MySQL-only options (`--databases=<db>`, `--skip-column-statistics`, `--ssl-mode=…`) to the bundled MariaDB binary, so the job died at option parsing before connecting; options are now tailored per client. **Backup, Restore, DB Sync and Schema Diff say which tool they use and where it came from** (`Using system mysqldump`, `Using bundled mariadb-dump`), explain a bundled copy that is present but cannot run, offer one-click copy of the install command, warn non-blockingly when the dump client's major version is older than the server's, and share the Objects view's form layout. **The app icon is back to its previous rendering** — the macOS "legacy plate" strip from #40 was reverted at the maintainer's request.
|
||||
|
||||
- **v0.8.2** — **MySQL values actually show up**: every non-string MySQL column — ids and other integers, `DECIMAL`, `BIT`, `YEAR`, `DATE`/`TIME`/`DATETIME`/`TIMESTAMP`, `FLOAT`/`DOUBLE` — was rendering as `NULL`, so primary-key columns looked empty; all 30 column types now decode by their declared type, with 64-bit integers and `DECIMAL` carried as strings so no digits are lost and `DATETIME(6)` keeping its microseconds. **MySQL restore works against MySQL 8** — it no longer shells out to a bundled MariaDB client (which cannot authenticate with `caching_sha2_password`), running in-process over the same driver browsing uses, stopping at the first error with the failing statement's position, cancellable, and `DELIMITER`-aware so dumps with triggers and stored routines apply. **The `Clean` toggle is now real**: it drops only the objects the dump recreates, so an unrelated table in the target survives — and since MySQL DDL auto-commits, the Restore form warns a failed restore cannot be rolled back. **`.sql` files open with Gridline**: right-click → Open With loads the script into a query tab named after the file; it never runs on open (Run still confirms anything destructive), and with no connection open the file is held with a prompt until you pick one.
|
||||
|
||||
- **v0.8.1** — **Neon / Supabase / PlanetScale connections fixed**: a pasted connection string's `?sslmode=` is now carried into the saved connection (it was silently dropped, so TLS stayed off and Neon replied *"connection is insecure (try using `sslmode=require`)"*); the SSH / SSL tab's mode and certificate paths persist on create *and* edit, managed presets pre-select their TLS mode, and `*.neon.tech` / `*.supabase.co` / `*.psdb.cloud` hosts default to `require` when no mode is stored — so previously-saved connections keep working. Pasting a URI also fills every setting it can and suggests a name, so a URL can be tested and saved with no retyping. **macOS keychain prompts self-heal**: items whose ACL still points at an older binary path made macOS ask for the keychain password on *every* access, once per secret — the app now checks the ACL (silently, before reading) and repairs it when needed, so you authorize a secret once. Version bump.
|
||||
|
||||
- **v0.7.15** — **Support channel on the landing page**: optional coffee via [Ko-fi](https://ko-fi.com/adrianbonpin), never gates a feature. Landing copy refreshed (em dashes dropped, OSS section simplified, funding FAQ); version bump.
|
||||
|
||||
- **v0.7.14** — **No more repeated macOS keychain prompts**: saved connection passwords and SSH secrets are now ACL-pinned to the app's code signature on every save (`keychain_acl`), so app upgrades never re-trigger the macOS keychain access dialog (users upgrading from pre-signing builds see one final prompt — choose **Always Allow**). **Install via Homebrew**: `brew tap AdrianBonpin/gridline https://github.com/AdrianBonpin/homebrew-gridline.git` then `brew install --cask gridline`; version bump.
|
||||
|
||||
- **v0.7.13** — **macOS is now Developer-ID signed and notarized**: the app is properly code-signed with a Developer ID Application certificate and notarized by Apple, so macOS opens it without the Gatekeeper "Open Anyway" prompt or the `xattr` quarantine workaround. The release script (`scripts/release-mac.sh`) auto-detects the signing identity from the keychain, notarizes + staples both architectures, and verifies the result before uploading; version bump.
|
||||
|
||||
- **v0.7.12** — **Reliable cell editing for non-text columns**: editing a PostgreSQL cell whose type isn't `text` (integers, booleans, UUIDs, `json`/`jsonb`, timestamps, enums, numerics, …) previously failed with a Rust serialization error; edited values are now sent to PostgreSQL in text wire format so the server parses them into the column's own type. **Recent connections fix**: the Recent strip no longer renders empty on first launch when the connections list hadn't loaded yet. **Full CI release pipeline for Windows**: self-hosted Windows runner builds the `.exe`/`.msi` installers (bundled `pg_dump`/`pg_restore`/`psql` + MariaDB clients) and uploads them to the release alongside Linux; version bump.
|
||||
|
||||
- **v0.7.10** — **Copy connection URL**: from a connection card's ⋮ menu, copy the connection string for use in env vars / other tools — **Copy connection URL** (includes the password, fetched from the OS keychain on demand) and **Copy connection URL (no password)** (safe to share). Builds `postgresql://` / `mysql://` / `sqlite://` / `redis://` strings with percent-encoded credentials and the PG `sslmode` appended; version bump.
|
||||
@@ -303,41 +318,48 @@ _Notes: DB Pro is an Electron app (launched Nov 2025) whose marketing copy overc
|
||||
|
||||
### Download a release
|
||||
|
||||
Pre-built installers for macOS, Windows, and Linux are published on the [Releases](https://git.ranio.xyz/adrianbonpin/gridline/releases) page.
|
||||
Pre-built installers for macOS, Windows, and Linux are published on the [Releases](https://github.com/AdrianBonpin/gridline/releases) page.
|
||||
|
||||
> ⚠️ Gridline is under active development. Expect rough edges and please [open issues](https://git.ranio.xyz/adrianbonpin/gridline/issues/new) when you hit them.
|
||||
> ⚠️ Gridline is under active development. Expect rough edges and please [open issues](https://github.com/AdrianBonpin/gridline/issues/new) when you hit them.
|
||||
|
||||
#### Installers are unsigned (for now)
|
||||
#### macOS signing & notarization
|
||||
|
||||
Gridline is currently distributed **unsigned** — it doesn't pay for code-signing certificates yet (macOS builds are *ad-hoc signed*, so they pass Apple Silicon's launch checks but aren't Developer-ID signed or notarized). Your OS will warn you the first time you open it. This is expected — the app is safe, it just hasn't paid the signing fee:
|
||||
Gridline's macOS builds are **Developer-ID signed and notarized by GitHub Actions**, so macOS opens them without a Gatekeeper prompt. Both the `.app` and the `.dmg` it ships inside are notarized and carry a stapled ticket — notarizing only the `.app` is not enough, because Gatekeeper assesses the downloaded DMG itself and rejects it as "Unnotarized Developer ID" even when the app within is fine.
|
||||
|
||||
> **Homebrew users:** the [Homebrew cask](#download) strips the quarantine flag automatically during install, so you skip all of the steps below — the app just launches.
|
||||
**One-time setup** (only the repo owner needs to do this):
|
||||
|
||||
- **macOS:** if you see *"Gridline can't be opened because the developer cannot be verified"* (or the app simply won't open from Finder), right-click the app → **Open** → **Open** (or System Settings → Privacy & Security → **Open Anyway**). Do this once per version.
|
||||
- **macOS — "Gridline is damaged and can't be opened":** remove the quarantine flag macOS attaches to downloaded apps, then launch normally:
|
||||
1. **Create a Developer ID Application certificate** — [developer.apple.com](https://developer.apple.com) → Certificates, Identifiers & Profiles → **+** → **Developer ID Application**. Generate a Certificate Signing Request from Keychain Access (Certificate Assistant → *Request a Certificate From a Certificate Authority*), upload it, download the `.cer`, and double-click to install into your login keychain. Verify with `security find-identity -v -p codesigning`.
|
||||
2. **Create an App Store Connect API key** for notarization — [appstoreconnect.apple.com](https://appstoreconnect.apple.com) → Users and Access → **Integrations** → **App Store Connect API** → **+** (name it, *Developer* access). Download the `.p8` (shown once) and note the **Key ID** and **Issuer ID**.
|
||||
3. **Add the signing secrets** to the repository (**Settings → Secrets and variables → Actions**):
|
||||
|
||||
```bash
|
||||
sudo xattr -dr com.apple.quarantine /Applications/Gridline.app
|
||||
```
|
||||
| Secret | Value |
|
||||
| :--------------------------- | :------------------------------------------------------------------------------------------------- |
|
||||
| `APPLE_CERTIFICATE` | base64 of the exported Developer ID `.p12` — `base64 -i DeveloperID.p12 \| pbcopy` |
|
||||
| `APPLE_CERTIFICATE_PASSWORD` | the password you set when exporting the `.p12` |
|
||||
| `APPLE_SIGNING_IDENTITY` | e.g. `Developer ID Application: Your Name (TEAMID)` — from `security find-identity -v -p codesigning` |
|
||||
| `KEYCHAIN_PASSWORD` | any throwaway password for the temporary CI keychain |
|
||||
| `APPLE_API_KEY` | App Store Connect **Key ID** |
|
||||
| `APPLE_API_ISSUER` | App Store Connect **Issuer ID** |
|
||||
| `APPLE_API_KEY_CONTENT` | contents of the downloaded `AuthKey_<KeyID>.p8` |
|
||||
|
||||
(`sudo` is required — some files inside the bundle are read-only, so removing the flag needs admin rights.) Re-run after **every upgrade** — each freshly-downloaded copy gets re-quarantined.
|
||||
- **Windows:** on the SmartScreen prompt, click **More info** → **Run anyway**.
|
||||
- **Linux:** no warning — install and run normally.
|
||||
When these secrets are absent the macOS jobs still build, but the DMGs are ad-hoc signed and **not** notarized (users get the “Open Anyway” prompt). The CI re-signs the bundled PostgreSQL/MariaDB client binaries with the same Developer ID identity, because Apple rejects ad-hoc-signed nested executables during notarization.
|
||||
|
||||
Code signing **will be added in the future** (Apple Developer Program + a Windows signing cert, e.g. Azure Trusted Signing) — the CI workflow is already wired to pick up the signing secrets automatically the moment they exist, no workflow changes needed.
|
||||
`scripts/release-mac.sh` is the **local fallback** path (handy for a signed build without burning CI minutes, or when Actions is unavailable). It auto-detects the Developer ID identity from your keychain, reads notarization credentials from `~/.config/gridline/notarize.env` (`APPLE_API_KEY`, `APPLE_API_ISSUER`, `APPLE_API_KEY_PATH`), signs both architectures, notarizes + staples them, verifies the result, then uploads the DMGs to the **GitHub** release for the tag (`gh release upload --clobber`, so a locally signed pair supersedes the CI-built ones — publish the draft afterwards). If the certificate or credentials are missing it fails with instructions (set `GRIDLINE_SKIP_NOTARIZE=1` to build signed-but-unnotarized for testing only).
|
||||
|
||||
**Windows** still shows a SmartScreen prompt (no Windows signing cert yet); **Linux** installs without a warning.
|
||||
|
||||
#### Which file should I download?
|
||||
|
||||
Each release contains **one file per platform** — you only need the one that matches your computer. The links below point at the current release (**v0.7.12**):
|
||||
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.8.3**):
|
||||
|
||||
| Your system | Download this | Notes |
|
||||
| :------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- |
|
||||
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.7.12_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_aarch64.dmg) | `aarch64` = Apple's own chip |
|
||||
| macOS **Intel** | [Gridline_0.7.12_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_x64.dmg) | `x64` = Intel/AMD |
|
||||
| **Windows** (most PCs) | [Gridline_0.7.12_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
|
||||
| **Debian / Ubuntu** | [Gridline_0.7.12_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.12_amd64.deb` |
|
||||
| **Fedora / RHEL / openSUSE** | [Gridline-0.7.12-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline-0.7.12-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.12-1.x86_64.rpm` |
|
||||
| **Any other Linux** | [Gridline_0.7.12_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it |
|
||||
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.8.3_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_aarch64.dmg) | `aarch64` = Apple's own chip |
|
||||
| macOS **Intel** | [Gridline_0.8.3_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_x64.dmg) | `x64` = Intel/AMD |
|
||||
| **Windows** (most PCs) | [Gridline_0.8.3_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
|
||||
| **Debian / Ubuntu** | [Gridline_0.8.3_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_amd64.deb) | Install: `sudo apt install ./Gridline_0.8.3_amd64.deb` |
|
||||
| **Fedora / RHEL / openSUSE** | [Gridline-0.8.3-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline-0.8.3-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.8.3-1.x86_64.rpm` |
|
||||
| **Any other Linux** | [Gridline_0.8.3_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.8.3/Gridline_0.8.3_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.
|
||||
|
||||
@@ -347,11 +369,13 @@ Cutting a release is one command — CI builds everything. **Releases are cut fr
|
||||
|
||||
```bash
|
||||
git checkout prod && git pull
|
||||
git tag v0.7.12
|
||||
git push origin v0.7.12
|
||||
git tag v0.7.15
|
||||
git push origin v0.7.15
|
||||
```
|
||||
|
||||
Gitea Actions (`.gitea/workflows/release.yml`) builds the **Linux and Windows** installers on the self-hosted runners, then opens a **draft release** on the [Releases](https://git.ranio.xyz/adrianbonpin/gridline/releases) page. **macOS is built manually** on the dev Mac and its DMGs uploaded to the same release via `scripts/release-mac.sh`. Review the draft and hit **Publish release**.
|
||||
GitHub Actions (`.github/workflows/release.yml`) builds the **macOS (Apple Silicon + Intel), Windows, and Linux** installers in parallel and opens a **draft release** on the [Releases](https://github.com/AdrianBonpin/gridline/releases) page. Review the draft and hit **Publish release** — publishing triggers a follow-up job that bumps the Homebrew cask in [`AdrianBonpin/homebrew-gridline`](https://github.com/AdrianBonpin/homebrew-gridline) (pushed via a write-enabled deploy key on the tap, `HOMEBREW_TAP_DEPLOY_KEY`; skipped when unset).
|
||||
|
||||
The self-hosted Gitea mirror (`.gitea/workflows/release.yml`) still builds Linux and Windows for the Gitea release, and `scripts/release-mac.sh` can build macOS locally — both are kept as a fallback if GitHub is unavailable.
|
||||
|
||||
Before tagging, make sure the version number is in sync across `desktop/package.json`, `desktop/src-tauri/Cargo.toml`, and `desktop/src-tauri/tauri.conf.json`, and update the **README download tables** (Download + Which file should I download?) to the new version's asset names.
|
||||
|
||||
@@ -359,7 +383,7 @@ Before tagging, make sure the version number is in sync across `desktop/package.
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone https://git.ranio.xyz/adrianbonpin/gridline.git
|
||||
git clone https://github.com/AdrianBonpin/gridline.git
|
||||
cd gridline
|
||||
|
||||
# 2. Install all workspace dependencies
|
||||
@@ -475,7 +499,7 @@ Highlights of what's next:
|
||||
|
||||
Contributions, bug reports, and feature ideas are welcome. Gridline is Apache 2.0-licensed and intentionally stays open — no paywalled tiers, no bundled proprietary services.
|
||||
|
||||
- Open an issue via the **[template chooser](https://git.ranio.xyz/adrianbonpin/gridline/issues/new)** — pick **Bug report** (with an environment table so we can reproduce issues quickly), **Feature request**, or **Improvement** (UX polish for existing features). New issues are auto-assigned to the maintainer.
|
||||
- Open an issue via the **[template chooser](https://github.com/AdrianBonpin/gridline/issues/new)** — pick **Bug report** (with an environment table so we can reproduce issues quickly), **Feature request**, or **Improvement** (UX polish for existing features). New issues are auto-assigned to the maintainer.
|
||||
- Submit a pull request. Keep Tauri commands thin, type IPC boundaries explicitly, and follow the existing Rust/React conventions.
|
||||
|
||||
---
|
||||
|
||||
+76
-53
@@ -6,38 +6,27 @@ This file is the **source of truth** for what Gridline is building. [AGENTS.md](
|
||||
|
||||
---
|
||||
|
||||
## ✅ Shipped (0.7.10)
|
||||
## ✅ Shipped (0.8.0)
|
||||
|
||||
- **Copy connection URL** — from a connection card's ⋮ menu, copy the connection string for use in env vars / other tools: **Copy connection URL** (includes the password, fetched from the OS keychain on demand) and **Copy connection URL (no password)** (safe to share). Builds `postgresql://` / `mysql://` / `sqlite://` / `redis://` strings with percent-encoded credentials and the PG `sslmode` appended.
|
||||
- **Version bump** 0.7.9 → **0.7.10**.
|
||||
- **Schema diff / compare** — same-engine structural diff (PG↔PG, MySQL-family↔MySQL-family, SQLite↔SQLite) with per-object sync SQL; non-destructive items stage through the changes queue, destructive items are copy-only. Target = current connection; source = any saved same-family connection.
|
||||
- **Query Workbench: multiple result sets** — stacked result panels per statement with DML/DDL affected-count notices and stop-at-first-error *(deferred from 0.7.0)*
|
||||
- **Query Workbench: result streaming to file** — full-result CSV/JSONL/JSON export with progress + cancel, constant memory (PG cursor fetch, MySQL/SQLite native streams)
|
||||
- **MariaDB support** — first-class `db_type` reusing the MySQL driver path; provider card, filter, URI schemes, backup via the already-bundled mariadb tools
|
||||
- **TimescaleDB support** — runtime extension detection; hypertables in the Objects view (dimensions, compression, chunks, size), read-only
|
||||
- **PlanetScale preset** — managed MySQL (Vitess) provider card + setup guide + `.psdb.cloud` host detection; persists as `db_type: mysql`
|
||||
- **Version bump** 0.7.15 → **0.8.0**.
|
||||
|
||||
## ✅ Shipped (0.7.9)
|
||||
## ✅ Shipped (unreleased)
|
||||
|
||||
- **macOS Finder launch fix** — the local store opens under the OS app-data directory instead of a cwd-relative `gridline.db` (Finder/LaunchServices launches run with cwd `/`, so the old path made the app silently exit with a Rust panic before the UI started).
|
||||
- **macOS ad-hoc code signing** — bundles are signed at build time (`bundle.macOS.signingIdentity "-"`, hardened runtime off), replacing the Xcode linker-only signature that macOS treated as unsigned ("damaged and can't be opened", silent Finder refusal). Users still get a one-time Gatekeeper prompt; README documents the `sudo xattr -dr com.apple.quarantine` workaround.
|
||||
- **Vendored OpenSSL for `ssh2`** — the release binary no longer links an absolute Homebrew `/opt/homebrew/.../libssl.3.dylib` path (dyld aborted on machines without it).
|
||||
- **Version bump** 0.7.8 → **0.7.9**.
|
||||
|
||||
## 🎯 Next up (0.8.0) — TBD
|
||||
|
||||
Scope to be determined.
|
||||
|
||||
## ✅ Shipped (0.7.8)
|
||||
|
||||
- **SQLite `.dump` support** — match the pg_dump UX for SQLite (backup a SQLite database to a portable SQL dump, restore it back)
|
||||
- **Backup / Restore / Sync for MySQL & SQLite** — extend the pg-only tooling today: SQLite backup/restore + MySQL via `mysqldump` (system-first; decide bundling)
|
||||
- **Excel (.xlsx) export** — alongside CSV/JSON/SQL/Markdown in the grid export toolbar
|
||||
- **Cancel long-running queries** — per-connection cancel button (`pg_cancel_backend` and equivalents) instead of waiting or killing the app
|
||||
- **Settings export / import** — share theme, accent, editor options, page sizes, and defaults across machines (JSON file)
|
||||
- **Windows/Linux title bar fix** — the macOS "Overlay" drag strip (`h-7` in `App.tsx`) renders on every Tauri platform, so Windows/Linux show a blank grabbable bar between the native title bar and the page; gate the strip to macOS only (native title bar already handles dragging elsewhere)
|
||||
- **SQLite table editor (Create/Edit Table)** — the visual Create Table / Edit Table flow is PostgreSQL-only today (`tableManagement` capability + PG-flavored SQL gen in `TableForm`); extend to SQLite: SQLite-aware type mapping (no `serial` — `INTEGER PRIMARY KEY AUTOINCREMENT` instead), `TEXT`/`REAL`/`BLOB`, and ALTER TABLE limits (`ADD COLUMN` can't add PK/UNIQUE, `DROP COLUMN` needs SQLite ≥3.35)
|
||||
- **Version bump** 0.7.7 → **0.7.8**.
|
||||
- **Connection-string SSL mode is honored end to end** — pasting a provider URL now carries its `?sslmode=` (Neon/Supabase `require`, `verify-full`, MySQL `ssl-mode=`) into the saved connection instead of silently connecting with TLS off, and the SSH/SSL tab's mode + certificate paths are persisted on create/edit (they were previously dropped before reaching the backend). Save/Test also fall back to a URL-derived label, so a pasted URL can be saved without typing anything. Fixes managed-provider connections failing with *"connection is insecure (try using `sslmode=require`)"*.
|
||||
- **Managed hosts default to TLS** — `*.neon.tech` / `*.supabase.co` / `*.psdb.cloud` resolve to `require` when a connection has no `ssl_mode`, so connections saved before the form persisted it (or imported without it) connect again without being re-saved. An explicit mode — including `disable` — always wins.
|
||||
- **Managed presets preselect their TLS mode** — Supabase/NeonDB → Require, PlanetScale → Verify Full.
|
||||
|
||||
## 📋 In the queue
|
||||
|
||||
### Full Redis Support
|
||||
|
||||
Connection + test work today; **key browsing is explicitly not part of 0.7.0** — it stays gated behind an "unsupported" state. Goal: first-class Redis like the PG/SQLite/MySQL viewers.
|
||||
Connection + test only today — browsing stays gated behind a clean "unsupported" state. Goal: first-class Redis like the PG/SQLite/MySQL viewers.
|
||||
|
||||
- Key browser (pattern search, filter by type, TTL display, key count)
|
||||
- Value editors per type: string, list, hash, set, zset, stream, JSON
|
||||
@@ -46,28 +35,25 @@ Connection + test work today; **key browsing is explicitly not part of 0.7.0**
|
||||
|
||||
### Additional Database Types
|
||||
|
||||
- **MariaDB** (wire-compatible with MySQL — should largely fall out of the 0.7.0 MySQL work)
|
||||
- **TimescaleDB** (PostgreSQL extension — largely free once PG browsing is solid; surface hypertables/compression in the object tree)
|
||||
- Candidates after that: CockroachDB, DuckDB, SQL Server, MongoDB (drivers are heavier lifts — revisit with demand)
|
||||
MariaDB and TimescaleDB moved to [0.8.0](#-next-up-080). Remaining candidates: CockroachDB, DuckDB, SQL Server, MongoDB (drivers are heavier lifts — revisit with demand).
|
||||
|
||||
### Managed Database Support (beyond Supabase / Neon)
|
||||
|
||||
Supabase and NeonDB presets shipped in v0.7.0. Remaining candidates:
|
||||
Supabase and NeonDB presets shipped in v0.7.0; PlanetScale moved to [0.8.0](#-next-up-080). Remaining:
|
||||
|
||||
- **PlanetScale** (Vitess/MySQL)
|
||||
- **Turso** (libSQL)
|
||||
- Provider detection guidance: paste a provider URL → Gridline auto-fills host/port/SSL mode; provider "connect" docs linked from the connection form
|
||||
- **Turso** (libSQL — not SQLite over the wire; needs a `libsql` driver path in the Rust backend, not just a connection preset)
|
||||
|
||||
### Query Workbench Upgrades
|
||||
|
||||
- **Multiple result sets** — one query, multiple result tabs (stacked/scrollable) instead of only the last result *(deferred from 0.7.0)*
|
||||
- **Result streaming to file** — export 500k+ rows without loading them all into memory
|
||||
Multiple result sets and result streaming to file moved to [0.8.0](#-next-up-080). Remaining:
|
||||
|
||||
- **Visual query builder** — drag-and-drop tables/joins/filters that generate SQL (TablePlus has one; DB Pro plans one)
|
||||
|
||||
### Schema & Data Tooling
|
||||
|
||||
- **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)*
|
||||
Schema diff / compare moved to [0.8.0](#-next-up-080). Remaining:
|
||||
|
||||
- **MySQL Objects view + schema visualizer** — functions/triggers/sequences/enums/extensions browsing and ER diagram for MySQL *(PostgreSQL-only today)*
|
||||
- **More export formats** — JSONL, Parquet alongside CSV/JSON/SQL/Markdown/Excel
|
||||
|
||||
## 🔮 Planned
|
||||
@@ -84,9 +70,9 @@ Bring-your-own-key — no bundled model, no paywall, key stored in the OS keycha
|
||||
|
||||
### Website & Docs
|
||||
|
||||
- Landing page with screenshots, feature tour, and download links
|
||||
The marketing site (`www/`, Astro) is live: landing page with screenshots, FAQ, and a changelog feed. Remaining:
|
||||
|
||||
- User documentation (connection setup, SSH/TLS, backup/sync, changes queue)
|
||||
- Blog / changelog feed
|
||||
|
||||
### UI/UX Improvements (rolling)
|
||||
|
||||
@@ -99,15 +85,63 @@ Continuous polish, tracked as issues rather than one-off milestones:
|
||||
- Session restore — reopen tabs and query state from the last session
|
||||
- Light-theme parity pass — dark is first-class; polish the light theme to match
|
||||
|
||||
Deferred from 0.7.0, slated for this bucket:
|
||||
Still deferred from 0.7.0, slated for this bucket:
|
||||
|
||||
- **Onboarding tour** — first-run walkthrough built around the Gridline Demo database; contextual tooltips per screen
|
||||
- **Settings export/import** — share theme, accent, editor options, page sizes, and defaults across machines (JSON file)
|
||||
- **SSH key management** — read/generate key pairs and paste private keys directly in the SSH tab (today: path inputs only)
|
||||
- **In-app changelog** — "What's new" panel fed from bundled release notes
|
||||
|
||||
---
|
||||
|
||||
## ✅ Shipped (0.7.10)
|
||||
|
||||
- **Copy connection URL** — from a connection card's ⋮ menu, copy the connection string for use in env vars / other tools: **Copy connection URL** (includes the password, fetched from the OS keychain on demand) and **Copy connection URL (no password)** (safe to share). Builds `postgresql://` / `mysql://` / `sqlite://` / `redis://` strings with percent-encoded credentials and the PG `sslmode` appended.
|
||||
- **Version bump** 0.7.9 → **0.7.10**.
|
||||
|
||||
## ✅ Shipped (0.7.15)
|
||||
|
||||
- **Insert Row** — adds an editable pending row at the top of the data grid (visible accent outline); committing inserts only the filled columns so serial/identity/generated defaults apply
|
||||
- **Smart cell editors** — date/time/datetime pickers, a boolean select, and number inputs for the matching column types (enums and FKs already had dropdowns), applied to both new rows and inline editing
|
||||
- **Landing page support channel** — coffee via Ko-fi (ko-fi.com/adrianbonpin), always optional, never gates a feature
|
||||
- **Landing page copy refresh** — em dashes dropped, OSS section simplified, FAQ added for how the project is funded
|
||||
- **Version bump** 0.7.14 → **0.7.15**.
|
||||
|
||||
## ✅ Shipped (0.7.14)
|
||||
|
||||
- **No more repeated macOS keychain prompts** — saved connection passwords and SSH secrets are ACL-pinned to the app's code signature when stored, so app upgrades never re-trigger the macOS keychain access dialog (users upgrading from older builds see one final prompt — choose Always Allow)
|
||||
- **Install via Homebrew** — add the Homebrew tap (`brew tap AdrianBonpin/gridline https://github.com/AdrianBonpin/homebrew-gridline.git`) and run `brew install --cask gridline`
|
||||
- **Version bump** 0.7.13 → **0.7.14**.
|
||||
|
||||
## ✅ Shipped (0.7.13)
|
||||
|
||||
- **Developer-ID signed + notarized macOS** — the app is properly code-signed with a Developer ID Application certificate and notarized by Apple, so macOS opens it without the Gatekeeper "Open Anyway" prompt or the `xattr` quarantine workaround
|
||||
- **Version bump** 0.7.12 → **0.7.13**.
|
||||
|
||||
## ✅ Shipped (0.7.12) *(includes 0.7.11: release-pipeline/docs refresh)*
|
||||
|
||||
- **Reliable cell editing for non-text columns** — editing a PostgreSQL cell whose type isn't text (integers, booleans, UUIDs, json/jsonb, timestamps, enums, numerics) now works; edited values are sent to PostgreSQL in text wire format so the server parses them into the column's own type
|
||||
- **Recent connections fix** — the Recent strip no longer renders empty on first launch when the connections list hadn't loaded yet
|
||||
- **Full CI release pipeline for Windows** — the self-hosted Windows runner now builds the .exe/.msi installers (bundled pg_dump/pg_restore/psql + MariaDB clients) and uploads them to the release alongside Linux
|
||||
- **Version bump** 0.7.11 → **0.7.12**.
|
||||
|
||||
## ✅ Shipped (0.7.9)
|
||||
|
||||
- **macOS Finder launch fix** — the local store opens under the OS app-data directory instead of a cwd-relative `gridline.db` (Finder/LaunchServices launches run with cwd `/`, so the old path made the app silently exit with a Rust panic before the UI started).
|
||||
- **macOS ad-hoc code signing** — bundles are signed at build time (`bundle.macOS.signingIdentity "-"`, hardened runtime off), replacing the Xcode linker-only signature that macOS treated as unsigned ("damaged and can't be opened", silent Finder refusal). Users still get a one-time Gatekeeper prompt; README documents the `sudo xattr -dr com.apple.quarantine` workaround.
|
||||
- **Vendored OpenSSL for `ssh2`** — the release binary no longer links an absolute Homebrew `/opt/homebrew/.../libssl.3.dylib` path (dyld aborted on machines without it).
|
||||
- **Version bump** 0.7.8 → **0.7.9**.
|
||||
|
||||
## ✅ Shipped (0.7.8)
|
||||
|
||||
- **SQLite `.dump` support** — backup a SQLite database to a portable SQL dump and restore it back, matching the pg_dump UX
|
||||
- **Backup / Restore / Sync for MySQL & SQLite** — the pg-only tooling extended: MySQL backup via the resolved `mariadb-dump`/`mysqldump` client (system-first, bundled fallback) with a native in-process restore, plus DB-to-DB sync beyond 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
|
||||
- **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`) is now gated to macOS; Windows/Linux use the native title bar for dragging
|
||||
- **SQLite table editor (Create/Edit Table)** — the visual Create Table / Edit Table flow extended to SQLite: SQLite-aware type mapping (`INTEGER PRIMARY KEY AUTOINCREMENT` instead of `serial`, `TEXT`/`REAL`/`BLOB`) and ALTER TABLE limits respected (`ADD COLUMN` can't add PK/UNIQUE, `DROP COLUMN` needs SQLite ≥3.35)
|
||||
- **Version bump** 0.7.7 → **0.7.8**.
|
||||
|
||||
## ✅ Shipped (0.7.7)
|
||||
|
||||
### Admin follow-up
|
||||
@@ -151,24 +185,13 @@ Deferred from 0.7.0, slated for this bucket:
|
||||
- **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.
|
||||
**Deferred from 0.7.0**: Redis key browsing and MySQL Objects/ERD views (still in the queue); SSH key management and onboarding tour (planned). Since shipped elsewhere: backup/restore/sync for MySQL & SQLite and settings import/export (0.7.8); multiple result sets (slated for 0.8.0).
|
||||
|
||||
## ✅ Shipped
|
||||
## ✅ Shipped — earlier releases (pre-0.7.0)
|
||||
|
||||
- Full PostgreSQL object management — create/edit/drop staged through the changes queue (v0.7.6)
|
||||
- Enable Keychain toggle — opt-out password persistence (v0.7.6)
|
||||
- Objects view tabbed workspace (v0.7.6)
|
||||
- Bundled PG client tools — `pg_dump`/`pg_restore`/`psql` shipped with the app, system-first with bundled fallback (v0.7.5)
|
||||
- Schema CRUD — create/rename/drop schemas with CASCADE + dependency warning (v0.7.5)
|
||||
- Global object search — Cmd+K across all object types in the current schema (v0.7.5)
|
||||
- Copy as DDL for any object (v0.7.5)
|
||||
- Object dependencies — `pg_depend` view before destructive drops (v0.7.5)
|
||||
- Tauri 2.0 + React 19 + TypeScript 5.8 project shell
|
||||
- PostgreSQL and SQLite browse/query support
|
||||
- Full MySQL DB viewer — connect, browse, query, edit + changes queue (v0.7.0)
|
||||
- New Connection screen revamp — provider grid + Supabase/NeonDB managed-pg presets (v0.7.0)
|
||||
- DB viewer capability gating + Redis unsupported state (v0.7.0)
|
||||
- Connection management with URI parser, SSH tunnels, TLS, OS keychain
|
||||
- PostgreSQL and SQLite browse/query support
|
||||
- Workspace tree, folders, tags, favorites, recents
|
||||
- Multi-tab DB viewer with virtualized grid, server-side filtering/sorting
|
||||
- FK preview, JSON popover, inline cell editing, changes queue
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gridline-desktop",
|
||||
"private": true,
|
||||
"version": "0.7.12",
|
||||
"version": "0.8.3",
|
||||
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Generated
+43
-1
@@ -339,6 +339,19 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bigdecimal"
|
||||
version = "0.4.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fa3f3d8cbf4dffcfe4991de61d012bef509a409ecbe9dd41049bfe32b4d4653"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"libm",
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
@@ -1783,11 +1796,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridline"
|
||||
version = "0.7.12"
|
||||
version = "0.8.3"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"core-foundation 0.10.1",
|
||||
"deadpool-postgres",
|
||||
"futures-util",
|
||||
"indexmap 2.14.0",
|
||||
"redis",
|
||||
"rusqlite",
|
||||
@@ -1795,6 +1810,8 @@ dependencies = [
|
||||
"rustls-native-certs",
|
||||
"rustls-pemfile 2.2.0",
|
||||
"rustls-pki-types",
|
||||
"security-framework 3.7.0",
|
||||
"security-framework-sys",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
@@ -1805,6 +1822,7 @@ dependencies = [
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-keyring-store",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-single-instance",
|
||||
"tokio",
|
||||
"tokio-postgres",
|
||||
"tokio-postgres-rustls",
|
||||
@@ -4421,8 +4439,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a999083c1af5b5d6c071d34a708a19ba3e02106ad82ef7bbd69f5e48266b613b"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"bigdecimal",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc",
|
||||
"crossbeam-queue",
|
||||
"either",
|
||||
@@ -4503,9 +4523,11 @@ checksum = "5afe4c38a9b417b6a9a5eeffe7235d0a106716495536e7727d1c7f4b1ff3eba6"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"base64 0.22.1",
|
||||
"bigdecimal",
|
||||
"bitflags 2.13.1",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc",
|
||||
"digest 0.10.7",
|
||||
"dotenvy",
|
||||
@@ -4545,8 +4567,10 @@ checksum = "b1dbb157e65f10dbe01f729339c06d239120221c9ad9fa0ba8408c4cc18ecf21"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"base64 0.22.1",
|
||||
"bigdecimal",
|
||||
"bitflags 2.13.1",
|
||||
"byteorder",
|
||||
"chrono",
|
||||
"crc",
|
||||
"dotenvy",
|
||||
"etcetera",
|
||||
@@ -4562,6 +4586,7 @@ dependencies = [
|
||||
"log",
|
||||
"md-5 0.10.6",
|
||||
"memchr",
|
||||
"num-bigint",
|
||||
"once_cell",
|
||||
"rand 0.8.7",
|
||||
"serde",
|
||||
@@ -4582,6 +4607,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b2cdd83c008a622d94499c0006d8ee5f821f36c89b7d625c900e5dc30b5c5ee"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"chrono",
|
||||
"flume",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
@@ -5016,6 +5042,22 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db817fe9295e19b7d8357e900af31edb93703dd9fb6de524b007b47b6afc63b0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "gridline"
|
||||
version = "0.7.12"
|
||||
version = "0.8.3"
|
||||
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
@@ -32,7 +32,10 @@ tauri-plugin-fs = "2"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1"] }
|
||||
bytes = "1"
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "mysql", "tls-rustls", "json"] }
|
||||
futures-util = "0.3"
|
||||
# chrono/bigdecimal: MySQL DECIMAL and DATE/TIME/DATETIME/TIMESTAMP have no
|
||||
# decoder without them, so those columns silently rendered as NULL (#41).
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "mysql", "tls-rustls", "json", "chrono", "bigdecimal"] }
|
||||
redis = { version = "0.27", features = ["tokio-comp"] }
|
||||
# async-ssh2 is not available at v0.4; using synchronous ssh2 via tokio::task::spawn_blocking per plan's fallback
|
||||
# vendored-openssl: link OpenSSL statically so the release binary has NO
|
||||
@@ -49,3 +52,19 @@ rustls-pemfile = "2"
|
||||
rustls-pki-types = "1"
|
||||
rustls-native-certs = "0.7"
|
||||
|
||||
# Keychain ACL normalization (explicit SecAccess on stored secrets so items
|
||||
# survive app re-signs without permission prompts) — macOS only.
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
security-framework = "3.7"
|
||||
security-framework-sys = "2.17"
|
||||
# core-foundation: CFString/CFArray/TCFType helpers for the SecAccess FFI
|
||||
# (already a transitive dep of security-framework; pinned to the same 0.10 line)
|
||||
core-foundation = "0.10"
|
||||
|
||||
# Windows/Linux deliver an OS "open with" request as argv. Without this a
|
||||
# second double-click would launch a second app instead of forwarding the path
|
||||
# to the running one. macOS needs no equivalent: LaunchServices routes to the
|
||||
# running app via RunEvent::Opened.
|
||||
[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies]
|
||||
tauri-plugin-single-instance = "2"
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Required for the WKWebView (JIT-compiled JavaScript). Without this the
|
||||
app crashes under the hardened runtime. -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<!-- The WebView allocates executable memory for JIT; allow it. -->
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<!-- Tauri's dev server / debug builds rely on DYLD_* environment variables. -->
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 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::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use rusqlite::InterruptHandle;
|
||||
@@ -59,6 +60,12 @@ pub enum CancelHandle {
|
||||
#[derive(Default)]
|
||||
pub struct CancelRegistry {
|
||||
map: Mutex<HashMap<String, CancelHandle>>,
|
||||
/// Live "cancel requested" flags, keyed by connection id. `cancel_query`
|
||||
/// sets one for MySQL; long-running in-process operations (currently the
|
||||
/// native restore engine) poll it between statements and clear it when
|
||||
/// they finish. Kept in a separate map from the handles so it needs no
|
||||
/// `Clone` on the registry and survives handle replacement.
|
||||
flags: Mutex<HashMap<String, Arc<AtomicBool>>>,
|
||||
}
|
||||
|
||||
impl CancelRegistry {
|
||||
@@ -66,6 +73,41 @@ impl CancelRegistry {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Get (or create) the cancellation flag for a connection. Polling callers
|
||||
/// should clear it before starting and after finishing.
|
||||
pub fn cancel_flag(&self, id: &str) -> Arc<AtomicBool> {
|
||||
self.flags
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(id.to_string())
|
||||
.or_default()
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Mark a connection as cancel-requested. Called by `cancel_query` before
|
||||
/// it sends the driver-specific cancel so in-process consumers observe the
|
||||
/// request even when the driver surfaces it as an unrecognized error.
|
||||
pub fn mark_cancelled(&self, id: &str) {
|
||||
self.cancel_flag(id).store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Whether a cancel has been requested for this connection.
|
||||
pub fn is_cancelled(&self, id: &str) -> bool {
|
||||
self.flags
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(id)
|
||||
.map(|f| f.load(Ordering::SeqCst))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Clear the cancel-requested flag for this connection.
|
||||
pub fn clear_cancelled(&self, id: &str) {
|
||||
if let Some(f) = self.flags.lock().unwrap().get(id) {
|
||||
f.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_pg(&self, id: &str, c: PgCancel) {
|
||||
self.set(id, CancelHandle::Pg(c));
|
||||
}
|
||||
@@ -157,6 +199,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_flag_is_tracked_per_connection() {
|
||||
let reg = CancelRegistry::new();
|
||||
assert!(!reg.is_cancelled("c1"));
|
||||
reg.mark_cancelled("c1");
|
||||
assert!(reg.is_cancelled("c1"));
|
||||
assert!(!reg.is_cancelled("c2"), "flag must not leak across connections");
|
||||
reg.clear_cancelled("c1");
|
||||
assert!(!reg.is_cancelled("c1"));
|
||||
// The same Arc is shared between getter and setter.
|
||||
let flag = reg.cancel_flag("c1");
|
||||
reg.mark_cancelled("c1");
|
||||
assert!(flag.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
fn fake_opts() -> sqlx::mysql::MySqlConnectOptions {
|
||||
sqlx::mysql::MySqlConnectOptions::new()
|
||||
.host("127.0.0.1").port(1).username("u").password("p").database("d")
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
|
||||
use crate::db::tool_resolver::{self, ToolRequest, ToolResolution};
|
||||
use crate::models::backup::*;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -40,74 +41,106 @@ impl PgConnParams {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn get_version(tool: &str) -> Option<String> {
|
||||
Command::new(tool)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
}
|
||||
|
||||
fn sanitize_error(s: &str) -> String {
|
||||
crate::commands::test_connection::sanitize_error(s)
|
||||
}
|
||||
|
||||
fn bundled_bin_name(tool: &str) -> String {
|
||||
if cfg!(windows) { format!("{tool}.exe") } else { tool.to_string() }
|
||||
}
|
||||
/// Tauri-facing one-shot resolution with the process-lifetime cache.
|
||||
/// `force` bypasses the cache for an explicit user re-check.
|
||||
fn resolve_request(app: &AppHandle, request: &ToolRequest, force: bool) -> ToolResolution {
|
||||
let key = request.cache_key();
|
||||
if !force {
|
||||
if let Some(hit) = tool_resolver::cache_get(&key) {
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
let resource_dir = app.path().resource_dir().ok();
|
||||
let bundled = resource_dir
|
||||
.as_ref()
|
||||
.map(|rd| tool_resolver::bundled_tool_path(rd, request.subdir, request.bundled_name()));
|
||||
let trusted = tool_resolver::trusted_dirs();
|
||||
let start = std::time::Instant::now();
|
||||
let probe = |p: &std::path::Path| {
|
||||
tool_resolver::probe_within(start, tool_resolver::AGGREGATE_DEADLINE, p)
|
||||
};
|
||||
let resolved = tool_resolver::resolve_with(
|
||||
request,
|
||||
&trusted,
|
||||
&tool_resolver::path_lookup,
|
||||
bundled.as_deref(),
|
||||
probe,
|
||||
);
|
||||
let version = resolved
|
||||
.as_ref()
|
||||
.and_then(|r| tool_resolver::version_of(&r.program));
|
||||
let bundled_available = bundled
|
||||
.as_deref()
|
||||
.map(tool_resolver::bundled_present)
|
||||
.unwrap_or(false);
|
||||
|
||||
let out = ToolResolution {
|
||||
resolved,
|
||||
bundled_path: bundled,
|
||||
bundled_available,
|
||||
version,
|
||||
};
|
||||
tool_resolver::cache_put(&key, out.clone());
|
||||
out
|
||||
}
|
||||
|
||||
/// 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)
|
||||
match tool_resolver::pg_request(tool) {
|
||||
Some(request) => {
|
||||
tool_resolver::adapt_resolution(&resolve_request(app, &request, false), tool)
|
||||
}
|
||||
None => (tool.to_string(), None),
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
PgToolPaths {
|
||||
pg_dump: resolve_tool(app, "pg_dump").0,
|
||||
pg_restore: resolve_tool(app, "pg_restore").0,
|
||||
psql: resolve_tool(app, "psql").0,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detect_pg_tools
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Shapes the tool status from resolved tool paths + sources. Headless so the
|
||||
/// Tauri command stays thin and the status logic stays unit-testable.
|
||||
fn build_pg_tool_status(
|
||||
dump: &str,
|
||||
restore: &str,
|
||||
dump_src: Option<String>,
|
||||
restore_src: Option<String>,
|
||||
) -> PgToolStatus {
|
||||
/// Shapes both PG statuses from resolved tools. Headless so the Tauri command
|
||||
/// stays thin and the shaping logic stays unit-testable.
|
||||
fn build_pg_tool_status(dump: &ToolResolution, restore: &ToolResolution) -> PgToolStatus {
|
||||
PgToolStatus {
|
||||
pg_dump_found: Command::new(dump).arg("--version").output().is_ok(),
|
||||
pg_restore_found: Command::new(restore).arg("--version").output().is_ok(),
|
||||
pg_dump_version: get_version(dump),
|
||||
pg_restore_version: get_version(restore),
|
||||
pg_dump_source: dump_src,
|
||||
pg_restore_source: restore_src,
|
||||
pg_dump_found: dump.resolved.is_some(),
|
||||
pg_restore_found: restore.resolved.is_some(),
|
||||
pg_dump_version: dump.version.clone(),
|
||||
pg_restore_version: restore.version.clone(),
|
||||
pg_dump_source: dump.resolved.as_ref().map(|r| r.source.as_str().to_string()),
|
||||
pg_restore_source: restore.resolved.as_ref().map(|r| r.source.as_str().to_string()),
|
||||
pg_dump_resolved_name: dump.resolved.as_ref().map(|r| r.name.clone()),
|
||||
pg_restore_resolved_name: restore.resolved.as_ref().map(|r| r.name.clone()),
|
||||
pg_dump_bundled_available: dump.bundled_available,
|
||||
pg_restore_bundled_available: restore.bundled_available,
|
||||
}
|
||||
}
|
||||
|
||||
/// Detection runs off the UI thread and can be forced past the cache.
|
||||
#[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)
|
||||
pub async fn detect_pg_tools(force: Option<bool>, app_handle: AppHandle) -> PgToolStatus {
|
||||
let force = force.unwrap_or(false);
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let dump = resolve_request(&app_handle, &tool_resolver::pg_dump_request(), force);
|
||||
let restore = resolve_request(&app_handle, &tool_resolver::pg_restore_request(), force);
|
||||
build_pg_tool_status(&dump, &restore)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
build_pg_tool_status(&ToolResolution::none(), &ToolResolution::none())
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -501,90 +534,127 @@ fn base_mysql_args(conn: &MySqlConnParams) -> Vec<String> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Whether a resolved client is MariaDB's. MariaDB's clients print a
|
||||
/// `...-MariaDB...` banner from `--version`; MySQL's print the Oracle banner.
|
||||
/// This matters because MariaDB's clients — including the `mysqldump`/`mysql`
|
||||
/// compatibility names some MariaDB packages ship — reject several MySQL-only
|
||||
/// options with "unknown option", aborting the dump before it connects.
|
||||
/// Probed once per program and cached for the process.
|
||||
fn client_is_mariadb(program: &str) -> bool {
|
||||
static CACHE: std::sync::OnceLock<
|
||||
std::sync::Mutex<std::collections::HashMap<String, bool>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
|
||||
|
||||
if let Ok(map) = cache.lock() {
|
||||
if let Some(hit) = map.get(program) {
|
||||
return *hit;
|
||||
}
|
||||
}
|
||||
|
||||
let is_mariadb = Command::new(program)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map(|o| {
|
||||
let mut text = String::from_utf8_lossy(&o.stdout).to_string();
|
||||
text.push_str(&String::from_utf8_lossy(&o.stderr));
|
||||
text.contains("MariaDB")
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
if let Ok(mut map) = cache.lock() {
|
||||
map.insert(program.to_string(), is_mariadb);
|
||||
}
|
||||
is_mariadb
|
||||
}
|
||||
|
||||
/// TLS flags for a dump client. MySQL's clients take `--ssl-mode=<mode>`;
|
||||
/// MariaDB's have no `--ssl-mode` and use the boolean `--ssl`, which is the
|
||||
/// equivalent of the only mode this code path uses (`REQUIRED`).
|
||||
pub fn tls_args_for_client(client_is_mariadb: bool, tls_mode: Option<&str>) -> Vec<String> {
|
||||
match (client_is_mariadb, tls_mode) {
|
||||
(true, Some("REQUIRED")) => vec!["--ssl".into()],
|
||||
(true, _) => Vec::new(),
|
||||
(false, Some(m)) => vec![format!("--ssl-mode={m}")],
|
||||
(false, None) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `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> {
|
||||
///
|
||||
/// `client_is_mariadb` gates the MySQL-only options: MariaDB's dump rejects
|
||||
/// `--skip-column-statistics`. `--databases` is always emitted as a boolean
|
||||
/// flag followed by the database name — `--databases=<db>` is rejected by
|
||||
/// clients as an invalid value.
|
||||
pub fn build_mysql_dump_args(
|
||||
conn: &MySqlConnParams,
|
||||
options: &MySqlBackupOptions,
|
||||
client_is_mariadb: bool,
|
||||
) -> 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("--databases".into());
|
||||
a.push(options.database.clone());
|
||||
a.push(format!("--result-file={}", options.file_path));
|
||||
if !client_is_mariadb {
|
||||
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).
|
||||
/// System-first mariadb-dump/mysqldump resolution (bundled fallback).
|
||||
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)
|
||||
match tool_resolver::mysql_request(tool) {
|
||||
Some(request) => {
|
||||
tool_resolver::adapt_resolution(&resolve_request(app, &request, false), tool)
|
||||
}
|
||||
None => (tool.to_string(), None),
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
MySqlToolPaths {
|
||||
mysqldump: resolve_mysql_tool(app, "mariadb-dump").0,
|
||||
mysql: resolve_mysql_tool(app, "mariadb").0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.)
|
||||
/// is translated per client — `--ssl-mode=` on MySQL clients, `--ssl` on
|
||||
/// MariaDB's (see `tls_args_for_client`) 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 is_mariadb = client_is_mariadb(&tools.mysqldump);
|
||||
let mut args = build_mysql_dump_args(conn, options, is_mariadb);
|
||||
args.extend(tls_args_for_client(is_mariadb, tls_mode));
|
||||
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 is_mariadb = client_is_mariadb(&tools.mysqldump);
|
||||
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}"));
|
||||
}
|
||||
dump_args.push("--databases".into());
|
||||
dump_args.push(source.database.clone());
|
||||
dump_args.extend(tls_args_for_client(is_mariadb, tls_mode));
|
||||
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);
|
||||
@@ -598,6 +668,147 @@ pub fn run_mysql_sync(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Native MySQL restore engine (in-process via sqlx — no CLI, no subprocess)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Restore reads from disk, so it is capped at the existing CSV/JSON import
|
||||
/// limit (100 MB) rather than the 5 MB *editor* cap — a file too large to open
|
||||
/// in the editor must still be restorable (spec §2).
|
||||
pub const MAX_RESTORE_FILE_BYTES: u64 = 100 * 1024 * 1024;
|
||||
|
||||
/// Enforce the restore size cap. Extracted from the command so the boundary is
|
||||
/// testable; restore reads from disk, so it uses the 100 MB bulk cap rather
|
||||
/// than the 5 MB *editor* cap (spec §2).
|
||||
pub fn check_restore_file_size(bytes: u64, cap: u64) -> Result<(), String> {
|
||||
if bytes <= cap {
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!(
|
||||
"Restore file is {} MB; the limit is {} MB",
|
||||
bytes.div_ceil(1024 * 1024),
|
||||
cap / (1024 * 1024)
|
||||
))
|
||||
}
|
||||
|
||||
/// Inputs for one native restore run. A struct rather than a long argument
|
||||
/// list so the headless core stays readable and directly testable.
|
||||
pub struct MySqlRestoreRun<'a> {
|
||||
pub database: &'a str,
|
||||
pub statements: &'a [String],
|
||||
pub clean: bool,
|
||||
}
|
||||
|
||||
/// Progress units for a restore: every statement, plus one for the clean
|
||||
/// drop phase so the fraction never jumps backwards.
|
||||
pub fn restore_progress_total(statement_count: usize, clean: bool) -> usize {
|
||||
statement_count + usize::from(clean)
|
||||
}
|
||||
|
||||
/// The error a failed restore reports. Includes the position so the user can
|
||||
/// find the statement in their file, and never echoes the statement text
|
||||
/// (SQL must not be logged — project guardrail).
|
||||
pub fn format_restore_statement_error(index_1based: usize, total: usize, detail: &str) -> String {
|
||||
format!("Statement {index_1based} of {total} failed: {detail}")
|
||||
}
|
||||
|
||||
/// Map a driver error for the restore path: a `KILL QUERY` cancellation must
|
||||
/// read as a cancellation, not as a broken statement.
|
||||
fn restore_statement_error(e: &sqlx::Error) -> String {
|
||||
if crate::commands::query::is_mysql_cancel_error(e) {
|
||||
"Query cancelled".to_string()
|
||||
} else {
|
||||
sanitize_error(&format!("{e}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute an already-split restore script over one live MySQL connection.
|
||||
///
|
||||
/// Stop-at-first-error by contract: MySQL DDL is non-transactional and
|
||||
/// auto-commits, so there is nothing to roll back and continuing past a
|
||||
/// failure would compound an already-partial restore (spec §6).
|
||||
pub async fn run_mysql_restore_on(
|
||||
conn: &mut sqlx::mysql::MySqlConnection,
|
||||
run: MySqlRestoreRun<'_>,
|
||||
on_progress: &mut (dyn FnMut(usize, usize) + Send),
|
||||
is_cancelled: &(dyn Fn() -> bool + Sync),
|
||||
) -> Result<(), String> {
|
||||
crate::commands::query::check_statement_budget(
|
||||
run.statements.len(),
|
||||
crate::commands::query::MAX_RESTORE_STATEMENTS,
|
||||
true,
|
||||
)?;
|
||||
|
||||
// The form lets the user target a database other than the one this
|
||||
// connection defaults to, so select it explicitly.
|
||||
exec_restore_statement(
|
||||
conn,
|
||||
&format!("USE {}", crate::db::mysql::mysql_quote_ident(run.database)),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
|
||||
let total = restore_progress_total(run.statements.len(), run.clean);
|
||||
let mut done = 0usize;
|
||||
|
||||
if run.clean {
|
||||
let drops = crate::db::mysql_clean::plan_clean_drops(run.database, run.statements);
|
||||
if !drops.is_empty() {
|
||||
// Foreign keys off for the drop phase, so object ordering cannot
|
||||
// fail the restore; restored on the same session right after.
|
||||
exec_restore_statement(conn, "SET FOREIGN_KEY_CHECKS = 0")
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
for drop_sql in &drops {
|
||||
if is_cancelled() {
|
||||
return Err("Query cancelled".to_string());
|
||||
}
|
||||
exec_restore_statement(conn, drop_sql)
|
||||
.await
|
||||
.map_err(|e| restore_statement_error(&e))?;
|
||||
}
|
||||
exec_restore_statement(conn, "SET FOREIGN_KEY_CHECKS = 1")
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
}
|
||||
done += 1;
|
||||
on_progress(done, total);
|
||||
}
|
||||
|
||||
for (idx, stmt) in run.statements.iter().enumerate() {
|
||||
if is_cancelled() {
|
||||
return Err("Query cancelled".to_string());
|
||||
}
|
||||
if let Err(e) = exec_restore_statement(conn, stmt).await {
|
||||
if crate::commands::query::is_mysql_cancel_error(&e) {
|
||||
return Err("Query cancelled".to_string());
|
||||
}
|
||||
return Err(format_restore_statement_error(
|
||||
idx + 1,
|
||||
run.statements.len(),
|
||||
&sanitize_error(&format!("{e}")),
|
||||
));
|
||||
}
|
||||
done += 1;
|
||||
on_progress(done, total);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a single restore statement, preserving the driver error so the caller
|
||||
/// can distinguish a cancellation from a genuine statement failure.
|
||||
///
|
||||
/// Uses the *simple* query protocol (a bare string through `Executor`), not the
|
||||
/// prepared-statement protocol: MySQL rejects several restore statements
|
||||
/// (`USE`, `SET`, some DDL) with error 1295 when prepared.
|
||||
async fn exec_restore_statement(
|
||||
conn: &mut sqlx::mysql::MySqlConnection,
|
||||
sql: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
use sqlx::Executor;
|
||||
conn.execute(sql).await.map(|_| ())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tauri commands (thin wrappers: store lookup + keychain + event emission)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -610,6 +821,7 @@ fn emit_result(app_handle: &AppHandle, job_id: &str, result: Result<(), String>)
|
||||
progress: Some(1.0),
|
||||
output_line: None,
|
||||
error: None,
|
||||
warning: None,
|
||||
},
|
||||
Err(e) => BackupProgressEvent {
|
||||
job_id: job_id.to_string(),
|
||||
@@ -617,6 +829,7 @@ fn emit_result(app_handle: &AppHandle, job_id: &str, result: Result<(), String>)
|
||||
progress: None,
|
||||
output_line: None,
|
||||
error: Some(e),
|
||||
warning: None,
|
||||
},
|
||||
};
|
||||
let _ = app_handle.emit("backup-progress", event);
|
||||
@@ -805,20 +1018,40 @@ pub async fn db_sync(
|
||||
// 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");
|
||||
fn build_mysql_tool_status(dump: &ToolResolution, client: &ToolResolution) -> MySqlToolStatus {
|
||||
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,
|
||||
mysqldump_found: dump.resolved.is_some(),
|
||||
mysql_found: client.resolved.is_some(),
|
||||
mysqldump_version: dump.version.clone(),
|
||||
mysql_version: client.version.clone(),
|
||||
mysqldump_source: dump.resolved.as_ref().map(|r| r.source.as_str().to_string()),
|
||||
mysql_source: client.resolved.as_ref().map(|r| r.source.as_str().to_string()),
|
||||
mysqldump_resolved_name: dump.resolved.as_ref().map(|r| r.name.clone()),
|
||||
mysql_resolved_name: client.resolved.as_ref().map(|r| r.name.clone()),
|
||||
mysqldump_is_mariadb: dump
|
||||
.resolved
|
||||
.as_ref()
|
||||
.map(|r| r.name.starts_with("mariadb"))
|
||||
.unwrap_or(false),
|
||||
mysqldump_bundled_available: dump.bundled_available,
|
||||
mysql_bundled_available: client.bundled_available,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn detect_mysql_tools(force: Option<bool>, app_handle: AppHandle) -> MySqlToolStatus {
|
||||
let force = force.unwrap_or(false);
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let dump = resolve_request(&app_handle, &tool_resolver::mysql_dump_request(), force);
|
||||
let client = resolve_request(&app_handle, &tool_resolver::mysql_client_request(), force);
|
||||
build_mysql_tool_status(&dump, &client)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
build_mysql_tool_status(&ToolResolution::none(), &ToolResolution::none())
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve (host, port, via_tunnel) for a MySQL connection, routing through the
|
||||
/// SSH tunnel endpoint when present.
|
||||
fn mysql_endpoint(
|
||||
@@ -833,6 +1066,68 @@ fn mysql_endpoint(
|
||||
(conn.host.clone(), conn.port.unwrap_or(3306), false)
|
||||
}
|
||||
|
||||
/// Advisory server version for the compatibility check. Fail-open by design:
|
||||
/// any error (TLS, SSH tunnel, auth) returns `None`, and the dump proceeds.
|
||||
async fn mysql_server_version(
|
||||
host: &str,
|
||||
port: i64,
|
||||
via_tunnel: bool,
|
||||
conn: &crate::models::Connection,
|
||||
password: &str,
|
||||
) -> Option<String> {
|
||||
let database = conn.database.clone().unwrap_or_default();
|
||||
let original_host = conn.host.clone();
|
||||
let ssl_mode = conn.ssl_mode.clone();
|
||||
let ssl_ca = conn.ssl_ca_path.clone();
|
||||
let username = conn.username.clone().unwrap_or_else(|| "root".into());
|
||||
let mut c = crate::commands::db_viewer::open_mysql_connection(
|
||||
host,
|
||||
port as u16,
|
||||
&username,
|
||||
password,
|
||||
&database,
|
||||
&original_host,
|
||||
ssl_mode.as_deref(),
|
||||
ssl_ca.as_deref(),
|
||||
via_tunnel,
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
sqlx::query_scalar::<_, String>("SELECT VERSION()")
|
||||
.fetch_one(&mut c)
|
||||
.await
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Emit the advisory warning when the resolved dump client is clearly older
|
||||
/// than the server. Never returns an error — the job always proceeds.
|
||||
async fn maybe_emit_version_warning(
|
||||
app_handle: &AppHandle,
|
||||
job_id: &str,
|
||||
resolution: &ToolResolution,
|
||||
server_version: Option<String>,
|
||||
) {
|
||||
let (Some(client_version), Some(tool)) = (&resolution.version, &resolution.resolved) else {
|
||||
return;
|
||||
};
|
||||
let Some(server_version) = server_version else { return };
|
||||
let Some(warning) = tool_resolver::warning_for(client_version, &server_version, &tool.name)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let _ = app_handle.emit(
|
||||
"backup-progress",
|
||||
BackupProgressEvent {
|
||||
job_id: job_id.to_string(),
|
||||
status: "running".into(),
|
||||
progress: Some(0.0),
|
||||
output_line: None,
|
||||
error: None,
|
||||
warning: Some(warning),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mysql_dump(
|
||||
connection_id: String,
|
||||
@@ -857,6 +1152,15 @@ pub async fn mysql_dump(
|
||||
.unwrap_or_default();
|
||||
|
||||
let (host, port, via_tunnel) = mysql_endpoint(&state, &connection_id, &conn);
|
||||
// Advisory client/server compatibility check — never blocks the job, and
|
||||
// only pays for a connection when the resolved client reported a version.
|
||||
let dump_resolution = resolve_request(&app_handle, &tool_resolver::mysql_dump_request(), false);
|
||||
let server_version = if dump_resolution.version.is_some() {
|
||||
mysql_server_version(&host, port, via_tunnel, &conn, &password).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
maybe_emit_version_warning(&app_handle, &job_id, &dump_resolution, server_version).await;
|
||||
let params = MySqlConnParams::new(
|
||||
host,
|
||||
port,
|
||||
@@ -901,20 +1205,129 @@ pub async fn mysql_restore(
|
||||
.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,
|
||||
|
||||
// Native restore: no tool resolution, no subprocess, no MYSQL_PWD in a
|
||||
// child environment. The sqlx driver handles MySQL 8 auth and TLS.
|
||||
let statements = {
|
||||
let bytes = std::fs::metadata(&options.file_path)
|
||||
.map_err(|e| format!("Cannot read restore file: {e}"))?
|
||||
.len();
|
||||
check_restore_file_size(bytes, MAX_RESTORE_FILE_BYTES)?;
|
||||
let text = std::fs::read_to_string(&options.file_path)
|
||||
.map_err(|e| format!("Cannot read restore file: {e}"))?;
|
||||
let statements = crate::db::sql_split::split_statements(&text);
|
||||
crate::commands::query::check_statement_budget(
|
||||
statements.len(),
|
||||
crate::commands::query::MAX_RESTORE_STATEMENTS,
|
||||
true,
|
||||
)?;
|
||||
statements
|
||||
};
|
||||
|
||||
let database = options.database.clone();
|
||||
let clean = options.clean;
|
||||
let original_host = conn.host.clone();
|
||||
let ssl_mode = conn.ssl_mode.clone();
|
||||
let ssl_ca = conn.ssl_ca_path.clone();
|
||||
let username = conn.username.clone().unwrap_or_else(|| "root".into());
|
||||
|
||||
// Real cancellation: a per-restore flag that the existing `cancel_query`
|
||||
// command sets, plus the restore session's MySQL thread id registered so
|
||||
// `KILL QUERY` aborts the in-flight statement. Cleared before each run so a
|
||||
// stale request from a previous operation cannot abort this one.
|
||||
let cancel_flag = state.cancel_registry.cancel_flag(&connection_id);
|
||||
cancel_flag.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
|
||||
let cancel_opts = crate::commands::db_viewer::mysql_connect_options(
|
||||
&host,
|
||||
port as u16,
|
||||
&username,
|
||||
&password,
|
||||
&database,
|
||||
&original_host,
|
||||
ssl_mode.as_deref(),
|
||||
ssl_ca.as_deref(),
|
||||
via_tunnel,
|
||||
);
|
||||
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);
|
||||
let connection_id_for_cancel = connection_id.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut last_emit = std::time::Instant::now() - std::time::Duration::from_secs(1);
|
||||
let mut emit_progress = |done: usize, total: usize| {
|
||||
// Throttle: a 50k-statement dump must not flood the IPC channel.
|
||||
if last_emit.elapsed() < std::time::Duration::from_millis(200) && done < total {
|
||||
return;
|
||||
}
|
||||
last_emit = std::time::Instant::now();
|
||||
let _ = app_handle_clone.emit(
|
||||
"backup-progress",
|
||||
BackupProgressEvent {
|
||||
job_id: job_id_clone.clone(),
|
||||
status: "running".into(),
|
||||
progress: Some(done as f64 / total.max(1) as f64),
|
||||
output_line: Some(format!("Statement {done} of {total}")),
|
||||
error: None,
|
||||
warning: None,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
let is_cancelled = {
|
||||
let flag = cancel_flag.clone();
|
||||
move || flag.load(std::sync::atomic::Ordering::SeqCst)
|
||||
};
|
||||
|
||||
let result = async {
|
||||
let mut mconn = crate::commands::db_viewer::open_mysql_connection(
|
||||
&host,
|
||||
port as u16,
|
||||
&username,
|
||||
&password,
|
||||
&database,
|
||||
&original_host,
|
||||
ssl_mode.as_deref(),
|
||||
ssl_ca.as_deref(),
|
||||
via_tunnel,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Register the restore session so the UI's `cancel_query` can
|
||||
// `KILL QUERY` it (mirrors how browsing registers pooled conns).
|
||||
// The registry is reached through the app handle because it is not
|
||||
// `Clone`.
|
||||
let registry = app_handle_clone.state::<crate::AppState>();
|
||||
let restore_conn_id: i64 =
|
||||
sqlx::query_scalar("SELECT CAST(CONNECTION_ID() AS SIGNED)")
|
||||
.fetch_one(&mut mconn)
|
||||
.await
|
||||
.unwrap_or(-1);
|
||||
registry.cancel_registry.set_mysql(
|
||||
&connection_id_for_cancel,
|
||||
crate::cancel::MySqlCancel {
|
||||
conn_id: Some(restore_conn_id),
|
||||
connect_options: cancel_opts,
|
||||
},
|
||||
);
|
||||
|
||||
let r = run_mysql_restore_on(
|
||||
&mut mconn,
|
||||
MySqlRestoreRun { database: &database, statements: &statements, clean },
|
||||
&mut emit_progress,
|
||||
&is_cancelled,
|
||||
)
|
||||
.await;
|
||||
|
||||
registry
|
||||
.cancel_registry
|
||||
.set_mysql_conn_id(&connection_id_for_cancel, None);
|
||||
registry.cancel_registry.clear_cancelled(&connection_id_for_cancel);
|
||||
r
|
||||
}
|
||||
.await;
|
||||
|
||||
emit_result(&app_handle_clone, &job_id_clone, result);
|
||||
});
|
||||
|
||||
@@ -971,6 +1384,23 @@ pub async fn mysql_sync(
|
||||
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);
|
||||
|
||||
// Advisory client/server compatibility check — never blocks the job, and
|
||||
// only pays for a connection when the resolved client reported a version.
|
||||
let dump_resolution = resolve_request(&app_handle, &tool_resolver::mysql_dump_request(), false);
|
||||
let server_version = if dump_resolution.version.is_some() {
|
||||
mysql_server_version(
|
||||
&src_host,
|
||||
src_port,
|
||||
src_via_tunnel,
|
||||
&source_conn,
|
||||
&src_password,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
maybe_emit_version_warning(&app_handle, &job_id, &dump_resolution, server_version).await;
|
||||
|
||||
let source = MySqlConnParams::new(
|
||||
src_host,
|
||||
src_port,
|
||||
|
||||
@@ -132,15 +132,44 @@ fn build_pg_restore_args_with_schema() {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn detect_pg_tools_does_not_panic() {
|
||||
// detect_pg_tools needs a Tauri AppHandle; the headless core keeps the
|
||||
// status-shaping logic testable without one.
|
||||
let status = build_pg_tool_status("pg_dump", "pg_restore", None, None);
|
||||
// May or may not find tools, but the call itself must not panic
|
||||
let _ = status.pg_dump_found;
|
||||
let _ = status.pg_restore_found;
|
||||
let _ = status.pg_dump_version;
|
||||
let _ = status.pg_restore_version;
|
||||
fn build_pg_tool_status_reflects_resolutions() {
|
||||
let dump = ToolResolution {
|
||||
resolved: Some(crate::db::tool_resolver::Resolution {
|
||||
program: "/opt/homebrew/bin/mariadb-dump".into(),
|
||||
source: crate::db::tool_resolver::ToolSource::TrustedDir,
|
||||
name: "pg_dump".into(),
|
||||
}),
|
||||
bundled_path: Some(std::path::PathBuf::from("/app/resources/pg_tools/pg_dump")),
|
||||
bundled_available: true,
|
||||
version: Some("pg_dump (PostgreSQL) 16.4".into()),
|
||||
};
|
||||
let status = build_pg_tool_status(&dump, &ToolResolution::none());
|
||||
assert!(status.pg_dump_found);
|
||||
assert!(!status.pg_restore_found);
|
||||
assert_eq!(status.pg_dump_source.as_deref(), Some("system"));
|
||||
assert_eq!(status.pg_dump_resolved_name.as_deref(), Some("pg_dump"));
|
||||
assert!(status.pg_dump_bundled_available);
|
||||
assert!(!status.pg_restore_bundled_available);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_mysql_tool_status_reports_the_resolved_dump_name() {
|
||||
let dump = ToolResolution {
|
||||
resolved: Some(crate::db::tool_resolver::Resolution {
|
||||
program: "/opt/homebrew/bin/mariadb-dump".into(),
|
||||
source: crate::db::tool_resolver::ToolSource::TrustedDir,
|
||||
name: "mariadb-dump".into(),
|
||||
}),
|
||||
bundled_path: Some(std::path::PathBuf::from("/app/resources/mysql_tools/mariadb-dump")),
|
||||
bundled_available: true,
|
||||
version: Some("mariadb-dump 11.4.5-MariaDB".into()),
|
||||
};
|
||||
let status = build_mysql_tool_status(&dump, &ToolResolution::none());
|
||||
assert!(status.mysqldump_found);
|
||||
assert!(!status.mysql_found);
|
||||
assert_eq!(status.mysqldump_resolved_name.as_deref(), Some("mariadb-dump"));
|
||||
assert!(status.mysqldump_is_mariadb);
|
||||
assert!(!status.mysql_bundled_available);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -152,6 +181,10 @@ fn pg_tool_status_serialization() {
|
||||
pg_restore_version: None,
|
||||
pg_dump_source: None,
|
||||
pg_restore_source: None,
|
||||
pg_dump_resolved_name: Some("pg_dump".into()),
|
||||
pg_restore_resolved_name: None,
|
||||
pg_dump_bundled_available: true,
|
||||
pg_restore_bundled_available: false,
|
||||
};
|
||||
let json = serde_json::to_string(&status).unwrap();
|
||||
assert!(json.contains("pg_dump_found"));
|
||||
@@ -171,6 +204,7 @@ fn backup_progress_event_completed() {
|
||||
progress: Some(1.0),
|
||||
output_line: None,
|
||||
error: None,
|
||||
warning: None,
|
||||
};
|
||||
let json = serde_json::to_string(&evt).unwrap();
|
||||
assert!(json.contains("\"completed\""));
|
||||
@@ -185,12 +219,41 @@ fn backup_progress_event_failed() {
|
||||
progress: None,
|
||||
output_line: None,
|
||||
error: Some("connection refused".into()),
|
||||
warning: None,
|
||||
};
|
||||
let json = serde_json::to_string(&evt).unwrap();
|
||||
assert!(json.contains("\"failed\""));
|
||||
assert!(json.contains("\"connection refused\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_progress_event_serializes_a_non_fatal_warning() {
|
||||
let evt = BackupProgressEvent {
|
||||
job_id: "job-warn".into(),
|
||||
status: "running".into(),
|
||||
progress: Some(0.0),
|
||||
output_line: None,
|
||||
error: None,
|
||||
warning: Some("Client/server version mismatch: …".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&evt).unwrap();
|
||||
assert!(json.contains("\"warning\":\"Client/server version mismatch"), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_progress_event_serializes_a_null_warning_when_absent() {
|
||||
let evt = BackupProgressEvent {
|
||||
job_id: "job-quiet".into(),
|
||||
status: "completed".into(),
|
||||
progress: Some(1.0),
|
||||
output_line: None,
|
||||
error: None,
|
||||
warning: None,
|
||||
};
|
||||
let json = serde_json::to_string(&evt).unwrap();
|
||||
assert!(json.contains("\"warning\":null"), "{json}");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Integration tests (headless, against live DBs)
|
||||
//
|
||||
@@ -406,19 +469,6 @@ fn integration_plain_dump_restore() {
|
||||
// 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)
|
||||
// ------------------------------------------------------------------
|
||||
@@ -507,28 +557,311 @@ fn mysql_dump_args_single_transaction_no_data_routines() {
|
||||
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);
|
||||
let args = build_mysql_dump_args(
|
||||
&MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into()),
|
||||
&opts,
|
||||
false,
|
||||
);
|
||||
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"));
|
||||
// `--databases` is a BOOLEAN flag: the database name must be a separate
|
||||
// positional argument. `--databases=shop` is rejected by clients as
|
||||
// "invalid value 'shop'".
|
||||
let i = args.iter().position(|a| a == "--databases").expect("--databases flag");
|
||||
assert_eq!(args[i + 1], "shop");
|
||||
assert!(!args.iter().any(|a| a.starts_with("--databases=")), "{args:?}");
|
||||
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"));
|
||||
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,
|
||||
false,
|
||||
);
|
||||
assert!(args.iter().all(|a| !a.starts_with("--password")));
|
||||
}
|
||||
|
||||
#[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")));
|
||||
fn mysql_dump_args_omit_column_statistics_for_mariadb_clients() {
|
||||
// The bundled client is MariaDB 11.4.5 and rejects the MySQL-8-only option
|
||||
// with `unknown option '--skip-column-statistics'`, aborting the dump.
|
||||
let opts = MySqlBackupOptions {
|
||||
database: "shop".into(), file_path: "/tmp/d.sql".into(),
|
||||
single_transaction: true, no_data: false, routines: true, triggers: true, events: false,
|
||||
};
|
||||
let conn = MySqlConnParams::new("h".into(), 3306, "u".into(), "shop".into(), "p".into());
|
||||
let mariadb = build_mysql_dump_args(&conn, &opts, true);
|
||||
assert!(!mariadb.iter().any(|a| a == "--skip-column-statistics"), "{mariadb:?}");
|
||||
let mysql = build_mysql_dump_args(&conn, &opts, false);
|
||||
assert!(mysql.iter().any(|a| a == "--skip-column-statistics"), "{mysql:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls_args_map_ssl_mode_only_for_mysql_clients() {
|
||||
// MySQL clients take `--ssl-mode=<mode>`; MariaDB clients have no such
|
||||
// option and use the boolean `--ssl`.
|
||||
assert_eq!(tls_args_for_client(false, Some("REQUIRED")), vec!["--ssl-mode=REQUIRED".to_string()]);
|
||||
assert_eq!(tls_args_for_client(true, Some("REQUIRED")), vec!["--ssl".to_string()]);
|
||||
assert_eq!(tls_args_for_client(false, None), Vec::<String>::new());
|
||||
assert_eq!(tls_args_for_client(true, None), Vec::<String>::new());
|
||||
assert_eq!(tls_args_for_client(true, Some("DISABLED")), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_is_mariadb_reads_the_version_banner() {
|
||||
// A program that cannot be executed must not panic — it reports false
|
||||
// (keep the MySQL-only flags), and the dump fails at spawn anyway.
|
||||
assert!(!client_is_mariadb("/nonexistent/definitely-not-a-tool"));
|
||||
}
|
||||
|
||||
/// Regression guard for the bundled fallback path: the args we generate must be
|
||||
/// *accepted* by the real bundled MariaDB client. It spawns the staged binary
|
||||
/// against a closed port and requires the failure to be a CONNECTION error —
|
||||
/// never an option-parsing error. No live server needed.
|
||||
///
|
||||
/// The binaries are gitignored (the release workflow downloads them), so when
|
||||
/// they are not staged this test logs loudly and returns instead of failing.
|
||||
#[test]
|
||||
fn bundled_mariadb_dump_accepts_our_generated_args() {
|
||||
let bundled = std::path::PathBuf::from(format!(
|
||||
"{}/resources/mysql_tools",
|
||||
env!("CARGO_MANIFEST_DIR")
|
||||
))
|
||||
.join(crate::db::tool_resolver::bundled_bin_name("mariadb-dump"));
|
||||
if !bundled.is_file() {
|
||||
eprintln!(
|
||||
"SKIPPED bundled_mariadb_dump_accepts_our_generated_args: {bundled:?} is not staged \
|
||||
(run the release workflow's tool-staging step to enable this guard)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let conn = MySqlConnParams::new("127.0.0.1".into(), 1, "root".into(), "mysql".into(), "unused".into());
|
||||
let opts = MySqlBackupOptions {
|
||||
database: "mysql".into(),
|
||||
file_path: std::env::temp_dir().join("gridline_args_guard.sql").to_string_lossy().to_string(),
|
||||
single_transaction: true, no_data: false, routines: true, triggers: true, events: false,
|
||||
};
|
||||
assert!(client_is_mariadb(bundled.to_str().unwrap()), "the bundled dump tool must be MariaDB's");
|
||||
|
||||
let mut args = build_mysql_dump_args(&conn, &opts, true);
|
||||
args.extend(tls_args_for_client(true, Some("REQUIRED")));
|
||||
|
||||
let out = std::process::Command::new(&bundled).args(&args).output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
|
||||
// "unknown option" covers `--skip-column-statistics`; "unknown variable"
|
||||
// covers `--ssl-mode` (MariaDB's own wording for the same class of mistake).
|
||||
assert!(!err.contains("unknown option"), "bundled client rejected an option: {err}");
|
||||
assert!(!err.contains("unknown variable"), "bundled client rejected an option: {err}");
|
||||
assert!(!err.contains("invalid value"), "bundled client rejected an option value: {err}");
|
||||
assert!(
|
||||
err.contains("2002") || err.contains("can't connect") || err.contains("connection refused"),
|
||||
"expected a connection failure (port 1 is closed), got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── native MySQL restore: error reporting contract ──────────────────
|
||||
|
||||
#[test]
|
||||
fn restore_failure_names_the_statement_position() {
|
||||
// The user must be able to find the offending statement in their file.
|
||||
let err = format_restore_statement_error(3, 10, "syntax error near 'CREAT'");
|
||||
assert_eq!(err, "Statement 3 of 10 failed: syntax error near 'CREAT'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_progress_total_counts_the_clean_phase() {
|
||||
// clean adds one progress unit (the drop phase) so the bar never resets.
|
||||
assert_eq!(restore_progress_total(5, false), 5);
|
||||
assert_eq!(restore_progress_total(5, true), 6);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn mysql_native_restore_applies_a_script_and_honours_clean() {
|
||||
use sqlx::ConnectOptions;
|
||||
use sqlx::Connection;
|
||||
|
||||
let host = match std::env::var("GRIDLINE_TEST_MYSQL_HOST") { Ok(v) => v, Err(_) => return };
|
||||
let port: u16 = std::env::var("GRIDLINE_TEST_MYSQL_PORT").unwrap_or_else(|_| "3306".into()).parse().unwrap();
|
||||
let user = std::env::var("GRIDLINE_TEST_MYSQL_USER").unwrap_or_else(|_| "root".into());
|
||||
let pass = std::env::var("GRIDLINE_TEST_MYSQL_PASS").unwrap_or_default();
|
||||
let db = match std::env::var("GRIDLINE_TEST_MYSQL_DB") { Ok(v) => v, Err(_) => return };
|
||||
|
||||
let mut conn = sqlx::mysql::MySqlConnectOptions::new()
|
||||
.host(&host).port(port).username(&user).password(&pass).database(&db)
|
||||
.ssl_mode(sqlx::mysql::MySqlSslMode::Disabled)
|
||||
.disable_statement_logging()
|
||||
.connect().await.expect("connect");
|
||||
|
||||
// Scratch table, removed again at the end, so the live DB is never harmed.
|
||||
let table = "gl_restore_probe";
|
||||
sqlx::query(&format!("DROP TABLE IF EXISTS `{db}`.`{table}`"))
|
||||
.execute(&mut conn).await.expect("pre-clean");
|
||||
|
||||
let script = format!(
|
||||
"CREATE TABLE `{table}` (id INT PRIMARY KEY, name VARCHAR(20));\n\
|
||||
INSERT INTO `{table}` VALUES (1, 'first');"
|
||||
);
|
||||
let statements = crate::db::sql_split::split_statements(&script);
|
||||
|
||||
let mut events: Vec<(usize, usize)> = Vec::new();
|
||||
let mut on_progress = |done: usize, total: usize| events.push((done, total));
|
||||
let never = || false;
|
||||
|
||||
run_mysql_restore_on(
|
||||
&mut conn,
|
||||
MySqlRestoreRun { database: &db, statements: &statements, clean: true },
|
||||
&mut on_progress,
|
||||
&never,
|
||||
).await.expect("first restore");
|
||||
|
||||
let count: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM `{db}`.`{table}`"))
|
||||
.fetch_one(&mut conn).await.expect("count");
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(events.last().copied(), Some((statements.len() + 1, statements.len() + 1)));
|
||||
|
||||
// Second run with clean: the table must be dropped and recreated, so the
|
||||
// insert cannot collide with the existing primary key.
|
||||
let mut events2: Vec<(usize, usize)> = Vec::new();
|
||||
let mut on_progress2 = |done: usize, total: usize| events2.push((done, total));
|
||||
run_mysql_restore_on(
|
||||
&mut conn,
|
||||
MySqlRestoreRun { database: &db, statements: &statements, clean: true },
|
||||
&mut on_progress2,
|
||||
&never,
|
||||
).await.expect("clean restore must not hit a duplicate key");
|
||||
|
||||
let count2: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM `{db}`.`{table}`"))
|
||||
.fetch_one(&mut conn).await.expect("count2");
|
||||
assert_eq!(count2, 1, "clean restore must replace, not append");
|
||||
|
||||
// A statement that cannot succeed must fail and name its position.
|
||||
let bad = crate::db::sql_split::split_statements("SELECT 1;\nSELECT * FROM gl_no_such_table_xyz;");
|
||||
let mut noop = |_: usize, _: usize| {};
|
||||
let err = run_mysql_restore_on(
|
||||
&mut conn,
|
||||
MySqlRestoreRun { database: &db, statements: &bad, clean: false },
|
||||
&mut noop,
|
||||
&never,
|
||||
).await.expect_err("must fail");
|
||||
assert!(err.starts_with("Statement 2 of 2 failed:"), "got {err:?}");
|
||||
|
||||
let _ = conn.close().await;
|
||||
let mut cleanup = sqlx::mysql::MySqlConnectOptions::new()
|
||||
.host(&host).port(port).username(&user).password(&pass).database(&db)
|
||||
.ssl_mode(sqlx::mysql::MySqlSslMode::Disabled).disable_statement_logging()
|
||||
.connect().await.expect("reconnect");
|
||||
sqlx::query(&format!("DROP TABLE IF EXISTS `{db}`.`{table}`"))
|
||||
.execute(&mut cleanup).await.expect("cleanup");
|
||||
let _ = cleanup.close().await;
|
||||
}
|
||||
|
||||
// ── restore file-size cap ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn restore_size_cap_accepts_a_file_at_the_limit() {
|
||||
assert!(check_restore_file_size(MAX_RESTORE_FILE_BYTES, MAX_RESTORE_FILE_BYTES).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_size_cap_rejects_a_file_over_the_limit_and_states_both_sizes() {
|
||||
let err = check_restore_file_size(MAX_RESTORE_FILE_BYTES + 1, MAX_RESTORE_FILE_BYTES)
|
||||
.expect_err("must reject");
|
||||
assert!(err.contains("100 MB"), "error must state the limit in MB; got {err:?}");
|
||||
assert!(err.contains("MB"), "error must state the actual size in MB; got {err:?}");
|
||||
}
|
||||
|
||||
// ── live MySQL dump: system tool vs. bundled tool ───────────────────
|
||||
//
|
||||
// Issue #44: the app reported "mysqldump not found" even with MySQL
|
||||
// client tools installed. These two #[ignore]d tests exercise the real
|
||||
// `run_mysql_dump` against a live server — once via a tool the shared
|
||||
// resolver found as a *system* client, once via the *bundled* MariaDB
|
||||
// client — so both provenance paths are covered end to end.
|
||||
//
|
||||
// Run with:
|
||||
//
|
||||
// GRIDLINE_TEST_MYSQL_HOST=... GRIDLINE_TEST_MYSQL_PORT=... \
|
||||
// GRIDLINE_TEST_MYSQL_USER=... GRIDLINE_TEST_MYSQL_DB=... \
|
||||
// GRIDLINE_TEST_MYSQL_PASSWORD=... \
|
||||
// cargo test --lib live_mysql_dump -- --ignored --nocapture
|
||||
|
||||
fn mysql_conn_from_env(prefix: &str) -> MySqlConnParams {
|
||||
MySqlConnParams::new(
|
||||
env(&format!("{prefix}_HOST")),
|
||||
env(&format!("{prefix}_PORT")).parse().unwrap(),
|
||||
env(&format!("{prefix}_USER")),
|
||||
env(&format!("{prefix}_DB")),
|
||||
env(&format!("{prefix}_PASSWORD")),
|
||||
)
|
||||
}
|
||||
|
||||
fn live_mysql_dump_opts(conn: &MySqlConnParams, file_name: &str) -> MySqlBackupOptions {
|
||||
MySqlBackupOptions {
|
||||
database: conn.database.clone(),
|
||||
file_path: std::env::temp_dir().join(file_name).to_string_lossy().to_string(),
|
||||
single_transaction: true,
|
||||
no_data: false,
|
||||
routines: true,
|
||||
triggers: true,
|
||||
events: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Requires GRIDLINE_TEST_MYSQL_{HOST,PORT,USER,DB,PASSWORD} and a system
|
||||
/// mariadb-dump/mysqldump on this machine.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn live_mysql_dump_with_a_system_tool() {
|
||||
let conn = mysql_conn_from_env("GRIDLINE_TEST_MYSQL");
|
||||
let opts = live_mysql_dump_opts(&conn, "gridline_live_mysql_system.sql");
|
||||
let req = crate::db::tool_resolver::mysql_dump_request();
|
||||
let trusted = crate::db::tool_resolver::trusted_dirs();
|
||||
let resolved = crate::db::tool_resolver::resolve_with(
|
||||
&req,
|
||||
&trusted,
|
||||
&crate::db::tool_resolver::path_lookup,
|
||||
None,
|
||||
crate::db::tool_resolver::probe,
|
||||
)
|
||||
.expect("no system mariadb-dump/mysqldump resolved");
|
||||
eprintln!("resolved: {} ({})", resolved.program, resolved.source.as_str());
|
||||
let tools = MySqlToolPaths {
|
||||
mysqldump: resolved.program.clone(),
|
||||
mysql: resolved.program.clone(),
|
||||
};
|
||||
run_mysql_dump(&conn, &opts, &tools, None).expect("dump failed");
|
||||
let len = std::fs::metadata(&opts.file_path).unwrap().len();
|
||||
assert!(len > 0, "dump file is empty");
|
||||
let _ = std::fs::remove_file(&opts.file_path);
|
||||
}
|
||||
|
||||
/// Requires GRIDLINE_TEST_MYSQL_* and a staged `resources/mysql_tools` tree.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn live_mysql_dump_with_the_bundled_tool() {
|
||||
let conn = mysql_conn_from_env("GRIDLINE_TEST_MYSQL");
|
||||
let opts = live_mysql_dump_opts(&conn, "gridline_live_mysql_bundled.sql");
|
||||
let bundled = std::path::PathBuf::from(format!(
|
||||
"{}/resources/mysql_tools",
|
||||
env!("CARGO_MANIFEST_DIR")
|
||||
))
|
||||
.join(crate::db::tool_resolver::bundled_bin_name("mariadb-dump"));
|
||||
assert!(bundled.is_file(), "bundled mariadb-dump not staged at {bundled:?}");
|
||||
assert!(crate::db::tool_resolver::probe(&bundled), "bundled mariadb-dump does not run");
|
||||
let tools = MySqlToolPaths {
|
||||
mysqldump: bundled.to_string_lossy().to_string(),
|
||||
mysql: bundled.to_string_lossy().to_string(),
|
||||
};
|
||||
run_mysql_dump(&conn, &opts, &tools, None).expect("bundled dump failed");
|
||||
let len = std::fs::metadata(&opts.file_path).unwrap().len();
|
||||
assert!(len > 0, "bundled dump file is empty");
|
||||
let _ = std::fs::remove_file(&opts.file_path);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::models::{Connection, ConnectionInput};
|
||||
use crate::store::Store;
|
||||
use std::sync::Mutex;
|
||||
|
||||
const VALID_DB_TYPES: [&str; 4] = ["postgresql", "mysql", "sqlite", "redis"];
|
||||
const VALID_DB_TYPES: [&str; 5] = ["postgresql", "mysql", "mariadb", "sqlite", "redis"];
|
||||
|
||||
fn validate(input: &ConnectionInput) -> Result<(), String> {
|
||||
if input.name.is_empty() || input.name.chars().count() > 100 {
|
||||
@@ -360,3 +360,7 @@ mod tests {
|
||||
assert_eq!(get_recent_connections_inner(&st, 10).unwrap().len(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "connections.test.rs"]
|
||||
mod connections_tests;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
use super::*;
|
||||
|
||||
fn mariadb_input() -> ConnectionInput {
|
||||
ConnectionInput {
|
||||
name: "Maria local".into(),
|
||||
db_type: "mariadb".into(),
|
||||
host: "127.0.0.1".into(),
|
||||
port: Some(3306),
|
||||
username: Some("root".into()),
|
||||
folder_id: None,
|
||||
tag_ids: vec![],
|
||||
password: None,
|
||||
database: Some("test".into()),
|
||||
environment: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
use_keychain: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_accepts_mariadb_with_port() {
|
||||
assert!(validate(&mariadb_input()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_mariadb_without_port() {
|
||||
let mut input = mariadb_input();
|
||||
input.port = None;
|
||||
assert!(validate(&input).is_err(), "mariadb requires a port like mysql");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_unknown_db_type() {
|
||||
let mut input = mariadb_input();
|
||||
input.db_type = "oracle".into();
|
||||
assert!(validate(&input).is_err());
|
||||
}
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
use crate::db::pool::{ConnectionPoolManager, DbConfig, DbHandle};
|
||||
use crate::models::db_viewer::{
|
||||
Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo, IndexInfo,
|
||||
QueryResult, SequenceInfo, TableInfo, TriggerInfo,
|
||||
Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo, HypertableInfo,
|
||||
HypertableListResponse, IndexInfo, QueryResult, SequenceInfo, TableInfo, TriggerInfo,
|
||||
};
|
||||
use bytes::BytesMut;
|
||||
use std::collections::HashMap;
|
||||
@@ -566,6 +566,14 @@ pub fn build_pg_insert_sql(
|
||||
table: &str,
|
||||
columns: &[(String, serde_json::Value)],
|
||||
) -> (String, Vec<serde_json::Value>) {
|
||||
// A table with no editable columns (e.g. only a serial/identity PK) inserts
|
||||
// with all defaults — `INSERT ... () VALUES ()` is invalid SQL.
|
||||
if columns.is_empty() {
|
||||
return (
|
||||
format!("INSERT INTO \"{}\".\"{}\" DEFAULT VALUES", schema, table),
|
||||
vec![],
|
||||
);
|
||||
}
|
||||
let cols: Vec<String> = columns.iter().map(|(c, _)| format!("\"{}\"", c)).collect();
|
||||
let mut params: Vec<serde_json::Value> = Vec::new();
|
||||
let placeholders: Vec<String> = columns
|
||||
@@ -933,6 +941,102 @@ where
|
||||
Ok((client, handle))
|
||||
}
|
||||
|
||||
/// Apply Gridline's MySQL TLS policy to connect options.
|
||||
///
|
||||
/// Extracted from `run_mysql_connect` so the native restore path connects
|
||||
/// *exactly* as browsing does. That equivalence is the whole point of the fix:
|
||||
/// the sqlx driver reaches MySQL 8 with `caching_sha2_password` and handles
|
||||
/// TLS in-process, where the bundled MariaDB client cannot.
|
||||
pub(crate) fn apply_mysql_tls(
|
||||
mut opts: sqlx::mysql::MySqlConnectOptions,
|
||||
original_host: &str,
|
||||
ssl_mode: Option<&str>,
|
||||
ssl_ca_path: Option<&str>,
|
||||
via_tunnel: bool,
|
||||
) -> sqlx::mysql::MySqlConnectOptions {
|
||||
use sqlx::mysql::MySqlSslMode;
|
||||
|
||||
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
||||
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
||||
let decision = crate::commands::ssh::effective_tls_decision(
|
||||
crate::db::tls::tls_decision_for(original_host, ssl_mode),
|
||||
via_tunnel,
|
||||
);
|
||||
match decision {
|
||||
crate::db::tls::TlsDecision::Disable => {
|
||||
opts = opts.ssl_mode(MySqlSslMode::Disabled);
|
||||
}
|
||||
crate::db::tls::TlsDecision::Require => {
|
||||
opts = opts.ssl_mode(MySqlSslMode::Required);
|
||||
}
|
||||
crate::db::tls::TlsDecision::Verify => {
|
||||
// sqlx 0.8 has no VerifyFull: verify-ca -> VerifyCa (chain only),
|
||||
// verify-full -> VerifyIdentity (chain + hostname).
|
||||
match ssl_mode {
|
||||
Some("verify-ca") => opts = opts.ssl_mode(MySqlSslMode::VerifyCa),
|
||||
_ => opts = opts.ssl_mode(MySqlSslMode::VerifyIdentity),
|
||||
}
|
||||
if let Some(ca) = ssl_ca_path {
|
||||
opts = opts.ssl_ca(ca);
|
||||
}
|
||||
}
|
||||
}
|
||||
opts
|
||||
}
|
||||
|
||||
/// Build the final MySQL connect options (base params + TLS policy) for a
|
||||
/// headless, non-pooled connection.
|
||||
pub(crate) fn mysql_connect_options(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: &str,
|
||||
database: &str,
|
||||
original_host: &str,
|
||||
ssl_mode: Option<&str>,
|
||||
ssl_ca_path: Option<&str>,
|
||||
via_tunnel: bool,
|
||||
) -> sqlx::mysql::MySqlConnectOptions {
|
||||
let opts = sqlx::mysql::MySqlConnectOptions::new()
|
||||
.host(host)
|
||||
.port(port)
|
||||
.username(username)
|
||||
.password(password)
|
||||
.database(database);
|
||||
apply_mysql_tls(opts, original_host, ssl_mode, ssl_ca_path, via_tunnel)
|
||||
}
|
||||
|
||||
/// Open a single standalone MySQL connection for a non-browsing operation
|
||||
/// (currently: native restore). Deliberately not pooled — the restore owns the
|
||||
/// session so `USE` and `FOREIGN_KEY_CHECKS` stay coherent for its whole run.
|
||||
pub(crate) async fn open_mysql_connection(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: &str,
|
||||
database: &str,
|
||||
original_host: &str,
|
||||
ssl_mode: Option<&str>,
|
||||
ssl_ca_path: Option<&str>,
|
||||
via_tunnel: bool,
|
||||
) -> Result<sqlx::mysql::MySqlConnection, String> {
|
||||
use sqlx::ConnectOptions;
|
||||
let opts = mysql_connect_options(
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
database,
|
||||
original_host,
|
||||
ssl_mode,
|
||||
ssl_ca_path,
|
||||
via_tunnel,
|
||||
);
|
||||
opts.connect()
|
||||
.await
|
||||
.map_err(|e| sanitize_error(&format!("MySQL connection failed: {e}")))
|
||||
}
|
||||
|
||||
/// Headless MySQL connect (no Tauri `State`). Opens an SSH tunnel when
|
||||
/// configured (binding 127.0.0.1 only), maps SSL modes, and registers a
|
||||
/// `DbHandle::MySql` pool. Errors are sanitized so no `mysql://user:pass@host`
|
||||
@@ -944,7 +1048,7 @@ pub(crate) async fn run_mysql_connect(
|
||||
pool_manager: &tokio::sync::Mutex<crate::db::pool::ConnectionPoolManager>,
|
||||
cancel_registry: &crate::cancel::CancelRegistry,
|
||||
) -> Result<(), String> {
|
||||
use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions, MySqlSslMode};
|
||||
use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions};
|
||||
if config.host.trim().is_empty() {
|
||||
return Err("host is required".to_string());
|
||||
}
|
||||
@@ -987,38 +1091,22 @@ pub(crate) async fn run_mysql_connect(
|
||||
via_tunnel = false;
|
||||
}
|
||||
|
||||
let mut opts = MySqlConnectOptions::new()
|
||||
let opts = MySqlConnectOptions::new()
|
||||
.host(&target_host)
|
||||
.port(target_port)
|
||||
.username(config.username.as_deref().unwrap_or("root"))
|
||||
.password(config.password.as_deref().unwrap_or(""))
|
||||
.database(config.database.as_deref().unwrap_or("mysql"));
|
||||
|
||||
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
||||
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
||||
let decision = crate::commands::ssh::effective_tls_decision(
|
||||
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
|
||||
// TLS: shared with the native restore path so browsing and restore make
|
||||
// identical TLS decisions.
|
||||
let opts = apply_mysql_tls(
|
||||
opts,
|
||||
&config.host,
|
||||
config.ssl_mode.as_deref(),
|
||||
config.ssl_ca_path.as_deref(),
|
||||
via_tunnel,
|
||||
);
|
||||
match decision {
|
||||
crate::db::tls::TlsDecision::Disable => {
|
||||
opts = opts.ssl_mode(MySqlSslMode::Disabled);
|
||||
}
|
||||
crate::db::tls::TlsDecision::Require => {
|
||||
opts = opts.ssl_mode(MySqlSslMode::Required);
|
||||
}
|
||||
crate::db::tls::TlsDecision::Verify => {
|
||||
// sqlx 0.8 has no VerifyFull: verify-ca -> VerifyCa (chain only),
|
||||
// verify-full -> VerifyIdentity (chain + hostname).
|
||||
match config.ssl_mode.as_deref() {
|
||||
Some("verify-ca") => opts = opts.ssl_mode(MySqlSslMode::VerifyCa),
|
||||
_ => opts = opts.ssl_mode(MySqlSslMode::VerifyIdentity),
|
||||
}
|
||||
if let Some(ca) = config.ssl_ca_path.as_deref() {
|
||||
opts = opts.ssl_ca(ca);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clone the (already final) options before `connect_with` consumes them so
|
||||
// `cancel_query` can open its own connection to run `KILL QUERY ?`.
|
||||
@@ -1076,7 +1164,7 @@ pub async fn db_connect(
|
||||
// connections honor the user's mode. Building this before opening the
|
||||
// tunnel means a config error can't leak the tunnel.
|
||||
let decision = crate::commands::ssh::effective_tls_decision(
|
||||
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
|
||||
crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()),
|
||||
will_tunnel,
|
||||
);
|
||||
let tls = crate::db::tls::build_tls_config(
|
||||
@@ -1190,7 +1278,7 @@ pub async fn db_connect(
|
||||
}
|
||||
Err(e) => Err(format!("Connection failed: {}", e)),
|
||||
}
|
||||
} else if config.db_type == "mysql" {
|
||||
} else if config.db_type == "mysql" || config.db_type == "mariadb" {
|
||||
run_mysql_connect(
|
||||
&connection_id,
|
||||
&config,
|
||||
@@ -2632,6 +2720,58 @@ pub async fn get_functions(
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_hypertables(
|
||||
connection_id: String,
|
||||
schema: Option<String>,
|
||||
state: State<'_, crate::AppState>,
|
||||
) -> Result<HypertableListResponse, String> {
|
||||
let mut pm = state.pool_manager.lock().await;
|
||||
match pm.get(&connection_id) {
|
||||
Some(DbHandle::Postgresql(client, _)) => {
|
||||
let detected: bool = client
|
||||
.query_one(&crate::db::introspection::pg_timescale_detect_query(), &[])
|
||||
.await
|
||||
.map(|r| r.get::<_, bool>(0))
|
||||
.unwrap_or(false);
|
||||
if !detected {
|
||||
return Ok(HypertableListResponse {
|
||||
available: false,
|
||||
reason: Some("TimescaleDB extension is not installed".into()),
|
||||
items: vec![],
|
||||
});
|
||||
}
|
||||
let schema = schema.unwrap_or_else(|| "public".to_string());
|
||||
let query = crate::db::introspection::pg_hypertables_query(&schema);
|
||||
let rows = client
|
||||
.query(&query, &[&schema])
|
||||
.await
|
||||
.map_err(|e| format!("TimescaleDB introspection failed: {e}"))?;
|
||||
Ok(HypertableListResponse {
|
||||
available: true,
|
||||
reason: None,
|
||||
items: rows
|
||||
.iter()
|
||||
.map(|r| HypertableInfo {
|
||||
name: r.get(0),
|
||||
schema: r.get(1),
|
||||
num_dimensions: r.get::<_, Option<i64>>(2).unwrap_or(0),
|
||||
compression_enabled: r.get::<_, Option<bool>>(3).unwrap_or(false),
|
||||
num_chunks: r.get::<_, Option<i64>>(4).unwrap_or(0),
|
||||
total_size_bytes: r.get::<_, Option<i64>>(5),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
Some(DbHandle::Sqlite(_)) | Some(DbHandle::MySql(_)) => Ok(HypertableListResponse {
|
||||
available: false,
|
||||
reason: Some("Hypertables are a TimescaleDB (PostgreSQL) feature".into()),
|
||||
items: vec![],
|
||||
}),
|
||||
None => Err("Connection not found".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_indexes(
|
||||
connection_id: String,
|
||||
@@ -3102,6 +3242,15 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A table with no editable columns (only a serial/identity PK) must insert
|
||||
/// with `DEFAULT VALUES`, not the invalid `INSERT ... () VALUES ()`.
|
||||
#[test]
|
||||
fn build_pg_insert_sql_empty_columns_uses_default_values() {
|
||||
let (sql, params) = build_pg_insert_sql("public", "t", &[]);
|
||||
assert_eq!(sql, "INSERT INTO \"public\".\"t\" DEFAULT VALUES");
|
||||
assert!(params.is_empty());
|
||||
}
|
||||
|
||||
/// Verify that `get_sqlite_ddl` returns the stored CREATE TABLE statement
|
||||
/// from `sqlite_master`.
|
||||
#[test]
|
||||
@@ -3620,3 +3769,7 @@ mod tests {
|
||||
assert!(!pg_dump_available_at("/nonexistent/pg_dump_999999"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "db_viewer.test.rs"]
|
||||
mod db_viewer_extra_tests;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
use crate::db::pool::{ConnectionPoolManager, DbHandle};
|
||||
|
||||
#[test]
|
||||
fn hypertables_query_targets_timescale_information_schema() {
|
||||
let q = crate::db::introspection::pg_hypertables_query("public");
|
||||
assert!(q.contains("timescaledb_information.hypertables"));
|
||||
assert!(q.contains("hypertable_schema = $1"));
|
||||
assert!(q.contains("timescaledb_information.chunks"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timescale_detect_query_checks_pg_extension() {
|
||||
let q = crate::db::introspection::pg_timescale_detect_query();
|
||||
assert!(q.contains("pg_extension"));
|
||||
assert!(q.contains("'timescaledb'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hypertable_response_degrades_gracefully() {
|
||||
let resp = crate::models::db_viewer::HypertableListResponse {
|
||||
available: false,
|
||||
reason: Some("TimescaleDB extension is not installed".into()),
|
||||
items: vec![],
|
||||
};
|
||||
let json = serde_json::to_string(&resp).expect("serializes");
|
||||
assert!(json.contains("\"available\":false"));
|
||||
}
|
||||
|
||||
/// Live test. On a plain PostgreSQL connection (no TimescaleDB) the detection
|
||||
/// helper must report `available: false`, never an error. Follows the exact
|
||||
/// env-var + client-construction convention used by the live PG tests in
|
||||
/// `commands/objects.test.rs`.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn hypertables_unavailable_on_plain_pg() {
|
||||
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-pg".to_string();
|
||||
pm.register(&id, DbHandle::Postgresql(client, handle));
|
||||
let pm = tokio::sync::Mutex::new(pm);
|
||||
|
||||
// Exercise the detection helper directly (the command needs `State`, which
|
||||
// is not constructible in tests).
|
||||
let detected = {
|
||||
let mut g = pm.lock().await;
|
||||
match g.get(&id) {
|
||||
Some(DbHandle::Postgresql(c, _)) => {
|
||||
c.query_one(&crate::db::introspection::pg_timescale_detect_query(), &[])
|
||||
.await
|
||||
}
|
||||
_ => panic!("expected a postgresql handle"),
|
||||
}
|
||||
};
|
||||
assert!(detected.is_ok(), "detection query must run on plain PG");
|
||||
let available: bool = detected.unwrap().get(0);
|
||||
assert!(!available, "plain PG must report available=false");
|
||||
}
|
||||
@@ -3,7 +3,7 @@ use crate::store::Store;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Mutex;
|
||||
|
||||
const VALID_DB_TYPES: [&str; 4] = ["postgresql", "mysql", "sqlite", "redis"];
|
||||
const VALID_DB_TYPES: [&str; 5] = ["postgresql", "mysql", "mariadb", "sqlite", "redis"];
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ImportRecord {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::keychain_acl;
|
||||
use tauri_plugin_keyring_store::KeyringExt;
|
||||
|
||||
/// Store a connection password in the OS keychain.
|
||||
@@ -11,7 +12,10 @@ pub fn save_connection_password(
|
||||
app.keyring()
|
||||
.store
|
||||
.set_password(&connection_id, &password)
|
||||
.map_err(|e| e.to_string())
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Best-effort: pin the item's ACL to this app so re-signed builds never prompt.
|
||||
keychain_acl::after_store(&app, &connection_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieve a connection password from the OS keychain.
|
||||
@@ -21,6 +25,9 @@ pub fn get_connection_password(
|
||||
app: tauri::AppHandle,
|
||||
connection_id: String,
|
||||
) -> Result<Option<String>, String> {
|
||||
// Repair a stale item ACL first: after a rebuild/path change macOS would
|
||||
// otherwise ask for the keychain password on every access (once per secret).
|
||||
keychain_acl::before_read(&app, &connection_id);
|
||||
app.keyring()
|
||||
.store
|
||||
.get_password(&connection_id)
|
||||
@@ -33,6 +40,7 @@ pub fn get_connection_password_internal(
|
||||
app: &tauri::AppHandle,
|
||||
connection_id: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
keychain_acl::before_read(app, connection_id);
|
||||
app.keyring()
|
||||
.store
|
||||
.get_password(connection_id)
|
||||
@@ -55,7 +63,9 @@ pub fn save_connection_ssh_password(
|
||||
app.keyring()
|
||||
.store
|
||||
.set_password(&ssh_account("password", &connection_id), &password)
|
||||
.map_err(|e| e.to_string())
|
||||
.map_err(|e| e.to_string())?;
|
||||
keychain_acl::after_store(&app, &ssh_account("password", &connection_id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieve an SSH tunnel password from the OS keychain.
|
||||
@@ -65,6 +75,7 @@ pub fn get_connection_ssh_password(
|
||||
app: tauri::AppHandle,
|
||||
connection_id: String,
|
||||
) -> Result<Option<String>, String> {
|
||||
keychain_acl::before_read(&app, &ssh_account("password", &connection_id));
|
||||
app.keyring()
|
||||
.store
|
||||
.get_password(&ssh_account("password", &connection_id))
|
||||
@@ -93,7 +104,9 @@ pub fn save_connection_ssh_passphrase(
|
||||
app.keyring()
|
||||
.store
|
||||
.set_password(&ssh_account("passphrase", &connection_id), &passphrase)
|
||||
.map_err(|e| e.to_string())
|
||||
.map_err(|e| e.to_string())?;
|
||||
keychain_acl::after_store(&app, &ssh_account("passphrase", &connection_id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieve an SSH private-key passphrase from the OS keychain.
|
||||
@@ -103,6 +116,7 @@ pub fn get_connection_ssh_passphrase(
|
||||
app: tauri::AppHandle,
|
||||
connection_id: String,
|
||||
) -> Result<Option<String>, String> {
|
||||
keychain_acl::before_read(&app, &ssh_account("passphrase", &connection_id));
|
||||
app.keyring()
|
||||
.store
|
||||
.get_password(&ssh_account("passphrase", &connection_id))
|
||||
@@ -127,6 +141,7 @@ pub fn get_connection_ssh_password_internal(
|
||||
app: &tauri::AppHandle,
|
||||
connection_id: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
keychain_acl::before_read(app, &ssh_account("password", connection_id));
|
||||
app.keyring()
|
||||
.store
|
||||
.get_password(&ssh_account("password", connection_id))
|
||||
|
||||
@@ -7,7 +7,10 @@ pub mod import_export;
|
||||
pub mod keychain;
|
||||
pub mod maintenance;
|
||||
pub mod objects;
|
||||
pub mod open_files;
|
||||
pub mod query;
|
||||
pub mod query_export;
|
||||
pub mod schema_diff;
|
||||
pub mod schema_graph;
|
||||
pub mod settings;
|
||||
pub mod ssh;
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//! OS-delivered "open this .sql file with Gridline" requests.
|
||||
//!
|
||||
//! Every platform funnels into [`accept_path`], which validates strictly and
|
||||
//! queues the path. The frontend only ever calls [`take_pending_sql_files`],
|
||||
//! which takes **no path argument** and drains what was queued — so the webview
|
||||
//! cannot name a file to read. That is the boundary that keeps this feature
|
||||
//! from becoming an arbitrary-filesystem-read primitive and defeating the
|
||||
//! deliberate `fs` scope (spec §2).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use tauri::State;
|
||||
|
||||
/// Editor-load cap. A larger script is routed to Tools → Restore instead of
|
||||
/// being handed to Monaco (spec §2).
|
||||
pub const MAX_SQL_FILE_BYTES: u64 = 5 * 1024 * 1024;
|
||||
|
||||
/// A validated open request, delivered to the frontend with its contents.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PendingSqlFile {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Process-wide FIFO of accepted paths. Lives in Rust (not the webview) so a
|
||||
/// window reload cannot lose an open request.
|
||||
#[derive(Default)]
|
||||
pub struct PendingOpenFiles(Mutex<Vec<PathBuf>>);
|
||||
|
||||
/// Strict validation. Fail closed: anything we are not certain about is
|
||||
/// rejected rather than opened (spec §6).
|
||||
pub fn validate_sql_path_with_cap(raw: &str, cap: u64) -> Option<PathBuf> {
|
||||
let path = Path::new(raw);
|
||||
|
||||
// Absolute only — the OS always hands us absolute paths, and accepting a
|
||||
// relative one would resolve against the process CWD. Note the app is
|
||||
// often launched with CWD `/` (see the app-data-dir note in lib.rs).
|
||||
if !path.is_absolute() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ext = path.extension()?.to_str()?;
|
||||
if !ext.eq_ignore_ascii_case("sql") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// symlink_metadata, not metadata: a symlink must not be followed into an
|
||||
// arbitrary target (and `file_type().is_file()` is false for one).
|
||||
let meta = std::fs::symlink_metadata(path).ok()?;
|
||||
if !meta.file_type().is_file() {
|
||||
return None;
|
||||
}
|
||||
if meta.len() > cap {
|
||||
return None;
|
||||
}
|
||||
Some(path.to_path_buf())
|
||||
}
|
||||
|
||||
/// Validate against the production cap.
|
||||
pub fn validate_sql_path(raw: &str) -> Option<PathBuf> {
|
||||
validate_sql_path_with_cap(raw, MAX_SQL_FILE_BYTES)
|
||||
}
|
||||
|
||||
/// Queue an OS-supplied path. Returns `true` when it was newly queued, so
|
||||
/// callers can decide whether a nudge event is worth emitting.
|
||||
pub fn accept_path(buffer: &PendingOpenFiles, raw: &str) -> bool {
|
||||
let Some(path) = validate_sql_path(raw) else {
|
||||
return false;
|
||||
};
|
||||
let mut queue = buffer.0.lock().unwrap();
|
||||
if queue.iter().any(|p| p == &path) {
|
||||
return false;
|
||||
}
|
||||
queue.push(path);
|
||||
true
|
||||
}
|
||||
|
||||
/// Read and remove everything queued. Separated from the command so it is
|
||||
/// testable without a Tauri `State`.
|
||||
pub fn take_from_buffer(buffer: &PendingOpenFiles) -> Result<Vec<PendingSqlFile>, String> {
|
||||
let drained: Vec<PathBuf> = {
|
||||
let mut queue = buffer.0.lock().unwrap();
|
||||
std::mem::take(&mut *queue)
|
||||
};
|
||||
|
||||
drained
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.to_string_lossy().into_owned());
|
||||
// Fail closed on undecodable bytes rather than guessing an
|
||||
// encoding for the user's SQL.
|
||||
let bytes =
|
||||
std::fs::read(&path).map_err(|e| format!("Cannot read {}: {e}", name))?;
|
||||
let content =
|
||||
String::from_utf8(bytes).map_err(|_| format!("{name} is not valid UTF-8"))?;
|
||||
Ok(PendingSqlFile {
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
name,
|
||||
content,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drain every queued open request. The only file-reading command the webview
|
||||
/// can call — deliberately argument-free (see the module docs).
|
||||
#[tauri::command]
|
||||
pub fn take_pending_sql_files(
|
||||
state: State<'_, PendingOpenFiles>,
|
||||
) -> Result<Vec<PendingSqlFile>, String> {
|
||||
take_from_buffer(&state)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "open_files.test.rs"]
|
||||
mod open_files_tests;
|
||||
@@ -0,0 +1,107 @@
|
||||
use super::*;
|
||||
|
||||
fn tmp_sql(name: &str, repeats: usize) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("gridline-open-{name}"));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("sample.sql");
|
||||
std::fs::write(&file, "SELECT 1;".repeat(repeats.max(1))).unwrap();
|
||||
file
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_relative_path() {
|
||||
assert!(validate_sql_path_with_cap("student_db.sql", 1024).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_non_sql_extension() {
|
||||
let f = tmp_sql("ext", 1);
|
||||
let renamed = f.with_extension("txt");
|
||||
std::fs::rename(&f, &renamed).unwrap();
|
||||
assert!(validate_sql_path_with_cap(&renamed.to_string_lossy(), 1024).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_directory() {
|
||||
let dir = std::env::temp_dir().join("gridline-open-dir");
|
||||
std::fs::create_dir_all(dir.join("not-a-file.sql")).unwrap();
|
||||
assert!(
|
||||
validate_sql_path_with_cap(&dir.join("not-a-file.sql").to_string_lossy(), 1024).is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_missing_file() {
|
||||
let p = std::env::temp_dir().join("gridline-definitely-missing-xyz.sql");
|
||||
assert!(validate_sql_path_with_cap(&p.to_string_lossy(), 1024).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_file_over_the_cap() {
|
||||
let f = tmp_sql("big", 200);
|
||||
assert!(validate_sql_path_with_cap(&f.to_string_lossy(), 16).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_symlink() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let f = tmp_sql("sym", 1);
|
||||
let link = f.with_file_name("link.sql");
|
||||
let _ = std::fs::remove_file(&link);
|
||||
std::os::unix::fs::symlink(&f, &link).unwrap();
|
||||
assert!(
|
||||
validate_sql_path_with_cap(&link.to_string_lossy(), 1024).is_none(),
|
||||
"a symlink must not be accepted (spec §6)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_a_valid_sql_file_with_an_uppercase_extension() {
|
||||
let f = tmp_sql("ok", 1);
|
||||
let upper = f.with_file_name("SAMPLE.SQL");
|
||||
std::fs::rename(&f, &upper).unwrap();
|
||||
assert!(validate_sql_path_with_cap(&upper.to_string_lossy(), 1024).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_path_dedupes_the_same_path() {
|
||||
let buffer = PendingOpenFiles::default();
|
||||
let f = tmp_sql("dedupe", 1);
|
||||
let s = f.to_string_lossy().to_string();
|
||||
assert!(accept_path(&buffer, &s));
|
||||
assert!(!accept_path(&buffer, &s), "second accept must be deduped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_path_ignores_an_invalid_path() {
|
||||
let buffer = PendingOpenFiles::default();
|
||||
assert!(!accept_path(&buffer, "relative.sql"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_returns_contents_then_drains() {
|
||||
let buffer = PendingOpenFiles::default();
|
||||
let f = tmp_sql("drain", 1);
|
||||
assert!(accept_path(&buffer, &f.to_string_lossy()));
|
||||
|
||||
let first = take_from_buffer(&buffer).expect("first drain");
|
||||
assert_eq!(first.len(), 1);
|
||||
assert_eq!(first[0].name, "sample.sql");
|
||||
assert_eq!(first[0].content, "SELECT 1;");
|
||||
|
||||
let second = take_from_buffer(&buffer).expect("second drain");
|
||||
assert!(second.is_empty(), "buffer must be drained, not reread");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_non_utf8_file() {
|
||||
let dir = std::env::temp_dir().join("gridline-open-binary");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let f = dir.join("binary.sql");
|
||||
std::fs::write(&f, [0xff, 0xfe, 0x00]).unwrap();
|
||||
let buffer = PendingOpenFiles::default();
|
||||
assert!(accept_path(&buffer, &f.to_string_lossy()));
|
||||
assert!(take_from_buffer(&buffer).is_err(), "non-UTF8 must fail closed");
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
//! 3. Every query is recorded in the local `query_history` table.
|
||||
|
||||
use crate::db::pool::DbHandle;
|
||||
use crate::models::db_viewer::{ColumnInfo, QueryResult};
|
||||
use crate::models::db_viewer::{ColumnInfo, MultiQueryResult, QueryResult, StatementNotice};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{Column, Row};
|
||||
use std::time::Instant;
|
||||
@@ -34,7 +34,7 @@ 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 {
|
||||
pub(crate) 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"),
|
||||
@@ -565,25 +565,194 @@ pub(crate) fn mysql_wrap_count(query: &str) -> String {
|
||||
format!("SELECT COUNT(*) FROM ({}) AS _gridline_cnt", query.trim())
|
||||
}
|
||||
|
||||
/// Convert a sqlx MySql row cell to serde_json::Value (via the `json` feature).
|
||||
/// How a MySQL column value must be decoded, decided from the column's
|
||||
/// declared type name.
|
||||
///
|
||||
/// Why type-directed rather than a blind try-chain: sqlx's MySQL integer
|
||||
/// compatibility is broad, so `try_get::<i16>` succeeds for a `BIGINT`
|
||||
/// holding a small value but fails once the value grows — the same column
|
||||
/// would emit different JSON types for different rows. `MySqlTypeInfo`
|
||||
/// exposes only `name()` publicly, which is sufficient because it already
|
||||
/// encodes signedness (`INT UNSIGNED`) and boolean-ness (`BOOLEAN`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum MysqlCellKind {
|
||||
/// Width in bits and signedness; drives the number-vs-string policy.
|
||||
Int { bits: u8, signed: bool },
|
||||
/// `BIT(M)` — a bitmask, rendered as a number (spec §4.1).
|
||||
Bit,
|
||||
Float,
|
||||
Double,
|
||||
Decimal,
|
||||
Date,
|
||||
Time,
|
||||
DateTime,
|
||||
Json,
|
||||
Text,
|
||||
Bytes,
|
||||
Null,
|
||||
/// Anything unrecognised (including `GEOMETRY`, which no sqlx decoder
|
||||
/// accepts even with `chrono`/`bigdecimal` enabled).
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Map a `sqlx` MySQL type name to its decode strategy.
|
||||
pub(crate) fn mysql_cell_kind(type_name: &str) -> MysqlCellKind {
|
||||
use MysqlCellKind::*;
|
||||
let t = type_name.trim().to_ascii_uppercase();
|
||||
match t.as_str() {
|
||||
"BOOLEAN" | "TINYINT" => Int { bits: 8, signed: true },
|
||||
"TINYINT UNSIGNED" => Int { bits: 8, signed: false },
|
||||
"SMALLINT" => Int { bits: 16, signed: true },
|
||||
"SMALLINT UNSIGNED" => Int { bits: 16, signed: false },
|
||||
"MEDIUMINT" => Int { bits: 24, signed: true },
|
||||
"MEDIUMINT UNSIGNED" => Int { bits: 24, signed: false },
|
||||
"INT" => Int { bits: 32, signed: true },
|
||||
"INT UNSIGNED" => Int { bits: 32, signed: false },
|
||||
"BIGINT" => Int { bits: 64, signed: true },
|
||||
"BIGINT UNSIGNED" => Int { bits: 64, signed: false },
|
||||
// YEAR is an unsigned 16-bit value; it needs the unsigned rung, not
|
||||
// the signed one (int_compatible excludes UNSIGNED columns).
|
||||
"YEAR" => Int { bits: 16, signed: false },
|
||||
"BIT" => Bit,
|
||||
"FLOAT" => Float,
|
||||
"DOUBLE" => Double,
|
||||
"DECIMAL" => Decimal,
|
||||
"DATE" => Date,
|
||||
"TIME" => Time,
|
||||
// TIMESTAMP shares the Datetime wire type. Decoded naive on purpose:
|
||||
// MySQL converts TIMESTAMP to/from the *session* time zone, so
|
||||
// claiming UTC would assert something the value does not support.
|
||||
"DATETIME" | "TIMESTAMP" => DateTime,
|
||||
"JSON" => Json,
|
||||
"CHAR" | "VARCHAR" | "TEXT" | "TINYTEXT" | "MEDIUMTEXT" | "LONGTEXT"
|
||||
| "ENUM" | "SET" => Text,
|
||||
"BINARY" | "VARBINARY" | "BLOB" | "TINYBLOB" | "MEDIUMBLOB" | "LONGBLOB" => Bytes,
|
||||
"NULL" => Null,
|
||||
_ => Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a sqlx MySql row cell to serde_json::Value, driven by the column's
|
||||
/// **declared** type (see [`mysql_cell_kind`]).
|
||||
///
|
||||
/// Type-directed rather than a blind try-chain because sqlx's MySQL integer
|
||||
/// compatibility is broad: `try_get::<i16>` succeeds for a `BIGINT` holding
|
||||
/// a small value and fails once the value grows, so a value-driven chain
|
||||
/// would emit different JSON types for different rows of one column.
|
||||
/// Shared with the DB-viewer commands (pub(crate)).
|
||||
pub(crate) fn mysql_cell_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde_json::Value {
|
||||
if let Ok(Some(v)) = row.try_get::<Option<serde_json::Value>, _>(i) {
|
||||
return v;
|
||||
use sqlx::{Row, TypeInfo, ValueRef};
|
||||
|
||||
// `MySqlTypeInfo.r#type`/`.flags` are `pub(crate)`, so the declared type is
|
||||
// read through the public `name()`, which already distinguishes
|
||||
// `INT UNSIGNED`, `BIGINT UNSIGNED` and `BOOLEAN` (TINYINT(1)).
|
||||
let type_name = match row.try_get_raw(i) {
|
||||
Ok(v) if !v.is_null() => v.type_info().name().to_string(),
|
||||
// Genuine SQL NULL, or a column we cannot even describe.
|
||||
_ => return serde_json::Value::Null,
|
||||
};
|
||||
|
||||
match mysql_cell_kind(&type_name) {
|
||||
MysqlCellKind::Int { bits, signed } => {
|
||||
if signed {
|
||||
match row.try_get::<i64, _>(i) {
|
||||
// <=32-bit fits a JS Number exactly; 64-bit is stringified
|
||||
// so the value survives the IPC boundary (matches PG).
|
||||
Ok(v) if bits <= 32 => serde_json::json!(v),
|
||||
Ok(v) => crate::commands::db_viewer::i64_to_json(v),
|
||||
Err(_) => serde_json::Value::Null,
|
||||
}
|
||||
if let Ok(s) = row.try_get::<Option<String>, _>(i) {
|
||||
return s
|
||||
.map(|s| serde_json::Value::String(s))
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
} else {
|
||||
match row.try_get::<u64, _>(i) {
|
||||
Ok(v) if bits <= 32 => serde_json::json!(v),
|
||||
Ok(v) => serde_json::Value::String(v.to_string()),
|
||||
Err(_) => serde_json::Value::Null,
|
||||
}
|
||||
if let Ok(b) = row.try_get::<Option<Vec<u8>>, _>(i) {
|
||||
return b
|
||||
.map(|b| serde_json::Value::String(String::from_utf8_lossy(&b).into_owned()))
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
}
|
||||
}
|
||||
MysqlCellKind::Bit => match row.try_get::<u64, _>(i) {
|
||||
Ok(v) => serde_json::json!(v),
|
||||
Err(_) => serde_json::Value::Null,
|
||||
},
|
||||
MysqlCellKind::Float => match row.try_get::<f32, _>(i) {
|
||||
Ok(v) => serde_json::json!(v),
|
||||
Err(_) => serde_json::Value::Null,
|
||||
},
|
||||
MysqlCellKind::Double => match row.try_get::<f64, _>(i) {
|
||||
Ok(v) => serde_json::json!(v),
|
||||
Err(_) => serde_json::Value::Null,
|
||||
},
|
||||
MysqlCellKind::Decimal => match row.try_get::<sqlx::types::BigDecimal, _>(i) {
|
||||
// Stringified for the same reason as 64-bit ints: exact digits.
|
||||
Ok(v) => serde_json::Value::String(v.to_string()),
|
||||
Err(_) => serde_json::Value::Null,
|
||||
},
|
||||
// Temporal values are emitted naive, never as an instant: MySQL
|
||||
// converts TIMESTAMP to and from the *session* time zone, so an
|
||||
// explicit `Z` would assert something the value does not support.
|
||||
MysqlCellKind::Date => match row.try_get::<chrono::NaiveDate, _>(i) {
|
||||
Ok(v) => serde_json::Value::String(v.to_string()),
|
||||
Err(_) => serde_json::Value::Null,
|
||||
},
|
||||
MysqlCellKind::Time => match row.try_get::<chrono::NaiveTime, _>(i) {
|
||||
Ok(v) => serde_json::Value::String(v.to_string()),
|
||||
Err(_) => serde_json::Value::Null,
|
||||
},
|
||||
MysqlCellKind::DateTime => {
|
||||
// `try_get_unchecked` (not `try_get`) because sqlx's `NaiveDateTime`
|
||||
// `Type`/`Compatible` impl matches `ColumnType::Datetime` only, so the
|
||||
// checked path rejects a TIMESTAMP column even though both share the
|
||||
// exact same wire format and `Decode` impl. Only the type-compat gate
|
||||
// is bypassed here; the decode is the same one DATETIME uses.
|
||||
match row.try_get_unchecked::<chrono::NaiveDateTime, _>(i) {
|
||||
Ok(v) => serde_json::Value::String(v.to_string()),
|
||||
Err(_) => serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
MysqlCellKind::Json => row
|
||||
.try_get::<serde_json::Value, _>(i)
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
MysqlCellKind::Text => mysql_text_to_json(row, i),
|
||||
MysqlCellKind::Bytes => mysql_bytes_to_json(row, i),
|
||||
MysqlCellKind::Null => serde_json::Value::Null,
|
||||
MysqlCellKind::Unknown => mysql_unknown_to_json(row, i),
|
||||
}
|
||||
}
|
||||
|
||||
/// Char/varchar/text/enum/set: a UTF-8 string, falling back to a lossy
|
||||
/// decode for the odd VARBINARY-shaped metadata column.
|
||||
fn mysql_text_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde_json::Value {
|
||||
use sqlx::Row;
|
||||
if let Ok(v) = row.try_get::<String, _>(i) {
|
||||
return serde_json::Value::String(v);
|
||||
}
|
||||
if let Ok(b) = row.try_get::<Vec<u8>, _>(i) {
|
||||
return serde_json::Value::String(String::from_utf8_lossy(&b).into_owned());
|
||||
}
|
||||
serde_json::Value::Null
|
||||
}
|
||||
|
||||
/// Binary/blob columns. Preserves the pre-existing lossy-UTF-8 rendering;
|
||||
/// changing binary presentation is explicitly out of scope (spec §3).
|
||||
fn mysql_bytes_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde_json::Value {
|
||||
use sqlx::Row;
|
||||
if let Ok(b) = row.try_get::<Vec<u8>, _>(i) {
|
||||
return serde_json::Value::String(String::from_utf8_lossy(&b).into_owned());
|
||||
}
|
||||
mysql_text_to_json(row, i)
|
||||
}
|
||||
|
||||
/// Conservative fallback for a type we do not recognise — exactly the three
|
||||
/// decoders that were safe before this change, so an unknown type can never
|
||||
/// be worse than before (`GEOMETRY` lands here and stays null).
|
||||
fn mysql_unknown_to_json(row: &sqlx::mysql::MySqlRow, i: usize) -> serde_json::Value {
|
||||
use sqlx::Row;
|
||||
if let Ok(v) = row.try_get::<serde_json::Value, _>(i) {
|
||||
return v;
|
||||
}
|
||||
mysql_text_to_json(row, i)
|
||||
}
|
||||
|
||||
async fn execute_mysql_query_on(
|
||||
conn: &mut sqlx::mysql::MySqlConnection,
|
||||
query: &str,
|
||||
@@ -708,6 +877,528 @@ async fn execute_mysql_raw(
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multiple result sets (execute_query_multi)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per the spec: statements execute sequentially; SELECT-producing statements
|
||||
// get wrapped pagination (first page per set + total count — per-set paging is
|
||||
// deliberately omitted because re-running a script would re-execute DML);
|
||||
// DML/DDL produce notices; the first error stops the run, prior sets survive.
|
||||
|
||||
/// Hard safety cap for an interactive multi-statement Run. Raised from 50
|
||||
/// because opening a `.sql` file and pressing Run is now a supported flow, and
|
||||
/// 50 rejected anything but toy scripts.
|
||||
pub(crate) const MAX_STATEMENTS: usize = 10_000;
|
||||
|
||||
/// Cap for the deliberate bulk operation (Tools → Restore). Far higher than
|
||||
/// the interactive cap because it is an explicit, confirmed, cancellable
|
||||
/// operation bounded by the 100 MB file cap rather than by a keystroke.
|
||||
pub(crate) const MAX_RESTORE_STATEMENTS: usize = 1_000_000;
|
||||
|
||||
/// Enforce a statement budget. `bulk` selects the restore wording: the
|
||||
/// interactive path tells the user where large scripts *should* run, while the
|
||||
/// bulk path must not point at a tool the user is already using.
|
||||
pub(crate) fn check_statement_budget(count: usize, cap: usize, bulk: bool) -> Result<(), String> {
|
||||
if count <= cap {
|
||||
return Ok(());
|
||||
}
|
||||
if bulk {
|
||||
Err(format!(
|
||||
"Script has {count} statements, exceeding the {cap} statement limit for a restore"
|
||||
))
|
||||
} else {
|
||||
Err(format!(
|
||||
"Too many statements: {count} (max {cap}). For large scripts use Tools → Restore."
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum StatementOutcome {
|
||||
Set(QueryResult),
|
||||
Notice {
|
||||
kind: &'static str,
|
||||
text: String,
|
||||
affected: Option<i64>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Classify a statement by its first significant keyword (execution-time
|
||||
/// classification per spec §4 — NOT in the tokenizer).
|
||||
pub(crate) fn statement_kind(stmt: &str) -> &'static str {
|
||||
let k: String = stmt
|
||||
.trim_start()
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_alphabetic())
|
||||
.collect::<String>()
|
||||
.to_lowercase();
|
||||
match k.as_str() {
|
||||
"select" | "with" | "values" | "table" => "select",
|
||||
"insert" | "update" | "delete" => "dml",
|
||||
_ => "ddl",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a PG CommandComplete tag ("INSERT 0 5" → 5; "CREATE TABLE" → None).
|
||||
pub(crate) fn affected_from_tag(tag: &str) -> Option<i64> {
|
||||
tag.split_whitespace().last()?.parse::<i64>().ok()
|
||||
}
|
||||
|
||||
fn notice_for(
|
||||
idx: usize,
|
||||
kind: &str,
|
||||
text: String,
|
||||
affected: Option<i64>,
|
||||
) -> StatementNotice {
|
||||
StatementNotice {
|
||||
statement_index: idx,
|
||||
kind: kind.to_string(),
|
||||
text,
|
||||
affected,
|
||||
}
|
||||
}
|
||||
|
||||
// ── SQLite walker ─────────────────────────────────────────────────
|
||||
|
||||
pub(crate) fn execute_sqlite_statement(
|
||||
conn: &rusqlite::Connection,
|
||||
stmt: &str,
|
||||
page: i64,
|
||||
page_size: i64,
|
||||
) -> Result<StatementOutcome, String> {
|
||||
match conn.execute(stmt, []) {
|
||||
Ok(n) => Ok(StatementOutcome::Notice {
|
||||
kind: if statement_kind(stmt) == "ddl" { "ddl" } else { "dml" },
|
||||
text: format!("OK — {n} rows affected"),
|
||||
affected: Some(n as i64),
|
||||
}),
|
||||
Err(rusqlite::Error::ExecuteReturnedResults) => {
|
||||
let (columns, all_rows) = execute_sqlite_with_query(conn, stmt)?;
|
||||
let total_rows = all_rows.len() as i64;
|
||||
let rows: Vec<Vec<serde_json::Value>> = all_rows
|
||||
.into_iter()
|
||||
.take(page_size.max(0) as usize)
|
||||
.collect();
|
||||
Ok(StatementOutcome::Set(QueryResult {
|
||||
columns,
|
||||
rows,
|
||||
total_rows,
|
||||
page,
|
||||
page_size,
|
||||
execution_time_ms: None,
|
||||
}))
|
||||
}
|
||||
Err(rusqlite::Error::SqliteFailure(e, _))
|
||||
if e.code == rusqlite::ErrorCode::OperationInterrupted =>
|
||||
{
|
||||
Err("Query cancelled".to_string())
|
||||
}
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run_sqlite_statements(
|
||||
conn: &rusqlite::Connection,
|
||||
statements: &[String],
|
||||
page: i64,
|
||||
page_size: i64,
|
||||
result_sets: &mut Vec<QueryResult>,
|
||||
notices: &mut Vec<StatementNotice>,
|
||||
) -> Option<String> {
|
||||
for (idx, stmt) in statements.iter().enumerate() {
|
||||
match execute_sqlite_statement(conn, stmt, page, page_size) {
|
||||
Ok(StatementOutcome::Set(qr)) => result_sets.push(qr),
|
||||
Ok(StatementOutcome::Notice { kind, text, affected }) => {
|
||||
notices.push(notice_for(idx, kind, text, affected))
|
||||
}
|
||||
Err(e) if e == "Query cancelled" => return Some(e),
|
||||
Err(e) => {
|
||||
notices.push(StatementNotice {
|
||||
statement_index: idx,
|
||||
kind: "error".into(),
|
||||
text: e.clone(),
|
||||
affected: None,
|
||||
});
|
||||
return Some(format!("Statement {} failed: {e}", idx + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ── PostgreSQL walker ─────────────────────────────────────────────
|
||||
|
||||
pub(crate) async fn execute_pg_statement(
|
||||
client: &tokio_postgres::Client,
|
||||
stmt: &str,
|
||||
page: i64,
|
||||
page_size: i64,
|
||||
) -> Result<StatementOutcome, String> {
|
||||
let trimmed = stmt.trim();
|
||||
let off = (page.saturating_sub(1).max(0)) * page_size;
|
||||
|
||||
// 1. Try wrapped pagination — a parse error here executes nothing.
|
||||
let wrapped_count = format!("SELECT COUNT(*) FROM ({}) AS _gridline_cnt", trimmed);
|
||||
if let Ok(row) = client.query_one(&wrapped_count, &[]).await {
|
||||
let total_rows: i64 = row.get(0);
|
||||
let wrapped_data = format!(
|
||||
"SELECT * FROM ({}) AS _gridline_data LIMIT $1 OFFSET $2",
|
||||
trimmed
|
||||
);
|
||||
let data_rows = client
|
||||
.query(&wrapped_data, &[&page_size, &off])
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if is_pg_cancel_error(&e) {
|
||||
"Query cancelled".to_string()
|
||||
} else {
|
||||
format!("{e}")
|
||||
}
|
||||
})?;
|
||||
let columns: Vec<ColumnInfo> = match data_rows.first() {
|
||||
Some(first) => first
|
||||
.columns()
|
||||
.iter()
|
||||
.map(|c| ColumnInfo {
|
||||
name: c.name().to_string(),
|
||||
data_type: format!("{:?}", c.type_()),
|
||||
is_nullable: true,
|
||||
is_pk: false,
|
||||
is_fk: false,
|
||||
fk_ref: None,
|
||||
default_value: None,
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
})
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let rows: Vec<Vec<serde_json::Value>> = data_rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
(0..row.len())
|
||||
.map(|i| crate::commands::db_viewer::pg_value_to_json(row, i))
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
return Ok(StatementOutcome::Set(QueryResult {
|
||||
columns,
|
||||
rows,
|
||||
total_rows,
|
||||
page,
|
||||
page_size,
|
||||
execution_time_ms: None,
|
||||
}));
|
||||
}
|
||||
|
||||
// 2. Raw single-statement execution via simple_query (captures the
|
||||
// CommandComplete tag so DML notices carry affected counts).
|
||||
let messages = client
|
||||
.simple_query(trimmed)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if is_pg_cancel_error(&e) {
|
||||
"Query cancelled".to_string()
|
||||
} else {
|
||||
crate::commands::db_viewer::pg_error_message(&e)
|
||||
}
|
||||
})?;
|
||||
|
||||
let mut columns: Vec<ColumnInfo> = Vec::new();
|
||||
let mut all_rows: Vec<Vec<serde_json::Value>> = Vec::new();
|
||||
let mut last_tag: Option<String> = None;
|
||||
for msg in messages {
|
||||
match msg {
|
||||
tokio_postgres::SimpleQueryMessage::Row(row) => {
|
||||
if columns.is_empty() {
|
||||
columns = row
|
||||
.columns()
|
||||
.iter()
|
||||
.map(|c| ColumnInfo {
|
||||
name: c.name().to_string(),
|
||||
data_type: "text".to_string(),
|
||||
is_nullable: true,
|
||||
is_pk: false,
|
||||
is_fk: false,
|
||||
fk_ref: None,
|
||||
default_value: None,
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let values: Vec<serde_json::Value> = (0..row.len())
|
||||
.map(|i| match row.try_get::<usize>(i) {
|
||||
Ok(Some(s)) => serde_json::Value::String(s.to_string()),
|
||||
_ => serde_json::Value::Null,
|
||||
})
|
||||
.collect();
|
||||
all_rows.push(values);
|
||||
}
|
||||
tokio_postgres::SimpleQueryMessage::CommandComplete(tag) => {
|
||||
last_tag = Some(tag.to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if all_rows.is_empty() {
|
||||
let tag = last_tag.clone().unwrap_or_else(|| "OK".to_string());
|
||||
let affected = last_tag.as_deref().and_then(affected_from_tag);
|
||||
return Ok(StatementOutcome::Notice {
|
||||
kind: if statement_kind(stmt) == "ddl" { "ddl" } else { "dml" },
|
||||
text: tag,
|
||||
affected,
|
||||
});
|
||||
}
|
||||
|
||||
let total_rows = all_rows.len() as i64;
|
||||
let rows: Vec<Vec<serde_json::Value>> = all_rows
|
||||
.into_iter()
|
||||
.take(page_size.max(0) as usize)
|
||||
.collect();
|
||||
Ok(StatementOutcome::Set(QueryResult {
|
||||
columns,
|
||||
rows,
|
||||
total_rows,
|
||||
page,
|
||||
page_size,
|
||||
execution_time_ms: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) async fn run_pg_statements(
|
||||
client: &tokio_postgres::Client,
|
||||
statements: &[String],
|
||||
page: i64,
|
||||
page_size: i64,
|
||||
result_sets: &mut Vec<QueryResult>,
|
||||
notices: &mut Vec<StatementNotice>,
|
||||
) -> Option<String> {
|
||||
for (idx, stmt) in statements.iter().enumerate() {
|
||||
match execute_pg_statement(client, stmt, page, page_size).await {
|
||||
Ok(StatementOutcome::Set(qr)) => result_sets.push(qr),
|
||||
Ok(StatementOutcome::Notice { kind, text, affected }) => {
|
||||
notices.push(notice_for(idx, kind, text, affected))
|
||||
}
|
||||
Err(e) if e == "Query cancelled" => return Some(e),
|
||||
Err(e) => {
|
||||
notices.push(StatementNotice {
|
||||
statement_index: idx,
|
||||
kind: "error".into(),
|
||||
text: e.clone(),
|
||||
affected: None,
|
||||
});
|
||||
return Some(format!("Statement {} failed: {e}", idx + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ── MySQL walker ──────────────────────────────────────────────────
|
||||
|
||||
async fn execute_mysql_statement_on(
|
||||
conn: &mut sqlx::mysql::MySqlConnection,
|
||||
stmt: &str,
|
||||
page: i64,
|
||||
page_size: i64,
|
||||
) -> Result<StatementOutcome, String> {
|
||||
let trimmed = stmt.trim();
|
||||
let off = (page.saturating_sub(1).max(0)) * page_size;
|
||||
|
||||
let total_rows: i64 = match sqlx::query_scalar::<_, i64>(&mysql_wrap_count(trimmed))
|
||||
.fetch_one(&mut *conn)
|
||||
.await
|
||||
{
|
||||
Ok(n) => n,
|
||||
Err(e) if is_mysql_cancel_error(&e) => return Err("Query cancelled".to_string()),
|
||||
Err(_) => {
|
||||
return execute_mysql_raw(conn, trimmed, page, page_size, off)
|
||||
.await
|
||||
.map(|qr| {
|
||||
if qr.columns.is_empty() {
|
||||
StatementOutcome::Notice {
|
||||
kind: if statement_kind(stmt) == "ddl" { "ddl" } else { "dml" },
|
||||
text: "OK".to_string(),
|
||||
affected: None,
|
||||
}
|
||||
} else {
|
||||
StatementOutcome::Set(qr)
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let data_rows = match sqlx::query(&mysql_wrap_data(trimmed))
|
||||
.bind(page_size)
|
||||
.bind(off)
|
||||
.fetch_all(&mut *conn)
|
||||
.await
|
||||
{
|
||||
Ok(rows) => rows,
|
||||
Err(e) if is_mysql_cancel_error(&e) => return Err("Query cancelled".to_string()),
|
||||
Err(e) => return Err(crate::commands::db_viewer::sanitize_error(&format!("{e}"))),
|
||||
};
|
||||
let columns: Vec<ColumnInfo> = match data_rows.first() {
|
||||
Some(first) => first
|
||||
.columns()
|
||||
.iter()
|
||||
.map(|c| ColumnInfo {
|
||||
name: c.name().to_string(),
|
||||
data_type: c.type_info().to_string(),
|
||||
is_nullable: true,
|
||||
is_pk: false,
|
||||
is_fk: false,
|
||||
fk_ref: None,
|
||||
default_value: None,
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
})
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let rows: Vec<Vec<serde_json::Value>> = data_rows
|
||||
.iter()
|
||||
.map(|r| (0..r.len()).map(|i| mysql_cell_to_json(r, i)).collect())
|
||||
.collect();
|
||||
Ok(StatementOutcome::Set(QueryResult {
|
||||
columns,
|
||||
rows,
|
||||
total_rows,
|
||||
page,
|
||||
page_size,
|
||||
execution_time_ms: None,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn run_mysql_statements(
|
||||
conn: &mut sqlx::mysql::MySqlConnection,
|
||||
statements: &[String],
|
||||
page: i64,
|
||||
page_size: i64,
|
||||
result_sets: &mut Vec<QueryResult>,
|
||||
notices: &mut Vec<StatementNotice>,
|
||||
cancel_registry: &crate::cancel::CancelRegistry,
|
||||
connection_id: &str,
|
||||
) -> Option<String> {
|
||||
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));
|
||||
for (idx, stmt) in statements.iter().enumerate() {
|
||||
match execute_mysql_statement_on(conn, stmt, page, page_size).await {
|
||||
Ok(StatementOutcome::Set(qr)) => result_sets.push(qr),
|
||||
Ok(StatementOutcome::Notice { kind, text, affected }) => {
|
||||
notices.push(notice_for(idx, kind, text, affected))
|
||||
}
|
||||
Err(e) if e == "Query cancelled" => {
|
||||
cancel_registry.set_mysql_conn_id(connection_id, None);
|
||||
return Some(e);
|
||||
}
|
||||
Err(e) => {
|
||||
notices.push(StatementNotice {
|
||||
statement_index: idx,
|
||||
kind: "error".into(),
|
||||
text: e.clone(),
|
||||
affected: None,
|
||||
});
|
||||
cancel_registry.set_mysql_conn_id(connection_id, None);
|
||||
return Some(format!("Statement {} failed: {e}", idx + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
cancel_registry.set_mysql_conn_id(connection_id, None);
|
||||
None
|
||||
}
|
||||
|
||||
/// Execute a multi-statement script, returning every result set and notices.
|
||||
/// `execute_query` is untouched; the query workspace calls this instead.
|
||||
#[tauri::command]
|
||||
pub async fn execute_query_multi(
|
||||
connection_id: String,
|
||||
query: String,
|
||||
page: Option<i64>,
|
||||
page_size: Option<i64>,
|
||||
state: State<'_, crate::AppState>,
|
||||
) -> Result<MultiQueryResult, String> {
|
||||
let start = Instant::now();
|
||||
let p = page.unwrap_or(1);
|
||||
let ps = page_size.unwrap_or(50);
|
||||
|
||||
let statements = crate::db::sql_split::split_statements(&query);
|
||||
if statements.is_empty() {
|
||||
return Err("Query cannot be empty".to_string());
|
||||
}
|
||||
check_statement_budget(statements.len(), MAX_STATEMENTS, false)?;
|
||||
|
||||
let mut result_sets: Vec<QueryResult> = Vec::new();
|
||||
let mut notices: Vec<StatementNotice> = Vec::new();
|
||||
|
||||
let err_text: Option<String>;
|
||||
let mut pm = state.pool_manager.lock().await;
|
||||
match pm.get(&connection_id) {
|
||||
Some(DbHandle::Postgresql(client, _)) => {
|
||||
err_text =
|
||||
run_pg_statements(client, &statements, p, ps, &mut result_sets, &mut notices).await;
|
||||
}
|
||||
Some(DbHandle::Sqlite(conn)) => {
|
||||
err_text = run_sqlite_statements(conn, &statements, p, ps, &mut result_sets, &mut notices);
|
||||
}
|
||||
Some(DbHandle::MySql(pool)) => {
|
||||
let mut conn = pool
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
|
||||
err_text = run_mysql_statements(
|
||||
&mut *conn,
|
||||
&statements,
|
||||
p,
|
||||
ps,
|
||||
&mut result_sets,
|
||||
&mut notices,
|
||||
&state.cancel_registry,
|
||||
&connection_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
None => return Err("Connection not found".to_string()),
|
||||
}
|
||||
|
||||
let elapsed = start.elapsed().as_millis() as i64;
|
||||
let row_count = result_sets.first().map(|q| q.rows.len() as i64).unwrap_or(0);
|
||||
let history_id = Uuid::new_v4().to_string();
|
||||
if let Some(e) = &err_text {
|
||||
insert_history(
|
||||
&state.db_store,
|
||||
&history_id,
|
||||
&connection_id,
|
||||
&query,
|
||||
Some(elapsed),
|
||||
Some(row_count),
|
||||
"error",
|
||||
Some(e.as_str()),
|
||||
);
|
||||
} else {
|
||||
insert_history(
|
||||
&state.db_store,
|
||||
&history_id,
|
||||
&connection_id,
|
||||
&query,
|
||||
Some(elapsed),
|
||||
Some(row_count),
|
||||
"success",
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(MultiQueryResult {
|
||||
result_sets,
|
||||
notices,
|
||||
execution_time_ms: elapsed,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query history commands
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -845,6 +1536,10 @@ pub async fn cancel_query(
|
||||
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))
|
||||
}
|
||||
Some(crate::cancel::CancelHandle::MySql(m)) => {
|
||||
// Record the request before the KILL lands: the in-process restore
|
||||
// engine polls this flag between statements, and the killed
|
||||
// statement's error is the only other signal.
|
||||
state.cancel_registry.mark_cancelled(&connection_id);
|
||||
let id = m
|
||||
.conn_id
|
||||
.ok_or_else(|| "No active query on this connection".to_string())?;
|
||||
@@ -1378,3 +2073,7 @@ mod tests {
|
||||
assert!(res.unwrap_err().contains("Query cancelled"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "query.test.rs"]
|
||||
mod query_tests;
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
use super::*;
|
||||
|
||||
// ── statement_kind / affected_from_tag (pure) ──────────────────────
|
||||
|
||||
#[test]
|
||||
fn statement_kind_classifies_by_first_keyword() {
|
||||
assert_eq!(statement_kind("SELECT 1"), "select");
|
||||
assert_eq!(statement_kind(" with c as (select 1) select * from c"), "select");
|
||||
assert_eq!(statement_kind("insert into t values (1)"), "dml");
|
||||
assert_eq!(statement_kind("UPDATE t SET a=1"), "dml");
|
||||
assert_eq!(statement_kind("DELETE FROM t"), "dml");
|
||||
assert_eq!(statement_kind("CREATE TABLE t (a int)"), "ddl");
|
||||
assert_eq!(statement_kind("-- c\nDROP TABLE t"), "ddl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affected_from_tag_parses_pg_command_tags() {
|
||||
assert_eq!(affected_from_tag("INSERT 0 5"), Some(5));
|
||||
assert_eq!(affected_from_tag("UPDATE 3"), Some(3));
|
||||
assert_eq!(affected_from_tag("CREATE TABLE"), None);
|
||||
}
|
||||
|
||||
// ── SQLite walker — real in-memory database, no env vars, not ignored ──
|
||||
|
||||
fn mem_conn() -> rusqlite::Connection {
|
||||
let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE t (a INTEGER, b TEXT);
|
||||
INSERT INTO t VALUES (1, 'x'), (2, 'y'), (3, 'z');",
|
||||
)
|
||||
.expect("seed");
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_walker_yields_notice_for_dml_with_affected() {
|
||||
let conn = mem_conn();
|
||||
let out = execute_sqlite_statement(&conn, "INSERT INTO t VALUES (4, 'w')", 1, 50)
|
||||
.expect("statement runs");
|
||||
match out {
|
||||
StatementOutcome::Notice { kind, affected, .. } => {
|
||||
assert_eq!(kind, "dml");
|
||||
assert_eq!(affected, Some(1));
|
||||
}
|
||||
_ => panic!("expected Notice, got Set"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_walker_yields_set_for_select() {
|
||||
let conn = mem_conn();
|
||||
let out = execute_sqlite_statement(&conn, "SELECT a FROM t ORDER BY a", 1, 2)
|
||||
.expect("statement runs");
|
||||
match out {
|
||||
StatementOutcome::Set(qr) => {
|
||||
assert_eq!(qr.columns.len(), 1);
|
||||
assert_eq!(qr.rows.len(), 2, "page size caps rows");
|
||||
assert_eq!(qr.total_rows, 3, "total counts all rows");
|
||||
}
|
||||
_ => panic!("expected Set, got Notice"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_walker_notices_ddl() {
|
||||
let conn = mem_conn();
|
||||
let out = execute_sqlite_statement(&conn, "CREATE TABLE u (x INT)", 1, 50).unwrap();
|
||||
match out {
|
||||
StatementOutcome::Notice { kind, .. } => assert_eq!(kind, "ddl"),
|
||||
_ => panic!("expected Notice"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_sqlite_statements_stops_at_first_error_with_prior_sets() {
|
||||
let conn = mem_conn();
|
||||
let statements = crate::db::sql_split::split_statements(
|
||||
"SELECT a FROM t; SELECT * FROM missing; SELECT 1",
|
||||
);
|
||||
let mut sets = Vec::new();
|
||||
let mut notices = Vec::new();
|
||||
let err = run_sqlite_statements(&conn, &statements, 1, 50, &mut sets, &mut notices);
|
||||
assert_eq!(sets.len(), 1, "first set is returned");
|
||||
assert!(err.is_some(), "the failed statement stops the run");
|
||||
}
|
||||
|
||||
/// Live PG walker test — mirror the env/client conventions of the live PG
|
||||
/// tests in `commands/objects.test.rs`. Assert: `SELECT 1; SELECT 2` (split
|
||||
/// via sql_split) produces two Sets via `run_pg_statements`, and
|
||||
/// `UPDATE`-on-missing-table produces a first-error stop with the error
|
||||
/// notice attached.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn pg_multi_statement_live() {
|
||||
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;
|
||||
});
|
||||
|
||||
// Two SELECTs → two Sets, no notices.
|
||||
let stmts = crate::db::sql_split::split_statements("SELECT 1; SELECT 2");
|
||||
let mut sets = vec![];
|
||||
let mut notices = vec![];
|
||||
let err = run_pg_statements(&client, &stmts, 1, 50, &mut sets, &mut notices).await;
|
||||
assert!(err.is_none());
|
||||
assert_eq!(sets.len(), 2);
|
||||
assert!(notices.is_empty());
|
||||
|
||||
// UPDATE on a missing table → first-error stop with an error notice.
|
||||
let bad = crate::db::sql_split::split_statements("UPDATE gridline_missing SET a=1");
|
||||
let mut sets2 = vec![];
|
||||
let mut notices2 = vec![];
|
||||
let err2 = run_pg_statements(&client, &bad, 1, 50, &mut sets2, &mut notices2).await;
|
||||
assert!(err2.is_some(), "missing table must stop the run");
|
||||
assert!(
|
||||
notices2.iter().any(|n| n.kind == "error"),
|
||||
"an error notice must be attached"
|
||||
);
|
||||
|
||||
let _ = handle;
|
||||
}
|
||||
|
||||
// ── sqlx type-feature coverage ──────────────────────────────────────
|
||||
|
||||
/// Connect to the optional live MySQL test server. Returns `None` when the
|
||||
/// env vars are absent so the whole suite stays green without a server
|
||||
/// (matching the existing `#[ignore]`d integration-test convention).
|
||||
pub(crate) async fn mysql_test_connection() -> Option<sqlx::mysql::MySqlConnection> {
|
||||
use sqlx::ConnectOptions;
|
||||
let host = std::env::var("GRIDLINE_TEST_MYSQL_HOST").ok()?;
|
||||
let port: u16 = std::env::var("GRIDLINE_TEST_MYSQL_PORT")
|
||||
.ok()?
|
||||
.parse()
|
||||
.ok()?;
|
||||
let user = std::env::var("GRIDLINE_TEST_MYSQL_USER").unwrap_or_else(|_| "root".into());
|
||||
let pass = std::env::var("GRIDLINE_TEST_MYSQL_PASS").unwrap_or_default();
|
||||
let db = std::env::var("GRIDLINE_TEST_MYSQL_DB").ok()?;
|
||||
sqlx::mysql::MySqlConnectOptions::new()
|
||||
.host(&host)
|
||||
.port(port)
|
||||
.username(&user)
|
||||
.password(&pass)
|
||||
.database(&db)
|
||||
.ssl_mode(sqlx::mysql::MySqlSslMode::Disabled)
|
||||
.disable_statement_logging()
|
||||
.connect()
|
||||
.await
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn mysql_decimal_and_date_decode_with_features_enabled() {
|
||||
use sqlx::Row;
|
||||
let Some(mut conn) = mysql_test_connection().await else { return };
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TEMPORARY TABLE gl_feature_probe (
|
||||
c_decimal DECIMAL(20,4), c_date DATE, c_datetime DATETIME(6) )",
|
||||
)
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.expect("create temporary table");
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO gl_feature_probe VALUES (
|
||||
12345678901234.5678, '2024-01-15', '2024-01-15 10:30:00.123456' )",
|
||||
)
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.expect("insert");
|
||||
|
||||
let row = sqlx::query("SELECT * FROM gl_feature_probe")
|
||||
.fetch_one(&mut conn)
|
||||
.await
|
||||
.expect("select");
|
||||
|
||||
let dec: sqlx::types::BigDecimal = row.try_get(0).expect("DECIMAL must decode");
|
||||
assert_eq!(dec.to_string(), "12345678901234.5678");
|
||||
|
||||
let date: chrono::NaiveDate = row.try_get(1).expect("DATE must decode");
|
||||
assert_eq!(date, chrono::NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
|
||||
|
||||
let dt: chrono::NaiveDateTime = row.try_get(2).expect("DATETIME(6) must decode");
|
||||
assert_eq!(dt.to_string(), "2024-01-15 10:30:00.123456");
|
||||
}
|
||||
|
||||
// ── mysql_cell_kind (pure) ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn mysql_cell_kind_maps_every_type_name_sqlx_can_report() {
|
||||
use MysqlCellKind::*;
|
||||
// Every string here was observed from a live MySQL 8 server during the
|
||||
// architect phase (ColumnType::name(flags, max_size)).
|
||||
let cases: &[(&str, MysqlCellKind)] = &[
|
||||
("BOOLEAN", Int { bits: 8, signed: true }),
|
||||
("TINYINT", Int { bits: 8, signed: true }),
|
||||
("TINYINT UNSIGNED", Int { bits: 8, signed: false }),
|
||||
("SMALLINT", Int { bits: 16, signed: true }),
|
||||
("SMALLINT UNSIGNED", Int { bits: 16, signed: false }),
|
||||
("MEDIUMINT", Int { bits: 24, signed: true }),
|
||||
("MEDIUMINT UNSIGNED", Int { bits: 24, signed: false }),
|
||||
("INT", Int { bits: 32, signed: true }),
|
||||
("INT UNSIGNED", Int { bits: 32, signed: false }),
|
||||
("BIGINT", Int { bits: 64, signed: true }),
|
||||
("BIGINT UNSIGNED", Int { bits: 64, signed: false }),
|
||||
("YEAR", Int { bits: 16, signed: false }),
|
||||
("BIT", Bit),
|
||||
("FLOAT", Float),
|
||||
("DOUBLE", Double),
|
||||
("DECIMAL", Decimal),
|
||||
("DATE", Date),
|
||||
("TIME", Time),
|
||||
("DATETIME", DateTime),
|
||||
("TIMESTAMP", DateTime),
|
||||
("JSON", Json),
|
||||
("CHAR", Text),
|
||||
("VARCHAR", Text),
|
||||
("TEXT", Text),
|
||||
("TINYTEXT", Text),
|
||||
("MEDIUMTEXT", Text),
|
||||
("LONGTEXT", Text),
|
||||
("ENUM", Text),
|
||||
("SET", Text),
|
||||
("BINARY", Bytes),
|
||||
("VARBINARY", Bytes),
|
||||
("BLOB", Bytes),
|
||||
("TINYBLOB", Bytes),
|
||||
("MEDIUMBLOB", Bytes),
|
||||
("LONGBLOB", Bytes),
|
||||
("NULL", Null),
|
||||
("GEOMETRY", Unknown),
|
||||
("SOMETHING_NEW", Unknown),
|
||||
];
|
||||
for (name, expected) in cases {
|
||||
assert_eq!(&mysql_cell_kind(name), expected, "type name {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_cell_kind_is_case_insensitive_and_trims() {
|
||||
assert_eq!(mysql_cell_kind(" bigint unsigned "), MysqlCellKind::Int { bits: 64, signed: false });
|
||||
assert_eq!(mysql_cell_kind("Datetime"), MysqlCellKind::DateTime);
|
||||
}
|
||||
|
||||
// ── mysql_cell_to_json: full type coverage (regression for #41) ─────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn mysql_cell_to_json_decodes_every_type() {
|
||||
use sqlx::Row;
|
||||
let Some(mut conn) = mysql_test_connection().await else { return };
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TEMPORARY TABLE gl_decode_probe (
|
||||
c_tiny TINYINT, c_tiny_u TINYINT UNSIGNED, c_bool BOOLEAN,
|
||||
c_small SMALLINT, c_small_u SMALLINT UNSIGNED, c_medium MEDIUMINT,
|
||||
c_int INT, c_int_u INT UNSIGNED, c_big BIGINT, c_big_u BIGINT UNSIGNED,
|
||||
c_float FLOAT, c_double DOUBLE, c_decimal DECIMAL(10,2), c_bit BIT(8),
|
||||
c_year YEAR, c_date DATE, c_time TIME, c_datetime DATETIME,
|
||||
c_datetime6 DATETIME(6), c_timestamp TIMESTAMP NULL,
|
||||
c_char CHAR(4), c_varchar VARCHAR(20), c_text TEXT,
|
||||
c_binary BINARY(4), c_varbinary VARBINARY(8), c_blob BLOB,
|
||||
c_json JSON, c_enum ENUM('a','b'), c_set SET('x','y'),
|
||||
c_geometry GEOMETRY )",
|
||||
)
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.expect("create temporary table");
|
||||
|
||||
// c_int is 70000 so a 16-bit rung cannot hold it; c_big is small so a
|
||||
// value-dependent chain would wrongly emit a number instead of a string.
|
||||
sqlx::query(
|
||||
"INSERT INTO gl_decode_probe VALUES (
|
||||
1, 2, 1, 5, 6, 7, 70000, 9, 300, 4294967296,
|
||||
11.5, 12.25, 13.75, b'10101010', 2024,
|
||||
'2024-01-15', '10:30:00', '2024-01-15 10:30:00',
|
||||
'2024-01-15 10:30:00.123456', '2024-01-15 10:30:00',
|
||||
'abcd', 'hello', 'text value', 'WXYZ', 0x0102, 0x01020304,
|
||||
JSON_OBJECT('k','v'), 'a', 'x,y', ST_GeomFromText('POINT(1 2)') )",
|
||||
)
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.expect("insert");
|
||||
|
||||
let row = sqlx::query("SELECT * FROM gl_decode_probe")
|
||||
.fetch_one(&mut conn)
|
||||
.await
|
||||
.expect("select");
|
||||
|
||||
use serde_json::{json, Value};
|
||||
let expected: Vec<(&str, Value)> = vec![
|
||||
("c_tiny", json!(1)),
|
||||
("c_tiny_u", json!(2)),
|
||||
("c_bool", json!(1)),
|
||||
("c_small", json!(5)),
|
||||
("c_small_u", json!(6)),
|
||||
("c_medium", json!(7)),
|
||||
("c_int", json!(70000)),
|
||||
("c_int_u", json!(9)),
|
||||
("c_big", Value::String("300".into())),
|
||||
("c_big_u", Value::String("4294967296".into())),
|
||||
("c_float", json!(11.5)),
|
||||
("c_double", json!(12.25)),
|
||||
("c_decimal", Value::String("13.75".into())),
|
||||
("c_bit", json!(170)),
|
||||
("c_year", json!(2024)),
|
||||
("c_date", Value::String("2024-01-15".into())),
|
||||
("c_time", Value::String("10:30:00".into())),
|
||||
("c_datetime", Value::String("2024-01-15 10:30:00".into())),
|
||||
("c_datetime6", Value::String("2024-01-15 10:30:00.123456".into())),
|
||||
("c_timestamp", Value::String("2024-01-15 10:30:00".into())),
|
||||
("c_char", Value::String("abcd".into())),
|
||||
("c_varchar", Value::String("hello".into())),
|
||||
("c_text", Value::String("text value".into())),
|
||||
("c_binary", Value::String("WXYZ".into())),
|
||||
("c_varbinary", Value::String("\u{1}\u{2}".into())),
|
||||
("c_blob", Value::String("\u{1}\u{2}\u{3}\u{4}".into())),
|
||||
("c_json", json!({"k": "v"})),
|
||||
("c_enum", Value::String("a".into())),
|
||||
("c_set", Value::String("x,y".into())),
|
||||
// GEOMETRY has no sqlx decoder even with chrono/bigdecimal enabled;
|
||||
// staying null is the documented limitation (spec §9).
|
||||
("c_geometry", Value::Null),
|
||||
];
|
||||
|
||||
assert_eq!(row.len(), expected.len(), "column count drifted from the fixture");
|
||||
for (i, (name, want)) in expected.iter().enumerate() {
|
||||
assert_eq!(row.columns()[i].name(), *name, "column order drifted");
|
||||
assert_eq!(&mysql_cell_to_json(&row, i), want, "column {name} decoded wrong");
|
||||
}
|
||||
}
|
||||
|
||||
// ── statement-count policy ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn statement_budget_allows_a_count_at_the_cap() {
|
||||
assert!(check_statement_budget(MAX_STATEMENTS, MAX_STATEMENTS, false).is_ok());
|
||||
assert!(check_statement_budget(MAX_RESTORE_STATEMENTS, MAX_RESTORE_STATEMENTS, true).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_over_budget_error_points_at_restore() {
|
||||
let err = check_statement_budget(MAX_STATEMENTS + 1, MAX_STATEMENTS, false)
|
||||
.expect_err("must reject");
|
||||
assert!(err.contains("Tools"), "error must route to Tools → Restore; got {err:?}");
|
||||
assert!(err.contains(&(MAX_STATEMENTS + 1).to_string()), "error must state the count; got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_over_budget_error_states_the_limit_without_the_tool_hint() {
|
||||
let err = check_statement_budget(MAX_RESTORE_STATEMENTS + 1, MAX_RESTORE_STATEMENTS, true)
|
||||
.expect_err("must reject");
|
||||
assert!(!err.contains("Tools"), "bulk path is already in Tools; got {err:?}");
|
||||
assert!(err.contains(&MAX_RESTORE_STATEMENTS.to_string()), "got {err:?}");
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
//! Streaming export of a full query result to a local file (spec §4/§5C).
|
||||
//!
|
||||
//! Headless `run_*_export` functions (testable without Tauri State) + one
|
||||
//! thin command that owns the pool lock, the cancel registration, and the
|
||||
//! `export-progress` events. Fail-closed: any error deletes the partial file.
|
||||
|
||||
use crate::db::pool::DbHandle;
|
||||
use crate::models::db_viewer::ColumnInfo;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{Column, Row};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::State;
|
||||
|
||||
const PROGRESS_ROWS: u64 = 5_000;
|
||||
const CURSOR_NAME: &str = "_gridline_export";
|
||||
const CURSOR_FETCH: usize = 1_000;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ExportFormat {
|
||||
Csv,
|
||||
Jsonl,
|
||||
Json,
|
||||
}
|
||||
|
||||
impl ExportFormat {
|
||||
pub fn from_str(s: &str) -> Result<Self, String> {
|
||||
match s {
|
||||
"csv" => Ok(ExportFormat::Csv),
|
||||
"jsonl" => Ok(ExportFormat::Jsonl),
|
||||
"json" => Ok(ExportFormat::Json),
|
||||
other => Err(format!("unsupported export format: {other} (csv | jsonl | json)")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ExportSummary {
|
||||
pub rows_written: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct ExportProgressEvent {
|
||||
pub job_id: String,
|
||||
/// "running" | "done" | "error"
|
||||
pub status: String,
|
||||
pub rows: u64,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Read-only heuristic mirroring the frontend `isDestructiveQuery` set (spec §2:
|
||||
/// never trust the frontend guard alone).
|
||||
pub fn is_read_only_sql(sql: &str) -> bool {
|
||||
let k: String = sql
|
||||
.trim_start()
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_alphabetic())
|
||||
.collect::<String>()
|
||||
.to_uppercase();
|
||||
!matches!(
|
||||
k.as_str(),
|
||||
"INSERT" | "UPDATE" | "DELETE" | "DROP" | "ALTER" | "TRUNCATE" | "CREATE" | "REPLACE"
|
||||
)
|
||||
}
|
||||
|
||||
/// Validate export inputs: single, read-only statement + absolute path.
|
||||
fn validate_export_inputs(sql: &str, path: &Path) -> Result<(), String> {
|
||||
if !path.is_absolute() {
|
||||
return Err("Export path must be absolute".to_string());
|
||||
}
|
||||
if !is_read_only_sql(sql) {
|
||||
return Err("Export accepts read-only queries only (INSERT/UPDATE/DELETE/DROP/ALTER/TRUNCATE/CREATE/REPLACE are rejected)".to_string());
|
||||
}
|
||||
let statements = crate::db::sql_split::split_statements(sql);
|
||||
if statements.len() != 1 {
|
||||
return Err("Export accepts a single query".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Format writers (pure) ─────────────────────────────────────────
|
||||
|
||||
/// Quote one CSV cell with formula-injection protection (spec §2) — mirrors
|
||||
/// the client-side `csvCell` brought to parity in Task 11.
|
||||
pub fn csv_cell(value: &serde_json::Value) -> String {
|
||||
let raw = match value {
|
||||
serde_json::Value::Null => String::new(),
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
let guarded = match raw.chars().next() {
|
||||
Some('=') | Some('+') | Some('-') | Some('@') | Some('\t') | Some('\r') => {
|
||||
format!("'{raw}")
|
||||
}
|
||||
_ => raw.clone(),
|
||||
};
|
||||
if guarded.contains(',') || guarded.contains('"') || guarded.contains('\n') || guarded.contains('\r') {
|
||||
format!("\"{}\"", guarded.replace('"', "\"\""))
|
||||
} else {
|
||||
guarded
|
||||
}
|
||||
}
|
||||
|
||||
pub fn csv_line(columns: &[ColumnInfo], row: &[serde_json::Value]) -> String {
|
||||
let mut line = String::new();
|
||||
for (i, _) in columns.iter().enumerate() {
|
||||
if i > 0 {
|
||||
line.push(',');
|
||||
}
|
||||
line.push_str(&csv_cell(row.get(i).unwrap_or(&serde_json::Value::Null)));
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
pub fn csv_header(columns: &[ColumnInfo]) -> String {
|
||||
columns
|
||||
.iter()
|
||||
.map(|c| csv_cell(&serde_json::Value::String(c.name.clone())))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
}
|
||||
|
||||
pub fn jsonl_line(columns: &[ColumnInfo], row: &[serde_json::Value]) -> String {
|
||||
let mut obj = serde_json::Map::new();
|
||||
for (i, c) in columns.iter().enumerate() {
|
||||
obj.insert(c.name.clone(), row.get(i).cloned().unwrap_or(serde_json::Value::Null));
|
||||
}
|
||||
serde_json::to_string(&serde_json::Value::Object(obj)).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn write_row<W: Write>(
|
||||
w: &mut W,
|
||||
format: ExportFormat,
|
||||
columns: &[ColumnInfo],
|
||||
row: &[serde_json::Value],
|
||||
first: bool,
|
||||
) -> std::io::Result<()> {
|
||||
match format {
|
||||
ExportFormat::Csv => writeln!(w, "{}", csv_line(columns, row)),
|
||||
ExportFormat::Jsonl => writeln!(w, "{}", jsonl_line(columns, row)),
|
||||
ExportFormat::Json => {
|
||||
if !first {
|
||||
writeln!(w, ",")?;
|
||||
}
|
||||
write!(w, "{}", jsonl_line(columns, row))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the export, deleting the partial file on any failure (fail-closed).
|
||||
fn with_partial_cleanup<W, F>(path: &PathBuf, w: W, f: F) -> Result<ExportSummary, String>
|
||||
where
|
||||
W: Write,
|
||||
F: FnOnce(&mut std::io::BufWriter<W>) -> Result<ExportSummary, String>,
|
||||
{
|
||||
let mut writer = std::io::BufWriter::with_capacity(8 * 1024 * 1024, w);
|
||||
match f(&mut writer) {
|
||||
Ok(summary) => {
|
||||
writer.flush().map_err(|e| e.to_string())?;
|
||||
Ok(summary)
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(path);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── SQLite (native streaming) ─────────────────────────────────────
|
||||
|
||||
pub fn run_sqlite_export(
|
||||
conn: &rusqlite::Connection,
|
||||
sql: &str,
|
||||
format: ExportFormat,
|
||||
path: &PathBuf,
|
||||
progress: &mut (dyn FnMut(u64) + Send),
|
||||
) -> Result<ExportSummary, String> {
|
||||
validate_export_inputs(sql, path)?;
|
||||
let file = std::fs::File::create(path).map_err(|e| format!("cannot create file: {e}"))?;
|
||||
|
||||
with_partial_cleanup(path, file, |writer| {
|
||||
let mut stmt = conn.prepare(sql).map_err(|e| e.to_string())?;
|
||||
let columns: Vec<ColumnInfo> = (0..stmt.column_count())
|
||||
.map(|i| ColumnInfo {
|
||||
name: stmt.column_name(i).unwrap_or("?").to_string(),
|
||||
data_type: "TEXT".to_string(),
|
||||
is_nullable: true,
|
||||
is_pk: false,
|
||||
is_fk: false,
|
||||
fk_ref: None,
|
||||
default_value: None,
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
})
|
||||
.collect();
|
||||
if format == ExportFormat::Csv {
|
||||
write!(writer, "\u{FEFF}").map_err(|e| e.to_string())?; // UTF-8 BOM
|
||||
writeln!(writer, "{}", csv_header(&columns)).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if format == ExportFormat::Json {
|
||||
write!(writer, "[").map_err(|e| e.to_string())?;
|
||||
}
|
||||
let col_count = columns.len();
|
||||
let mut rows_written: u64 = 0;
|
||||
let mut first = true;
|
||||
let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
|
||||
while let Some(row) = rows.next().map_err(|e| e.to_string())? {
|
||||
let values: Vec<serde_json::Value> = (0..col_count)
|
||||
.map(|i| sqlite_ref_to_json(row, i))
|
||||
.collect();
|
||||
write_row(writer, format, &columns, &values, first).map_err(|e| e.to_string())?;
|
||||
first = false;
|
||||
rows_written += 1;
|
||||
if rows_written % PROGRESS_ROWS == 0 {
|
||||
progress(rows_written);
|
||||
}
|
||||
}
|
||||
if format == ExportFormat::Json {
|
||||
writeln!(writer, "]").map_err(|e| e.to_string())?;
|
||||
}
|
||||
progress(rows_written);
|
||||
Ok(ExportSummary { rows_written })
|
||||
})
|
||||
}
|
||||
|
||||
fn sqlite_ref_to_json(row: &rusqlite::Row, i: usize) -> serde_json::Value {
|
||||
use rusqlite::types::ValueRef;
|
||||
match row.get_ref(i) {
|
||||
Ok(ValueRef::Null) => serde_json::Value::Null,
|
||||
Ok(ValueRef::Integer(v)) => serde_json::json!(v),
|
||||
Ok(ValueRef::Real(v)) => serde_json::json!(v),
|
||||
Ok(ValueRef::Text(v)) => serde_json::Value::String(String::from_utf8_lossy(v).to_string()),
|
||||
Ok(ValueRef::Blob(v)) => serde_json::Value::String(format!("[{}B blob]", v.len())),
|
||||
Err(_) => serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
// ── PostgreSQL (cursor FETCH loop — constant memory, stable order) ──
|
||||
|
||||
pub async fn run_pg_export(
|
||||
client: &tokio_postgres::Client,
|
||||
sql: &str,
|
||||
format: ExportFormat,
|
||||
path: &PathBuf,
|
||||
progress: &mut (dyn FnMut(u64) + Send),
|
||||
) -> Result<ExportSummary, String> {
|
||||
validate_export_inputs(sql, path)?;
|
||||
let file = std::fs::File::create(path).map_err(|e| format!("cannot create file: {e}"))?;
|
||||
let trimmed = sql.trim();
|
||||
let mut writer = std::io::BufWriter::with_capacity(8 * 1024 * 1024, file);
|
||||
|
||||
// LINEAR FORM: run the cursor loop inline; on any error drop the writer
|
||||
// and remove the partial file.
|
||||
let run = async {
|
||||
client.simple_query("BEGIN").await.map_err(|e| format!("export begin failed: {e}"))?;
|
||||
if let Err(e) = client
|
||||
.simple_query(&format!("DECLARE {CURSOR_NAME} CURSOR FOR ({trimmed})"))
|
||||
.await
|
||||
{
|
||||
let _ = client.simple_query("ROLLBACK").await;
|
||||
return Err(format!("export declare failed: {e}"));
|
||||
}
|
||||
let mut columns: Vec<ColumnInfo> = Vec::new();
|
||||
let mut rows_written: u64 = 0;
|
||||
let mut first = true;
|
||||
if format == ExportFormat::Json {
|
||||
write!(writer, "[").map_err(|e| e.to_string())?;
|
||||
}
|
||||
loop {
|
||||
let msgs = client
|
||||
.simple_query(&format!("FETCH FORWARD {CURSOR_FETCH} FROM {CURSOR_NAME}"))
|
||||
.await;
|
||||
let msgs = match msgs {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
let _ = client.simple_query("ROLLBACK").await;
|
||||
return Err(format!("export fetch failed: {e}"));
|
||||
}
|
||||
};
|
||||
let mut batch_rows: Vec<Vec<serde_json::Value>> = Vec::new();
|
||||
for msg in msgs {
|
||||
if let tokio_postgres::SimpleQueryMessage::Row(row) = msg {
|
||||
if columns.is_empty() {
|
||||
columns = row
|
||||
.columns()
|
||||
.iter()
|
||||
.map(|c| ColumnInfo {
|
||||
name: c.name().to_string(),
|
||||
data_type: "text".to_string(),
|
||||
is_nullable: true,
|
||||
is_pk: false,
|
||||
is_fk: false,
|
||||
fk_ref: None,
|
||||
default_value: None,
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
// Simple-query protocol returns every value as text; decode
|
||||
// as &str (usize has no FromSql impl) and NULL as None.
|
||||
let values: Vec<serde_json::Value> = (0..row.len())
|
||||
.map(|i| match row.try_get(i) {
|
||||
Ok(Some(s)) => serde_json::Value::String(s.to_string()),
|
||||
_ => serde_json::Value::Null,
|
||||
})
|
||||
.collect();
|
||||
batch_rows.push(values);
|
||||
}
|
||||
}
|
||||
if batch_rows.is_empty() {
|
||||
break;
|
||||
}
|
||||
if format == ExportFormat::Csv && rows_written == 0 {
|
||||
write!(writer, "\u{FEFF}").map_err(|e| e.to_string())?; // UTF-8 BOM
|
||||
writeln!(writer, "{}", csv_header(&columns)).map_err(|e| e.to_string())?;
|
||||
}
|
||||
for row in batch_rows {
|
||||
write_row(&mut writer, format, &columns, &row, first).map_err(|e| e.to_string())?;
|
||||
first = false;
|
||||
rows_written += 1;
|
||||
if rows_written % PROGRESS_ROWS == 0 {
|
||||
progress(rows_written);
|
||||
}
|
||||
}
|
||||
}
|
||||
if format == ExportFormat::Json {
|
||||
writeln!(writer, "]").map_err(|e| e.to_string())?;
|
||||
}
|
||||
let _ = client.simple_query(&format!("CLOSE {CURSOR_NAME}")).await;
|
||||
let _ = client.simple_query("ROLLBACK").await;
|
||||
progress(rows_written);
|
||||
Ok(ExportSummary { rows_written })
|
||||
}
|
||||
.await;
|
||||
|
||||
match run {
|
||||
Ok(summary) => {
|
||||
writer.flush().map_err(|e| e.to_string())?;
|
||||
Ok(summary)
|
||||
}
|
||||
Err(e) => {
|
||||
drop(writer);
|
||||
let _ = std::fs::remove_file(path);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── MySQL (sqlx stream — native streaming) ─────────────────────────
|
||||
|
||||
pub async fn run_mysql_export(
|
||||
conn: &mut sqlx::mysql::MySqlConnection,
|
||||
sql: &str,
|
||||
format: ExportFormat,
|
||||
path: &PathBuf,
|
||||
progress: &mut (dyn FnMut(u64) + Send),
|
||||
) -> Result<ExportSummary, String> {
|
||||
validate_export_inputs(sql, path)?;
|
||||
let file = std::fs::File::create(path).map_err(|e| format!("cannot create file: {e}"))?;
|
||||
use futures_util::TryStreamExt;
|
||||
let mut writer = std::io::BufWriter::with_capacity(8 * 1024 * 1024, file);
|
||||
|
||||
let run = async {
|
||||
let mut stream = sqlx::query(sql).fetch(&mut *conn);
|
||||
let mut columns: Vec<ColumnInfo> = Vec::new();
|
||||
let mut rows_written: u64 = 0;
|
||||
let mut first = true;
|
||||
if format == ExportFormat::Json {
|
||||
write!(writer, "[").map_err(|e| e.to_string())?;
|
||||
}
|
||||
while let Some(row) = stream.try_next().await.map_err(|e| {
|
||||
crate::commands::db_viewer::sanitize_error(&format!("{e}"))
|
||||
})? {
|
||||
if columns.is_empty() {
|
||||
columns = row
|
||||
.columns()
|
||||
.iter()
|
||||
.map(|c| ColumnInfo {
|
||||
name: c.name().to_string(),
|
||||
data_type: c.type_info().to_string(),
|
||||
is_nullable: true,
|
||||
is_pk: false,
|
||||
is_fk: false,
|
||||
fk_ref: None,
|
||||
default_value: None,
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
})
|
||||
.collect();
|
||||
if format == ExportFormat::Csv {
|
||||
write!(writer, "\u{FEFF}").map_err(|e| e.to_string())?; // UTF-8 BOM
|
||||
writeln!(writer, "{}", csv_header(&columns)).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
let values: Vec<serde_json::Value> =
|
||||
(0..row.len()).map(|i| crate::commands::query::mysql_cell_to_json(&row, i)).collect();
|
||||
write_row(&mut writer, format, &columns, &values, first).map_err(|e| e.to_string())?;
|
||||
first = false;
|
||||
rows_written += 1;
|
||||
if rows_written % PROGRESS_ROWS == 0 {
|
||||
progress(rows_written);
|
||||
}
|
||||
}
|
||||
if format == ExportFormat::Json {
|
||||
writeln!(writer, "]").map_err(|e| e.to_string())?;
|
||||
}
|
||||
progress(rows_written);
|
||||
Ok(ExportSummary { rows_written })
|
||||
}
|
||||
.await;
|
||||
|
||||
match run {
|
||||
Ok(summary) => {
|
||||
writer.flush().map_err(|e| e.to_string())?;
|
||||
Ok(summary)
|
||||
}
|
||||
Err(e) => {
|
||||
drop(writer);
|
||||
let _ = std::fs::remove_file(path);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dispatcher + thin command ──────────────────────────────────────
|
||||
|
||||
/// Dispatch across engines. The caller (command) owns the pool lock and the
|
||||
/// cancel registration; MySQL callers must hold a dedicated connection.
|
||||
pub(crate) async fn run_export_query(
|
||||
handle: &mut DbHandle,
|
||||
sql: &str,
|
||||
format: ExportFormat,
|
||||
path: &PathBuf,
|
||||
progress: &mut (dyn FnMut(u64) + Send),
|
||||
) -> Result<ExportSummary, String> {
|
||||
match handle {
|
||||
DbHandle::Sqlite(conn) => run_sqlite_export(conn, sql, format, path, progress),
|
||||
DbHandle::Postgresql(client, _) => run_pg_export(client, sql, format, path, progress).await,
|
||||
DbHandle::MySql(pool) => {
|
||||
let mut conn = pool
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
|
||||
run_mysql_export(&mut *conn, sql, format, path, progress).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn export_query_to_file(
|
||||
connection_id: String,
|
||||
sql: String,
|
||||
format: String,
|
||||
path: String,
|
||||
state: State<'_, crate::AppState>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
use tauri::Emitter;
|
||||
|
||||
let fmt = ExportFormat::from_str(&format)?;
|
||||
let path_buf = PathBuf::from(&path);
|
||||
let job_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let mut last_emit = std::time::Instant::now();
|
||||
let job = job_id.clone();
|
||||
let mut progress_cb = |rows: u64| {
|
||||
if last_emit.elapsed() >= std::time::Duration::from_millis(250) {
|
||||
last_emit = std::time::Instant::now();
|
||||
let _ = app.emit(
|
||||
"export-progress",
|
||||
ExportProgressEvent {
|
||||
job_id: job.clone(),
|
||||
status: "running".into(),
|
||||
rows,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let mut pm = state.pool_manager.lock().await;
|
||||
let handle = pm
|
||||
.get(&connection_id)
|
||||
.ok_or_else(|| "Connection not found".to_string())?;
|
||||
|
||||
// MySQL: the STREAMING connection must be the one registered for
|
||||
// KILL QUERY cancellation — acquire it once here, register its id,
|
||||
// stream through it, then clear the registration.
|
||||
let result = match handle {
|
||||
DbHandle::MySql(pool) => {
|
||||
let mut conn = pool
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
|
||||
let conn_id: i64 = sqlx::query_scalar("SELECT CAST(CONNECTION_ID() AS SIGNED)")
|
||||
.fetch_one(&mut *conn)
|
||||
.await
|
||||
.unwrap_or(-1);
|
||||
state.cancel_registry.set_mysql_conn_id(&connection_id, Some(conn_id));
|
||||
let r = run_mysql_export(&mut *conn, &sql, fmt, &path_buf, &mut progress_cb).await;
|
||||
state.cancel_registry.set_mysql_conn_id(&connection_id, None);
|
||||
r
|
||||
}
|
||||
other => run_export_query(other, &sql, fmt, &path_buf, &mut progress_cb).await,
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(summary) => {
|
||||
let _ = app.emit(
|
||||
"export-progress",
|
||||
ExportProgressEvent {
|
||||
job_id: job_id.clone(),
|
||||
status: "done".into(),
|
||||
rows: summary.rows_written,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
Ok(serde_json::json!({ "rows_written": summary.rows_written, "path": path }))
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = app.emit(
|
||||
"export-progress",
|
||||
ExportProgressEvent {
|
||||
job_id: job_id.clone(),
|
||||
status: "error".into(),
|
||||
rows: 0,
|
||||
error: Some(e.clone()),
|
||||
},
|
||||
);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "query_export.test.rs"]
|
||||
mod query_export_tests;
|
||||
@@ -0,0 +1,235 @@
|
||||
use super::*;
|
||||
use sqlx::Connection;
|
||||
|
||||
#[test]
|
||||
fn read_only_heuristic_matches_frontend_guard_set() {
|
||||
for sql in ["SELECT 1", " with x as (select 1) select * from x", "-- note\nSELECT 1"] {
|
||||
assert!(is_read_only_sql(sql), "should allow: {sql}");
|
||||
}
|
||||
for sql in [
|
||||
"INSERT INTO t VALUES (1)",
|
||||
"update t set a = 1",
|
||||
"DELETE FROM t",
|
||||
"DROP TABLE t",
|
||||
"ALTER TABLE t ADD c int",
|
||||
"TRUNCATE t",
|
||||
"CREATE TABLE t (a int)",
|
||||
"REPLACE INTO t VALUES (1)",
|
||||
] {
|
||||
assert!(!is_read_only_sql(sql), "should reject: {sql}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_cell_quotes_and_guards_formula_injection() {
|
||||
assert_eq!(csv_cell(&serde_json::Value::String("plain".into())), "plain");
|
||||
assert_eq!(csv_cell(&serde_json::Value::String("a,b".into())), "\"a,b\"");
|
||||
assert_eq!(csv_cell(&serde_json::Value::String("a\"b".into())), "\"a\"\"b\"");
|
||||
assert_eq!(csv_cell(&serde_json::Value::String("a\nb".into())), "\"a\nb\"");
|
||||
assert_eq!(csv_cell(&serde_json::Value::String("=SUM(A1)".into())), "'=SUM(A1)");
|
||||
assert_eq!(csv_cell(&serde_json::Value::String("+x".into())), "'+x");
|
||||
assert_eq!(csv_cell(&serde_json::Value::String("-1".into())), "'-1");
|
||||
assert_eq!(csv_cell(&serde_json::Value::String("@cmd".into())), "'@cmd");
|
||||
assert_eq!(csv_cell(&serde_json::Value::Null), "");
|
||||
assert_eq!(csv_cell(&serde_json::json!(42)), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_line_bom_is_written_by_header_only() {
|
||||
let cols = vec![
|
||||
crate::models::db_viewer::ColumnInfo { name: "a".into(), data_type: "int".into(), is_nullable: true, is_pk: false, is_fk: false, fk_ref: None, default_value: None, editable: true, is_generated: false },
|
||||
crate::models::db_viewer::ColumnInfo { name: "b".into(), data_type: "text".into(), is_nullable: true, is_pk: false, is_fk: false, fk_ref: None, default_value: None, editable: true, is_generated: false },
|
||||
];
|
||||
let line = csv_line(&cols, &[serde_json::json!("1"), serde_json::Value::Null]);
|
||||
assert_eq!(line, "1,");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonl_line_encodes_row_object() {
|
||||
let cols = vec![crate::models::db_viewer::ColumnInfo { name: "a".into(), data_type: "int".into(), is_nullable: true, is_pk: false, is_fk: false, fk_ref: None, default_value: None, editable: true, is_generated: false }];
|
||||
let line = jsonl_line(&cols, &[serde_json::json!("x\"y")]);
|
||||
assert_eq!(line, "{\"a\":\"x\\\"y\"}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_export_streams_all_rows_with_bom_and_guard() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE t (a INTEGER, b TEXT);
|
||||
INSERT INTO t VALUES (1, 'plain');
|
||||
INSERT INTO t VALUES (2, '=SUM(A1)');
|
||||
INSERT INTO t VALUES (3, NULL);",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let path = std::env::temp_dir().join(format!("gridline-export-test-{}.csv", std::process::id()));
|
||||
let mut rows_seen = 0u64;
|
||||
let summary = run_sqlite_export(&conn, "SELECT a, b FROM t ORDER BY a", ExportFormat::Csv, &path, &mut |n| rows_seen = n)
|
||||
.expect("export runs");
|
||||
assert_eq!(summary.rows_written, 3);
|
||||
assert_eq!(rows_seen, 3);
|
||||
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert!(bytes.starts_with(&[0xEF, 0xBB, 0xBF]), "UTF-8 BOM first");
|
||||
let text = String::from_utf8_lossy(&bytes[3..]);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines[0], "a,b");
|
||||
assert_eq!(lines[1], "1,plain");
|
||||
assert_eq!(lines[2], "2,'=SUM(A1)");
|
||||
assert_eq!(lines[3], "3,");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_export_rejects_write_statements_and_multi_statement() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
let path = std::env::temp_dir().join("gridline-export-should-not-exist.csv");
|
||||
let err = run_sqlite_export(&conn, "INSERT INTO t VALUES (1)", ExportFormat::Csv, &path, &mut |_| {})
|
||||
.expect_err("write SQL must be rejected");
|
||||
assert!(err.contains("read-only"), "got: {err}");
|
||||
let err = run_sqlite_export(&conn, "SELECT 1; SELECT 2", ExportFormat::Csv, &path, &mut |_| {})
|
||||
.expect_err("multi-statement must be rejected");
|
||||
assert!(err.contains("single"), "got: {err}");
|
||||
assert!(!path.exists(), "no partial file may be left behind");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_export_error_deletes_partial_file() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch("CREATE TABLE t (a INTEGER); INSERT INTO t VALUES (1),(2),(3);").unwrap();
|
||||
let path = std::env::temp_dir().join(format!("gridline-export-partial-{}.csv", std::process::id()));
|
||||
let err = run_sqlite_export(&conn, "SELECT a, b FROM t", ExportFormat::Csv, &path, &mut |_| {})
|
||||
.expect_err("bad column must fail");
|
||||
assert!(!err.is_empty());
|
||||
assert!(!path.exists(), "partial file must be deleted on error");
|
||||
}
|
||||
|
||||
/// Live PG export — mirror the env/client conventions of the live PG tests in
|
||||
/// `commands/objects.test.rs`. Assert a cursor export of a 3-row table
|
||||
/// produces the same 4 CSV lines as the SQLite test (BOM + header + rows).
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn pg_export_live() {
|
||||
let h = match std::env::var("GRIDLINE_TEST_PG_HOST") {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
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 _conn_task = tokio::spawn(async move {
|
||||
let _ = conn.await;
|
||||
});
|
||||
client
|
||||
.batch_execute(
|
||||
"DROP TABLE IF EXISTS gridline_export_test;
|
||||
CREATE TABLE gridline_export_test (a INTEGER, b TEXT);
|
||||
INSERT INTO gridline_export_test VALUES (1,'plain'),(2,'=SUM(A1)'),(3,NULL);",
|
||||
)
|
||||
.await
|
||||
.expect("setup");
|
||||
|
||||
let path = std::env::temp_dir().join(format!("gridline-export-pg-{}.csv", std::process::id()));
|
||||
let mut rows_seen = 0u64;
|
||||
let summary = run_pg_export(
|
||||
&client,
|
||||
"SELECT a, b FROM gridline_export_test ORDER BY a",
|
||||
ExportFormat::Csv,
|
||||
&path,
|
||||
&mut |n| rows_seen = n,
|
||||
)
|
||||
.await
|
||||
.expect("export runs");
|
||||
assert_eq!(summary.rows_written, 3);
|
||||
assert_eq!(rows_seen, 3);
|
||||
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert!(bytes.starts_with(&[0xEF, 0xBB, 0xBF]), "UTF-8 BOM first");
|
||||
let text = String::from_utf8_lossy(&bytes[3..]);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines[0], "a,b");
|
||||
assert_eq!(lines[1], "1,plain");
|
||||
assert_eq!(lines[2], "2,'=SUM(A1)");
|
||||
assert_eq!(lines[3], "3,");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = client.batch_execute("DROP TABLE IF EXISTS gridline_export_test").await;
|
||||
}
|
||||
|
||||
/// Live MySQL export — mirror the env conventions of the MySQL live tests in
|
||||
/// `commands/db_viewer.rs` (GRIDLINE_TEST_MYSQL_*). Same assertions.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn mysql_export_live() {
|
||||
let host = match std::env::var("GRIDLINE_TEST_MYSQL_HOST") {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
let port: u16 = std::env::var("GRIDLINE_TEST_MYSQL_PORT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(3306);
|
||||
let user = std::env::var("GRIDLINE_TEST_MYSQL_USER").unwrap_or_else(|_| "root".into());
|
||||
let pass = std::env::var("GRIDLINE_TEST_MYSQL_PASS").unwrap_or_default();
|
||||
let db = match std::env::var("GRIDLINE_TEST_MYSQL_DB") {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
let opts = sqlx::mysql::MySqlConnectOptions::new()
|
||||
.host(&host)
|
||||
.port(port)
|
||||
.username(&user)
|
||||
.password(&pass)
|
||||
.database(&db);
|
||||
let mut conn = sqlx::mysql::MySqlConnection::connect_with(&opts)
|
||||
.await
|
||||
.expect("connect to test MySQL");
|
||||
sqlx::query("DROP TABLE IF EXISTS gridline_export_test")
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.ok();
|
||||
sqlx::query("CREATE TABLE gridline_export_test (a INT, b TEXT)")
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.expect("create");
|
||||
sqlx::query("INSERT INTO gridline_export_test VALUES (1,'plain'),(2,'=SUM(A1)'),(3,NULL)")
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.expect("insert");
|
||||
|
||||
let path = std::env::temp_dir().join(format!("gridline-export-mysql-{}.csv", std::process::id()));
|
||||
let mut rows_seen = 0u64;
|
||||
let summary = run_mysql_export(
|
||||
&mut conn,
|
||||
"SELECT a, b FROM gridline_export_test ORDER BY a",
|
||||
ExportFormat::Csv,
|
||||
&path,
|
||||
&mut |n| rows_seen = n,
|
||||
)
|
||||
.await
|
||||
.expect("export runs");
|
||||
assert_eq!(summary.rows_written, 3);
|
||||
assert_eq!(rows_seen, 3);
|
||||
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert!(bytes.starts_with(&[0xEF, 0xBB, 0xBF]), "UTF-8 BOM first");
|
||||
let text = String::from_utf8_lossy(&bytes[3..]);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines[0], "a,b");
|
||||
assert_eq!(lines[1], "1,plain");
|
||||
assert_eq!(lines[2], "2,'=SUM(A1)");
|
||||
assert_eq!(lines[3], "3,");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = sqlx::query("DROP TABLE IF EXISTS gridline_export_test")
|
||||
.execute(&mut conn)
|
||||
.await;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Thin `compare_schemas` command (spec §4: two pool lookups → snapshots →
|
||||
//! diff; errors name the failing side; staging never happens here).
|
||||
|
||||
use crate::models::db_viewer::DiffReport;
|
||||
use tauri::State;
|
||||
|
||||
/// Engine family for diff pairing: mysql and mariadb are one family.
|
||||
pub fn engine_family(db_type: &str) -> String {
|
||||
match db_type {
|
||||
"mysql" | "mariadb" => "mysql".to_string(),
|
||||
"sqlite" => "sqlite".to_string(),
|
||||
"postgresql" => "postgresql".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_same_family(source_db_type: &str, target_db_type: &str) -> Result<(), String> {
|
||||
let (s, t) = (engine_family(source_db_type), engine_family(target_db_type));
|
||||
if s != t {
|
||||
return Err(format!(
|
||||
"Schema diff requires the same engine family — source is {s}, target is {t}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn connection_db_type(
|
||||
db_store: &std::sync::Mutex<crate::store::Store>,
|
||||
connection_id: &str,
|
||||
) -> Result<String, String> {
|
||||
let store = db_store.lock().map_err(|e| e.to_string())?;
|
||||
let conns = store.get_connections().map_err(|e| e.to_string())?;
|
||||
conns
|
||||
.iter()
|
||||
.find(|c| c.id == connection_id)
|
||||
.map(|c| c.db_type.clone())
|
||||
.ok_or_else(|| format!("connection {connection_id} not found"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn compare_schemas(
|
||||
source_connection_id: String,
|
||||
source_schema: String,
|
||||
target_connection_id: String,
|
||||
target_schema: String,
|
||||
state: State<'_, crate::AppState>,
|
||||
) -> Result<DiffReport, String> {
|
||||
let source_db_type = connection_db_type(&state.db_store, &source_connection_id)
|
||||
.map_err(|e| format!("Source connection failed: {e}"))?;
|
||||
let target_db_type = connection_db_type(&state.db_store, &target_connection_id)
|
||||
.map_err(|e| format!("Target connection failed: {e}"))?;
|
||||
validate_same_family(&source_db_type, &target_db_type)?;
|
||||
|
||||
let mut pm = state.pool_manager.lock().await;
|
||||
|
||||
let source = {
|
||||
let handle = pm
|
||||
.get(&source_connection_id)
|
||||
.ok_or_else(|| "Source connection failed: not connected (open it in the DB viewer first)".to_string())?;
|
||||
crate::db::schema_diff::capture_snapshot(handle, &source_db_type, &source_schema)
|
||||
.await
|
||||
.map_err(|e| format!("Source connection failed: {e}"))?
|
||||
};
|
||||
|
||||
let target = {
|
||||
let handle = pm
|
||||
.get(&target_connection_id)
|
||||
.ok_or_else(|| "Target connection failed: not connected".to_string())?;
|
||||
crate::db::schema_diff::capture_snapshot(handle, &target_db_type, &target_schema)
|
||||
.await
|
||||
.map_err(|e| format!("Target connection failed: {e}"))?
|
||||
};
|
||||
|
||||
let mut report = crate::db::schema_diff::diff_snapshots(&source, &target);
|
||||
report.source_label = format!("{} · {}", report.source_label, source_connection_id);
|
||||
report.target_label = format!("{} · {}", report.target_label, target_connection_id);
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "schema_diff.test.rs"]
|
||||
mod schema_diff_tests;
|
||||
@@ -0,0 +1,25 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn engine_family_pairs_mysql_with_mariadb() {
|
||||
assert_eq!(engine_family("mysql"), engine_family("mariadb"));
|
||||
assert_eq!(engine_family("mysql"), "mysql");
|
||||
assert_eq!(engine_family("postgresql"), "postgresql");
|
||||
assert_eq!(engine_family("sqlite"), "sqlite");
|
||||
assert_ne!(engine_family("postgresql"), engine_family("mysql"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn family_check_rejects_cross_engine_pairs() {
|
||||
let err = validate_same_family("postgresql", "mysql").expect_err("must reject");
|
||||
assert!(err.contains("same engine"), "got: {err}");
|
||||
assert!(validate_same_family("mariadb", "mysql").is_ok());
|
||||
assert!(validate_same_family("mysql", "mariadb").is_ok());
|
||||
assert!(validate_same_family("postgresql", "postgresql").is_ok());
|
||||
}
|
||||
|
||||
/// Live end-to-end: register two connections and diff their schemas through
|
||||
/// the real pool path.
|
||||
#[test]
|
||||
#[ignore = "needs the pool manager wiring from the command; run via the UI or the live harness"]
|
||||
fn compare_command_live_pg() {}
|
||||
@@ -107,7 +107,7 @@ fn url_scheme_end(msg: &str, i: usize) -> Option<usize> {
|
||||
pub fn validate_test_input(config: &DbConfig) -> Option<String> {
|
||||
let db_type = config.db_type.to_lowercase();
|
||||
|
||||
let valid_types = ["postgresql", "mysql", "sqlite", "redis"];
|
||||
let valid_types = ["postgresql", "mysql", "mariadb", "sqlite", "redis"];
|
||||
if !valid_types.contains(&db_type.as_str()) {
|
||||
return Some(format!(
|
||||
"unsupported database type: {}. Supported types: {}",
|
||||
@@ -220,6 +220,7 @@ pub async fn test_database_connection(config: &DbConfig, ssh: &SshManager) -> Te
|
||||
let result = match config.db_type.to_lowercase().as_str() {
|
||||
"postgresql" => test_pg_connection(config, ssh).await,
|
||||
"mysql" => test_mysql_connection(config, ssh).await,
|
||||
"mariadb" => test_mysql_connection(config, ssh).await,
|
||||
"sqlite" => test_sqlite_connection(config),
|
||||
"redis" => test_redis_connection(config, ssh).await,
|
||||
other => TestConnectionResult {
|
||||
@@ -264,7 +265,7 @@ async fn test_pg_connection(config: &DbConfig, ssh: &SshManager) -> TestConnecti
|
||||
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
||||
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
||||
let decision = crate::commands::ssh::effective_tls_decision(
|
||||
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
|
||||
crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()),
|
||||
target.via_tunnel,
|
||||
);
|
||||
let tls = match crate::db::tls::build_tls_config(
|
||||
@@ -367,7 +368,7 @@ async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
|
||||
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
||||
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
||||
let decision = crate::commands::ssh::effective_tls_decision(
|
||||
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
|
||||
crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()),
|
||||
target.via_tunnel,
|
||||
);
|
||||
match decision {
|
||||
@@ -724,3 +725,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "test_connection.test.rs"]
|
||||
mod test_connection_tests;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
use super::*;
|
||||
use crate::commands::ssh::{Ssh2Backend, SshTunnelManager};
|
||||
use crate::db::pool::DbConfig;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn mariadb_config(port: Option<i64>) -> DbConfig {
|
||||
DbConfig {
|
||||
db_type: "mariadb".into(),
|
||||
host: "127.0.0.1".into(),
|
||||
port,
|
||||
username: Some("root".into()),
|
||||
password: None,
|
||||
database: Some("test".into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_test_input_accepts_mariadb_with_port() {
|
||||
assert_eq!(validate_test_input(&mariadb_config(Some(3306))), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_test_input_rejects_mariadb_without_port() {
|
||||
assert!(validate_test_input(&mariadb_config(None)).is_some());
|
||||
}
|
||||
|
||||
/// Live test against a real MariaDB server. Run with:
|
||||
/// `GRIDLINE_TEST_MARIADB_HOST=… GRIDLINE_TEST_MARIADB_PORT=3306 \
|
||||
/// GRIDLINE_TEST_MARIADB_USER=… GRIDLINE_TEST_MARIADB_PASSWORD=… \
|
||||
/// GRIDLINE_TEST_MARIADB_DB=… cargo test -- --ignored mariadb_live`
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn mariadb_live_test_connection_reports_mariadb_version() {
|
||||
let config = DbConfig {
|
||||
host: std::env::var("GRIDLINE_TEST_MARIADB_HOST").expect("GRIDLINE_TEST_MARIADB_HOST"),
|
||||
port: std::env::var("GRIDLINE_TEST_MARIADB_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse::<i64>().ok()),
|
||||
username: std::env::var("GRIDLINE_TEST_MARIADB_USER").ok(),
|
||||
password: std::env::var("GRIDLINE_TEST_MARIADB_PASSWORD").ok(),
|
||||
database: std::env::var("GRIDLINE_TEST_MARIADB_DB").ok(),
|
||||
..mariadb_config(None)
|
||||
};
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
let ssh = std::sync::Mutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend)));
|
||||
let result = rt.block_on(async { test_mysql_connection(&config, &ssh).await });
|
||||
assert!(result.ok, "connection failed: {:?}", result.error);
|
||||
let version = result.server_version.unwrap_or_default().to_lowercase();
|
||||
assert!(
|
||||
version.contains("mariadb"),
|
||||
"expected a MariaDB server, got version: {version:?}"
|
||||
);
|
||||
}
|
||||
@@ -887,3 +887,123 @@ mod tests {
|
||||
assert!(sql.contains("$1") && sql.contains("$2"));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TimescaleDB (hypertables)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Detect whether the timescaledb extension is installed on this database.
|
||||
pub fn pg_timescale_detect_query() -> String {
|
||||
"SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')".to_string()
|
||||
}
|
||||
|
||||
/// Query hypertables in a schema (TimescaleDB 2.x information views).
|
||||
/// Columns: name, schema, num_dimensions, compression_enabled, num_chunks, total_size_bytes.
|
||||
pub fn pg_hypertables_query(_schema: &str) -> String {
|
||||
"SELECT h.hypertable_name, h.hypertable_schema, \
|
||||
h.num_dimensions, \
|
||||
COALESCE(h.compression_enabled, false), \
|
||||
(SELECT COUNT(*) FROM timescaledb_information.chunks c \
|
||||
WHERE c.hypertable_schema = h.hypertable_schema \
|
||||
AND c.hypertable_name = h.hypertable_name), \
|
||||
pg_total_relation_size(format('%I.%I', h.hypertable_schema, h.hypertable_name)::regclass)::bigint \
|
||||
FROM timescaledb_information.hypertables h \
|
||||
WHERE h.hypertable_schema = $1 \
|
||||
ORDER BY h.hypertable_name"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema-diff snapshot queries (0.8.0) — one set-based query per object class
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Table columns with resolved types, nullability, and defaults.
|
||||
/// Columns: table_name, column_name, data_type, is_nullable, default_expr
|
||||
pub fn pg_diff_columns_query() -> String {
|
||||
"SELECT c.relname, a.attname, \
|
||||
pg_catalog.format_type(a.atttypid, a.atttypmod), \
|
||||
NOT a.attnotnull, \
|
||||
pg_catalog.pg_get_expr(d.adbin, d.adrelid) \
|
||||
FROM pg_catalog.pg_class c \
|
||||
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
|
||||
JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped \
|
||||
LEFT JOIN pg_catalog.pg_attrdef d ON d.adrelid = c.oid AND d.adnum = a.attnum \
|
||||
WHERE n.nspname = $1 AND c.relkind IN ('r', 'p') \
|
||||
ORDER BY c.relname, a.attnum"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Constraints (PK/UNIQUE/CHECK/FK) with exact column lists and FK metadata.
|
||||
/// Columns: table_name, constraint_name, contype, cols, ref_cols, ref_table, confdeltype, confupdtype, definition
|
||||
pub fn pg_diff_constraints_query() -> String {
|
||||
"SELECT cl.relname, con.conname, con.contype::text, \
|
||||
(SELECT string_agg(a.attname, ', ' ORDER BY k.ord) \
|
||||
FROM unnest(con.conkey) WITH ORDINALITY AS k(attnum, ord) \
|
||||
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k.attnum), \
|
||||
(SELECT string_agg(a.attname, ', ' ORDER BY k.ord) \
|
||||
FROM unnest(con.confkey) WITH ORDINALITY AS k(attnum, ord) \
|
||||
JOIN pg_attribute a ON a.attrelid = con.confrelid AND a.attnum = k.attnum), \
|
||||
(SELECT n2.nspname || '.' || cl2.relname \
|
||||
FROM pg_class cl2 JOIN pg_namespace n2 ON n2.oid = cl2.relnamespace \
|
||||
WHERE cl2.oid = con.confrelid), \
|
||||
con.confdeltype::text, con.confupdtype::text, \
|
||||
pg_get_constraintdef(con.oid, true) \
|
||||
FROM pg_catalog.pg_constraint con \
|
||||
JOIN pg_catalog.pg_class cl ON cl.oid = con.conrelid \
|
||||
JOIN pg_catalog.pg_namespace n ON n.oid = cl.relnamespace \
|
||||
WHERE n.nspname = $1 AND con.contype IN ('p', 'u', 'c', 'f') \
|
||||
ORDER BY cl.relname, con.contype, con.conname"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Secondary indexes (constraint-backed indexes excluded), with column lists.
|
||||
/// Columns: table_name, index_name, is_unique, cols
|
||||
pub fn pg_diff_indexes_query() -> String {
|
||||
"SELECT t.relname, i.relname, ix.indisunique, \
|
||||
(SELECT string_agg(a.attname, ', ' ORDER BY k.ord) \
|
||||
FROM unnest(ix.indkey) WITH ORDINALITY AS k(attnum, ord) \
|
||||
JOIN pg_attribute a ON a.attrelid = ix.indrelid AND a.attnum = k.attnum \
|
||||
WHERE k.attnum > 0) \
|
||||
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 n.oid = t.relnamespace \
|
||||
WHERE n.nspname = $1 AND NOT ix.indisprimary \
|
||||
AND NOT EXISTS (SELECT 1 FROM pg_constraint con WHERE con.conindid = i.oid) \
|
||||
ORDER BY t.relname, i.relname"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Views + materialized views. Columns: schema, name, definition, materialized
|
||||
pub fn pg_diff_views_query() -> String {
|
||||
"SELECT v.schemaname, v.viewname, v.definition, false \
|
||||
FROM pg_views v WHERE v.schemaname = $1 \
|
||||
UNION ALL \
|
||||
SELECT v.schemaname, v.matviewname, v.definition, true \
|
||||
FROM pg_matviews v WHERE v.schemaname = $1 \
|
||||
ORDER BY 2"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Sequences. Columns: schema, name, start, increment, min, max, cycle
|
||||
pub fn pg_diff_sequences_query() -> String {
|
||||
"SELECT sequence_schema, sequence_name, \
|
||||
COALESCE(start_value::text, '1'), COALESCE(increment::text, '1'), \
|
||||
COALESCE(minimum_value::text, '1'), COALESCE(maximum_value::text, '9223372036854775807'), \
|
||||
cycle_option = 'YES' \
|
||||
FROM information_schema.sequences \
|
||||
WHERE sequence_schema = $1 \
|
||||
ORDER BY sequence_name"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Enums with ordered labels. Columns: schema, name, label (row per label)
|
||||
pub fn pg_diff_enums_query() -> String {
|
||||
"SELECT n.nspname, t.typname, e.enumlabel \
|
||||
FROM pg_type t \
|
||||
JOIN pg_namespace n ON n.oid = t.typnamespace \
|
||||
JOIN pg_enum e ON e.enumtypid = t.oid \
|
||||
WHERE t.typtype = 'e' AND n.nspname = $1 \
|
||||
ORDER BY t.typname, e.enumsortorder"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
pub mod introspection;
|
||||
pub mod mysql;
|
||||
pub mod mysql_clean;
|
||||
pub mod sql_split;
|
||||
pub mod object_crud;
|
||||
pub mod object_ddl;
|
||||
pub mod pool;
|
||||
pub mod tls;
|
||||
pub mod tool_resolver;
|
||||
pub mod schema_diff;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use pool::{ConnectionPoolManager, DbConfig, DbHandle};
|
||||
|
||||
@@ -324,3 +324,63 @@ mod tests {
|
||||
assert_eq!(mysql_default_sort(&[]), "");
|
||||
}
|
||||
}
|
||||
// ── Schema-diff snapshot queries (0.8.0) ──────────────────────────────
|
||||
|
||||
/// Columns: table_name, column_name, column_type, is_nullable, column_default
|
||||
pub fn mysql_snapshot_columns_query(schema: &str) -> String {
|
||||
format!(
|
||||
"SELECT table_name, column_name, column_type, is_nullable, \
|
||||
COALESCE(column_default, '') \
|
||||
FROM information_schema.columns \
|
||||
WHERE table_schema = '{}' \
|
||||
ORDER BY table_name, ordinal_position",
|
||||
schema.replace('\'', "''")
|
||||
)
|
||||
}
|
||||
|
||||
/// Constraints (one row per constraint-column). Columns: table_name,
|
||||
/// constraint_name, constraint_type, column_name, referenced_table_name,
|
||||
/// delete_rule, update_rule, check_clause
|
||||
pub fn mysql_snapshot_constraints_query(schema: &str) -> String {
|
||||
format!(
|
||||
"SELECT kcu.table_name, kcu.constraint_name, tc.constraint_type, kcu.column_name, \
|
||||
COALESCE(kcu.referenced_table_name, ''), \
|
||||
COALESCE(rc.delete_rule, ''), COALESCE(rc.update_rule, ''), \
|
||||
COALESCE((SELECT cc.check_clause FROM information_schema.check_constraints cc \
|
||||
WHERE cc.constraint_schema = kcu.constraint_schema \
|
||||
AND cc.constraint_name = kcu.constraint_name), '') \
|
||||
FROM information_schema.key_column_usage kcu \
|
||||
LEFT JOIN information_schema.table_constraints tc \
|
||||
ON tc.constraint_schema = kcu.constraint_schema \
|
||||
AND tc.constraint_name = kcu.constraint_name \
|
||||
AND tc.table_name = kcu.table_name \
|
||||
LEFT JOIN information_schema.referential_constraints rc \
|
||||
ON rc.constraint_schema = kcu.constraint_schema \
|
||||
AND rc.constraint_name = kcu.constraint_name \
|
||||
WHERE kcu.constraint_schema = '{}' AND tc.constraint_type IS NOT NULL \
|
||||
ORDER BY kcu.table_name, kcu.constraint_name, kcu.ordinal_position",
|
||||
schema.replace('\'', "''")
|
||||
)
|
||||
}
|
||||
|
||||
/// Secondary indexes (one row per index-column). Columns: table_name,
|
||||
/// index_name, non_unique, column_name
|
||||
pub fn mysql_snapshot_indexes_query(schema: &str) -> String {
|
||||
format!(
|
||||
"SELECT table_name, index_name, non_unique, column_name \
|
||||
FROM information_schema.statistics \
|
||||
WHERE table_schema = '{}' AND index_name != 'PRIMARY' \
|
||||
ORDER BY table_name, index_name, seq_in_index",
|
||||
schema.replace('\'', "''")
|
||||
)
|
||||
}
|
||||
|
||||
/// Views. Columns: table_schema, table_name, view_definition
|
||||
pub fn mysql_snapshot_views_query(schema: &str) -> String {
|
||||
format!(
|
||||
"SELECT table_schema, table_name, view_definition \
|
||||
FROM information_schema.views \
|
||||
WHERE table_schema = '{}'",
|
||||
schema.replace('\'', "''")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Planning for a MySQL "clean" restore.
|
||||
//!
|
||||
//! `clean` drops only the objects the dump recreates, matching the UI label
|
||||
//! "Clean — DROP before CREATE" and `pg_restore --clean` semantics: objects in
|
||||
//! the target database that the dump does not mention survive. It is
|
||||
//! deliberately *not* `DROP DATABASE` (spec §4.3).
|
||||
//!
|
||||
//! Pure by design: takes already-split statements, returns the DROP script.
|
||||
|
||||
use crate::db::mysql::mysql_quote_ident;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ObjectKind {
|
||||
Table,
|
||||
View,
|
||||
Procedure,
|
||||
Function,
|
||||
Trigger,
|
||||
Event,
|
||||
}
|
||||
|
||||
impl ObjectKind {
|
||||
fn keyword(self) -> &'static str {
|
||||
match self {
|
||||
ObjectKind::Table => "TABLE",
|
||||
ObjectKind::View => "VIEW",
|
||||
ObjectKind::Procedure => "PROCEDURE",
|
||||
ObjectKind::Function => "FUNCTION",
|
||||
ObjectKind::Trigger => "TRIGGER",
|
||||
ObjectKind::Event => "EVENT",
|
||||
}
|
||||
}
|
||||
|
||||
/// Dependents first, tables last, so the drop phase reads predictably.
|
||||
/// Foreign-key ordering is handled by the caller disabling
|
||||
/// `FOREIGN_KEY_CHECKS`, so this is about clarity, not correctness.
|
||||
fn order(self) -> u8 {
|
||||
match self {
|
||||
ObjectKind::Trigger => 0,
|
||||
ObjectKind::Event => 1,
|
||||
ObjectKind::Procedure => 2,
|
||||
ObjectKind::Function => 3,
|
||||
ObjectKind::View => 4,
|
||||
ObjectKind::Table => 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tokens that may appear between the object keyword and its name.
|
||||
const MODIFIERS: &[&str] = &[
|
||||
"OR", "REPLACE", "TEMPORARY", "IF", "NOT", "EXISTS", "AGGREGATE", "SQL", "SECURITY",
|
||||
"DEFINER", "ALGORITHM", "UNDEFINED", "MERGE", "TEMPTABLE", "CASCADED", "LOCAL", "INVOKER",
|
||||
"=",
|
||||
];
|
||||
|
||||
/// Build the ordered `DROP … IF EXISTS` script for a clean restore.
|
||||
/// Returns an empty vector when no object can be identified (fail closed).
|
||||
pub fn plan_clean_drops(default_schema: &str, statements: &[String]) -> Vec<String> {
|
||||
let mut found: Vec<(ObjectKind, String)> = Vec::new();
|
||||
for stmt in statements {
|
||||
if let Some((kind, name)) = parse_create_target(stmt) {
|
||||
let qualified = qualify(default_schema, &name);
|
||||
if !found.iter().any(|(k, n)| *k == kind && *n == qualified) {
|
||||
found.push((kind, qualified));
|
||||
}
|
||||
}
|
||||
}
|
||||
found.sort_by_key(|(k, _)| k.order());
|
||||
found
|
||||
.into_iter()
|
||||
.map(|(kind, name)| format!("DROP {} IF EXISTS {}", kind.keyword(), name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Extract `(kind, raw name)` from a `CREATE …` statement, or `None`.
|
||||
fn parse_create_target(stmt: &str) -> Option<(ObjectKind, String)> {
|
||||
let tokens = unwrap_version_comments(stmt);
|
||||
let tokens = tokenize(&tokens);
|
||||
let mut it = tokens.iter().peekable();
|
||||
while let Some(tok) = it.next() {
|
||||
if !tok.eq_ignore_ascii_case("CREATE") {
|
||||
continue;
|
||||
}
|
||||
// Walk forward until an object keyword is found.
|
||||
while let Some(t) = it.next() {
|
||||
let upper = t.to_ascii_uppercase();
|
||||
let kind = match upper.as_str() {
|
||||
"TABLE" => ObjectKind::Table,
|
||||
"VIEW" => ObjectKind::View,
|
||||
"PROCEDURE" => ObjectKind::Procedure,
|
||||
"FUNCTION" => ObjectKind::Function,
|
||||
"TRIGGER" => ObjectKind::Trigger,
|
||||
"EVENT" => ObjectKind::Event,
|
||||
_ => continue,
|
||||
};
|
||||
// The first following token that is neither a modifier nor an
|
||||
// `x=y` clause is the object name.
|
||||
for cand in it.by_ref() {
|
||||
let up = cand.to_ascii_uppercase();
|
||||
if MODIFIERS.contains(&up.as_str()) || cand.contains('=') {
|
||||
continue;
|
||||
}
|
||||
// A routine's name is followed by its argument list with no
|
||||
// space (`p`()`, hence a single token); strip that trailing
|
||||
// `()` so the DROP names the routine, not its signature. This
|
||||
// must be a *string* suffix — `trim_end_matches('(')` (a char
|
||||
// pattern) strips nothing from `p`()`.
|
||||
let name = cand.strip_suffix("()").unwrap_or(cand).to_string();
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Fail closed: a token that is not a plausible identifier means
|
||||
// we cannot name the object, so plan no DROP rather than emit a
|
||||
// bogus one. `CREATE TABLE (id int)` tokenizes as
|
||||
// `["CREATE", "TABLE", "(id", "int)"]`, and `(id` is a column
|
||||
// definition fragment, not a table name.
|
||||
if !is_plausible_identifier(&name) {
|
||||
return None;
|
||||
}
|
||||
return Some((kind, name));
|
||||
}
|
||||
return None;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Reject candidates that are clearly not object names. A `(`, `)`, `,` or `;`
|
||||
/// outside a backtick-quoted region means the token is part of an argument
|
||||
/// list, a column definition, or a statement terminator — not an identifier.
|
||||
fn is_plausible_identifier(cand: &str) -> bool {
|
||||
if cand.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut in_backtick = false;
|
||||
for ch in cand.chars() {
|
||||
match ch {
|
||||
'`' => in_backtick = !in_backtick,
|
||||
'(' | ')' | ',' | ';' if !in_backtick => return false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Replace `/*!50003 … */` executable comments with their inner text — the
|
||||
/// shape mysqldump uses to wrap routine and trigger definitions.
|
||||
fn unwrap_version_comments(stmt: &str) -> String {
|
||||
let mut out = String::with_capacity(stmt.len());
|
||||
let chars: Vec<char> = stmt.chars().collect();
|
||||
let mut i = 0usize;
|
||||
while i < chars.len() {
|
||||
if chars[i] == '/' && i + 2 < chars.len() && chars[i + 1] == '*' && chars[i + 2] == '!' {
|
||||
// skip `/*!` and any leading digits
|
||||
let mut j = i + 3;
|
||||
while j < chars.len() && chars[j].is_ascii_digit() {
|
||||
j += 1;
|
||||
}
|
||||
out.push(' ');
|
||||
while j < chars.len()
|
||||
&& !(chars[j] == '*' && j + 1 < chars.len() && chars[j + 1] == '/')
|
||||
{
|
||||
out.push(chars[j]);
|
||||
j += 1;
|
||||
}
|
||||
out.push(' ');
|
||||
i = (j + 2).min(chars.len());
|
||||
} else {
|
||||
out.push(chars[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Split on whitespace, treating backtick-quoted regions as part of one token.
|
||||
fn tokenize(s: &str) -> Vec<String> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut in_backtick = false;
|
||||
for ch in s.chars() {
|
||||
match ch {
|
||||
'`' => {
|
||||
in_backtick = !in_backtick;
|
||||
cur.push(ch);
|
||||
}
|
||||
c if c.is_whitespace() && !in_backtick => {
|
||||
if !cur.is_empty() {
|
||||
tokens.push(std::mem::take(&mut cur));
|
||||
}
|
||||
}
|
||||
c => cur.push(c),
|
||||
}
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
tokens.push(cur);
|
||||
}
|
||||
tokens
|
||||
}
|
||||
|
||||
/// `a` -> `schema`.`a`; `db`.`a` / `db.a` -> `db`.`a`.
|
||||
fn qualify(default_schema: &str, name: &str) -> String {
|
||||
let parts: Vec<String> = name
|
||||
.split('.')
|
||||
.map(|p| mysql_quote_ident(strip_backticks(p)))
|
||||
.collect();
|
||||
if parts.len() == 1 {
|
||||
format!("{}.{}", mysql_quote_ident(default_schema), parts[0])
|
||||
} else {
|
||||
parts.join(".")
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_backticks(s: &str) -> &str {
|
||||
s.trim_matches('`')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mysql_clean.test.rs"]
|
||||
mod mysql_clean_tests;
|
||||
@@ -0,0 +1,78 @@
|
||||
use super::*;
|
||||
|
||||
fn plan(sql: &str) -> Vec<String> {
|
||||
plan_clean_drops("shop", &crate::db::sql_split::split_statements(sql))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plans_a_drop_for_each_recreated_table() {
|
||||
let got = plan("CREATE TABLE `orders` (id int);\nCREATE TABLE IF NOT EXISTS users (id int);");
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
"DROP TABLE IF EXISTS `shop`.`orders`".to_string(),
|
||||
"DROP TABLE IF EXISTS `shop`.`users`".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualifies_unqualified_names_with_the_target_schema() {
|
||||
assert_eq!(
|
||||
plan("CREATE TABLE orders (id int);"),
|
||||
vec!["DROP TABLE IF EXISTS `shop`.`orders`".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_an_explicit_schema_qualifier() {
|
||||
assert_eq!(
|
||||
plan("CREATE TABLE `other`.`orders` (id int);"),
|
||||
vec!["DROP TABLE IF EXISTS `other`.`orders`".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwraps_mysqldump_version_comments_for_routines_and_triggers() {
|
||||
// This is the shape mysqldump actually emits for routines/triggers.
|
||||
let got = plan(
|
||||
"/*!50003 CREATE*/ /*!50020 DEFINER=`root`@`localhost`*/ /*!50003 PROCEDURE `p`() BEGIN END */;;\n\
|
||||
/*!50003 CREATE*/ /*!50017 DEFINER=`root`@`localhost`*/ /*!50003 TRIGGER `trg` BEFORE INSERT ON `orders` FOR EACH ROW BEGIN END */;;",
|
||||
);
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
"DROP TRIGGER IF EXISTS `shop`.`trg`".to_string(),
|
||||
"DROP PROCEDURE IF EXISTS `shop`.`p`".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicates_repeated_objects() {
|
||||
let got = plan("CREATE TABLE a (id int);\nCREATE TABLE a (id int);");
|
||||
assert_eq!(got, vec!["DROP TABLE IF EXISTS `shop`.`a`".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orders_dependents_before_tables() {
|
||||
let got = plan("CREATE TABLE t (id int);\nCREATE VIEW v AS SELECT 1;\nCREATE TRIGGER g BEFORE INSERT ON t FOR EACH ROW BEGIN END;");
|
||||
let kinds: Vec<&str> = got
|
||||
.iter()
|
||||
.map(|s| s.split_whitespace().nth(1).unwrap())
|
||||
.collect();
|
||||
assert_eq!(kinds, vec!["TRIGGER", "VIEW", "TABLE"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_create_statements() {
|
||||
let got = plan("INSERT INTO t VALUES (1);\nDROP TABLE x;\nALTER TABLE t ADD c int;\nSELECT 1;");
|
||||
assert!(got.is_empty(), "got {got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_a_create_whose_name_cannot_be_determined() {
|
||||
// Fail closed: if we cannot name the object we must not guess at a DROP.
|
||||
let got = plan("CREATE TABLE (id int);");
|
||||
assert!(got.is_empty(), "got {got:?}");
|
||||
}
|
||||
@@ -0,0 +1,992 @@
|
||||
//! Schema snapshot capture + the pure diff engine (engine lands in Task 14).
|
||||
//!
|
||||
//! Snapshots are read-only, single-use, and capped (5,000 objects — spec §2).
|
||||
//! Sync SQL is generated in the TARGET's dialect with identifiers quoted
|
||||
//! per engine (spec §2: never raw interpolation).
|
||||
|
||||
use crate::db::pool::DbHandle;
|
||||
use crate::models::db_viewer::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(crate) const SNAPSHOT_CAP: usize = 5_000;
|
||||
|
||||
/// Map a PG `conf*type` action code to its SQL name.
|
||||
pub(crate) fn pg_fk_action(code: &str) -> &'static str {
|
||||
match code {
|
||||
"r" => "RESTRICT",
|
||||
"c" => "CASCADE",
|
||||
"n" => "SET NULL",
|
||||
"d" => "SET DEFAULT",
|
||||
_ => "NO ACTION",
|
||||
}
|
||||
}
|
||||
|
||||
/// Enforce the 5,000-object cap, setting `truncated` (never silent).
|
||||
pub(crate) fn apply_snapshot_cap(snap: &mut SchemaSnapshot) {
|
||||
let mut total = snap.tables.len() + snap.views.len() + snap.sequences.len() + snap.enums.len();
|
||||
let over = total > SNAPSHOT_CAP;
|
||||
if over {
|
||||
let mut remaining = SNAPSHOT_CAP;
|
||||
fn take<T>(v: &mut Vec<T>, budget: &mut usize) {
|
||||
if v.len() > *budget {
|
||||
v.truncate(*budget);
|
||||
}
|
||||
*budget -= v.len();
|
||||
}
|
||||
take(&mut snap.tables, &mut remaining);
|
||||
take(&mut snap.views, &mut remaining);
|
||||
take(&mut snap.sequences, &mut remaining);
|
||||
take(&mut snap.enums, &mut remaining);
|
||||
total = SNAPSHOT_CAP;
|
||||
}
|
||||
snap.object_count = total;
|
||||
snap.truncated = over;
|
||||
}
|
||||
|
||||
fn csv_names(s: &str) -> Vec<String> {
|
||||
s.split(", ").map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect()
|
||||
}
|
||||
|
||||
/// Capture a PostgreSQL schema snapshot (six set-based queries, one per class).
|
||||
pub(crate) async fn capture_pg(
|
||||
client: &tokio_postgres::Client,
|
||||
engine: &str,
|
||||
schema: &str,
|
||||
) -> Result<SchemaSnapshot, String> {
|
||||
let mut tables: BTreeMap<String, SnapshotTable> = BTreeMap::new();
|
||||
let mut views: Vec<SnapshotView> = Vec::new();
|
||||
let mut sequences: Vec<SnapshotSequence> = Vec::new();
|
||||
let mut enums: BTreeMap<String, SnapshotEnum> = BTreeMap::new();
|
||||
|
||||
// Columns
|
||||
let rows = client
|
||||
.query(&crate::db::introspection::pg_diff_columns_query(), &[&schema])
|
||||
.await
|
||||
.map_err(|e| format!("columns query failed: {e}"))?;
|
||||
for r in rows {
|
||||
let tname: String = r.get(0);
|
||||
let entry = tables.entry(tname.clone()).or_insert_with(|| SnapshotTable {
|
||||
schema: schema.to_string(),
|
||||
name: tname,
|
||||
..Default::default()
|
||||
});
|
||||
entry.columns.push(SnapshotColumn {
|
||||
name: r.get(1),
|
||||
data_type: r.get(2),
|
||||
nullable: r.get(3),
|
||||
default_value: r.get::<_, Option<String>>(4),
|
||||
});
|
||||
}
|
||||
|
||||
// Constraints (PK / UNIQUE / CHECK / FK)
|
||||
// Column order: table_name(0), constraint_name(1), contype(2), cols(3),
|
||||
// ref_cols(4), ref_table(5), confdeltype(6), confupdtype(7), definition(8)
|
||||
let rows = client
|
||||
.query(&crate::db::introspection::pg_diff_constraints_query(), &[&schema])
|
||||
.await
|
||||
.map_err(|e| format!("constraints query failed: {e}"))?;
|
||||
for r in rows {
|
||||
let tname: String = r.get(0);
|
||||
let cname: String = r.get(1);
|
||||
let contype: String = r.get(2);
|
||||
let Some(entry) = tables.get_mut(&tname) else { continue };
|
||||
match contype.as_str() {
|
||||
"p" => entry.primary_key = csv_names(&r.get::<_, Option<String>>(3).unwrap_or_default()),
|
||||
"u" => entry.uniques.push(SnapshotConstraint {
|
||||
name: cname,
|
||||
kind: "unique".into(),
|
||||
definition: r.get::<_, Option<String>>(3).unwrap_or_default(),
|
||||
}),
|
||||
"c" => entry.checks.push(SnapshotConstraint {
|
||||
name: cname,
|
||||
kind: "check".into(),
|
||||
definition: r.get::<_, Option<String>>(8).unwrap_or_default(),
|
||||
}),
|
||||
"f" => {
|
||||
let ref_qualified: Option<String> = r.get(5);
|
||||
let (ref_schema, ref_table) = ref_qualified
|
||||
.map(|q| match q.split_once('.') {
|
||||
Some((s, t)) => (s.to_string(), t.to_string()),
|
||||
None => (String::new(), q.clone()),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
entry.foreign_keys.push(SnapshotFk {
|
||||
name: cname,
|
||||
columns: csv_names(&r.get::<_, Option<String>>(3).unwrap_or_default()),
|
||||
ref_schema,
|
||||
ref_table,
|
||||
ref_columns: csv_names(&r.get::<_, Option<String>>(4).unwrap_or_default()),
|
||||
on_delete: pg_fk_action(&r.get::<_, Option<String>>(6).unwrap_or_default()).to_string(),
|
||||
on_update: pg_fk_action(&r.get::<_, Option<String>>(7).unwrap_or_default()).to_string(),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Indexes
|
||||
let rows = client
|
||||
.query(&crate::db::introspection::pg_diff_indexes_query(), &[&schema])
|
||||
.await
|
||||
.map_err(|e| format!("indexes query failed: {e}"))?;
|
||||
for r in rows {
|
||||
let tname: String = r.get(0);
|
||||
let Some(entry) = tables.get_mut(&tname) else { continue };
|
||||
entry.indexes.push(SnapshotIndex {
|
||||
name: r.get(1),
|
||||
columns: csv_names(&r.get::<_, Option<String>>(3).unwrap_or_default()),
|
||||
unique: r.get(2),
|
||||
});
|
||||
}
|
||||
|
||||
// Views + matviews
|
||||
let rows = client
|
||||
.query(&crate::db::introspection::pg_diff_views_query(), &[&schema])
|
||||
.await
|
||||
.map_err(|e| format!("views query failed: {e}"))?;
|
||||
for r in rows {
|
||||
views.push(SnapshotView {
|
||||
schema: r.get(0),
|
||||
name: r.get(1),
|
||||
definition: r.get(2),
|
||||
materialized: r.get(3),
|
||||
});
|
||||
}
|
||||
|
||||
// Sequences
|
||||
let rows = client
|
||||
.query(&crate::db::introspection::pg_diff_sequences_query(), &[&schema])
|
||||
.await
|
||||
.map_err(|e| format!("sequences query failed: {e}"))?;
|
||||
for r in rows {
|
||||
sequences.push(SnapshotSequence {
|
||||
schema: r.get(0),
|
||||
name: r.get(1),
|
||||
start: r.get(2),
|
||||
increment: r.get(3),
|
||||
minimum: r.get(4),
|
||||
maximum: r.get(5),
|
||||
cycle: r.get(6),
|
||||
});
|
||||
}
|
||||
|
||||
// Enums (one row per label — aggregate by name)
|
||||
let rows = client
|
||||
.query(&crate::db::introspection::pg_diff_enums_query(), &[&schema])
|
||||
.await
|
||||
.map_err(|e| format!("enums query failed: {e}"))?;
|
||||
for r in rows {
|
||||
let ename: String = r.get(1);
|
||||
let entry = enums.entry(ename.clone()).or_insert_with(|| SnapshotEnum {
|
||||
schema: r.get(0),
|
||||
name: ename,
|
||||
labels: Vec::new(),
|
||||
});
|
||||
entry.labels.push(r.get(2));
|
||||
}
|
||||
|
||||
let mut snap = SchemaSnapshot {
|
||||
engine: engine.to_string(),
|
||||
schema: schema.to_string(),
|
||||
tables: tables.into_values().collect(),
|
||||
views,
|
||||
sequences,
|
||||
enums: enums.into_values().collect(),
|
||||
object_count: 0,
|
||||
truncated: false,
|
||||
};
|
||||
apply_snapshot_cap(&mut snap);
|
||||
Ok(snap)
|
||||
}
|
||||
|
||||
/// Capture a SQLite (in-file/in-memory) snapshot.
|
||||
pub(crate) fn capture_sqlite(conn: &rusqlite::Connection) -> Result<SchemaSnapshot, String> {
|
||||
crate::db::schema_diff::capture::sqlite(conn)
|
||||
}
|
||||
|
||||
/// Capture a MySQL/MariaDB snapshot.
|
||||
pub(crate) async fn capture_mysql(
|
||||
conn: &mut sqlx::mysql::MySqlConnection,
|
||||
engine: &str,
|
||||
schema: &str,
|
||||
) -> Result<SchemaSnapshot, String> {
|
||||
crate::db::schema_diff::capture::mysql(conn, engine, schema).await
|
||||
}
|
||||
|
||||
/// Dispatch a snapshot capture across engines. `engine` is the CONNECTION's
|
||||
/// db_type (mariadb vs mysql matters for the diff dialect).
|
||||
pub(crate) async fn capture_snapshot(
|
||||
handle: &mut DbHandle,
|
||||
engine: &str,
|
||||
schema: &str,
|
||||
) -> Result<SchemaSnapshot, String> {
|
||||
match handle {
|
||||
DbHandle::Postgresql(client, _) => capture_pg(client, engine, schema).await,
|
||||
DbHandle::MySql(pool) => {
|
||||
let mut conn = pool
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|e| format!("source/target connection failed: {e}"))?;
|
||||
capture_mysql(&mut *conn, engine, schema).await
|
||||
}
|
||||
DbHandle::Sqlite(conn) => capture_sqlite(conn),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) mod capture {
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn sqlite(conn: &rusqlite::Connection) -> Result<SchemaSnapshot, String> {
|
||||
let mut snap = SchemaSnapshot {
|
||||
engine: "sqlite".into(),
|
||||
schema: "main".into(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT type, name, sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' AND type IN ('table', 'view') ORDER BY name")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let objects: Vec<(String, String, Option<String>)> = stmt
|
||||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
for (otype, name, sql) in objects {
|
||||
if otype == "view" {
|
||||
snap.views.push(SnapshotView {
|
||||
schema: "main".into(),
|
||||
name,
|
||||
definition: sql.unwrap_or_default(),
|
||||
materialized: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let mut table = SnapshotTable {
|
||||
schema: "main".into(),
|
||||
name: name.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
// Columns + PK + defaults
|
||||
let mut cols = conn
|
||||
.prepare(&format!("PRAGMA table_info({})", quote_double(&name)))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let col_rows: Vec<(String, String, i64, Option<String>, i64)> = cols
|
||||
.query_map([], |r| Ok((r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)))
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
drop(cols);
|
||||
for (cname, ctype, notnull, default, pk) in col_rows {
|
||||
if pk > 0 {
|
||||
table.primary_key.push(cname.clone());
|
||||
}
|
||||
table.columns.push(SnapshotColumn {
|
||||
name: cname,
|
||||
data_type: ctype,
|
||||
nullable: notnull == 0,
|
||||
default_value: default,
|
||||
});
|
||||
}
|
||||
// Indexes (skip the PK-mandated sqlite_autoindex_*)
|
||||
let mut idx = conn
|
||||
.prepare(&format!("PRAGMA index_list({})", quote_double(&name)))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let idx_rows: Vec<(String, i64)> = idx
|
||||
.query_map([], |r| Ok((r.get(1)?, r.get(2)?)))
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
drop(idx);
|
||||
for (iname, unique) in idx_rows {
|
||||
if iname.starts_with("sqlite_autoindex") {
|
||||
continue;
|
||||
}
|
||||
let mut info = conn
|
||||
.prepare(&format!("PRAGMA index_info({})", quote_double(&iname)))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let icols: Vec<String> = info
|
||||
.query_map([], |r| r.get::<_, String>(2))
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
drop(info);
|
||||
table.indexes.push(SnapshotIndex {
|
||||
name: iname,
|
||||
columns: icols,
|
||||
unique: unique != 0,
|
||||
});
|
||||
}
|
||||
// Foreign keys
|
||||
let mut fks = conn
|
||||
.prepare(&format!("PRAGMA foreign_key_list({})", quote_double(&name)))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let fk_rows: Vec<(i64, i64, String, String, String, String, String)> = fks
|
||||
.query_map([], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?))
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
drop(fks);
|
||||
// (id, seq, table, from, to, on_update, on_delete) — group by id
|
||||
let mut grouped: BTreeMap<i64, SnapshotFk> = BTreeMap::new();
|
||||
for (id, _seq, reftable, from, to, on_update, on_delete) in fk_rows {
|
||||
let fk = grouped.entry(id).or_insert_with(|| SnapshotFk {
|
||||
name: format!("fk_{name}_{id}"),
|
||||
columns: Vec::new(),
|
||||
ref_schema: "main".into(),
|
||||
ref_table: reftable,
|
||||
ref_columns: Vec::new(),
|
||||
on_delete: String::new(),
|
||||
on_update: String::new(),
|
||||
});
|
||||
fk.columns.push(from);
|
||||
if !to.is_empty() {
|
||||
fk.ref_columns.push(to);
|
||||
}
|
||||
if !on_delete.is_empty() {
|
||||
fk.on_delete = on_delete.to_uppercase();
|
||||
}
|
||||
if !on_update.is_empty() {
|
||||
fk.on_update = on_update.to_uppercase();
|
||||
}
|
||||
}
|
||||
table.foreign_keys = grouped.into_values().collect();
|
||||
snap.tables.push(table);
|
||||
}
|
||||
|
||||
apply_snapshot_cap(&mut snap);
|
||||
Ok(snap)
|
||||
}
|
||||
|
||||
pub(crate) async fn mysql(
|
||||
conn: &mut sqlx::mysql::MySqlConnection,
|
||||
engine: &str,
|
||||
schema: &str,
|
||||
) -> Result<SchemaSnapshot, String> {
|
||||
use crate::db::mysql::{mysql_row_string, mysql_snapshot_columns_query, mysql_snapshot_constraints_query, mysql_snapshot_indexes_query, mysql_snapshot_views_query};
|
||||
|
||||
let mut tables: BTreeMap<String, SnapshotTable> = BTreeMap::new();
|
||||
let mut views: Vec<SnapshotView> = Vec::new();
|
||||
|
||||
let rows = sqlx::query(&mysql_snapshot_columns_query(schema))
|
||||
.fetch_all(&mut *conn)
|
||||
.await
|
||||
.map_err(|e| format!("columns query failed: {e}"))?;
|
||||
for r in rows {
|
||||
let tname = mysql_row_string(&r, 0);
|
||||
let entry = tables.entry(tname.clone()).or_insert_with(|| SnapshotTable {
|
||||
schema: schema.to_string(),
|
||||
name: tname,
|
||||
..Default::default()
|
||||
});
|
||||
entry.columns.push(SnapshotColumn {
|
||||
name: mysql_row_string(&r, 1),
|
||||
data_type: mysql_row_string(&r, 2),
|
||||
nullable: mysql_row_string(&r, 3) == "YES",
|
||||
default_value: (!mysql_row_string(&r, 4).is_empty()).then(|| mysql_row_string(&r, 4)),
|
||||
});
|
||||
}
|
||||
|
||||
let rows = sqlx::query(&mysql_snapshot_constraints_query(schema))
|
||||
.fetch_all(&mut *conn)
|
||||
.await
|
||||
.map_err(|e| format!("constraints query failed: {e}"))?;
|
||||
for r in rows {
|
||||
let tname = mysql_row_string(&r, 0);
|
||||
let cname = mysql_row_string(&r, 1);
|
||||
let ctype = mysql_row_string(&r, 2);
|
||||
let col = mysql_row_string(&r, 3);
|
||||
let Some(entry) = tables.get_mut(&tname) else { continue };
|
||||
match ctype.as_str() {
|
||||
"PRIMARY KEY" => {
|
||||
if !entry.primary_key.contains(&col) {
|
||||
entry.primary_key.push(col);
|
||||
}
|
||||
}
|
||||
"UNIQUE" => {
|
||||
if let Some(u) = entry.uniques.iter_mut().find(|u| u.name == cname) {
|
||||
u.definition = if u.definition.is_empty() { col.clone() } else { format!("{}, {col}", u.definition) };
|
||||
} else {
|
||||
entry.uniques.push(SnapshotConstraint {
|
||||
name: cname,
|
||||
kind: "unique".into(),
|
||||
definition: col,
|
||||
});
|
||||
}
|
||||
}
|
||||
"FOREIGN KEY" => {
|
||||
let ref_table = mysql_row_string(&r, 4);
|
||||
let on_delete = mysql_row_string(&r, 5).to_uppercase();
|
||||
let on_update = mysql_row_string(&r, 6).to_uppercase();
|
||||
if let Some(fk) = entry.foreign_keys.iter_mut().find(|f| f.name == cname) {
|
||||
fk.columns.push(col);
|
||||
} else {
|
||||
entry.foreign_keys.push(SnapshotFk {
|
||||
name: cname,
|
||||
columns: vec![col],
|
||||
ref_schema: String::new(),
|
||||
ref_table,
|
||||
ref_columns: Vec::new(),
|
||||
on_delete,
|
||||
on_update,
|
||||
});
|
||||
}
|
||||
}
|
||||
"CHECK" => {
|
||||
if let Some(c) = entry.checks.iter_mut().find(|c| c.name == cname) {
|
||||
c.definition = format!("{} {}", c.definition, mysql_row_string(&r, 7));
|
||||
} else {
|
||||
entry.checks.push(SnapshotConstraint {
|
||||
name: cname,
|
||||
kind: "check".into(),
|
||||
definition: mysql_row_string(&r, 7),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let rows = sqlx::query(&mysql_snapshot_indexes_query(schema))
|
||||
.fetch_all(&mut *conn)
|
||||
.await
|
||||
.map_err(|e| format!("indexes query failed: {e}"))?;
|
||||
for r in rows {
|
||||
let tname = mysql_row_string(&r, 0);
|
||||
let iname = mysql_row_string(&r, 1);
|
||||
let unique = mysql_row_string(&r, 2) == "0";
|
||||
let col = mysql_row_string(&r, 3);
|
||||
let Some(entry) = tables.get_mut(&tname) else { continue };
|
||||
if let Some(ix) = entry.indexes.iter_mut().find(|i| i.name == iname) {
|
||||
ix.columns.push(col);
|
||||
} else {
|
||||
entry.indexes.push(SnapshotIndex {
|
||||
name: iname,
|
||||
columns: vec![col],
|
||||
unique,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let rows = sqlx::query(&mysql_snapshot_views_query(schema))
|
||||
.fetch_all(&mut *conn)
|
||||
.await
|
||||
.map_err(|e| format!("views query failed: {e}"))?;
|
||||
for r in rows {
|
||||
views.push(SnapshotView {
|
||||
schema: mysql_row_string(&r, 0),
|
||||
name: mysql_row_string(&r, 1),
|
||||
definition: mysql_row_string(&r, 2),
|
||||
materialized: false,
|
||||
});
|
||||
}
|
||||
|
||||
let mut snap = SchemaSnapshot {
|
||||
engine: engine.to_string(),
|
||||
schema: schema.to_string(),
|
||||
tables: tables.into_values().collect(),
|
||||
views,
|
||||
sequences: Vec::new(),
|
||||
enums: Vec::new(),
|
||||
object_count: 0,
|
||||
truncated: false,
|
||||
};
|
||||
apply_snapshot_cap(&mut snap);
|
||||
Ok(snap)
|
||||
}
|
||||
}
|
||||
|
||||
/// Double-quote an identifier (PG/SQLite) with embedded-quote doubling.
|
||||
pub(crate) fn quote_double(name: &str) -> String {
|
||||
format!("\"{}\"", name.replace('"', "\"\""))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure diff engine (spec §4/§5A)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Destructive classification (binding): DROP TABLE/VIEW/INDEX/SEQUENCE/TYPE/
|
||||
// CONSTRAINT, DROP COLUMN, and ALTER COLUMN TYPE are destructive (copy-only);
|
||||
// everything else (CREATE, ADD COLUMN, ADD CONSTRAINT, RENAME, SET/DROP
|
||||
// DEFAULT, SET/DROP NOT NULL) is stageable.
|
||||
|
||||
pub(crate) fn engine_family(engine: &str) -> &'static str {
|
||||
match engine {
|
||||
"mysql" | "mariadb" => "mysql",
|
||||
"sqlite" => "sqlite",
|
||||
_ => "postgresql",
|
||||
}
|
||||
}
|
||||
|
||||
fn quote(family: &str, name: &str) -> String {
|
||||
match family {
|
||||
"mysql" => format!("`{}`", name.replace('`', "``")),
|
||||
_ => format!("\"{}\"", name.replace('"', "\"\"")),
|
||||
}
|
||||
}
|
||||
|
||||
fn qualified(family: &str, schema: &str, name: &str) -> String {
|
||||
match family {
|
||||
"mysql" => format!("`{}`.`{}`", schema.replace('`', "``"), name.replace('`', "``")),
|
||||
"sqlite" => quote(family, name),
|
||||
_ => format!("\"{}\".\"{}\"", schema.replace('"', "\"\""), name.replace('"', "\"\"")),
|
||||
}
|
||||
}
|
||||
|
||||
fn detail_line(label: &str, old: &str, new: &str) -> DiffDetailLine {
|
||||
DiffDetailLine { label: label.into(), old: old.into(), new: new.into() }
|
||||
}
|
||||
|
||||
fn item(ty: &str, name: &str, kind: &str, detail: Vec<DiffDetailLine>, sql: Vec<String>, destructive: bool) -> DiffItem {
|
||||
DiffItem {
|
||||
object_type: ty.into(),
|
||||
name: name.into(),
|
||||
kind: kind.into(),
|
||||
detail,
|
||||
sync_sql: sql,
|
||||
destructive,
|
||||
}
|
||||
}
|
||||
|
||||
/// Diff two snapshots into a report. Sync SQL is in the TARGET's dialect.
|
||||
pub fn diff_snapshots(source: &SchemaSnapshot, target: &SchemaSnapshot) -> DiffReport {
|
||||
let family = engine_family(&target.engine);
|
||||
let schema = &target.schema;
|
||||
let mut items: Vec<DiffItem> = Vec::new();
|
||||
|
||||
// ── Enums (PG) — must come first (tables may depend on them) ──────────
|
||||
for (s_enum, t_enum) in enum_pairs(&source.enums, &target.enums) {
|
||||
match (s_enum, t_enum) {
|
||||
(Some(s), None) => items.push(item(
|
||||
"enum", &s.name, "added", vec![],
|
||||
vec![format!(
|
||||
"CREATE TYPE {}.{} AS ENUM ({});",
|
||||
quote(family, &s.schema),
|
||||
quote(family, &s.name),
|
||||
s.labels.iter().map(|l| format!("'{}'", l.replace('\'', "''"))).collect::<Vec<_>>().join(", ")
|
||||
)],
|
||||
false,
|
||||
)),
|
||||
(None, Some(t)) => items.push(item(
|
||||
"enum", &t.name, "removed", vec![],
|
||||
vec![format!("DROP TYPE {}.{};", quote(family, &t.schema), quote(family, &t.name))],
|
||||
true,
|
||||
)),
|
||||
(Some(s), Some(t)) => {
|
||||
let mut added = Vec::new();
|
||||
let mut removed_or_reordered = false;
|
||||
for l in &s.labels {
|
||||
if !t.labels.contains(l) { added.push(l.clone()); }
|
||||
}
|
||||
if t.labels.iter().any(|l| !s.labels.contains(l)) || !common_prefix(&s.labels, &t.labels) {
|
||||
removed_or_reordered = true;
|
||||
}
|
||||
if !added.is_empty() {
|
||||
if removed_or_reordered {
|
||||
items.push(item("enum", &s.name, "changed", vec![detail_line("labels", &t.labels.join(", "), &s.labels.join(", "))], vec![], true));
|
||||
} else {
|
||||
items.push(item(
|
||||
"enum", &s.name, "changed",
|
||||
vec![detail_line("added labels", "", &added.join(", "))],
|
||||
added.iter().map(|l| format!("ALTER TYPE {}.{} ADD VALUE '{}';", quote(family, &s.schema), quote(family, &s.name), l.replace('\'', "''"))).collect(),
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sequences (PG) ─────────────────────────────────────────────────────
|
||||
for (s_seq, t_seq) in pair_by(&source.sequences, &target.sequences, |s| s.name.clone()) {
|
||||
match (s_seq, t_seq) {
|
||||
(Some(s), None) => items.push(item(
|
||||
"sequence", &s.name, "added", vec![],
|
||||
vec![format!(
|
||||
"CREATE SEQUENCE {q} START WITH {start} INCREMENT BY {inc} MINVALUE {min} MAXVALUE {max} {cycle};",
|
||||
q = qualified(family, schema, &s.name),
|
||||
start = s.start, inc = s.increment, min = s.minimum, max = s.maximum,
|
||||
cycle = if s.cycle { "CYCLE" } else { "NO CYCLE" }
|
||||
)],
|
||||
false,
|
||||
)),
|
||||
(None, Some(t)) => items.push(item(
|
||||
"sequence", &t.name, "removed", vec![],
|
||||
vec![format!("DROP SEQUENCE {};", qualified(family, schema, &t.name))],
|
||||
true,
|
||||
)),
|
||||
(Some(s), Some(t)) => {
|
||||
let mut sql = String::new();
|
||||
let mut detail = Vec::new();
|
||||
for (label, sv, tv) in [
|
||||
("start", &s.start, &t.start),
|
||||
("increment", &s.increment, &t.increment),
|
||||
("minimum", &s.minimum, &t.minimum),
|
||||
("maximum", &s.maximum, &t.maximum),
|
||||
] {
|
||||
if sv != tv {
|
||||
detail.push(detail_line(label, tv, sv));
|
||||
if !sql.is_empty() { sql.push(' '); }
|
||||
match label {
|
||||
"start" => sql.push_str(&format!("START WITH {sv}")),
|
||||
"increment" => sql.push_str(&format!("INCREMENT BY {sv}")),
|
||||
"minimum" => sql.push_str(&format!("MINVALUE {sv}")),
|
||||
"maximum" => sql.push_str(&format!("MAXVALUE {sv}")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.cycle != t.cycle {
|
||||
detail.push(detail_line("cycle", &t.cycle.to_string(), &s.cycle.to_string()));
|
||||
if !sql.is_empty() { sql.push(' '); }
|
||||
sql.push_str(if s.cycle { "CYCLE" } else { "NO CYCLE" });
|
||||
}
|
||||
if !sql.is_empty() {
|
||||
items.push(item("sequence", &s.name, "changed", detail, vec![format!("ALTER SEQUENCE {} {};", qualified(family, schema, &s.name), sql)], false));
|
||||
}
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tables ─────────────────────────────────────────────────────────────
|
||||
for (s_t, t_t) in pair_by(&source.tables, &target.tables, |t| t.name.clone()) {
|
||||
match (s_t, t_t) {
|
||||
(Some(s), None) => {
|
||||
let mut defs: Vec<String> = s.columns.iter().map(|c| {
|
||||
let mut d = format!("{} {}", quote(family, &c.name), c.data_type);
|
||||
if !c.nullable { d.push_str(" NOT NULL"); }
|
||||
if let Some(def) = &c.default_value { d.push_str(&format!(" DEFAULT {def}")); }
|
||||
d
|
||||
}).collect();
|
||||
if !s.primary_key.is_empty() {
|
||||
defs.push(format!("PRIMARY KEY ({})", s.primary_key.iter().map(|p| quote(family, p)).collect::<Vec<_>>().join(", ")));
|
||||
}
|
||||
items.push(item(
|
||||
"table", &s.name, "added", vec![],
|
||||
vec![format!("CREATE TABLE {} ({});", qualified(family, schema, &s.name), defs.join(", "))],
|
||||
false,
|
||||
));
|
||||
push_constraint_items(&mut items, family, schema, s, "added");
|
||||
}
|
||||
(None, Some(t)) => items.push(item(
|
||||
"table", &t.name, "removed", vec![],
|
||||
vec![format!("DROP TABLE {};", qualified(family, schema, &t.name))],
|
||||
true,
|
||||
)),
|
||||
(Some(s), Some(t)) => diff_table(&mut items, family, schema, s, t),
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Views ──────────────────────────────────────────────────────────────
|
||||
for (s_v, t_v) in pair_by(&source.views, &target.views, |v| v.name.clone()) {
|
||||
match (s_v, t_v) {
|
||||
(Some(s), None) => items.push(item(
|
||||
"view", &s.name, "added", vec![],
|
||||
vec![format!("CREATE {}VIEW {} AS {};", if s.materialized { "MATERIALIZED " } else { "" }, qualified(family, schema, &s.name), s.definition.trim_end_matches(';'))],
|
||||
false,
|
||||
)),
|
||||
(None, Some(t)) => items.push(item(
|
||||
"view", &t.name, "removed", vec![],
|
||||
vec![format!("DROP {}VIEW {};", if t.materialized { "MATERIALIZED " } else { "" }, qualified(family, schema, &t.name))],
|
||||
true,
|
||||
)),
|
||||
(Some(s), Some(t)) if s.definition.trim() != t.definition.trim() => {
|
||||
if family == "postgresql" || family == "mysql" {
|
||||
if s.materialized || t.materialized {
|
||||
items.push(item("view", &s.name, "changed", vec![detail_line("definition", &t.definition, &s.definition)],
|
||||
vec![format!("DROP MATERIALIZED VIEW {q}; CREATE MATERIALIZED VIEW {q} AS {d};", q = qualified(family, schema, &s.name), d = s.definition.trim_end_matches(';'))], true));
|
||||
} else {
|
||||
items.push(item("view", &s.name, "changed", vec![detail_line("definition", &t.definition, &s.definition)],
|
||||
vec![format!("CREATE OR REPLACE VIEW {} AS {};", qualified(family, schema, &s.name), s.definition.trim_end_matches(';'))], false));
|
||||
}
|
||||
} else {
|
||||
items.push(item("view", &s.name, "changed", vec![detail_line("definition", &t.definition, &s.definition)], vec![], true));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
DiffReport {
|
||||
items,
|
||||
source_label: format!("{} ({})", source.schema, source.engine),
|
||||
target_label: format!("{} ({})", target.schema, target.engine),
|
||||
truncated: source.truncated || target.truncated,
|
||||
}
|
||||
}
|
||||
|
||||
/// Items safe to stage: non-destructive AND carrying SQL.
|
||||
pub fn stageable_items(report: &DiffReport) -> Vec<&DiffItem> {
|
||||
report.items.iter().filter(|i| !i.destructive && !i.sync_sql.is_empty()).collect()
|
||||
}
|
||||
|
||||
fn common_prefix(source: &[String], target: &[String]) -> bool {
|
||||
let mut ti = 0usize;
|
||||
for l in target {
|
||||
if source.contains(l) {
|
||||
if ti >= source.len() || &source[ti] != l {
|
||||
return false;
|
||||
}
|
||||
ti += 1;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Pair two keyed lists into (source, target) options by key.
|
||||
fn pair_by<'a, T, K: Ord + Clone + Eq>(src: &'a [T], tgt: &'a [T], key: impl Fn(&T) -> K) -> Vec<(Option<&'a T>, Option<&'a T>)> {
|
||||
use std::collections::BTreeMap;
|
||||
let mut map: BTreeMap<K, (Option<&T>, Option<&T>)> = BTreeMap::new();
|
||||
for s in src { map.entry(key(s)).or_insert((None, None)).0 = Some(s); }
|
||||
for t in tgt { map.entry(key(t)).or_insert((None, None)).1 = Some(t); }
|
||||
map.into_values().collect()
|
||||
}
|
||||
|
||||
fn enum_pairs<'a>(src: &'a [SnapshotEnum], tgt: &'a [SnapshotEnum]) -> Vec<(Option<&'a SnapshotEnum>, Option<&'a SnapshotEnum>)> {
|
||||
pair_by(src, tgt, |e| e.name.clone())
|
||||
}
|
||||
|
||||
fn push_constraint_items(items: &mut Vec<DiffItem>, family: &str, schema: &str, table: &SnapshotTable, kind: &str) {
|
||||
let qtn = qualified(family, schema, &table.name);
|
||||
for fk in &table.foreign_keys {
|
||||
items.push(item(
|
||||
"constraint", &format!("{}.{}", table.name, fk.name), kind, vec![],
|
||||
vec![constraint_fk_sql(family, schema, &qtn, fk)],
|
||||
false,
|
||||
));
|
||||
}
|
||||
for u in &table.uniques {
|
||||
let cols = u.definition.split(", ").map(|c| quote(family, c.trim())).collect::<Vec<_>>().join(", ");
|
||||
items.push(item(
|
||||
"constraint", &format!("{}.{}", table.name, u.name), kind, vec![],
|
||||
vec![format!("ALTER TABLE {qtn} ADD CONSTRAINT {n} UNIQUE ({cols});", n = quote(family, &u.name), cols = cols)],
|
||||
false,
|
||||
));
|
||||
}
|
||||
for c in &table.checks {
|
||||
items.push(item(
|
||||
"constraint", &format!("{}.{}", table.name, c.name), kind, vec![],
|
||||
vec![format!("ALTER TABLE {qtn} ADD CONSTRAINT {n} CHECK ({d});", n = quote(family, &c.name), d = c.definition.trim())],
|
||||
false,
|
||||
));
|
||||
}
|
||||
for ix in &table.indexes {
|
||||
let cols = ix.columns.iter().map(|c| quote(family, c)).collect::<Vec<_>>().join(", ");
|
||||
items.push(item(
|
||||
"index", &format!("{}.{}", table.name, ix.name), kind, vec![],
|
||||
vec![format!("CREATE {u}INDEX {n} ON {qtn} ({cols});", u = if ix.unique { "UNIQUE " } else { "" }, n = quote(family, &ix.name), cols = cols)],
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn constraint_fk_sql(family: &str, _schema: &str, qtn: &str, fk: &SnapshotFk) -> String {
|
||||
let cols = fk.columns.iter().map(|c| quote(family, c)).collect::<Vec<_>>().join(", ");
|
||||
let ref_cols = fk.ref_columns.iter().map(|c| quote(family, c)).collect::<Vec<_>>().join(", ");
|
||||
let ref_qualified = match family {
|
||||
"mysql" => format!("`{}`", fk.ref_table.replace('`', "``")),
|
||||
"sqlite" => quote(family, &fk.ref_table),
|
||||
_ => qualified(family, &fk.ref_schema, &fk.ref_table),
|
||||
};
|
||||
format!(
|
||||
"ALTER TABLE {qtn} ADD CONSTRAINT {n} FOREIGN KEY ({cols}) REFERENCES {r} ({rc}) ON DELETE {od} ON UPDATE {ou};",
|
||||
n = quote(family, &fk.name), cols = cols, r = ref_qualified, rc = ref_cols,
|
||||
od = fk.on_delete, ou = fk.on_update
|
||||
)
|
||||
}
|
||||
|
||||
fn diff_table(items: &mut Vec<DiffItem>, family: &str, schema: &str, s: &SnapshotTable, t: &SnapshotTable) {
|
||||
let qtn = qualified(family, schema, &s.name);
|
||||
|
||||
for (s_c, t_c) in pair_by(&s.columns, &t.columns, |c| c.name.clone()) {
|
||||
match (s_c, t_c) {
|
||||
(Some(c), None) => {
|
||||
let mut sql = format!("ALTER TABLE {qtn} ADD COLUMN {n} {ty}", n = quote(family, &c.name), ty = c.data_type);
|
||||
if !c.nullable {
|
||||
if family == "sqlite" && c.default_value.is_none() {
|
||||
items.push(item("column", &format!("{}.{}", s.name, c.name), "added",
|
||||
vec![detail_line("nullable", "false", "true (SQLite cannot ADD COLUMN NOT NULL without DEFAULT)")],
|
||||
vec![format!("{sql};")], false));
|
||||
continue;
|
||||
}
|
||||
sql.push_str(" NOT NULL");
|
||||
}
|
||||
if let Some(d) = &c.default_value { sql.push_str(&format!(" DEFAULT {d}")); }
|
||||
items.push(item("column", &format!("{}.{}", s.name, c.name), "added", vec![], vec![format!("{sql};")], false));
|
||||
}
|
||||
(None, Some(c)) => items.push(item(
|
||||
"column", &format!("{}.{}", s.name, c.name), "removed", vec![],
|
||||
vec![format!("ALTER TABLE {qtn} DROP COLUMN {n};", n = quote(family, &c.name))],
|
||||
true,
|
||||
)),
|
||||
(Some(sc), Some(tc)) => {
|
||||
if sc.data_type != tc.data_type {
|
||||
let sql = match family {
|
||||
"postgresql" => format!("ALTER TABLE {qtn} ALTER COLUMN {n} TYPE {ty} USING {n}::{ty};", n = quote(family, &sc.name), ty = sc.data_type),
|
||||
"mysql" => format!("ALTER TABLE {qtn} MODIFY COLUMN {n} {ty}{nn};", n = quote(family, &sc.name), ty = sc.data_type, nn = if sc.nullable { String::new() } else { " NOT NULL".into() }),
|
||||
_ => String::new(),
|
||||
};
|
||||
items.push(item("column", &format!("{}.{}", s.name, sc.name), "changed",
|
||||
vec![detail_line("type", &tc.data_type, &sc.data_type)],
|
||||
if sql.is_empty() { vec![] } else { vec![sql] }, true));
|
||||
}
|
||||
if sc.nullable != tc.nullable {
|
||||
let sql = match family {
|
||||
"postgresql" => format!("ALTER TABLE {qtn} ALTER COLUMN {n} {op};", n = quote(family, &sc.name), op = if sc.nullable { "DROP NOT NULL" } else { "SET NOT NULL" }),
|
||||
"mysql" => format!("ALTER TABLE {qtn} MODIFY COLUMN {n} {ty}{nn};", n = quote(family, &sc.name), ty = sc.data_type, nn = if sc.nullable { String::new() } else { " NOT NULL".into() }),
|
||||
_ => String::new(),
|
||||
};
|
||||
if !sql.is_empty() {
|
||||
items.push(item("column", &format!("{}.{}", s.name, sc.name), "changed",
|
||||
vec![detail_line("nullable", &tc.nullable.to_string(), &sc.nullable.to_string())],
|
||||
vec![sql], false));
|
||||
}
|
||||
}
|
||||
if sc.default_value != tc.default_value {
|
||||
let sql = match family {
|
||||
"postgresql" => match &sc.default_value {
|
||||
Some(d) => format!("ALTER TABLE {qtn} ALTER COLUMN {n} SET DEFAULT {d};", n = quote(family, &sc.name)),
|
||||
None => format!("ALTER TABLE {qtn} ALTER COLUMN {n} DROP DEFAULT;", n = quote(family, &sc.name)),
|
||||
},
|
||||
"mysql" => match &sc.default_value {
|
||||
Some(d) => format!("ALTER TABLE {qtn} ALTER COLUMN {n} SET DEFAULT {d};", n = quote(family, &sc.name)),
|
||||
None => format!("ALTER TABLE {qtn} ALTER COLUMN {n} DROP DEFAULT;", n = quote(family, &sc.name)),
|
||||
},
|
||||
_ => String::new(),
|
||||
};
|
||||
if !sql.is_empty() {
|
||||
items.push(item("column", &format!("{}.{}", s.name, sc.name), "changed",
|
||||
vec![detail_line("default", &tc.default_value.as_deref().unwrap_or_default(), &sc.default_value.clone().unwrap_or_default())],
|
||||
vec![sql], false));
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
|
||||
if s.primary_key != t.primary_key {
|
||||
items.push(item("table", &s.name, "changed",
|
||||
vec![detail_line("primary key", &t.primary_key.join(", "), &s.primary_key.join(", "))],
|
||||
vec![], true));
|
||||
}
|
||||
|
||||
for (s_c, t_c) in pair_by(&s.uniques, &t.uniques, |c| c.name.clone()) {
|
||||
push_constraint_delta(items, family, s, "UNIQUE", s_c, t_c);
|
||||
}
|
||||
for (s_c, t_c) in pair_by(&s.checks, &t.checks, |c| c.name.clone()) {
|
||||
push_constraint_delta(items, family, s, "CHECK", s_c, t_c);
|
||||
}
|
||||
|
||||
for (s_f, t_f) in pair_by(&s.foreign_keys, &t.foreign_keys, |f| f.name.clone()) {
|
||||
let qtn2 = qualified(family, schema, &s.name);
|
||||
match (s_f, t_f) {
|
||||
(Some(f), None) => {
|
||||
if family == "sqlite" {
|
||||
items.push(item("constraint", &format!("{}.{}", s.name, f.name), "added",
|
||||
vec![detail_line("note", "", "SQLite cannot add foreign keys to existing tables — table rebuild required")],
|
||||
vec![], false));
|
||||
} else {
|
||||
items.push(item("constraint", &format!("{}.{}", s.name, f.name), "added", vec![],
|
||||
vec![constraint_fk_sql(family, schema, &qtn2, f)], false));
|
||||
}
|
||||
}
|
||||
(None, Some(f)) => items.push(item(
|
||||
"constraint", &format!("{}.{}", s.name, f.name), "removed", vec![],
|
||||
vec![format!("ALTER TABLE {qtn2} DROP CONSTRAINT {n};", n = quote(family, &f.name))],
|
||||
true,
|
||||
)),
|
||||
(Some(sf), Some(tf)) if sf.definition_differs(tf) => {
|
||||
items.push(item("constraint", &format!("{}.{}", s.name, sf.name), "changed",
|
||||
vec![detail_line("definition", &tf.on_delete, &sf.on_delete)],
|
||||
vec![format!("ALTER TABLE {qtn2} DROP CONSTRAINT {n}; ALTER TABLE {qtn2} ADD CONSTRAINT {n2} FOREIGN KEY ({cols}) REFERENCES {r} ({rc}) ON DELETE {od} ON UPDATE {ou};",
|
||||
n = quote(family, &sf.name), n2 = quote(family, &sf.name),
|
||||
cols = sf.columns.iter().map(|c| quote(family, c)).collect::<Vec<_>>().join(", "),
|
||||
r = qualified(family, &sf.ref_schema, &sf.ref_table),
|
||||
rc = sf.ref_columns.iter().map(|c| quote(family, c)).collect::<Vec<_>>().join(", "),
|
||||
od = sf.on_delete, ou = sf.on_update)],
|
||||
true));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
for (s_i, t_i) in pair_by(&s.indexes, &t.indexes, |i| i.name.clone()) {
|
||||
match (s_i, t_i) {
|
||||
(Some(ix), None) => items.push(item(
|
||||
"index", &format!("{}.{}", s.name, ix.name), "added", vec![],
|
||||
vec![format!("CREATE {u}INDEX {n} ON {qtn} ({cols});", u = if ix.unique { "UNIQUE " } else { "" }, n = qualified(family, schema, &ix.name), cols = ix.columns.iter().map(|c| quote(family, c)).collect::<Vec<_>>().join(", "))],
|
||||
false,
|
||||
)),
|
||||
(None, Some(ix)) => items.push(item(
|
||||
"index", &format!("{}.{}", s.name, ix.name), "removed", vec![],
|
||||
vec![format!("DROP INDEX {n};", n = quote(family, &ix.name))],
|
||||
true,
|
||||
)),
|
||||
(Some(si), Some(ti)) if si.columns != ti.columns || si.unique != ti.unique => {
|
||||
items.push(item("index", &format!("{}.{}", s.name, si.name), "changed",
|
||||
vec![detail_line("columns", &ti.columns.join(", "), &si.columns.join(", "))],
|
||||
vec![format!("DROP INDEX {n}; CREATE {u}INDEX {n} ON {qtn} ({cols});", n = qualified(family, schema, &si.name), u = if si.unique { "UNIQUE " } else { "" }, cols = si.columns.iter().map(|c| quote(family, c)).collect::<Vec<_>>().join(", "))],
|
||||
true));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_constraint_delta(items: &mut Vec<DiffItem>, family: &str, table: &SnapshotTable, kind: &str,
|
||||
s_c: Option<&SnapshotConstraint>, t_c: Option<&SnapshotConstraint>) {
|
||||
let qtn = qualified(family, &table.schema, &table.name);
|
||||
match (s_c, t_c) {
|
||||
(Some(c), None) if kind == "UNIQUE" => items.push(item(
|
||||
"constraint", &format!("{}.{}", table.name, c.name), "added", vec![],
|
||||
vec![format!("ALTER TABLE {qtn} ADD CONSTRAINT {n} UNIQUE ({cols});", n = quote(family, &c.name), cols = c.definition.split(", ").map(|x| quote(family, x.trim())).collect::<Vec<_>>().join(", "))],
|
||||
false,
|
||||
)),
|
||||
(Some(c), None) if kind == "CHECK" => items.push(item(
|
||||
"constraint", &format!("{}.{}", table.name, c.name), "added", vec![],
|
||||
vec![format!("ALTER TABLE {qtn} ADD CONSTRAINT {n} CHECK ({d});", n = quote(family, &c.name), d = c.definition.trim())],
|
||||
false,
|
||||
)),
|
||||
(Some(c), None) if family == "sqlite" => items.push(item(
|
||||
"constraint", &format!("{}.{}", table.name, c.name), "added",
|
||||
vec![detail_line("note", "", "SQLite cannot add constraints to existing tables — table rebuild required")],
|
||||
vec![], false)),
|
||||
(None, Some(c)) => items.push(item(
|
||||
"constraint", &format!("{}.{}", table.name, c.name), "removed", vec![],
|
||||
vec![format!("ALTER TABLE {qtn} DROP CONSTRAINT {n};", n = quote(family, &c.name))],
|
||||
true,
|
||||
)),
|
||||
(Some(sc), Some(tc)) if sc.definition.trim() != tc.definition.trim() => {
|
||||
let sql = if kind == "UNIQUE" {
|
||||
format!("ALTER TABLE {qtn} DROP CONSTRAINT {n}; ALTER TABLE {qtn} ADD CONSTRAINT {n} UNIQUE ({cols});",
|
||||
n = quote(family, &sc.name), cols = sc.definition.split(", ").map(|x| quote(family, x.trim())).collect::<Vec<_>>().join(", "))
|
||||
} else {
|
||||
format!("ALTER TABLE {qtn} DROP CONSTRAINT {n}; ALTER TABLE {qtn} ADD CONSTRAINT {n} CHECK ({d});",
|
||||
n = quote(family, &sc.name), d = sc.definition.trim())
|
||||
};
|
||||
items.push(item("constraint", &format!("{}.{}", table.name, sc.name), "changed",
|
||||
vec![detail_line("definition", &tc.definition, &sc.definition)],
|
||||
vec![sql], true));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
impl SnapshotFk {
|
||||
fn definition_differs(&self, other: &SnapshotFk) -> bool {
|
||||
self.columns != other.columns
|
||||
|| self.ref_table != other.ref_table
|
||||
|| self.ref_columns != other.ref_columns
|
||||
|| self.on_delete != other.on_delete
|
||||
|| self.on_update != other.on_update
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "schema_diff.test.rs"]
|
||||
mod schema_diff_tests;
|
||||
@@ -0,0 +1,362 @@
|
||||
use super::*;
|
||||
|
||||
// ── Pure helpers (PG constraint row mapping) ────────────────────────
|
||||
|
||||
#[test]
|
||||
fn pg_fk_action_maps_codes() {
|
||||
assert_eq!(pg_fk_action("a"), "NO ACTION");
|
||||
assert_eq!(pg_fk_action("r"), "RESTRICT");
|
||||
assert_eq!(pg_fk_action("c"), "CASCADE");
|
||||
assert_eq!(pg_fk_action("n"), "SET NULL");
|
||||
assert_eq!(pg_fk_action("d"), "SET DEFAULT");
|
||||
assert_eq!(pg_fk_action("x"), "NO ACTION");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_cap_sets_truncated() {
|
||||
// 5,001 objects → truncated report, never silent (spec §2)
|
||||
let mut snap = SchemaSnapshot::default();
|
||||
snap.engine = "postgresql".into();
|
||||
snap.schema = "s".into();
|
||||
for i in 0..5_001 {
|
||||
snap.tables.push(SnapshotTable {
|
||||
schema: "s".into(),
|
||||
name: format!("t{i}"),
|
||||
columns: vec![],
|
||||
primary_key: vec![],
|
||||
uniques: vec![],
|
||||
checks: vec![],
|
||||
indexes: vec![],
|
||||
foreign_keys: vec![],
|
||||
});
|
||||
}
|
||||
apply_snapshot_cap(&mut snap);
|
||||
assert!(snap.truncated);
|
||||
assert!(snap.object_count <= 5_000);
|
||||
assert_eq!(snap.tables.len(), snap.object_count);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_builders_target_the_right_catalogs() {
|
||||
let cols = crate::db::introspection::pg_diff_columns_query();
|
||||
assert!(cols.contains("pg_attribute") && cols.contains("format_type"));
|
||||
let cons = crate::db::introspection::pg_diff_constraints_query();
|
||||
assert!(cons.contains("pg_constraint") && cons.contains("contype"));
|
||||
let idx = crate::db::introspection::pg_diff_indexes_query();
|
||||
assert!(idx.contains("pg_index") && idx.contains("indisunique"));
|
||||
let views = crate::db::introspection::pg_diff_views_query();
|
||||
assert!(views.contains("pg_views") && views.contains("pg_matviews"));
|
||||
let seqs = crate::db::introspection::pg_diff_sequences_query();
|
||||
assert!(seqs.contains("information_schema.sequences"));
|
||||
let enums = crate::db::introspection::pg_diff_enums_query();
|
||||
assert!(enums.contains("pg_type") && enums.contains("pg_enum"));
|
||||
}
|
||||
|
||||
// ── SQLite capture (in-memory, real, NOT ignored) ──────────────────
|
||||
|
||||
#[test]
|
||||
fn sqlite_capture_reads_pragmas() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL DEFAULT 'x', email TEXT);
|
||||
CREATE UNIQUE INDEX idx_users_email ON users(email);
|
||||
CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE);
|
||||
CREATE VIEW v_users AS SELECT id, name FROM users;",
|
||||
)
|
||||
.unwrap();
|
||||
let snap = capture_sqlite(&conn).expect("capture");
|
||||
assert_eq!(snap.engine, "sqlite");
|
||||
assert_eq!(snap.tables.len(), 2);
|
||||
let users = snap.tables.iter().find(|t| t.name == "users").unwrap();
|
||||
assert_eq!(users.primary_key, vec!["id"]);
|
||||
assert_eq!(users.columns.len(), 3);
|
||||
assert!(users.columns.iter().any(|c| c.name == "name" && !c.nullable));
|
||||
assert!(users.columns.iter().any(|c| c.name == "email" && c.nullable));
|
||||
assert!(users.indexes.iter().any(|i| i.name == "idx_users_email" && i.unique));
|
||||
let orders = snap.tables.iter().find(|t| t.name == "orders").unwrap();
|
||||
assert_eq!(orders.foreign_keys.len(), 1);
|
||||
let fk = &orders.foreign_keys[0];
|
||||
assert!(fk.ref_table.contains("users"));
|
||||
assert_eq!(fk.on_delete, "CASCADE");
|
||||
assert_eq!(snap.views.len(), 1);
|
||||
assert!(snap.views[0].definition.contains("SELECT"));
|
||||
}
|
||||
|
||||
/// Live PG capture — mirror the env/client conventions of the live PG tests in
|
||||
/// `commands/objects.test.rs`. Create two diverged tables in a temp schema,
|
||||
/// assert capture shape (columns with defaults, PK, FK with actions, indexes,
|
||||
/// views, a sequence, an enum).
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn pg_capture_live() {
|
||||
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 schema = "gridline_diff_test";
|
||||
client
|
||||
.batch_execute(&format!(
|
||||
"DROP SCHEMA IF EXISTS {schema} CASCADE; CREATE SCHEMA {schema};"
|
||||
))
|
||||
.await
|
||||
.expect("create temp schema");
|
||||
|
||||
// Two diverged tables: one with PK/FK/index/default, one plain.
|
||||
client
|
||||
.batch_execute(&format!(
|
||||
"CREATE TABLE {schema}.parent (id serial PRIMARY KEY, code text UNIQUE);
|
||||
CREATE TABLE {schema}.child (
|
||||
id serial PRIMARY KEY,
|
||||
parent_id integer REFERENCES {schema}.parent(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
name text NOT NULL DEFAULT 'x',
|
||||
email text
|
||||
);
|
||||
CREATE INDEX idx_child_email ON {schema}.child(email);
|
||||
CREATE VIEW {schema}.v_child AS SELECT id, name FROM {schema}.child;
|
||||
CREATE SEQUENCE {schema}.seq_test START 5 INCREMENT 2;
|
||||
CREATE TYPE {schema}.mood AS ENUM ('happy', 'sad');"
|
||||
))
|
||||
.await
|
||||
.expect("create objects");
|
||||
|
||||
let snap = capture_pg(&client, "postgresql", schema).await.expect("capture");
|
||||
assert_eq!(snap.engine, "postgresql");
|
||||
assert_eq!(snap.schema, schema);
|
||||
assert_eq!(snap.tables.len(), 2);
|
||||
|
||||
let child = snap.tables.iter().find(|t| t.name == "child").expect("child table");
|
||||
assert_eq!(child.primary_key, vec!["id"]);
|
||||
assert!(child.columns.iter().any(|c| c.name == "name" && !c.nullable && c.default_value.is_some()));
|
||||
assert!(child.columns.iter().any(|c| c.name == "email" && c.nullable));
|
||||
assert!(child.indexes.iter().any(|i| i.name == "idx_child_email"));
|
||||
assert_eq!(child.foreign_keys.len(), 1);
|
||||
let fk = &child.foreign_keys[0];
|
||||
assert!(fk.ref_table.contains("parent"));
|
||||
assert_eq!(fk.on_delete, "CASCADE");
|
||||
assert_eq!(fk.on_update, "RESTRICT");
|
||||
|
||||
let parent = snap.tables.iter().find(|t| t.name == "parent").expect("parent table");
|
||||
assert!(parent.uniques.iter().any(|u| u.kind == "unique"));
|
||||
|
||||
assert!(snap.views.iter().any(|v| v.name == "v_child"));
|
||||
assert!(snap.sequences.iter().any(|s| s.name == "seq_test" && s.start == "5"));
|
||||
assert!(snap.enums.iter().any(|e| e.name == "mood" && e.labels == vec!["happy", "sad"]));
|
||||
|
||||
client
|
||||
.batch_execute(&format!("DROP SCHEMA {schema} CASCADE;"))
|
||||
.await
|
||||
.expect("drop temp schema");
|
||||
let _ = handle;
|
||||
}
|
||||
|
||||
// ── Task 14: pure diff engine + sync-SQL generation ─────────────────
|
||||
|
||||
fn col(name: &str, ty: &str, nullable: bool, default: Option<&str>) -> SnapshotColumn {
|
||||
SnapshotColumn {
|
||||
name: name.into(),
|
||||
data_type: ty.into(),
|
||||
nullable,
|
||||
default_value: default.map(|d| d.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn table(name: &str, columns: Vec<SnapshotColumn>) -> SnapshotTable {
|
||||
SnapshotTable {
|
||||
schema: "s".into(),
|
||||
name: name.into(),
|
||||
columns,
|
||||
primary_key: vec![],
|
||||
uniques: vec![],
|
||||
checks: vec![],
|
||||
indexes: vec![],
|
||||
foreign_keys: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn pg_snap(tables: Vec<SnapshotTable>) -> SchemaSnapshot {
|
||||
let object_count = tables.len();
|
||||
SchemaSnapshot {
|
||||
engine: "postgresql".into(),
|
||||
schema: "s".into(),
|
||||
tables,
|
||||
views: vec![],
|
||||
sequences: vec![],
|
||||
enums: vec![],
|
||||
object_count,
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn added_table_generates_create_with_pk_and_is_stageable() {
|
||||
let mut src = pg_snap(vec![]);
|
||||
let mut t = table("users", vec![col("id", "integer", false, None), col("email", "text", true, Some("''"))]);
|
||||
t.primary_key = vec!["id".into()];
|
||||
src.tables.push(t);
|
||||
let tgt = pg_snap(vec![]);
|
||||
let r = diff_snapshots(&src, &tgt);
|
||||
let item = r.items.iter().find(|i| i.object_type == "table" && i.name == "users").unwrap();
|
||||
assert_eq!(item.kind, "added");
|
||||
assert!(!item.destructive);
|
||||
assert_eq!(item.sync_sql.len(), 1);
|
||||
let sql = &item.sync_sql[0];
|
||||
assert!(sql.starts_with("CREATE TABLE \"s\".\"users\""), "got: {sql}");
|
||||
assert!(sql.contains("\"id\" integer NOT NULL"));
|
||||
assert!(sql.contains("\"email\" text DEFAULT ''"));
|
||||
assert!(sql.contains("PRIMARY KEY (\"id\")"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_table_is_destructive_copy_only() {
|
||||
let tgt = pg_snap(vec![table("old", vec![col("id", "integer", false, None)])]);
|
||||
let src = pg_snap(vec![]);
|
||||
let r = diff_snapshots(&src, &tgt);
|
||||
let item = r.items.iter().find(|i| i.name == "old").unwrap();
|
||||
assert_eq!(item.kind, "removed");
|
||||
assert!(item.destructive);
|
||||
assert_eq!(item.sync_sql, vec!["DROP TABLE \"s\".\"old\";"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_diffs_classify_exactly_per_spec() {
|
||||
let mut src = pg_snap(vec![table(
|
||||
"t",
|
||||
vec![
|
||||
col("kept", "int", true, None),
|
||||
col("added", "int", true, None),
|
||||
col("retyped", "bigint", true, None),
|
||||
col("tightened", "int", false, None),
|
||||
col("defchanged", "int", true, Some("2")),
|
||||
],
|
||||
)]);
|
||||
src.tables[0].name = "t".into();
|
||||
let tgt = pg_snap(vec![table(
|
||||
"t",
|
||||
vec![
|
||||
col("kept", "int", true, None),
|
||||
col("gone", "int", true, None),
|
||||
col("retyped", "int", true, None),
|
||||
col("tightened", "int", true, None),
|
||||
col("defchanged", "int", true, Some("1")),
|
||||
],
|
||||
)]);
|
||||
|
||||
let r = diff_snapshots(&src, &tgt);
|
||||
|
||||
let add = r.items.iter().find(|i| i.name == "t.added").unwrap();
|
||||
assert_eq!((add.kind.as_str(), add.destructive), ("added", false));
|
||||
assert_eq!(add.sync_sql[0], "ALTER TABLE \"s\".\"t\" ADD COLUMN \"added\" int;");
|
||||
|
||||
let gone = r.items.iter().find(|i| i.name == "t.gone").unwrap();
|
||||
assert_eq!((gone.kind.as_str(), gone.destructive), ("removed", true));
|
||||
assert_eq!(gone.sync_sql[0], "ALTER TABLE \"s\".\"t\" DROP COLUMN \"gone\";");
|
||||
|
||||
let retyped = r.items.iter().find(|i| i.name == "t.retyped").unwrap();
|
||||
assert!(retyped.destructive, "ALTER COLUMN TYPE is destructive per spec");
|
||||
assert!(retyped.sync_sql[0].contains("ALTER COLUMN \"retyped\" TYPE bigint"));
|
||||
|
||||
let tight = r.items.iter().find(|i| i.name == "t.tightened").unwrap();
|
||||
assert!(!tight.destructive);
|
||||
assert!(tight.sync_sql[0].contains("SET NOT NULL"));
|
||||
|
||||
let def = r.items.iter().find(|i| i.name == "t.defchanged").unwrap();
|
||||
assert!(!def.destructive);
|
||||
assert!(def.sync_sql[0].contains("SET DEFAULT 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constraint_index_fk_view_sequence_enum_diffs() {
|
||||
let mut src = pg_snap(vec![table("t", vec![col("id", "int", false, None)])]);
|
||||
let mut tgt = pg_snap(vec![table("t", vec![col("id", "int", false, None)])]);
|
||||
|
||||
src.tables[0].foreign_keys.push(SnapshotFk {
|
||||
name: "fk_t_ref".into(),
|
||||
columns: vec!["id".into()],
|
||||
ref_schema: "s".into(),
|
||||
ref_table: "other".into(),
|
||||
ref_columns: vec!["id".into()],
|
||||
on_delete: "CASCADE".into(),
|
||||
on_update: "NO ACTION".into(),
|
||||
});
|
||||
src.tables[0].indexes.push(SnapshotIndex { name: "ix_t".into(), columns: vec!["id".into()], unique: false });
|
||||
src.views.push(SnapshotView { schema: "s".into(), name: "v".into(), definition: "SELECT 1".into(), materialized: false });
|
||||
src.sequences.push(SnapshotSequence { schema: "s".into(), name: "seq".into(), start: "1".into(), increment: "1".into(), minimum: "1".into(), maximum: "100".into(), cycle: false });
|
||||
src.enums.push(SnapshotEnum { schema: "s".into(), name: "color".into(), labels: vec!["red".into(), "green".into()] });
|
||||
tgt.enums.push(SnapshotEnum { schema: "s".into(), name: "color".into(), labels: vec!["red".into()] });
|
||||
|
||||
let r = diff_snapshots(&src, &tgt);
|
||||
|
||||
let fk = r.items.iter().find(|i| i.object_type == "constraint" && i.name == "t.fk_t_ref").unwrap();
|
||||
assert!(!fk.destructive);
|
||||
assert!(fk.sync_sql[0].contains("ADD CONSTRAINT \"fk_t_ref\" FOREIGN KEY (\"id\")"));
|
||||
assert!(fk.sync_sql[0].contains("REFERENCES \"s\".\"other\" (\"id\")"));
|
||||
assert!(fk.sync_sql[0].contains("ON DELETE CASCADE"));
|
||||
|
||||
let ix = r.items.iter().find(|i| i.object_type == "index" && i.name == "t.ix_t").unwrap();
|
||||
assert_eq!(ix.sync_sql[0], "CREATE INDEX \"s\".\"ix_t\" ON \"s\".\"t\" (\"id\");");
|
||||
|
||||
let view = r.items.iter().find(|i| i.object_type == "view" && i.name == "v").unwrap();
|
||||
assert!(view.sync_sql[0].starts_with("CREATE VIEW \"s\".\"v\" AS"));
|
||||
|
||||
let seq = r.items.iter().find(|i| i.object_type == "sequence" && i.name == "seq").unwrap();
|
||||
assert!(seq.sync_sql[0].starts_with("CREATE SEQUENCE \"s\".\"seq\""));
|
||||
|
||||
let en = r.items.iter().find(|i| i.object_type == "enum" && i.name == "color").unwrap();
|
||||
assert!(!en.destructive);
|
||||
assert!(en.sync_sql[0].contains("ALTER TYPE \"s\".\"color\" ADD VALUE 'green'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_family_uses_backticks() {
|
||||
let mut src = SchemaSnapshot { engine: "mariadb".into(), schema: "app".into(), ..Default::default() };
|
||||
src.tables.push(SnapshotTable {
|
||||
schema: "app".into(),
|
||||
name: "users".into(),
|
||||
columns: vec![SnapshotColumn { name: "id".into(), data_type: "int".into(), nullable: false, default_value: None }],
|
||||
primary_key: vec!["id".into()],
|
||||
uniques: vec![],
|
||||
checks: vec![],
|
||||
indexes: vec![],
|
||||
foreign_keys: vec![],
|
||||
});
|
||||
let tgt = SchemaSnapshot { engine: "mysql".into(), schema: "app".into(), ..Default::default() };
|
||||
let r = diff_snapshots(&src, &tgt);
|
||||
let item = r.items.iter().find(|i| i.name == "users").unwrap();
|
||||
assert!(item.sync_sql[0].starts_with("CREATE TABLE `app`.`users`"), "got: {}", item.sync_sql[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_changed_view_is_destructive_note_only() {
|
||||
let mut src = SchemaSnapshot { engine: "sqlite".into(), schema: "main".into(), ..Default::default() };
|
||||
src.views.push(SnapshotView { schema: "main".into(), name: "v".into(), definition: "SELECT 2".into(), materialized: false });
|
||||
let mut tgt = SchemaSnapshot { engine: "sqlite".into(), schema: "main".into(), ..Default::default() };
|
||||
tgt.views.push(SnapshotView { schema: "main".into(), name: "v".into(), definition: "SELECT 1".into(), materialized: false });
|
||||
let r = diff_snapshots(&src, &tgt);
|
||||
let item = r.items.iter().find(|i| i.object_type == "view").unwrap();
|
||||
assert_eq!(item.kind, "changed");
|
||||
assert!(item.destructive);
|
||||
assert!(item.sync_sql.is_empty(), "SQLite cannot CREATE OR REPLACE a view");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_snapshots_diff_empty_and_stageable_filter() {
|
||||
let snap = pg_snap(vec![table("t", vec![col("id", "int", false, None)])]);
|
||||
let r = diff_snapshots(&snap, &snap);
|
||||
assert!(r.items.is_empty());
|
||||
assert!(stageable_items(&r).is_empty());
|
||||
|
||||
let mut src2 = pg_snap(vec![]);
|
||||
src2.tables.push(table("new", vec![col("id", "int", false, None)]));
|
||||
src2.tables.push(table("dropme", vec![col("id", "int", false, None)]));
|
||||
let r2 = diff_snapshots(&src2, &snap);
|
||||
assert!(stageable_items(&r2).iter().all(|i| !i.destructive && !i.sync_sql.is_empty()));
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Pure SQL statement splitter shared by multi-result execution and the
|
||||
//! streaming export (single-statement validation).
|
||||
//!
|
||||
//! A hand-rolled state machine (no new dependencies). It understands:
|
||||
//! single/double/backtick-quoted strings with doubled-quote escapes,
|
||||
//! `--` line comments, `/* */` block comments, and PostgreSQL dollar-quoted
|
||||
//! bodies (`$$…$$`, `$tag$…$tag$`). Conservative by design: anything it
|
||||
//! cannot confidently close is kept inside the current statement rather
|
||||
//! than split — a naive semicolon split that could execute fragmentary SQL
|
||||
//! is unacceptable (spec §2).
|
||||
//!
|
||||
//! Statement classification (SELECT vs DML vs DDL) happens at execution
|
||||
//! time, not here.
|
||||
|
||||
/// Split `sql` into ordered statement texts. Comment-only and empty
|
||||
/// fragments are dropped. Statement text keeps its comments verbatim.
|
||||
pub fn split_statements(sql: &str) -> Vec<String> {
|
||||
let chars: Vec<char> = sql.chars().collect();
|
||||
let mut stmts: Vec<String> = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut i = 0usize;
|
||||
// MySQL's client-side `DELIMITER` directive. Default matches the SQL
|
||||
// standard terminator so behaviour is unchanged when no directive appears.
|
||||
let mut delimiter: Vec<char> = vec![';'];
|
||||
|
||||
while i < chars.len() {
|
||||
// A `DELIMITER` line is a client directive, not SQL. Only recognised
|
||||
// at the start of a line: quoted regions, block comments and line
|
||||
// comments are copied atomically below, so a directive inside any of
|
||||
// them can never reach this check.
|
||||
if i == 0 || chars[i - 1] == '\n' {
|
||||
if let Some((end, token)) = match_delimiter_directive(&chars, i) {
|
||||
delimiter = token.chars().collect();
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let c = chars[i];
|
||||
let at_delimiter = starts_with(&chars, i, &delimiter);
|
||||
match c {
|
||||
'\'' | '"' | '`' => {
|
||||
copy_quoted(&chars, &mut i, &mut current, c);
|
||||
}
|
||||
'-' if peek(&chars, i + 1) == Some('-') => {
|
||||
copy_until(&chars, &mut i, &mut current, |ch| ch == '\n');
|
||||
}
|
||||
'/' if peek(&chars, i + 1) == Some('*') => {
|
||||
copy_block_comment(&chars, &mut i, &mut current);
|
||||
}
|
||||
_ if at_delimiter => {
|
||||
if has_executable_text(¤t) {
|
||||
stmts.push(current.trim().to_string());
|
||||
}
|
||||
current.clear();
|
||||
i += delimiter.len();
|
||||
}
|
||||
'$' => {
|
||||
if let Some(end) = dollar_quote_end(&chars, i) {
|
||||
for _ in i..end {
|
||||
if i < chars.len() {
|
||||
current.push(chars[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
current.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
current.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if has_executable_text(¤t) {
|
||||
stmts.push(current.trim().to_string());
|
||||
}
|
||||
stmts
|
||||
}
|
||||
|
||||
fn peek(chars: &[char], idx: usize) -> Option<char> {
|
||||
chars.get(idx).copied()
|
||||
}
|
||||
|
||||
/// Copy a quoted region starting at the opening `quote` char. Doubled quotes
|
||||
/// are escapes and stay inside the region. Unterminated quotes consume the
|
||||
/// rest of the input (conservative).
|
||||
fn copy_quoted(chars: &[char], i: &mut usize, out: &mut String, quote: char) {
|
||||
out.push(chars[*i]);
|
||||
*i += 1;
|
||||
while *i < chars.len() {
|
||||
let c = chars[*i];
|
||||
out.push(c);
|
||||
*i += 1;
|
||||
if c == quote {
|
||||
if peek(chars, *i) == Some(quote) {
|
||||
// doubled quote — escaped, keep it and continue
|
||||
out.push(quote);
|
||||
*i += 1;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy characters until `stop` matches (inclusive for `\n` handled by the
|
||||
/// caller's predicate) or input ends.
|
||||
fn copy_until(chars: &[char], i: &mut usize, out: &mut String, stop: impl Fn(char) -> bool) {
|
||||
while *i < chars.len() {
|
||||
let c = chars[*i];
|
||||
out.push(c);
|
||||
*i += 1;
|
||||
if stop(c) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy a `/* … */` block comment (no nesting per the SQL standard).
|
||||
fn copy_block_comment(chars: &[char], i: &mut usize, out: &mut String) {
|
||||
out.push(chars[*i]); // '/'
|
||||
*i += 1;
|
||||
if *i < chars.len() {
|
||||
out.push(chars[*i]); // '*'
|
||||
*i += 1;
|
||||
}
|
||||
while *i < chars.len() {
|
||||
let c = chars[*i];
|
||||
out.push(c);
|
||||
*i += 1;
|
||||
if c == '*' && peek(chars, *i) == Some('/') {
|
||||
out.push('/');
|
||||
*i += 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// At `chars[start] == '$'`, try to match `$[A-Za-z_][A-Za-z0-9_]*$` and find
|
||||
/// the matching closing delimiter. Returns the index one past the closing
|
||||
/// delimiter, or None if this is not a well-formed (and closable) dollar
|
||||
/// quote — in which case the `$` is ordinary text.
|
||||
fn dollar_quote_end(chars: &[char], start: usize) -> Option<usize> {
|
||||
let mut j = start + 1;
|
||||
if j < chars.len() && chars[j] == '$' {
|
||||
// bare $$ body
|
||||
let tag = String::new();
|
||||
return find_dollar_close(chars, j + 1, &tag);
|
||||
}
|
||||
let mut tag = String::new();
|
||||
while j < chars.len() && (chars[j].is_ascii_alphanumeric() || chars[j] == '_') {
|
||||
tag.push(chars[j]);
|
||||
j += 1;
|
||||
}
|
||||
if tag.is_empty() || j >= chars.len() || chars[j] != '$' {
|
||||
return None; // not $tag$
|
||||
}
|
||||
find_dollar_close(chars, j + 1, &tag)
|
||||
}
|
||||
|
||||
fn find_dollar_close(chars: &[char], from: usize, tag: &str) -> Option<usize> {
|
||||
let closing: Vec<char> = format!("${tag}$").chars().collect();
|
||||
let mut j = from;
|
||||
while j + closing.len() <= chars.len() {
|
||||
if chars[j..j + closing.len()] == closing[..] {
|
||||
return Some(j + closing.len());
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// True if the fragment contains anything other than whitespace and comments
|
||||
/// (comments are preserved in statement text, but a fragment that is ONLY
|
||||
/// comments/whitespace is not a statement).
|
||||
pub fn has_executable_text(stmt: &str) -> bool {
|
||||
let chars: Vec<char> = stmt.chars().collect();
|
||||
let mut i = 0usize;
|
||||
while i < chars.len() {
|
||||
let c = chars[i];
|
||||
match c {
|
||||
'\'' | '"' | '`' => {
|
||||
let mut probe = String::new();
|
||||
let mut j = i;
|
||||
copy_quoted(&chars, &mut j, &mut probe, c);
|
||||
return true; // a quoted region IS executable text
|
||||
}
|
||||
'-' if peek(&chars, i + 1) == Some('-') => {
|
||||
skip_until(&chars, &mut i, |ch| ch == '\n');
|
||||
}
|
||||
'/' if peek(&chars, i + 1) == Some('*') => {
|
||||
// MySQL executable comments (`/*! … */`, optionally
|
||||
// version-gated like `/*!50003 … */`) are executed by the
|
||||
// server, so a fragment that is only such a comment is still
|
||||
// a statement. mysqldump wraps routine and trigger definitions
|
||||
// in them with no surrounding SQL. Ordinary block comments
|
||||
// remain skipped.
|
||||
if peek(&chars, i + 2) == Some('!') {
|
||||
return true;
|
||||
}
|
||||
skip_block_comment(&chars, &mut i);
|
||||
}
|
||||
c if c.is_whitespace() => i += 1,
|
||||
_ => return true,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn skip_until(chars: &[char], i: &mut usize, stop: impl Fn(char) -> bool) {
|
||||
while *i < chars.len() {
|
||||
let c = chars[*i];
|
||||
*i += 1;
|
||||
if stop(c) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn skip_block_comment(chars: &[char], i: &mut usize) {
|
||||
*i += 2; // "/*"
|
||||
while *i < chars.len() {
|
||||
if chars[*i] == '*' && peek(chars, *i + 1) == Some('/') {
|
||||
*i += 2;
|
||||
return;
|
||||
}
|
||||
*i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// True when `chars[i..]` begins with `pat`.
|
||||
fn starts_with(chars: &[char], i: usize, pat: &[char]) -> bool {
|
||||
!pat.is_empty() && i + pat.len() <= chars.len() && chars[i..i + pat.len()] == *pat
|
||||
}
|
||||
|
||||
/// Match a MySQL client `DELIMITER <token>` directive that occupies a whole
|
||||
/// line. Returns the index just past the directive's newline and the new
|
||||
/// delimiter token. Conservative: anything other than
|
||||
/// `^[ \t]*DELIMITER[ \t]+<token>[ \t]*$` is left as ordinary text.
|
||||
fn match_delimiter_directive(chars: &[char], i: usize) -> Option<(usize, String)> {
|
||||
let mut j = i;
|
||||
while j < chars.len() && matches!(chars[j], ' ' | '\t') {
|
||||
j += 1;
|
||||
}
|
||||
for kw in ['D', 'E', 'L', 'I', 'M', 'I', 'T', 'E', 'R'] {
|
||||
if j >= chars.len() || chars[j].to_ascii_uppercase() != kw {
|
||||
return None;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
if j >= chars.len() || !matches!(chars[j], ' ' | '\t') {
|
||||
return None; // `DELIMITERx` is not a directive
|
||||
}
|
||||
while j < chars.len() && matches!(chars[j], ' ' | '\t') {
|
||||
j += 1;
|
||||
}
|
||||
let start = j;
|
||||
while j < chars.len() && !matches!(chars[j], ' ' | '\t' | '\r' | '\n') {
|
||||
j += 1;
|
||||
}
|
||||
if start == j {
|
||||
return None; // no token
|
||||
}
|
||||
let token: String = chars[start..j].iter().collect();
|
||||
// The rest of the line must be blank, otherwise this is not a directive.
|
||||
let mut k = j;
|
||||
while k < chars.len() && matches!(chars[k], ' ' | '\t') {
|
||||
k += 1;
|
||||
}
|
||||
if k < chars.len() && !matches!(chars[k], '\r' | '\n') {
|
||||
return None;
|
||||
}
|
||||
while k < chars.len() && matches!(chars[k], '\r' | '\n') {
|
||||
k += 1;
|
||||
}
|
||||
Some((k, token))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sql_split.test.rs"]
|
||||
mod sql_split_tests;
|
||||
@@ -0,0 +1,141 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn splits_two_statements() {
|
||||
assert_eq!(
|
||||
split_statements("SELECT 1; SELECT 2"),
|
||||
vec!["SELECT 1", "SELECT 2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_trailing_semicolon_is_fine() {
|
||||
assert_eq!(split_statements("SELECT 1"), vec!["SELECT 1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semicolon_inside_string_is_not_a_boundary() {
|
||||
assert_eq!(split_statements("SELECT 'a;b'"), vec!["SELECT 'a;b'"]);
|
||||
assert_eq!(
|
||||
split_statements("SELECT 'a;b'; SELECT 2"),
|
||||
vec!["SELECT 'a;b'", "SELECT 2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doubled_quote_escapes() {
|
||||
assert_eq!(
|
||||
split_statements("SELECT 'it''s'; SELECT 2"),
|
||||
vec!["SELECT 'it''s'", "SELECT 2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_quoted_and_backtick_identifiers() {
|
||||
assert_eq!(
|
||||
split_statements("SELECT \"a;b\"; SELECT `x;y`"),
|
||||
vec!["SELECT \"a;b\"", "SELECT `x;y`"]
|
||||
);
|
||||
assert_eq!(
|
||||
split_statements("SELECT `a``b`; SELECT 2"),
|
||||
vec!["SELECT `a``b`", "SELECT 2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_comments_do_not_break_statements() {
|
||||
assert_eq!(
|
||||
split_statements("SELECT 1 -- ; not a boundary\n; SELECT 2"),
|
||||
vec!["SELECT 1 -- ; not a boundary", "SELECT 2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_comments_do_not_break_statements() {
|
||||
assert_eq!(split_statements("SELECT /* ; */ 1"), vec!["SELECT /* ; */ 1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dollar_quoted_bodies() {
|
||||
assert_eq!(
|
||||
split_statements("SELECT $$a;b$$; SELECT 2"),
|
||||
vec!["SELECT $$a;b$$", "SELECT 2"]
|
||||
);
|
||||
assert_eq!(
|
||||
split_statements("SELECT $fn$x;y$fn$; SELECT 2"),
|
||||
vec!["SELECT $fn$x;y$fn$", "SELECT 2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unclosed_dollar_is_literal() {
|
||||
assert_eq!(split_statements("SELECT a$b"), vec!["SELECT a$b"]);
|
||||
assert_eq!(split_statements("SELECT 1$"), vec!["SELECT 1$"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_only_statements_are_skipped() {
|
||||
assert_eq!(split_statements("-- nothing\n; SELECT 1"), vec!["SELECT 1"]);
|
||||
assert_eq!(split_statements("/* c */"), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_whitespace_input() {
|
||||
assert_eq!(split_statements(""), Vec::<String>::new());
|
||||
assert_eq!(split_statements(" ; ; "), Vec::<String>::new());
|
||||
}
|
||||
|
||||
// ── MySQL client DELIMITER directive ────────────────────────────────
|
||||
//
|
||||
// NOTE: the splitter strips the active statement terminator, exactly as the
|
||||
// pre-existing tests above assert (`splits_two_statements` -> ["SELECT 1",
|
||||
// "SELECT 2"]). The expectations below therefore never contain a trailing
|
||||
// `;`/delimiter, matching that established contract.
|
||||
|
||||
#[test]
|
||||
fn delimiter_directive_changes_statement_terminator() {
|
||||
let sql = "DELIMITER //\nCREATE PROCEDURE p()\nBEGIN\n SELECT 1;\nEND//\nDELIMITER ;\nSELECT 2;";
|
||||
let stmts = split_statements(sql);
|
||||
assert_eq!(stmts.len(), 2, "got {stmts:?}");
|
||||
assert!(stmts[0].starts_with("CREATE PROCEDURE p()"), "got {:?}", stmts[0]);
|
||||
// The inner `;` must NOT have split the body.
|
||||
assert!(stmts[0].contains("SELECT 1;"), "body was split: {:?}", stmts[0]);
|
||||
assert!(!stmts[0].contains("DELIMITER"), "directive leaked into statement: {:?}", stmts[0]);
|
||||
assert_eq!(stmts[1], "SELECT 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delimiter_directive_is_case_and_space_tolerant() {
|
||||
let sql = " delimiter $$\n SELECT 1$$\n Delimiter ;\nSELECT 2;";
|
||||
let stmts = split_statements(sql);
|
||||
assert_eq!(stmts, vec!["SELECT 1".to_string(), "SELECT 2".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delimiter_text_inside_a_string_is_not_a_directive() {
|
||||
let stmts = split_statements("SELECT 'DELIMITER //';\nSELECT 2;");
|
||||
assert_eq!(stmts.len(), 2);
|
||||
assert_eq!(stmts[0], "SELECT 'DELIMITER //'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delimiter_text_inside_a_line_comment_is_not_a_directive() {
|
||||
let stmts = split_statements("-- DELIMITER //\nSELECT 1;");
|
||||
assert_eq!(stmts.len(), 1);
|
||||
assert!(stmts[0].contains("SELECT 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delimiter_with_trailing_junk_is_ordinary_text() {
|
||||
let stmts = split_statements("DELIMITER // extra\nSELECT 1;");
|
||||
assert_eq!(stmts.len(), 1);
|
||||
assert!(stmts[0].contains("DELIMITER // extra"), "got {:?}", stmts[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_semicolon_is_ordinary_text_under_a_custom_delimiter() {
|
||||
let stmts = split_statements("DELIMITER //\nSELECT 1; SELECT 2//\nDELIMITER ;\nSELECT 3;");
|
||||
assert_eq!(stmts.len(), 2);
|
||||
assert!(stmts[0].contains("SELECT 1; SELECT 2"), "got {:?}", stmts[0]);
|
||||
assert_eq!(stmts[1], "SELECT 3");
|
||||
}
|
||||
@@ -29,6 +29,37 @@ pub fn tls_decision(ssl_mode: Option<&str>) -> TlsDecision {
|
||||
}
|
||||
}
|
||||
|
||||
/// Host suffixes of managed providers that reject plaintext connections with
|
||||
/// `connection is insecure (try using 'sslmode=require')`.
|
||||
const MANAGED_SSL_HOST_SUFFIXES: [&str; 3] = [".neon.tech", ".supabase.co", ".psdb.cloud"];
|
||||
|
||||
/// Resolve the SSL mode to use for a connection, defaulting these managed
|
||||
/// providers to `require` when the connection carries no explicit mode.
|
||||
///
|
||||
/// Connections saved before the UI persisted `ssl_mode` (or imported without
|
||||
/// it) would otherwise connect with TLS disabled and be refused by the server.
|
||||
/// An explicit mode always wins, so a deliberate `disable` is never overridden,
|
||||
/// and the inferred mode only encrypts — it never verifies certificates.
|
||||
pub fn resolve_ssl_mode(host: &str, ssl_mode: Option<&str>) -> Option<String> {
|
||||
if let Some(mode) = ssl_mode {
|
||||
return Some(mode.to_string());
|
||||
}
|
||||
let host = host.to_ascii_lowercase();
|
||||
if MANAGED_SSL_HOST_SUFFIXES
|
||||
.iter()
|
||||
.any(|suffix| host.ends_with(suffix))
|
||||
{
|
||||
return Some("require".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// TLS decision for a connection, applying the managed-provider default from
|
||||
/// [`resolve_ssl_mode`] before mapping to a [`TlsDecision`].
|
||||
pub fn tls_decision_for(host: &str, ssl_mode: Option<&str>) -> TlsDecision {
|
||||
tls_decision(resolve_ssl_mode(host, ssl_mode).as_deref())
|
||||
}
|
||||
|
||||
/// Build a rustls `ClientConfig` for tokio-postgres, or `None` for disable.
|
||||
/// `ca_path` is required for Verify; `cert_path`/`key_path` are optional client auth.
|
||||
pub fn build_tls_config(
|
||||
@@ -197,6 +228,50 @@ mod tests {
|
||||
assert!(matches!(tls_decision(Some("bogus")), TlsDecision::Disable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_ssl_mode_defaults_managed_hosts_to_require() {
|
||||
// Saved connections that predate the form storing `ssl_mode` still work.
|
||||
for host in [
|
||||
"ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech",
|
||||
"db.abcdefghijklmnopqrst.supabase.co",
|
||||
"xxxx.us-east-2.psdb.cloud",
|
||||
"EP-X-POOLER.EU.AWS.NEON.TECH",
|
||||
] {
|
||||
assert_eq!(resolve_ssl_mode(host, None).as_deref(), Some("require"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_ssl_mode_leaves_other_hosts_and_explicit_modes_alone() {
|
||||
assert_eq!(resolve_ssl_mode("localhost", None), None);
|
||||
assert_eq!(resolve_ssl_mode("prod.example.com", None), None);
|
||||
// An explicit mode always wins — a deliberate `disable` is never overridden.
|
||||
assert_eq!(
|
||||
resolve_ssl_mode("ep-x.eu.aws.neon.tech", Some("disable")).as_deref(),
|
||||
Some("disable")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_ssl_mode("ep-x.eu.aws.neon.tech", Some("verify-full")).as_deref(),
|
||||
Some("verify-full")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls_decision_for_honors_managed_default_and_explicit_modes() {
|
||||
assert!(matches!(
|
||||
tls_decision_for("ep-x.eu.aws.neon.tech", None),
|
||||
TlsDecision::Require
|
||||
));
|
||||
assert!(matches!(
|
||||
tls_decision_for("ep-x.eu.aws.neon.tech", Some("disable")),
|
||||
TlsDecision::Disable
|
||||
));
|
||||
assert!(matches!(
|
||||
tls_decision_for("db.example.com", None),
|
||||
TlsDecision::Disable
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tls_disable_returns_none() {
|
||||
assert!(build_tls_config(TlsDecision::Disable, None, None, None)
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
//! Unified, headless external-client-tool resolver shared by the PostgreSQL
|
||||
//! and MySQL backup/restore/sync paths.
|
||||
//!
|
||||
//! Fixes issue #44: bundled tools live under `resource_dir/resources/<subdir>/`
|
||||
//! (Tauri preserves the `resources/` component from `tauri.conf.json`), not
|
||||
//! directly under `resource_dir/`. System lookup is ordered — trusted absolute
|
||||
//! directories first, then the inherited PATH, then bundled — and probes both
|
||||
//! `mariadb-dump`/`mysqldump` style names.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The nested directory component Tauri injects when copying
|
||||
/// `bundle.resources`. Pinned by `bundled_prefix_matches_tauri_config_resources`.
|
||||
pub const BUNDLED_RESOURCE_PREFIX: &str = "resources";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ToolSource {
|
||||
/// Found in a fixed, trusted absolute directory.
|
||||
TrustedDir,
|
||||
/// Found on the inherited `PATH`.
|
||||
Path,
|
||||
/// Found in the app's bundled resource directory.
|
||||
Bundled,
|
||||
}
|
||||
|
||||
impl ToolSource {
|
||||
/// The value surfaced to the UI (matches the pre-existing
|
||||
/// `"system" | "bundled"` strings).
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
ToolSource::TrustedDir | ToolSource::Path => "system",
|
||||
ToolSource::Bundled => "bundled",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A successfully resolved tool: the runnable program plus its provenance.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Resolution {
|
||||
pub program: String,
|
||||
pub source: ToolSource,
|
||||
/// The binary name that resolved (`mariadb-dump`, `mysqldump`, …).
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// One engine + tool role, e.g. the MySQL dump tool.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ToolRequest {
|
||||
/// Resource subdirectory name, e.g. `mysql_tools` / `pg_tools`.
|
||||
pub subdir: &'static str,
|
||||
/// Candidate binary names, in preference order.
|
||||
pub names: Vec<&'static str>,
|
||||
}
|
||||
|
||||
impl ToolRequest {
|
||||
pub fn new(subdir: &'static str, names: &[&'static str]) -> Self {
|
||||
Self { subdir, names: names.to_vec() }
|
||||
}
|
||||
|
||||
/// The bundled binary that ships for this role.
|
||||
pub fn bundled_name(&self) -> &'static str {
|
||||
self.names[0]
|
||||
}
|
||||
|
||||
/// Stable cache key for this role.
|
||||
pub fn cache_key(&self) -> String {
|
||||
format!("{}::{}", self.subdir, self.names.join(","))
|
||||
}
|
||||
}
|
||||
|
||||
/// Platform executable suffix.
|
||||
pub fn exe_suffix() -> &'static str {
|
||||
if cfg!(windows) { ".exe" } else { "" }
|
||||
}
|
||||
|
||||
/// `pg_dump` -> `pg_dump` (POSIX) / `pg_dump.exe` (Windows).
|
||||
pub fn bundled_bin_name(bin: &str) -> String {
|
||||
format!("{bin}{}", exe_suffix())
|
||||
}
|
||||
|
||||
/// Fixed, trusted absolute directories probed before the inherited `PATH`.
|
||||
/// Windows deliberately returns an empty list (spec §9 Q5): PATH + bundled only.
|
||||
pub fn trusted_dirs() -> Vec<PathBuf> {
|
||||
let raw: &[&str] = if cfg!(target_os = "macos") {
|
||||
&[
|
||||
"/opt/homebrew/bin",
|
||||
"/opt/homebrew/opt/mysql-client/bin",
|
||||
"/opt/homebrew/opt/mariadb/bin",
|
||||
"/opt/homebrew/opt/libpq/bin",
|
||||
"/usr/local/bin",
|
||||
"/usr/local/opt/mysql-client/bin",
|
||||
"/usr/local/opt/mariadb/bin",
|
||||
"/usr/local/opt/libpq/bin",
|
||||
"/opt/local/bin",
|
||||
"/Applications/Postgres.app/Contents/Versions/latest/bin",
|
||||
]
|
||||
} else if cfg!(target_os = "linux") {
|
||||
&["/usr/bin", "/usr/local/bin", "/usr/local/mysql/bin", "/opt/mysql/bin"]
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
raw.iter().map(PathBuf::from).collect()
|
||||
}
|
||||
|
||||
pub fn pg_dump_request() -> ToolRequest { ToolRequest::new("pg_tools", &["pg_dump"]) }
|
||||
pub fn pg_restore_request() -> ToolRequest { ToolRequest::new("pg_tools", &["pg_restore"]) }
|
||||
pub fn psql_request() -> ToolRequest { ToolRequest::new("pg_tools", &["psql"]) }
|
||||
|
||||
/// Maps a PostgreSQL tool name to its request; `None` for unknown names.
|
||||
pub fn pg_request(tool: &str) -> Option<ToolRequest> {
|
||||
match tool {
|
||||
"pg_dump" => Some(pg_dump_request()),
|
||||
"pg_restore" => Some(pg_restore_request()),
|
||||
"psql" => Some(psql_request()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// MySQL dump role: system `mariadb-dump` -> system `mysqldump` -> bundled.
|
||||
pub fn mysql_dump_request() -> ToolRequest {
|
||||
ToolRequest::new("mysql_tools", &["mariadb-dump", "mysqldump"])
|
||||
}
|
||||
|
||||
/// MySQL client role (used by sync as the restore target).
|
||||
pub fn mysql_client_request() -> ToolRequest {
|
||||
ToolRequest::new("mysql_tools", &["mariadb", "mysql"])
|
||||
}
|
||||
|
||||
/// Maps a MySQL tool name to its request; `None` for unknown names.
|
||||
pub fn mysql_request(tool: &str) -> Option<ToolRequest> {
|
||||
match tool {
|
||||
"mariadb-dump" | "mysqldump" => Some(mysql_dump_request()),
|
||||
"mariadb" | "mysql" => Some(mysql_client_request()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The bundled binary path for a role. Note the nested
|
||||
/// `BUNDLED_RESOURCE_PREFIX` component — this is the issue #44 fix.
|
||||
pub fn bundled_tool_path(resource_dir: &Path, subdir: &str, bin: &str) -> PathBuf {
|
||||
resource_dir
|
||||
.join(BUNDLED_RESOURCE_PREFIX)
|
||||
.join(subdir)
|
||||
.join(bundled_bin_name(bin))
|
||||
}
|
||||
|
||||
/// Locate `name` on a `PATH`-shaped value without invoking a shell.
|
||||
pub fn path_lookup_in(name: &str, path_var: &OsStr) -> Option<PathBuf> {
|
||||
let file = bundled_bin_name(name);
|
||||
for dir in std::env::split_paths(path_var) {
|
||||
if dir.as_os_str().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let candidate = dir.join(&file);
|
||||
if candidate.is_file() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Locate `name` on the inherited `PATH`.
|
||||
pub fn path_lookup(name: &str) -> Option<PathBuf> {
|
||||
let path_var = std::env::var_os("PATH")?;
|
||||
path_lookup_in(name, &path_var)
|
||||
}
|
||||
|
||||
/// Per-probe timeout. `--version` returns in milliseconds; this only guards a
|
||||
/// pathological binary.
|
||||
pub const PROBE_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
/// Aggregate deadline for one detection pass across all candidates.
|
||||
pub const AGGREGATE_DEADLINE: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Run `<program> --version` with a bounded wait. The child's stdio is
|
||||
/// discarded — output is read separately by `version_of`.
|
||||
pub fn run_version(program: &Path, timeout: Duration) -> bool {
|
||||
let mut cmd = Command::new(program);
|
||||
cmd.arg("--version")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
let Ok(mut child) = cmd.spawn() else {
|
||||
return false;
|
||||
};
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => return status.success(),
|
||||
Ok(None) => {
|
||||
if Instant::now() >= deadline {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return false;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = child.kill();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tool candidate counts only when it is a regular file AND runs.
|
||||
pub fn probe(program: &Path) -> bool {
|
||||
program.is_file() && run_version(program, PROBE_TIMEOUT)
|
||||
}
|
||||
|
||||
/// A probe that stops running anything once the aggregate deadline is crossed.
|
||||
pub fn probe_within(start: Instant, deadline: Duration, program: &Path) -> bool {
|
||||
if start.elapsed() >= deadline {
|
||||
return false;
|
||||
}
|
||||
probe(program)
|
||||
}
|
||||
|
||||
/// Ordered, injectable resolution.
|
||||
///
|
||||
/// 1. every trusted absolute directory, for each candidate name in order;
|
||||
/// 2. the inherited `PATH`, for each candidate name in order;
|
||||
/// 3. the bundled binary.
|
||||
///
|
||||
/// `probe_fn` decides whether a candidate is runnable, so tests never touch
|
||||
/// the real filesystem. The aggregate deadline is enforced by the caller's
|
||||
/// `probe_fn` (see `probe_within`).
|
||||
pub fn resolve_with<F>(
|
||||
request: &ToolRequest,
|
||||
trusted: &[PathBuf],
|
||||
path_lookup: &dyn Fn(&str) -> Option<PathBuf>,
|
||||
bundled: Option<&Path>,
|
||||
mut probe_fn: F,
|
||||
) -> Option<Resolution>
|
||||
where
|
||||
F: FnMut(&Path) -> bool,
|
||||
{
|
||||
for dir in trusted {
|
||||
for name in &request.names {
|
||||
let candidate = dir.join(bundled_bin_name(name));
|
||||
if probe_fn(&candidate) {
|
||||
return Some(Resolution {
|
||||
program: candidate.to_string_lossy().to_string(),
|
||||
source: ToolSource::TrustedDir,
|
||||
name: (*name).to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for name in &request.names {
|
||||
if let Some(found) = path_lookup(name) {
|
||||
if probe_fn(&found) {
|
||||
return Some(Resolution {
|
||||
program: found.to_string_lossy().to_string(),
|
||||
source: ToolSource::Path,
|
||||
name: (*name).to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(bundled) = bundled {
|
||||
if probe_fn(bundled) {
|
||||
let name = bundled
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| request.bundled_name().to_string());
|
||||
return Some(Resolution {
|
||||
program: bundled.to_string_lossy().to_string(),
|
||||
source: ToolSource::Bundled,
|
||||
name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Read the first non-empty line of `<program> --version` (stdout, else stderr).
|
||||
pub fn version_of(program: &str) -> Option<String> {
|
||||
let out = Command::new(program).arg("--version").output().ok()?;
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if !stdout.is_empty() {
|
||||
return Some(stdout);
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
if stderr.is_empty() { None } else { Some(stderr) }
|
||||
}
|
||||
|
||||
/// Lenient major-version extraction. Scans for numeric groups, unwraps the
|
||||
/// legacy MariaDB `5.5.5-<real>-MariaDB` compatibility prefix, and returns
|
||||
/// `None` when there is no number at all.
|
||||
pub fn parse_major(version: &str) -> Option<u32> {
|
||||
let mut nums: Vec<u32> = Vec::new();
|
||||
let mut cur = String::new();
|
||||
for ch in version.chars() {
|
||||
if ch.is_ascii_digit() {
|
||||
cur.push(ch);
|
||||
continue;
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
nums.push(cur.parse::<u32>().ok()?);
|
||||
cur.clear();
|
||||
// `6` is the only magic number here: it caps how many groups an
|
||||
// adversarial/advertising version string can make us collect
|
||||
// (we only ever read the first four).
|
||||
if nums.len() == 6 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
if let Ok(n) = cur.parse::<u32>() {
|
||||
nums.push(n);
|
||||
}
|
||||
}
|
||||
if nums.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if nums.len() >= 4 && nums[0] == 5 && nums[1] == 5 && nums[2] == 5 {
|
||||
return Some(nums[3]);
|
||||
}
|
||||
Some(nums[0])
|
||||
}
|
||||
|
||||
/// Client/server version relationship. Advisory only — never an error.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Compat {
|
||||
Ok,
|
||||
/// The client's major version is below the server's.
|
||||
ClientOlder,
|
||||
/// Either version could not be parsed.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
pub fn compat(client: Option<u32>, server: Option<u32>) -> Compat {
|
||||
match (client, server) {
|
||||
(Some(c), Some(s)) if s > c => Compat::ClientOlder,
|
||||
(Some(_), Some(_)) => Compat::Ok,
|
||||
_ => Compat::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-blocking warning text, or `None` when there is nothing to say.
|
||||
pub fn warning_for(client_version: &str, server_version: &str, client_name: &str) -> Option<String> {
|
||||
match compat(parse_major(client_version), parse_major(server_version)) {
|
||||
Compat::ClientOlder => Some(format!(
|
||||
"Client/server version mismatch: the resolved dump tool `{client_name}` reports {client_version}, \
|
||||
but the server is {server_version}. The backup may fail or omit data — installing a matching MySQL client is recommended."
|
||||
)),
|
||||
Compat::Ok | Compat::Unknown => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything one detection pass knows about a tool role.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ToolResolution {
|
||||
pub resolved: Option<Resolution>,
|
||||
pub bundled_path: Option<PathBuf>,
|
||||
/// Whether the bundled file exists — regardless of whether it runs. Lets
|
||||
/// the UI say "ships with the app but could not run" instead of "absent".
|
||||
pub bundled_available: bool,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
impl ToolResolution {
|
||||
pub fn none() -> Self {
|
||||
Self { resolved: None, bundled_path: None, bundled_available: false, version: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a bundled tool file exists at `path`.
|
||||
pub fn bundled_present(path: &Path) -> bool {
|
||||
path.is_file()
|
||||
}
|
||||
|
||||
/// Shape the existing resolver call sites expect: `(command, source)`.
|
||||
pub fn adapt_resolution(res: &ToolResolution, fallback_name: &str) -> (String, Option<String>) {
|
||||
match &res.resolved {
|
||||
Some(r) => (r.program.clone(), Some(r.source.as_str().to_string())),
|
||||
None => (fallback_name.to_string(), None),
|
||||
}
|
||||
}
|
||||
|
||||
// Cache is keyed by `ToolRequest::cache_key()` and holds no secrets.
|
||||
static CACHE: OnceLock<Mutex<HashMap<String, ToolResolution>>> = OnceLock::new();
|
||||
|
||||
fn cache() -> &'static Mutex<HashMap<String, ToolResolution>> {
|
||||
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
pub fn cache_get(key: &str) -> Option<ToolResolution> {
|
||||
cache().lock().ok()?.get(key).cloned()
|
||||
}
|
||||
|
||||
pub fn cache_put(key: &str, value: ToolResolution) {
|
||||
if let Ok(mut map) = cache().lock() {
|
||||
map.insert(key.to_string(), value);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cache_clear() {
|
||||
if let Ok(mut map) = cache().lock() {
|
||||
map.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tool_resolver.test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,403 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bundled_bin_name_appends_exe_only_on_windows() {
|
||||
let expected = if cfg!(windows) { "pg_dump.exe" } else { "pg_dump" };
|
||||
assert_eq!(bundled_bin_name("pg_dump"), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_request_maps_known_tool_names() {
|
||||
assert_eq!(pg_request("pg_dump").unwrap(), ToolRequest::new("pg_tools", &["pg_dump"]));
|
||||
assert_eq!(pg_request("pg_restore").unwrap(), ToolRequest::new("pg_tools", &["pg_restore"]));
|
||||
assert_eq!(pg_request("psql").unwrap(), ToolRequest::new("pg_tools", &["psql"]));
|
||||
assert!(pg_request("not_a_tool").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_dump_request_prefers_mariadb_dump_then_mysqldump() {
|
||||
let req = mysql_dump_request();
|
||||
assert_eq!(req.subdir, "mysql_tools");
|
||||
assert_eq!(req.names, vec!["mariadb-dump", "mysqldump"]);
|
||||
assert_eq!(req.bundled_name(), "mariadb-dump");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_client_request_prefers_mariadb_then_mysql() {
|
||||
let req = mysql_client_request();
|
||||
assert_eq!(req.names, vec!["mariadb", "mysql"]);
|
||||
assert_eq!(req.bundled_name(), "mariadb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_key_is_stable_and_name_ordered() {
|
||||
assert_eq!(mysql_dump_request().cache_key(), "mysql_tools::mariadb-dump,mysqldump");
|
||||
assert_eq!(pg_dump_request().cache_key(), "pg_tools::pg_dump");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_source_maps_to_ui_string() {
|
||||
assert_eq!(ToolSource::TrustedDir.as_str(), "system");
|
||||
assert_eq!(ToolSource::Path.as_str(), "system");
|
||||
assert_eq!(ToolSource::Bundled.as_str(), "bundled");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn trusted_dirs_include_homebrew_and_postgres_app_on_macos() {
|
||||
let dirs = trusted_dirs();
|
||||
assert!(dirs.contains(&PathBuf::from("/opt/homebrew/bin")), "{dirs:?}");
|
||||
assert!(dirs.contains(&PathBuf::from("/opt/homebrew/opt/mysql-client/bin")), "{dirs:?}");
|
||||
assert!(dirs.contains(&PathBuf::from("/usr/local/opt/libpq/bin")), "{dirs:?}");
|
||||
assert!(dirs.iter().any(|d| d.ends_with("Postgres.app/Contents/Versions/latest/bin")), "{dirs:?}");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn trusted_dirs_include_usr_bin_on_linux() {
|
||||
assert!(trusted_dirs().contains(&PathBuf::from("/usr/bin")));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn trusted_dirs_are_empty_on_windows() {
|
||||
assert!(trusted_dirs().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_tool_path_includes_the_nested_resources_prefix() {
|
||||
// THE BUG FROM ISSUE #44: the shipped app stores the tool at
|
||||
// <resource_dir>/resources/mysql_tools/mariadb-dump, never at
|
||||
// <resource_dir>/mysql_tools/mariadb-dump.
|
||||
let p = bundled_tool_path(Path::new("/app/Contents/Resources"), "mysql_tools", "mariadb-dump");
|
||||
assert_eq!(
|
||||
p,
|
||||
PathBuf::from("/app/Contents/Resources/resources/mysql_tools").join(bundled_bin_name("mariadb-dump"))
|
||||
);
|
||||
assert!(p.to_string_lossy().ends_with("resources/mysql_tools/mariadb-dump"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_tool_path_works_for_postgres_tools_too() {
|
||||
let p = bundled_tool_path(Path::new("/app/Contents/Resources"), "pg_tools", "pg_dump");
|
||||
assert!(p.to_string_lossy().ends_with("resources/pg_tools/pg_dump"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_prefix_matches_tauri_config_resources() {
|
||||
let conf = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/tauri.conf.json"))
|
||||
.expect("tauri.conf.json must be readable");
|
||||
let json: serde_json::Value = serde_json::from_str(&conf).unwrap();
|
||||
let resources = json["bundle"]["resources"]
|
||||
.as_array()
|
||||
.expect("bundle.resources must be an array");
|
||||
assert!(!resources.is_empty(), "bundle.resources must not be empty");
|
||||
for entry in resources {
|
||||
let glob = entry.as_str().unwrap();
|
||||
let top = Path::new(glob).components().next().unwrap();
|
||||
assert_eq!(
|
||||
top.as_os_str().to_str().unwrap(),
|
||||
BUNDLED_RESOURCE_PREFIX,
|
||||
"resource glob `{glob}` must start with `{BUNDLED_RESOURCE_PREFIX}/`"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_lookup_finds_an_executable_by_name() {
|
||||
let dir = unique_dir();
|
||||
let exe = dir.join(bundled_bin_name("mysqldump"));
|
||||
std::fs::write(&exe, b"").unwrap();
|
||||
let path_var = std::env::join_paths([&dir]).unwrap();
|
||||
assert_eq!(path_lookup_in("mysqldump", &path_var), Some(exe));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_lookup_skips_empty_entries_and_missing_names() {
|
||||
let dir = unique_dir();
|
||||
let path_var = std::env::join_paths([PathBuf::from(""), dir.clone()]).unwrap();
|
||||
assert_eq!(path_lookup_in("definitely-absent-tool", &path_var), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_rejects_a_missing_file_and_a_directory() {
|
||||
let dir = unique_dir();
|
||||
assert!(!probe(&dir.join("absent-tool")));
|
||||
assert!(!probe(&dir), "a directory must never be reported as a runnable tool");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_version_is_true_for_zero_exit_and_false_for_nonzero() {
|
||||
let ok = unique_dir().join("ok_tool");
|
||||
write_exe(&ok, "#!/bin/sh\nexit 0\n");
|
||||
assert!(run_version(&ok, Duration::from_secs(1)));
|
||||
|
||||
let bad = unique_dir().join("bad_tool");
|
||||
write_exe(&bad, "#!/bin/sh\nexit 3\n");
|
||||
assert!(!run_version(&bad, Duration::from_secs(1)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_version_kills_a_hung_process_at_the_timeout() {
|
||||
let slow = unique_dir().join("slow_tool");
|
||||
write_exe(&slow, "#!/bin/sh\nsleep 5\n");
|
||||
assert!(!run_version(&slow, Duration::from_millis(50)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn probe_accepts_a_runnable_script() {
|
||||
let tool = unique_dir().join(bundled_bin_name("mariadb-dump"));
|
||||
write_exe(&tool, "#!/bin/sh\necho 'mariadb-dump 11.4.5-MariaDB'\nexit 0\n");
|
||||
assert!(probe(&tool));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn version_of_reads_the_version_from_stdout() {
|
||||
let tool = unique_dir().join("versioned_tool");
|
||||
write_exe(&tool, "#!/bin/sh\necho 'mariadb-dump 11.4.5-MariaDB'\n");
|
||||
let v = version_of(tool.to_str().unwrap()).unwrap();
|
||||
assert!(v.contains("11.4.5-MariaDB"), "got {v}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn probe_within_returns_false_once_the_aggregate_deadline_passed() {
|
||||
let tool = unique_dir().join("ok_tool");
|
||||
write_exe(&tool, "#!/bin/sh\nexit 0\n");
|
||||
let past = Instant::now() - Duration::from_secs(10);
|
||||
assert!(!probe_within(past, Duration::from_secs(3), &tool));
|
||||
assert!(probe_within(Instant::now(), Duration::from_secs(3), &tool));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_prefers_a_trusted_dir_and_the_first_candidate_name() {
|
||||
let req = mysql_dump_request();
|
||||
let trusted = vec![PathBuf::from("/trusted")];
|
||||
let path_lookup = |_n: &str| -> Option<PathBuf> { None };
|
||||
let r = resolve_with(&req, &trusted, &path_lookup, None, |_p: &Path| true).unwrap();
|
||||
assert_eq!(r.source, ToolSource::TrustedDir);
|
||||
assert_eq!(r.name, "mariadb-dump");
|
||||
assert_eq!(r.program, format!("/trusted/{}", bundled_bin_name("mariadb-dump")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_falls_through_to_the_inherited_path() {
|
||||
let req = mysql_dump_request();
|
||||
let trusted = vec![PathBuf::from("/trusted")];
|
||||
let path_lookup = |name: &str| -> Option<PathBuf> {
|
||||
if name == "mysqldump" { Some(PathBuf::from("/usr/bin/mysqldump")) } else { None }
|
||||
};
|
||||
let probe = |p: &Path| p == Path::new("/usr/bin/mysqldump");
|
||||
let r = resolve_with(&req, &trusted, &path_lookup, None, probe).unwrap();
|
||||
assert_eq!(r.source, ToolSource::Path);
|
||||
assert_eq!(r.name, "mysqldump");
|
||||
assert_eq!(r.program, "/usr/bin/mysqldump");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_prefers_any_system_tool_over_the_bundled_one() {
|
||||
let req = mysql_dump_request();
|
||||
let trusted = vec![PathBuf::from("/trusted")];
|
||||
let path_lookup = |_n: &str| -> Option<PathBuf> { None };
|
||||
let bundled = Path::new("/rd/resources/mysql_tools/mariadb-dump");
|
||||
let probe = |p: &Path| p != bundled; // only the trusted candidate runs
|
||||
let r = resolve_with(&req, &trusted, &path_lookup, Some(bundled), probe).unwrap();
|
||||
assert_eq!(r.source, ToolSource::TrustedDir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_uses_the_bundled_binary_when_no_system_tool_runs() {
|
||||
let req = mysql_dump_request();
|
||||
let bundled = Path::new("/rd/resources/mysql_tools/mariadb-dump");
|
||||
let path_lookup = |_n: &str| -> Option<PathBuf> { None };
|
||||
let probe = |p: &Path| p == bundled;
|
||||
let r = resolve_with(&req, &[], &path_lookup, Some(bundled), probe).unwrap();
|
||||
assert_eq!(r.source, ToolSource::Bundled);
|
||||
assert_eq!(r.name, "mariadb-dump");
|
||||
assert_eq!(r.program, "/rd/resources/mysql_tools/mariadb-dump");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_skips_a_candidate_that_fails_the_probe() {
|
||||
let req = mysql_dump_request();
|
||||
let trusted = vec![PathBuf::from("/trusted")];
|
||||
let target = PathBuf::from("/trusted").join(bundled_bin_name("mysqldump"));
|
||||
let probe = move |p: &Path| p == target.as_path();
|
||||
let path_lookup = |_n: &str| -> Option<PathBuf> { None };
|
||||
let r = resolve_with(&req, &trusted, &path_lookup, None, probe).unwrap();
|
||||
assert_eq!(r.name, "mysqldump");
|
||||
assert_eq!(r.source, ToolSource::TrustedDir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_returns_none_when_nothing_runs() {
|
||||
let req = mysql_dump_request();
|
||||
let path_lookup = |_n: &str| -> Option<PathBuf> { None };
|
||||
let r = resolve_with(&req, &[PathBuf::from("/trusted")], &path_lookup, Some(Path::new("/rd/x")), |_p: &Path| false);
|
||||
assert!(r.is_none());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_exe(path: &Path, body: &str) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::write(path, body).unwrap();
|
||||
let mut perms = std::fs::metadata(path).unwrap().permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(path, perms).unwrap();
|
||||
}
|
||||
|
||||
fn unique_dir() -> PathBuf {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let dir = std::env::temp_dir()
|
||||
.join(format!("gridline_tool_resolver_{}_{}", std::process::id(), nanos));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_major_handles_common_version_strings() {
|
||||
assert_eq!(parse_major("8.0.36"), Some(8));
|
||||
assert_eq!(parse_major("8.4.0"), Some(8));
|
||||
assert_eq!(parse_major("11.4.5-MariaDB"), Some(11));
|
||||
assert_eq!(parse_major("5.7.44-log"), Some(5));
|
||||
assert_eq!(parse_major("26.7.0"), Some(26));
|
||||
assert_eq!(parse_major("mariadb-dump from 11.4.5-MariaDB, client 10.19"), Some(11));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_major_unwraps_the_legacy_mariadb_compat_prefix() {
|
||||
// MariaDB historically reported 5.5.5-<real version>-MariaDB for compat
|
||||
// with MySQL-era clients; the real major is the 4th group.
|
||||
assert_eq!(parse_major("5.5.5-10.4.11-MariaDB"), Some(10));
|
||||
assert_eq!(parse_major("5.5.5-10.11.6-MariaDB-log"), Some(10));
|
||||
// A genuine 5.5.5 has only three groups and must stay 5.
|
||||
assert_eq!(parse_major("5.5.5"), Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_major_returns_none_for_garbage() {
|
||||
assert_eq!(parse_major("no digits here"), None);
|
||||
assert_eq!(parse_major(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compat_flags_only_a_clearly_older_client() {
|
||||
assert_eq!(compat(Some(8), Some(26)), Compat::ClientOlder);
|
||||
assert_eq!(compat(Some(11), Some(8)), Compat::Ok);
|
||||
assert_eq!(compat(Some(8), Some(8)), Compat::Ok);
|
||||
assert_eq!(compat(None, Some(8)), Compat::Unknown);
|
||||
assert_eq!(compat(Some(8), None), Compat::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warning_for_is_silent_unless_the_client_is_older() {
|
||||
assert!(warning_for("mariadb-dump 11.4.5-MariaDB", "8.0.36", "mariadb-dump").is_none());
|
||||
assert!(warning_for("unknown", "8.0.36", "mariadb-dump").is_none());
|
||||
let w = warning_for("mariadb-dump 11.4.5-MariaDB", "26.7.0", "mariadb-dump").unwrap();
|
||||
assert!(w.contains("11.4.5-MariaDB"), "{w}");
|
||||
assert!(w.contains("26.7.0"), "{w}");
|
||||
assert!(w.contains("mariadb-dump"), "{w}");
|
||||
// The `\` continuation in the format literal must not leave stray leading
|
||||
// indentation, so assert the exact single-line render (no newline, no run
|
||||
// of consecutive spaces).
|
||||
assert!(!w.contains('\n'), "warning must be one line: {w:?}");
|
||||
assert!(!w.contains(" "), "warning must not contain double spaces: {w:?}");
|
||||
assert_eq!(
|
||||
w,
|
||||
concat!(
|
||||
"Client/server version mismatch: the resolved dump tool `mariadb-dump` reports ",
|
||||
"mariadb-dump 11.4.5-MariaDB, but the server is 26.7.0. ",
|
||||
"The backup may fail or omit data — installing a matching MySQL client is recommended."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_major_never_panics_on_hostile_input() {
|
||||
// A digit group that overflows u32 yields None, never a panic.
|
||||
assert_eq!(parse_major("99999999999999999999.1.1"), None);
|
||||
assert_eq!(parse_major(&"9".repeat(4096)), None);
|
||||
// Non-ASCII digits, unicode and control characters are simply skipped.
|
||||
assert_eq!(parse_major("\u{fc}nicode ٨.٠.٣٦"), None);
|
||||
assert_eq!(parse_major("\u{1F600}"), None);
|
||||
assert_eq!(parse_major("\u{0}\u{1}\u{2}"), None);
|
||||
assert_eq!(parse_major("....."), None);
|
||||
// Long adversarial input: the group collector is capped at six, and only
|
||||
// the first group decides the major.
|
||||
assert_eq!(parse_major(&"1.2.3.4.".repeat(1000)), Some(1));
|
||||
// An overflowing *trailing* group is skipped by the final flush, so a
|
||||
// genuine-looking `5.5.5` prefix still yields 5 rather than panicking.
|
||||
// Harmless: no real tool prints such a version, and the result is advisory.
|
||||
assert_eq!(parse_major("5.5.5-99999999999999999999"), Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapt_resolution_returns_program_and_source() {
|
||||
let res = ToolResolution {
|
||||
resolved: Some(Resolution {
|
||||
program: "/x/mariadb-dump".into(),
|
||||
source: ToolSource::Bundled,
|
||||
name: "mariadb-dump".into(),
|
||||
}),
|
||||
bundled_path: Some(PathBuf::from("/x/mariadb-dump")),
|
||||
bundled_available: true,
|
||||
version: Some("11.4.5".into()),
|
||||
};
|
||||
assert_eq!(
|
||||
adapt_resolution(&res, "mariadb-dump"),
|
||||
("/x/mariadb-dump".to_string(), Some("bundled".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapt_resolution_falls_back_to_the_requested_name() {
|
||||
assert_eq!(
|
||||
adapt_resolution(&ToolResolution::none(), "pg_dump"),
|
||||
("pg_dump".to_string(), None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_round_trips_and_clears() {
|
||||
cache_clear();
|
||||
let res = ToolResolution {
|
||||
resolved: Some(Resolution {
|
||||
program: "/c/tool".into(),
|
||||
source: ToolSource::Path,
|
||||
name: "tool".into(),
|
||||
}),
|
||||
bundled_path: None,
|
||||
bundled_available: false,
|
||||
version: None,
|
||||
};
|
||||
assert!(cache_get("k_task6_unique").is_none());
|
||||
cache_put("k_task6_unique", res.clone());
|
||||
assert_eq!(cache_get("k_task6_unique"), Some(res));
|
||||
cache_clear();
|
||||
assert!(cache_get("k_task6_unique").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_present_is_true_only_for_a_regular_file() {
|
||||
let dir = unique_dir();
|
||||
assert!(!bundled_present(&dir), "a directory is not a present bundled tool");
|
||||
let file = dir.join("mariadb-dump");
|
||||
std::fs::write(&file, b"x").unwrap();
|
||||
assert!(bundled_present(&file));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_request_maps_known_tool_names() {
|
||||
assert_eq!(mysql_request("mariadb-dump").unwrap(), mysql_dump_request());
|
||||
assert_eq!(mysql_request("mysqldump").unwrap(), mysql_dump_request());
|
||||
assert_eq!(mysql_request("mariadb").unwrap(), mysql_client_request());
|
||||
assert_eq!(mysql_request("mysql").unwrap(), mysql_client_request());
|
||||
assert!(mysql_request("mysqladmin").is_none());
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
//! Keychain ACL normalization and repair for stored secrets (macOS).
|
||||
//!
|
||||
//! # Why this exists
|
||||
//!
|
||||
//! Generic-password items stored via the legacy keychain API are guarded by an
|
||||
//! item ACL. For a dev (linker-signed / ad-hoc) binary macOS records that ACL's
|
||||
//! trusted application as **the path of the executable** — not a code
|
||||
//! requirement — so the item stays readable across rebuilds *as long as the
|
||||
//! binary keeps living at that path*. A properly signed bundle is recorded as a
|
||||
//! code requirement plus a `cdhash` partition list, which is stable across
|
||||
//! versions and even moves.
|
||||
//!
|
||||
//! Two failure modes follow from that:
|
||||
//!
|
||||
//! 1. **The path changes** (monorepo move `src-tauri/` → `desktop/src-tauri/`,
|
||||
//! app reinstalled elsewhere, release vs. dev binary). The stored paths no
|
||||
//! longer match the running binary, so macOS treats it as a *foreign* app and
|
||||
//! asks for the keychain password on **every** access — per secret, and with
|
||||
//! no "Always Allow", because the item's ACL lists only other apps.
|
||||
//! 2. **A differently-signed build wrote the item** and the pin never ran again.
|
||||
//!
|
||||
//! Pinning only after writes (`after_store`) can't repair either case: writing
|
||||
//! to an item we can't access already needs the same authorization, so the ACL
|
||||
//! is never corrected. This module therefore also *repairs before reads*.
|
||||
//!
|
||||
//! # How the repair works
|
||||
//!
|
||||
//! [`ensure_access`] first asks, **without prompting**, whether this process is
|
||||
//! already represented in the item's ACL (see [`is_item_trusted`]). Only when it
|
||||
//! is not does it call `SecKeychainItemSetAccess` with an ACL whose trusted list
|
||||
//! is exactly this app. That call is what macOS gates behind the authorization
|
||||
//! dialog — so a stale item costs the user **one** authorization, and every
|
||||
//! later access (including after future rebuilds at the same path) is silent.
|
||||
//! Items that are already trusted are left alone, so the ACL never accumulates
|
||||
//! duplicate entries.
|
||||
//!
|
||||
//! All operations are best-effort: they log to stderr and never fail the
|
||||
//! caller's keychain read/write.
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos_ffi {
|
||||
use core_foundation::base::{CFTypeID, CFTypeRef};
|
||||
|
||||
/// Minimal `TCFType` wrapper for `SecTrustedApplication` — the
|
||||
/// security-framework crate exposes no high-level API for it.
|
||||
#[repr(C)]
|
||||
pub struct OpaqueSecTrustedApplication {
|
||||
_priv: [u8; 0],
|
||||
}
|
||||
pub type SecTrustedApplicationRef = *mut OpaqueSecTrustedApplication;
|
||||
|
||||
use core_foundation::{declare_TCFType, impl_TCFType};
|
||||
declare_TCFType!(SecTrustedApplication, SecTrustedApplicationRef);
|
||||
impl_TCFType!(
|
||||
SecTrustedApplication,
|
||||
SecTrustedApplicationRef,
|
||||
SecTrustedApplicationGetTypeID
|
||||
);
|
||||
unsafe impl Send for SecTrustedApplication {}
|
||||
unsafe impl Sync for SecTrustedApplication {}
|
||||
|
||||
extern "C" {
|
||||
// SecAccess/SecTrustedApplication APIs are deprecated in the macOS SDK
|
||||
// but remain fully functional (the legacy keychain store this app uses
|
||||
// via tauri-plugin-keyring-store relies on the same API family).
|
||||
pub fn SecTrustedApplicationGetTypeID() -> CFTypeID;
|
||||
pub fn SecTrustedApplicationCreateFromPath(
|
||||
path: CFTypeRef,
|
||||
app: *mut SecTrustedApplicationRef,
|
||||
) -> i32;
|
||||
pub fn SecTrustedApplicationCopyData(
|
||||
app: CFTypeRef,
|
||||
data: *mut core_foundation::data::CFDataRef,
|
||||
) -> i32;
|
||||
pub fn SecAccessCreate(
|
||||
descriptor: CFTypeRef,
|
||||
trustedlist: core_foundation::array::CFArrayRef,
|
||||
access: *mut security_framework_sys::base::SecAccessRef,
|
||||
) -> i32;
|
||||
pub fn SecKeychainItemCopyAccess(
|
||||
item: security_framework_sys::base::SecKeychainItemRef,
|
||||
access: *mut security_framework_sys::base::SecAccessRef,
|
||||
) -> i32;
|
||||
pub fn SecAccessCopyACLList(
|
||||
access: security_framework_sys::base::SecAccessRef,
|
||||
list: *mut core_foundation::array::CFArrayRef,
|
||||
) -> i32;
|
||||
pub fn SecACLCopyContents(
|
||||
acl: CFTypeRef,
|
||||
apps: *mut core_foundation::array::CFArrayRef,
|
||||
desc: *mut core_foundation::string::CFStringRef,
|
||||
prompt: *mut u32,
|
||||
) -> i32;
|
||||
pub fn SecKeychainItemSetAccess(
|
||||
item: security_framework_sys::base::SecKeychainItemRef,
|
||||
access: security_framework_sys::base::SecAccessRef,
|
||||
) -> i32;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use core_foundation::base::TCFType;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use macos_ffi::{
|
||||
SecACLCopyContents, SecAccessCopyACLList, SecAccessCreate, SecKeychainItemCopyAccess,
|
||||
SecKeychainItemSetAccess, SecTrustedApplication, SecTrustedApplicationCopyData,
|
||||
SecTrustedApplicationCreateFromPath,
|
||||
};
|
||||
|
||||
/// Best-effort ACL normalization after a successful keychain write.
|
||||
pub fn after_store(app: &tauri::AppHandle, account: &str) {
|
||||
ensure_access(app, account);
|
||||
}
|
||||
|
||||
/// Best-effort ACL repair *before* reading a secret.
|
||||
///
|
||||
/// A stale ACL is what makes macOS ask for the keychain password on every
|
||||
/// access, so this runs before reads: it authorizes/repairs the item once and
|
||||
/// leaves it silent from then on. Prompt-free (and cheap) when the item is
|
||||
/// already trusted or does not exist.
|
||||
pub fn before_read(app: &tauri::AppHandle, account: &str) {
|
||||
ensure_access(app, account);
|
||||
}
|
||||
|
||||
/// Resolve the keyring service name (the Tauri bundle identifier) and repair the
|
||||
/// item's ACL when this app is not already trusted. Never fails the caller.
|
||||
fn ensure_access(app: &tauri::AppHandle, account: &str) {
|
||||
let service = app.config().identifier.clone();
|
||||
if let Err(e) = ensure_item_acl(&service, account) {
|
||||
eprintln!("[keychain_acl] skipped for {account}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Make sure this app can read `account` without an authorization prompt.
|
||||
///
|
||||
/// Idempotent and cheap when the item is already trusted (the common case):
|
||||
/// the trust check is a metadata-only, prompt-free lookup. Returns `Ok(())` when
|
||||
/// the item exists and now trusts this app, or when there is nothing to do.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn ensure_item_acl(service: &str, account: &str) -> Result<(), String> {
|
||||
use security_framework::os::macos::access::SecAccess;
|
||||
use security_framework_sys::base::SecAccessRef;
|
||||
|
||||
// Locate the item WITHOUT reading its secret: a ref-only lookup
|
||||
// (`kSecReturnRef`, no data/attributes) never triggers an authorization
|
||||
// prompt, so merely probing an item we can't yet access is safe.
|
||||
let item = match item_ref(service, account)? {
|
||||
Some(item) => item,
|
||||
// No item (yet) — nothing to normalize.
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
// Already trusted → leave the ACL alone (repairing again would only
|
||||
// authorize-and-append a duplicate entry).
|
||||
if is_item_trusted(&item) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
fn cvt(status: i32) -> Result<(), String> {
|
||||
if status == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("security framework error {status}"))
|
||||
}
|
||||
}
|
||||
|
||||
// Build an access object whose trusted list is exactly this app. A NULL
|
||||
// path resolves the trusted application to the current process's identity
|
||||
// (its executable path for ad-hoc dev builds, its code requirement for a
|
||||
// signed bundle).
|
||||
unsafe {
|
||||
let mut trusted_app_ref = std::ptr::null_mut();
|
||||
cvt(SecTrustedApplicationCreateFromPath(
|
||||
std::ptr::null(),
|
||||
&mut trusted_app_ref,
|
||||
))?;
|
||||
let trusted_app = SecTrustedApplication::wrap_under_create_rule(trusted_app_ref);
|
||||
let trusted_list = core_foundation::array::CFArray::from_CFTypes(&[trusted_app]);
|
||||
|
||||
let descriptor = core_foundation::string::CFString::new("Gridline");
|
||||
let mut access_ref: SecAccessRef = std::ptr::null_mut();
|
||||
cvt(SecAccessCreate(
|
||||
descriptor.as_concrete_TypeRef() as core_foundation::base::CFTypeRef,
|
||||
trusted_list.as_concrete_TypeRef(),
|
||||
&mut access_ref,
|
||||
))?;
|
||||
let access = SecAccess::wrap_under_create_rule(access_ref);
|
||||
|
||||
// Replace the item's ACL. Silent when this app already owns the item;
|
||||
// the one-time authorization dialog otherwise (this is what makes a
|
||||
// stale item permanently silent again).
|
||||
cvt(SecKeychainItemSetAccess(
|
||||
item.as_concrete_TypeRef(),
|
||||
access.as_concrete_TypeRef(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Ref-only lookup for a generic-password item. Prompt-free.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn item_ref(
|
||||
service: &str,
|
||||
account: &str,
|
||||
) -> Result<Option<security_framework::os::macos::keychain_item::SecKeychainItem>, String> {
|
||||
use security_framework::item::{ItemClass, ItemSearchOptions, Reference, SearchResult};
|
||||
|
||||
let found = match ItemSearchOptions::new()
|
||||
.class(ItemClass::generic_password())
|
||||
.service(service)
|
||||
.account(account)
|
||||
.load_refs(true)
|
||||
.load_data(false)
|
||||
.load_attributes(false)
|
||||
.search()
|
||||
{
|
||||
Ok(found) => found,
|
||||
// `search()` reports "nothing matched" as an error, not an empty vec.
|
||||
// That is the normal case for connections without a stored secret.
|
||||
Err(e) if e.code() == security_framework_sys::base::errSecItemNotFound => return Ok(None),
|
||||
Err(e) => return Err(format!("keychain item lookup failed: {e}")),
|
||||
};
|
||||
|
||||
Ok(match found.first() {
|
||||
Some(SearchResult::Ref(Reference::KeychainItem(item))) => Some(item.clone()),
|
||||
// No item or an unexpected result shape — nothing to normalize.
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// This process's identity as a `SecTrustedApplication`, serialized.
|
||||
///
|
||||
/// For a signed bundle this is the code requirement; for a dev/ad-hoc binary it
|
||||
/// is the resolved executable path (NUL-terminated). Either way, byte-comparing
|
||||
/// it against an item's ACL entries tells us whether the ACL already admits us.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn own_identity() -> Option<Vec<u8>> {
|
||||
unsafe {
|
||||
let mut app = std::ptr::null_mut();
|
||||
if SecTrustedApplicationCreateFromPath(std::ptr::null(), &mut app) != 0 || app.is_null() {
|
||||
return None;
|
||||
}
|
||||
let app = SecTrustedApplication::wrap_under_create_rule(app);
|
||||
let mut data: core_foundation::data::CFDataRef = std::ptr::null();
|
||||
if SecTrustedApplicationCopyData(
|
||||
app.as_concrete_TypeRef() as core_foundation::base::CFTypeRef,
|
||||
&mut data,
|
||||
) != 0
|
||||
|| data.is_null()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let bytes = std::slice::from_raw_parts(
|
||||
core_foundation::data::CFDataGetBytePtr(data),
|
||||
core_foundation::data::CFDataGetLength(data) as usize,
|
||||
)
|
||||
.to_vec();
|
||||
core_foundation::base::CFRelease(data as core_foundation::base::CFTypeRef);
|
||||
Some(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this process is already admitted by the item's ACL — **prompt-free**.
|
||||
///
|
||||
/// True when an ACL entry's serialized trusted application is byte-identical to
|
||||
/// this process's identity, or when the item carries a `cdhash:` partition list
|
||||
/// (how macOS records access for a signed bundle — we leave those untouched).
|
||||
#[cfg(target_os = "macos")]
|
||||
fn is_item_trusted(item: &security_framework::os::macos::keychain_item::SecKeychainItem) -> bool {
|
||||
use core_foundation::array::{CFArray, CFArrayRef};
|
||||
use core_foundation::base::{CFTypeRef, TCFType};
|
||||
use core_foundation::string::{CFString, CFStringRef};
|
||||
|
||||
let Some(own) = own_identity() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
unsafe {
|
||||
let mut access: security_framework_sys::base::SecAccessRef = std::ptr::null_mut();
|
||||
if SecKeychainItemCopyAccess(item.as_concrete_TypeRef(), &mut access) != 0
|
||||
|| access.is_null()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut acl_list: CFArrayRef = std::ptr::null();
|
||||
if SecAccessCopyACLList(access, &mut acl_list) != 0 || acl_list.is_null() {
|
||||
return false;
|
||||
}
|
||||
let acls = CFArray::<CFTypeRef>::wrap_under_create_rule(acl_list);
|
||||
|
||||
for acl in acls.iter() {
|
||||
let mut apps: CFArrayRef = std::ptr::null();
|
||||
let mut desc: CFStringRef = std::ptr::null();
|
||||
let mut prompt: u32 = 0;
|
||||
if SecACLCopyContents(*acl, &mut apps, &mut desc, &mut prompt) != 0 {
|
||||
continue;
|
||||
}
|
||||
if !desc.is_null() {
|
||||
// Signed-bundle items store a code-signing partition list here.
|
||||
if CFString::wrap_under_create_rule(desc)
|
||||
.to_string()
|
||||
.contains("cdhash:")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if apps.is_null() {
|
||||
continue;
|
||||
}
|
||||
let list = CFArray::<CFTypeRef>::wrap_under_create_rule(apps);
|
||||
for app in list.iter() {
|
||||
let mut data: core_foundation::data::CFDataRef = std::ptr::null();
|
||||
if SecTrustedApplicationCopyData(*app, &mut data) != 0 || data.is_null() {
|
||||
continue;
|
||||
}
|
||||
let bytes = std::slice::from_raw_parts(
|
||||
core_foundation::data::CFDataGetBytePtr(data),
|
||||
core_foundation::data::CFDataGetLength(data) as usize,
|
||||
);
|
||||
if bytes == own.as_slice() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-macOS platforms have no keychain ACL concept — nothing to do.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn ensure_item_acl(_service: &str, _account: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use security_framework::os::macos::keychain::SecKeychain;
|
||||
use security_framework::os::macos::passwords::find_generic_password;
|
||||
|
||||
/// A missing item is a silent no-op — no lookup errors, no prompts. This is
|
||||
/// the path taken for every connection that never stored a password, so it
|
||||
/// must stay cheap and quiet.
|
||||
#[test]
|
||||
fn ensure_item_acl_missing_item_is_noop() {
|
||||
let service = "com.adrianbonpin.gridline.acl-test.missing";
|
||||
let account = "definitely-not-stored";
|
||||
ensure_item_acl(service, account).expect("no-op for a missing item");
|
||||
}
|
||||
|
||||
/// Real-keychain integration test: creates a generic-password item the same
|
||||
/// way the keyring store does, asserts the trust check, repairs the ACL,
|
||||
/// verifies the item is now trusted (and that repairing again is a silent
|
||||
/// no-op), reads the secret back, and cleans up.
|
||||
///
|
||||
/// Ignored by default (`cargo test`) so CI/dev runs never touch the login
|
||||
/// keychain. Run explicitly with:
|
||||
/// `cargo test -- --ignored keychain_acl`
|
||||
#[test]
|
||||
#[ignore = "touches the real login keychain"]
|
||||
fn keychain_acl_normalizes_item() {
|
||||
let keychain = SecKeychain::default().expect("default keychain");
|
||||
let service = "com.adrianbonpin.gridline.acl-test";
|
||||
let account = format!("acl-test-{}", std::process::id());
|
||||
|
||||
// Create the item exactly like apple-native-keyring-store does
|
||||
// (legacy SecKeychain generic-password API).
|
||||
keychain
|
||||
.set_generic_password(service, &account, b"test-secret")
|
||||
.expect("create test item");
|
||||
|
||||
// Fresh items are created with this build as the trusted app, so the
|
||||
// trust check must already be true and the repair a no-op.
|
||||
let item = item_ref(service, &account)
|
||||
.expect("lookup")
|
||||
.expect("item exists");
|
||||
assert!(is_item_trusted(&item), "fresh item should already trust us");
|
||||
|
||||
ensure_item_acl(service, &account).expect("normalize ACL");
|
||||
assert!(
|
||||
is_item_trusted(&item),
|
||||
"item must be trusted after repair"
|
||||
);
|
||||
|
||||
// Idempotent: a second repair must not need authorization again.
|
||||
ensure_item_acl(service, &account).expect("second repair");
|
||||
|
||||
// The item must remain readable with the correct secret.
|
||||
let (password, item) =
|
||||
find_generic_password(Some(&[keychain]), service, &account).expect("read back");
|
||||
assert_eq!(password.as_ref(), b"test-secret");
|
||||
|
||||
// Cleanup: delete the test item.
|
||||
item.delete();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
mod cancel;
|
||||
mod commands;
|
||||
mod db;
|
||||
mod keychain_acl;
|
||||
mod models;
|
||||
mod store;
|
||||
|
||||
@@ -12,7 +13,7 @@ use commands::ssh::{Ssh2Backend, SshTunnelManager};
|
||||
use db::pool::ConnectionPoolManager;
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use store::Store;
|
||||
use tauri::Manager;
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
pub struct AppState {
|
||||
pub db_store: StdMutex<Store>,
|
||||
@@ -23,7 +24,7 @@ pub struct AppState {
|
||||
|
||||
use commands::{
|
||||
backup, connections, db_viewer, demo, folders, import_export, keychain, maintenance, objects, query,
|
||||
schema_graph, settings, tags,
|
||||
query_export, schema_diff, schema_graph, settings, tags,
|
||||
};
|
||||
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
@@ -38,11 +39,32 @@ pub fn run() {
|
||||
// another provider is already installed).
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
tauri::Builder::default()
|
||||
let builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_keyring_store::init())
|
||||
// OS "open with" paths are queued here before the webview exists.
|
||||
.manage(crate::commands::open_files::PendingOpenFiles::default());
|
||||
|
||||
// Shadowed, not `mut`: on macOS this cfg block is compiled out, so a
|
||||
// mutable binding would only earn an `unused_mut` warning. The plugin
|
||||
// itself stays a Windows/Linux-only, target-scoped dependency.
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
let builder = builder.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
|
||||
let pending = app.state::<crate::commands::open_files::PendingOpenFiles>();
|
||||
let mut accepted = false;
|
||||
for arg in argv.iter().skip(1) {
|
||||
if crate::commands::open_files::accept_path(&pending, arg) {
|
||||
accepted = true;
|
||||
}
|
||||
}
|
||||
if accepted {
|
||||
let _ = app.emit("sql-file-opened", ());
|
||||
}
|
||||
}));
|
||||
|
||||
builder
|
||||
.setup(move |app| {
|
||||
// Open the local store under the OS app-data directory. When the
|
||||
// app is launched from Finder/LaunchServices the working directory
|
||||
@@ -58,6 +80,14 @@ pub fn run() {
|
||||
let store =
|
||||
Store::open(&data_dir.join("gridline.db").to_string_lossy()).expect("failed to open db");
|
||||
|
||||
// Cold-start "open with": the path arrived as a process argument
|
||||
// before the webview existed, so queue it here. macOS uses
|
||||
// RunEvent::Opened instead.
|
||||
for arg in std::env::args().skip(1) {
|
||||
let pending = app.state::<crate::commands::open_files::PendingOpenFiles>();
|
||||
let _ = crate::commands::open_files::accept_path(&pending, &arg);
|
||||
}
|
||||
|
||||
app.manage(AppState {
|
||||
db_store: StdMutex::new(store),
|
||||
pool_manager: tokio::sync::Mutex::new(ConnectionPoolManager::new()),
|
||||
@@ -135,6 +165,7 @@ pub fn run() {
|
||||
db_viewer::get_extensions,
|
||||
db_viewer::get_indexes,
|
||||
db_viewer::get_constraints,
|
||||
db_viewer::get_hypertables,
|
||||
objects::create_schema,
|
||||
objects::rename_schema,
|
||||
objects::drop_schema,
|
||||
@@ -171,8 +202,12 @@ pub fn run() {
|
||||
settings::export_settings,
|
||||
settings::import_settings,
|
||||
schema_graph::get_schema_graph,
|
||||
schema_diff::compare_schemas,
|
||||
query::execute_query,
|
||||
query::execute_query_multi,
|
||||
query_export::export_query_to_file,
|
||||
query::cancel_query,
|
||||
crate::commands::open_files::take_pending_sql_files,
|
||||
query::get_query_history,
|
||||
query::clear_query_history,
|
||||
query::set_history_favorite,
|
||||
@@ -183,16 +218,34 @@ pub fn run() {
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app_handle, event| {
|
||||
.run(|app_handle, event| match event {
|
||||
// macOS: Finder / LaunchServices "open with". File URLs only.
|
||||
#[cfg(target_os = "macos")]
|
||||
tauri::RunEvent::Opened { urls } => {
|
||||
let pending = app_handle
|
||||
.state::<crate::commands::open_files::PendingOpenFiles>();
|
||||
let mut accepted = false;
|
||||
for url in urls {
|
||||
if let Ok(path) = url.to_file_path() {
|
||||
if crate::commands::open_files::accept_path(
|
||||
&pending,
|
||||
&path.to_string_lossy(),
|
||||
) {
|
||||
accepted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if accepted {
|
||||
let _ = app_handle.emit("sql-file-opened", ());
|
||||
}
|
||||
}
|
||||
// Close all SSH tunnels on exit: ExitRequested fires before the
|
||||
// event loop ends, Exit fires after it has.
|
||||
if matches!(
|
||||
event,
|
||||
tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit
|
||||
) {
|
||||
tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit => {
|
||||
if let Ok(mut mgr) = app_handle.state::<AppState>().ssh_manager.lock() {
|
||||
mgr.close_all();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -42,6 +42,12 @@ pub struct PgToolStatus {
|
||||
pub pg_restore_version: Option<String>,
|
||||
pub pg_dump_source: Option<String>, // "system" | "bundled" | None
|
||||
pub pg_restore_source: Option<String>,
|
||||
/// The binary name that actually resolved (`mariadb-dump`, `mysqldump`, …).
|
||||
pub pg_dump_resolved_name: Option<String>,
|
||||
pub pg_restore_resolved_name: Option<String>,
|
||||
/// Whether a bundled file exists at the expected path (runnable or not).
|
||||
pub pg_dump_bundled_available: bool,
|
||||
pub pg_restore_bundled_available: bool,
|
||||
}
|
||||
|
||||
/// Resolved on-disk paths for the three client tools (system-first, bundled-fallback).
|
||||
@@ -111,6 +117,12 @@ pub struct MySqlToolStatus {
|
||||
pub mysql_version: Option<String>,
|
||||
pub mysqldump_source: Option<String>,
|
||||
pub mysql_source: Option<String>,
|
||||
pub mysqldump_resolved_name: Option<String>,
|
||||
pub mysql_resolved_name: Option<String>,
|
||||
/// True when the dump tool that resolved is MariaDB's `mariadb-dump`.
|
||||
pub mysqldump_is_mariadb: bool,
|
||||
pub mysqldump_bundled_available: bool,
|
||||
pub mysql_bundled_available: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -141,6 +153,9 @@ pub struct BackupProgressEvent {
|
||||
pub progress: Option<f64>,
|
||||
pub output_line: Option<String>,
|
||||
pub error: Option<String>,
|
||||
/// Non-fatal advisory message (e.g. a client/server version mismatch).
|
||||
/// `None` for ordinary progress events.
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -185,7 +200,11 @@ mod tests {
|
||||
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()) };
|
||||
pg_dump_source: Some("system".into()), pg_restore_source: Some("bundled".into()),
|
||||
pg_dump_resolved_name: Some("pg_dump".into()),
|
||||
pg_restore_resolved_name: None,
|
||||
pg_dump_bundled_available: true,
|
||||
pg_restore_bundled_available: false };
|
||||
let json = serde_json::to_string(&s).unwrap();
|
||||
assert!(json.contains("\"pg_dump_source\":\"system\""));
|
||||
assert!(json.contains("\"pg_restore_source\":\"bundled\""));
|
||||
@@ -225,6 +244,11 @@ mod tests {
|
||||
mysql_version: Some("mariadb 10.6".into()),
|
||||
mysqldump_source: Some("bundled".into()),
|
||||
mysql_source: Some("system".into()),
|
||||
mysqldump_resolved_name: Some("mariadb-dump".into()),
|
||||
mysql_resolved_name: Some("mariadb".into()),
|
||||
mysqldump_is_mariadb: true,
|
||||
mysqldump_bundled_available: true,
|
||||
mysql_bundled_available: true,
|
||||
};
|
||||
let json = serde_json::to_string(&s).unwrap();
|
||||
assert!(json.contains("\"mysqldumpSource\":\"bundled\""));
|
||||
|
||||
@@ -72,6 +72,22 @@ pub struct QueryResult {
|
||||
pub execution_time_ms: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StatementNotice {
|
||||
pub statement_index: usize,
|
||||
/// "dml" | "ddl" | "error"
|
||||
pub kind: String,
|
||||
pub text: String,
|
||||
pub affected: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MultiQueryResult {
|
||||
pub result_sets: Vec<QueryResult>,
|
||||
pub notices: Vec<StatementNotice>,
|
||||
pub execution_time_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Pagination {
|
||||
pub page: i64,
|
||||
@@ -132,6 +148,23 @@ pub struct ExtensionInfo {
|
||||
pub comment: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HypertableInfo {
|
||||
pub name: String,
|
||||
pub schema: String,
|
||||
pub num_dimensions: i64,
|
||||
pub compression_enabled: bool,
|
||||
pub num_chunks: i64,
|
||||
pub total_size_bytes: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HypertableListResponse {
|
||||
pub available: bool,
|
||||
pub reason: Option<String>,
|
||||
pub items: Vec<HypertableInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObjectSearchHit {
|
||||
pub name: String,
|
||||
@@ -312,6 +345,125 @@ pub struct Relationship {
|
||||
pub cardinality: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema diff (0.8.0)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub struct SnapshotColumn {
|
||||
pub name: String,
|
||||
pub data_type: String,
|
||||
pub nullable: bool,
|
||||
pub default_value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub struct SnapshotIndex {
|
||||
pub name: String,
|
||||
pub columns: Vec<String>,
|
||||
pub unique: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub struct SnapshotFk {
|
||||
pub name: String,
|
||||
pub columns: Vec<String>,
|
||||
pub ref_schema: String,
|
||||
pub ref_table: String,
|
||||
pub ref_columns: Vec<String>,
|
||||
pub on_delete: String,
|
||||
pub on_update: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub struct SnapshotConstraint {
|
||||
pub name: String,
|
||||
/// "unique" | "check"
|
||||
pub kind: String,
|
||||
/// check: expression; unique: comma-joined columns
|
||||
pub definition: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub struct SnapshotTable {
|
||||
pub schema: String,
|
||||
pub name: String,
|
||||
pub columns: Vec<SnapshotColumn>,
|
||||
pub primary_key: Vec<String>,
|
||||
pub uniques: Vec<SnapshotConstraint>,
|
||||
pub checks: Vec<SnapshotConstraint>,
|
||||
pub indexes: Vec<SnapshotIndex>,
|
||||
pub foreign_keys: Vec<SnapshotFk>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub struct SnapshotView {
|
||||
pub schema: String,
|
||||
pub name: String,
|
||||
pub definition: String,
|
||||
pub materialized: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub struct SnapshotSequence {
|
||||
pub schema: String,
|
||||
pub name: String,
|
||||
pub start: String,
|
||||
pub increment: String,
|
||||
pub minimum: String,
|
||||
pub maximum: String,
|
||||
pub cycle: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub struct SnapshotEnum {
|
||||
pub schema: String,
|
||||
pub name: String,
|
||||
pub labels: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub struct SchemaSnapshot {
|
||||
/// "postgresql" | "mysql" | "mariadb" | "sqlite" — the CONNECTION's db_type
|
||||
pub engine: String,
|
||||
pub schema: String,
|
||||
pub tables: Vec<SnapshotTable>,
|
||||
pub views: Vec<SnapshotView>,
|
||||
pub sequences: Vec<SnapshotSequence>,
|
||||
pub enums: Vec<SnapshotEnum>,
|
||||
pub object_count: usize,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DiffDetailLine {
|
||||
pub label: String,
|
||||
pub old: String,
|
||||
pub new: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DiffItem {
|
||||
/// "table" | "column" | "constraint" | "index" | "view" | "sequence" | "enum"
|
||||
pub object_type: String,
|
||||
/// For column/constraint/index items: "<table>.<name>"; else the object name
|
||||
pub name: String,
|
||||
/// "added" | "removed" | "changed"
|
||||
pub kind: String,
|
||||
pub detail: Vec<DiffDetailLine>,
|
||||
/// Target-side statements (usually exactly one per item)
|
||||
pub sync_sql: Vec<String>,
|
||||
pub destructive: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiffReport {
|
||||
pub items: Vec<DiffItem>,
|
||||
pub source_label: String,
|
||||
pub target_label: String,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Gridline",
|
||||
"version": "0.7.12",
|
||||
"version": "0.8.3",
|
||||
"identifier": "com.adrianbonpin.gridline",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
@@ -27,9 +27,20 @@
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"resources": ["resources/pg_tools/*", "resources/mysql_tools/*"],
|
||||
"fileAssociations": [
|
||||
{
|
||||
"ext": ["sql"],
|
||||
"name": "SQL Script",
|
||||
"description": "SQL script",
|
||||
"mimeType": "application/sql",
|
||||
"role": "Editor",
|
||||
"rank": "Alternate"
|
||||
}
|
||||
],
|
||||
"macOS": {
|
||||
"signingIdentity": "-",
|
||||
"hardenedRuntime": false
|
||||
"hardenedRuntime": true,
|
||||
"entitlements": "entitlements.plist"
|
||||
},
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
|
||||
@@ -9,7 +9,9 @@ import { NewConnectionScreen } from "./components/connections/NewConnectionScree
|
||||
import { ErrorBanner } from "./components/ui/ErrorBanner";
|
||||
import { ToastContainer } from "./components/ui/Toast";
|
||||
import { DbViewerScreen } from "./components/db-viewer/DbViewerScreen";
|
||||
import { PendingSqlFileBanner } from "./components/layout/PendingSqlFileBanner";
|
||||
import { useAppearance } from "./hooks/useAppearance";
|
||||
import { useSqlFileOpen } from "./hooks/useSqlFileOpen";
|
||||
import { isMacOS } from "./lib/platform";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
@@ -36,6 +38,7 @@ export default function App() {
|
||||
const clearPrefilledConnectionString = useUiStore((s) => s.clearPrefilledConnectionString);
|
||||
|
||||
useAppearance(settings?.theme ?? "system", settings?.font_size ?? "medium", settings?.accent_color ?? "#2563EB");
|
||||
useSqlFileOpen();
|
||||
|
||||
useEffect(() => {
|
||||
loadConnections();
|
||||
@@ -90,6 +93,7 @@ export default function App() {
|
||||
className="h-7 shrink-0 bg-canvas select-none"
|
||||
/>
|
||||
)}
|
||||
<PendingSqlFileBanner />
|
||||
<div className="flex-1 min-h-0">
|
||||
{connectionError && (
|
||||
<div className="px-6 pt-4">
|
||||
|
||||
@@ -4,16 +4,19 @@ import type { ConnectionFormData } from "./connectionFormData";
|
||||
interface ConnectionMetadataRowProps {
|
||||
form: ConnectionFormData;
|
||||
onChange: (updates: Partial<ConnectionFormData>) => void;
|
||||
/** Shown when the label is empty — usually the name derived from a pasted
|
||||
* connection string, which Test/Save uses as a fallback. */
|
||||
namePlaceholder?: string;
|
||||
}
|
||||
|
||||
export function ConnectionMetadataRow({ form, onChange }: ConnectionMetadataRowProps) {
|
||||
export function ConnectionMetadataRow({ form, onChange, namePlaceholder }: ConnectionMetadataRowProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm text-text mb-1.5">Connection Label</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(value) => onChange({ name: value })}
|
||||
placeholder="My Production Database"
|
||||
placeholder={namePlaceholder || "My Production Database"}
|
||||
aria-label="Connection Label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -8,15 +8,18 @@ import type { ConnectionFormData } from "./connectionFormData";
|
||||
export interface DetailedConnectionFormProps {
|
||||
form: ConnectionFormData;
|
||||
onChange: (updates: Partial<ConnectionFormData>) => void;
|
||||
managedPreset?: "supabase" | "neon" | null;
|
||||
managedPreset?: "supabase" | "neon" | "planetscale" | null;
|
||||
/** Placeholder for the Connection Label when the field is empty, e.g. the
|
||||
* name derived from a pasted connection string. */
|
||||
namePlaceholder?: string;
|
||||
}
|
||||
|
||||
export function DetailedConnectionForm({ form, onChange, managedPreset }: DetailedConnectionFormProps) {
|
||||
export function DetailedConnectionForm({ form, onChange, managedPreset, namePlaceholder }: DetailedConnectionFormProps) {
|
||||
const [activeTab, setActiveTab] = useState<"general" | "tagsEnv" | "ssh">("general");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ConnectionMetadataRow form={form} onChange={onChange} />
|
||||
<ConnectionMetadataRow form={form} onChange={onChange} namePlaceholder={namePlaceholder} />
|
||||
<div>
|
||||
<div className="flex gap-6 border-b border-border mb-4">
|
||||
<button type="button" onClick={() => setActiveTab("general")} className={`pb-2 text-sm cursor-pointer transition-colors ${activeTab === "general" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"}`}>General</button>
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { ConnectionFormData } from "./connectionFormData";
|
||||
export interface GeneralTabProps {
|
||||
form: ConnectionFormData;
|
||||
onChange: (updates: Partial<ConnectionFormData>) => void;
|
||||
managedPreset?: "supabase" | "neon" | null;
|
||||
managedPreset?: "supabase" | "neon" | "planetscale" | null;
|
||||
}
|
||||
|
||||
const AUTH_OPTIONS = ["User & Password"];
|
||||
@@ -32,7 +32,7 @@ export function GeneralTab({ form, onChange, managedPreset }: GeneralTabProps) {
|
||||
)}
|
||||
{managedPreset && (
|
||||
<p className="text-xs text-accent-muted mt-1.5">
|
||||
{managedPreset === "supabase" ? "Supabase" : "NeonDB"} requires SSL — enable it under SSH / SSL.
|
||||
{managedPreset === "supabase" ? "Supabase" : managedPreset === "planetscale" ? "PlanetScale" : "NeonDB"} requires SSL — enable it under SSH / SSL.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -52,6 +52,60 @@ describe("NewConnectionScreen", () => {
|
||||
expect(screen.getByLabelText("User")).toHaveValue("u");
|
||||
});
|
||||
|
||||
it("paste a Neon URL pre-fills every required setting, including SSL mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NewConnectionScreen />);
|
||||
await user.type(
|
||||
screen.getByLabelText(/connection uri/i),
|
||||
"postgresql://neondb_owner:npg_secret@ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require",
|
||||
);
|
||||
// Every field the URL can supply is filled, so no manual cleanup is needed
|
||||
expect(screen.getByLabelText("Host")).toHaveValue(
|
||||
"ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech",
|
||||
);
|
||||
expect(screen.getByLabelText("Port")).toHaveValue(5432);
|
||||
expect(screen.getByLabelText("User")).toHaveValue("neondb_owner");
|
||||
expect(screen.getByLabelText("Password")).toHaveValue("npg_secret");
|
||||
expect(screen.getByLabelText("Database")).toHaveValue("neondb");
|
||||
// The label stays untouched but is suggested from the database name.
|
||||
expect(screen.getByLabelText(/connection label/i)).toHaveValue("");
|
||||
expect(screen.getByLabelText(/connection label/i)).toHaveAttribute(
|
||||
"placeholder",
|
||||
"neondb",
|
||||
);
|
||||
|
||||
// SSL mode is visible on the SSH / SSL tab...
|
||||
await user.click(screen.getByRole("button", { name: /ssh \/ ssl/i }));
|
||||
await user.click(screen.getByRole("button", { name: "SSL" }));
|
||||
expect(screen.getByRole("button", { name: "SSL Mode" })).toHaveTextContent("Require");
|
||||
|
||||
// ...and reaches the save payload with no extra clicks.
|
||||
await user.click(screen.getByText("Save Connection"));
|
||||
await waitFor(() => expect(createConnection).toHaveBeenCalledTimes(1));
|
||||
expect(createConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "neondb",
|
||||
db_type: "postgresql",
|
||||
ssl_mode: "require",
|
||||
username: "neondb_owner",
|
||||
password: "npg_secret",
|
||||
database: "neondb",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("a typed label wins over the URL-derived suggestion", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NewConnectionScreen />);
|
||||
await user.type(screen.getByLabelText(/connection uri/i), "postgresql://u:p@localhost:5432/db");
|
||||
await user.type(screen.getByLabelText(/connection label/i), "My DB");
|
||||
await user.click(screen.getByText("Save Connection"));
|
||||
await waitFor(() => expect(createConnection).toHaveBeenCalledTimes(1));
|
||||
expect(createConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "My DB" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("clicking a provider tab reveals the form with that db_type", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NewConnectionScreen />);
|
||||
@@ -60,6 +114,19 @@ describe("NewConnectionScreen", () => {
|
||||
expect(screen.getByLabelText("Port")).toHaveValue(3306);
|
||||
});
|
||||
|
||||
it("NeonDB card preselects Require SSL in the save payload", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NewConnectionScreen />);
|
||||
await user.click(screen.getByRole("button", { name: /NeonDB/i }));
|
||||
await user.type(screen.getByLabelText(/connection label/i), "Neon prod");
|
||||
await user.type(screen.getByLabelText("Host"), "ep-x-pooler.eu.aws.neon.tech");
|
||||
await user.click(screen.getByText("Save Connection"));
|
||||
await waitFor(() => expect(createConnection).toHaveBeenCalledTimes(1));
|
||||
expect(createConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "Neon prod", ssl_mode: "require" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("SQLite tab swaps the URI input for a File Path input", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NewConnectionScreen />);
|
||||
@@ -103,6 +170,20 @@ describe("NewConnectionScreen", () => {
|
||||
expect(notify).toHaveBeenCalledWith("Connection successful", "success");
|
||||
});
|
||||
|
||||
it("tests a pasted URL with its SSL mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NewConnectionScreen />);
|
||||
await user.type(
|
||||
screen.getByLabelText(/connection uri/i),
|
||||
"postgresql://u:p@ep-x-pooler.eu.aws.neon.tech/db?sslmode=verify-full",
|
||||
);
|
||||
await user.click(screen.getByText("Test Connection"));
|
||||
await waitFor(() => expect(testConnection).toHaveBeenCalledTimes(1));
|
||||
expect(testConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ssl_mode: "verify-full" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("invokes onCancel when Back is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
@@ -9,6 +9,8 @@ import { ProviderSetupGuide } from "./ProviderSetupGuide";
|
||||
import {
|
||||
parseConnectionString,
|
||||
detectProviderFromHost,
|
||||
suggestConnectionLabel,
|
||||
type ParsedConnectionString,
|
||||
} from "../../lib/connectionString";
|
||||
import { getProviderById, type ProviderId } from "../../lib/providers";
|
||||
import { validateConnectionInput } from "../../lib/utils";
|
||||
@@ -31,6 +33,7 @@ type Stage = "entry" | "configured";
|
||||
const FALLBACK_PORTS: Record<string, number> = {
|
||||
postgresql: 5432,
|
||||
mysql: 3306,
|
||||
mariadb: 3306,
|
||||
redis: 6379,
|
||||
};
|
||||
|
||||
@@ -52,6 +55,10 @@ function createEmptyForm(
|
||||
database: null,
|
||||
use_keychain: true,
|
||||
ssh_password: null,
|
||||
ssl_mode: null,
|
||||
ssl_ca_path: null,
|
||||
ssl_cert_path: null,
|
||||
ssl_key_path: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,6 +70,40 @@ function getDefaultPort(dbType: string): number {
|
||||
);
|
||||
}
|
||||
|
||||
type ManagedPreset = "supabase" | "neon" | "planetscale" | null;
|
||||
|
||||
/** Managed-provider highlight for a parsed URL (Postgres/MySQL hosts only). */
|
||||
function providerForParsed(parsed: ParsedConnectionString): ManagedPreset {
|
||||
return parsed.db_type === "postgresql" || parsed.db_type === "mysql"
|
||||
? detectProviderFromHost(parsed.host)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a pasted connection string can populate, so the connection can be
|
||||
* tested and saved with no manual cleanup: connection type, host, port,
|
||||
* user/password/database, and the `?sslmode=` TLS mode.
|
||||
*
|
||||
* The URL's SSL mode wins when present; otherwise the mode the user already
|
||||
* picked under SSH / SSL is kept. The label is left blank when the user has not
|
||||
* typed one (the derived name is offered as a placeholder and used on save by
|
||||
* `buildPayload`), so a suggestion never clobbers or gets appended to.
|
||||
*/
|
||||
function parsedFormPatch(
|
||||
parsed: ParsedConnectionString,
|
||||
prev: ConnectionFormData,
|
||||
): Partial<ConnectionFormData> {
|
||||
return {
|
||||
db_type: parsed.db_type,
|
||||
host: parsed.host,
|
||||
port: parsed.port ?? getDefaultPort(parsed.db_type),
|
||||
username: parsed.username,
|
||||
password: parsed.password,
|
||||
database: parsed.database,
|
||||
ssl_mode: parsed.ssl_mode ?? prev.ssl_mode ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function NewConnectionScreen({
|
||||
defaultFolderId = null,
|
||||
prefilledConnectionString = "",
|
||||
@@ -72,9 +113,7 @@ export function NewConnectionScreen({
|
||||
onCancel,
|
||||
}: NewConnectionScreenProps) {
|
||||
const [stage, setStage] = useState<Stage>("entry");
|
||||
const [managedPreset, setManagedPreset] = useState<
|
||||
"supabase" | "neon" | null
|
||||
>(null);
|
||||
const [managedPreset, setManagedPreset] = useState<ManagedPreset>(null);
|
||||
const [form, setForm] = useState<ConnectionFormData>(() =>
|
||||
createEmptyForm(
|
||||
defaultFolderId,
|
||||
@@ -85,6 +124,12 @@ export function NewConnectionScreen({
|
||||
const [saveLoading, setSaveLoading] = useState(false);
|
||||
const createConnection = useConnectionStore((s) => s.createConnection);
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
// Name to use when the label is left blank: derived from the pasted URL
|
||||
// (database name, else host/file stem). Shown as the Connection Label's
|
||||
// placeholder and applied at save/test time, so a suggestion never
|
||||
// clobbers or gets appended to typed text.
|
||||
const parsedUri = parseConnectionString(form.connection_string);
|
||||
const suggestedName = parsedUri ? suggestConnectionLabel(parsedUri) : "";
|
||||
|
||||
const revealConfigured = useCallback(
|
||||
(updates: Partial<ConnectionFormData>) => {
|
||||
@@ -96,26 +141,17 @@ export function NewConnectionScreen({
|
||||
|
||||
const handleConnectionStringChange = useCallback((value: string) => {
|
||||
const parsed = parseConnectionString(value);
|
||||
if (parsed) {
|
||||
const provider =
|
||||
parsed.db_type === "postgresql"
|
||||
? detectProviderFromHost(parsed.host)
|
||||
: null;
|
||||
setManagedPreset(provider);
|
||||
if (!parsed) {
|
||||
setManagedPreset(null);
|
||||
setForm((prev) => ({ ...prev, connection_string: value }));
|
||||
return;
|
||||
}
|
||||
setManagedPreset(providerForParsed(parsed));
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
connection_string: value,
|
||||
db_type: parsed.db_type,
|
||||
host: parsed.host,
|
||||
port: parsed.port ?? getDefaultPort(parsed.db_type),
|
||||
username: parsed.username,
|
||||
password: parsed.password,
|
||||
database: parsed.database,
|
||||
...parsedFormPatch(parsed, prev),
|
||||
}));
|
||||
} else {
|
||||
setManagedPreset(null);
|
||||
setForm((prev) => ({ ...prev, connection_string: value }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -138,7 +174,9 @@ export function NewConnectionScreen({
|
||||
(id: ProviderId) => {
|
||||
const provider = getProviderById(id)!;
|
||||
setManagedPreset(
|
||||
provider.isManagedPreset ? (id as "supabase" | "neon") : null,
|
||||
provider.isManagedPreset
|
||||
? (id as "supabase" | "neon" | "planetscale")
|
||||
: null,
|
||||
);
|
||||
revealConfigured({
|
||||
db_type: provider.dbType,
|
||||
@@ -147,6 +185,15 @@ export function NewConnectionScreen({
|
||||
? null
|
||||
: getDefaultPort(provider.dbType),
|
||||
...(provider.dbType === "sqlite" ? { host: "" } : {}),
|
||||
// Managed providers refuse plaintext connections. Preselect
|
||||
// their documented mode so Test/Save works on the first try.
|
||||
// (PlanetScale/MySQL requires identity verification.)
|
||||
...(id === "planetscale"
|
||||
? { ssl_mode: "verify-full" as const }
|
||||
: {}),
|
||||
...(id === "supabase" || id === "neon"
|
||||
? { ssl_mode: "require" as const }
|
||||
: {}),
|
||||
});
|
||||
},
|
||||
[revealConfigured],
|
||||
@@ -162,22 +209,11 @@ export function NewConnectionScreen({
|
||||
const value = updates.connection_string;
|
||||
const parsed = parseConnectionString(value);
|
||||
if (parsed) {
|
||||
const provider =
|
||||
parsed.db_type === "postgresql"
|
||||
? detectProviderFromHost(parsed.host)
|
||||
: null;
|
||||
setManagedPreset(provider);
|
||||
setManagedPreset(providerForParsed(parsed));
|
||||
return {
|
||||
...prev,
|
||||
...updates,
|
||||
db_type: parsed.db_type,
|
||||
host: parsed.host,
|
||||
port:
|
||||
parsed.port ??
|
||||
getDefaultPort(parsed.db_type),
|
||||
username: parsed.username,
|
||||
password: parsed.password,
|
||||
database: parsed.database,
|
||||
...parsedFormPatch(parsed, prev),
|
||||
};
|
||||
}
|
||||
return { ...prev, ...updates };
|
||||
@@ -190,7 +226,7 @@ export function NewConnectionScreen({
|
||||
|
||||
const buildPayload = useCallback((): ConnectionInput => {
|
||||
return {
|
||||
name: form.name,
|
||||
name: form.name.trim() || suggestedName,
|
||||
db_type: form.db_type,
|
||||
host: form.host,
|
||||
port: form.port,
|
||||
@@ -209,8 +245,12 @@ export function NewConnectionScreen({
|
||||
ssh_private_key_path: form.ssh_private_key ?? null,
|
||||
ssh_password: form.ssh_password ?? null,
|
||||
ssh_passphrase: form.ssh_passphrase ?? null,
|
||||
ssl_mode: form.ssl_mode ?? null,
|
||||
ssl_ca_path: form.ssl_ca_path ?? null,
|
||||
ssl_cert_path: form.ssl_cert_path ?? null,
|
||||
ssl_key_path: form.ssl_key_path ?? null,
|
||||
};
|
||||
}, [form]);
|
||||
}, [form, suggestedName]);
|
||||
|
||||
const validate = useCallback((): string | null => {
|
||||
const result = validateConnectionInput(buildPayload());
|
||||
@@ -332,6 +372,7 @@ export function NewConnectionScreen({
|
||||
<DetailedConnectionForm
|
||||
form={form}
|
||||
onChange={updateForm}
|
||||
namePlaceholder={suggestedName || undefined}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -3,12 +3,13 @@ import { ChevronDown } from "lucide-react";
|
||||
import { SETUP_GUIDES } from "../../lib/providers";
|
||||
|
||||
interface ProviderSetupGuideProps {
|
||||
provider: "supabase" | "neon";
|
||||
provider: "supabase" | "neon" | "planetscale";
|
||||
}
|
||||
|
||||
const SSL_NOTE: Record<"supabase" | "neon", string> = {
|
||||
const SSL_NOTE: Record<"supabase" | "neon" | "planetscale", string> = {
|
||||
supabase: "SSL is required by Supabase.",
|
||||
neon: "Neon requires SSL.",
|
||||
planetscale: "PlanetScale requires SSL with identity verification.",
|
||||
};
|
||||
|
||||
export function ProviderSetupGuide({ provider }: ProviderSetupGuideProps) {
|
||||
|
||||
@@ -24,7 +24,7 @@ export function ProviderTabsGrid({ selectedId, onSelect }: ProviderTabsGridProps
|
||||
}`}
|
||||
>
|
||||
<span className="w-8 h-8 flex items-center justify-center">
|
||||
{p.isManagedPreset ? <ProviderIcon id={p.id as "supabase" | "neon"} size={24} /> : <DbIcon type={p.dbType} size={24} />}
|
||||
{p.isManagedPreset ? <ProviderIcon id={p.id as "supabase" | "neon" | "planetscale"} size={24} /> : <DbIcon type={p.dbType} size={24} />}
|
||||
</span>
|
||||
<span className="text-sm font-medium">{p.label}</span>
|
||||
{p.isManagedPreset && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { SslFields } from "./SslFields";
|
||||
|
||||
const notify = vi.fn();
|
||||
@@ -35,4 +35,33 @@ describe("SslFields", () => {
|
||||
expect(screen.queryByLabelText("Client Certificate")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Client Key")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("writes certificate paths under the names the backend persists", () => {
|
||||
// Regression guard: these used to be ssl_ca_cert / ssl_client_cert /
|
||||
// ssl_client_key, which the Rust ConnectionInput silently ignores — the
|
||||
// connection was then saved with TLS disabled.
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<SslFields
|
||||
values={{ ssl_mode: "verify-full", ssl_ca_path: "/tmp/ca.pem" }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("CA Certificate")).toHaveValue("/tmp/ca.pem");
|
||||
fireEvent.change(screen.getByLabelText("CA Certificate"), {
|
||||
target: { value: "/tmp/root.pem" },
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith({ ssl_ca_path: "/tmp/root.pem" });
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Client Certificate"), {
|
||||
target: { value: "/tmp/client.pem" },
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith({ ssl_cert_path: "/tmp/client.pem" });
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Client Key"), {
|
||||
target: { value: "/tmp/client.key" },
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith({ ssl_key_path: "/tmp/client.key" });
|
||||
});
|
||||
});
|
||||
@@ -55,14 +55,14 @@ export function SslFields({ values, onChange }: SslFieldsProps) {
|
||||
<label className="block text-sm text-text mb-1.5">CA Certificate</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={(values.ssl_ca_cert as string) ?? ""}
|
||||
onChange={(value) => onChange({ ssl_ca_cert: value })}
|
||||
value={(values.ssl_ca_path as string) ?? ""}
|
||||
onChange={(value) => onChange({ ssl_ca_path: value })}
|
||||
placeholder="/path/to/ca-cert.pem"
|
||||
aria-label="CA Certificate"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePickFile("ssl_ca_cert")}
|
||||
onClick={() => handlePickFile("ssl_ca_path")}
|
||||
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
Browse
|
||||
@@ -74,14 +74,14 @@ export function SslFields({ values, onChange }: SslFieldsProps) {
|
||||
<label className="block text-sm text-text mb-1.5">Client Certificate</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={(values.ssl_client_cert as string) ?? ""}
|
||||
onChange={(value) => onChange({ ssl_client_cert: value })}
|
||||
value={(values.ssl_cert_path as string) ?? ""}
|
||||
onChange={(value) => onChange({ ssl_cert_path: value })}
|
||||
placeholder="/path/to/client-cert.pem"
|
||||
aria-label="Client Certificate"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePickFile("ssl_client_cert")}
|
||||
onClick={() => handlePickFile("ssl_cert_path")}
|
||||
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
Browse
|
||||
@@ -93,14 +93,14 @@ export function SslFields({ values, onChange }: SslFieldsProps) {
|
||||
<label className="block text-sm text-text mb-1.5">Client Key</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={(values.ssl_client_key as string) ?? ""}
|
||||
onChange={(value) => onChange({ ssl_client_key: value })}
|
||||
value={(values.ssl_key_path as string) ?? ""}
|
||||
onChange={(value) => onChange({ ssl_key_path: value })}
|
||||
placeholder="/path/to/client-key.pem"
|
||||
aria-label="Client Key"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePickFile("ssl_client_key")}
|
||||
onClick={() => handlePickFile("ssl_key_path")}
|
||||
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
Browse
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DbType } from "../../lib/types";
|
||||
import type { Environment } from "./EnvironmentSelect";
|
||||
import type { SslMode } from "../../lib/connectionString";
|
||||
|
||||
export interface ConnectionFormData {
|
||||
name: string;
|
||||
@@ -24,4 +25,13 @@ export interface ConnectionFormData {
|
||||
ssh_private_key?: string | null;
|
||||
ssh_password: string | null;
|
||||
ssh_passphrase?: string | null;
|
||||
// SSL/TLS fields. These must live on the form data (and survive into the
|
||||
// submit payload) or the SSH/SSL tab's selections are silently dropped — the
|
||||
// backend then connects with TLS disabled, which managed providers (Neon,
|
||||
// Supabase, PlanetScale) reject with "connection is insecure".
|
||||
// `null` = unset, which the backend treats as TLS disabled.
|
||||
ssl_mode?: SslMode | null;
|
||||
ssl_ca_path?: string | null;
|
||||
ssl_cert_path?: string | null;
|
||||
ssl_key_path?: string | null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||
import { Button } from "../ui/Button";
|
||||
import { CopyButton } from "../ui/CopyButton";
|
||||
import { Select } from "../ui/Select";
|
||||
import { BackupProgress } from "./BackupProgress";
|
||||
import { useBackupStore } from "../../stores/backupStore";
|
||||
@@ -108,6 +109,7 @@ export function BackupDialog({ open, connectionId, onClose }: BackupDialogProps)
|
||||
}, [filePath, format, schema, noOwner, connectionId, startJob, notify, onClose]);
|
||||
|
||||
const toolsMissing = toolStatus && !toolStatus.pg_dump_found;
|
||||
const installInstructions = getPlatformInstructions();
|
||||
|
||||
return (
|
||||
<AnimatedModal open={open} onClose={onClose}>
|
||||
@@ -124,10 +126,19 @@ export function BackupDialog({ open, connectionId, onClose }: BackupDialogProps)
|
||||
<p className="text-amber-200/80 text-xs">
|
||||
The PostgreSQL client tools are required for backup/restore operations. Install them using:
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-amber-200/60">Install</span>
|
||||
<CopyButton
|
||||
text={installInstructions}
|
||||
className="text-amber-200/70 hover:text-amber-100"
|
||||
/>
|
||||
</div>
|
||||
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded p-2 whitespace-pre-wrap">
|
||||
{getPlatformInstructions()}
|
||||
{installInstructions}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingTools && !toolsMissing && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { BackupPage } from "./BackupPage";
|
||||
import { useBackupStore } from "../../stores/backupStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
@@ -84,3 +84,104 @@ describe("BackupPage DB-aware", () => {
|
||||
expect(screen.getByText("Custom Archive")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BackupPage tool provenance and re-check", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockConnections.length = 0;
|
||||
useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 });
|
||||
useNotificationStore.setState({ notifications: [] });
|
||||
vi.spyOn(commands, "getSchemas").mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("shows the resolved tool and its source", async () => {
|
||||
mockConnections.push({ id: "c1", db_type: "mysql", name: "m", database: "db1" });
|
||||
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({
|
||||
mysqldumpFound: true,
|
||||
mysqlFound: true,
|
||||
mysqldumpVersion: "mariadb-dump 11.4.5-MariaDB",
|
||||
mysqlVersion: "mariadb 11.4.5-MariaDB",
|
||||
mysqldumpSource: "bundled",
|
||||
mysqlSource: "bundled",
|
||||
mysqldumpResolvedName: "mariadb-dump",
|
||||
mysqlResolvedName: "mariadb",
|
||||
mysqldumpBundledAvailable: true,
|
||||
mysqlBundledAvailable: true,
|
||||
});
|
||||
render(<BackupPage connectionId="c1" />);
|
||||
await waitFor(() => expect(screen.getByText(/Using bundled mariadb-dump/)).toBeTruthy());
|
||||
});
|
||||
|
||||
it("re-checks with force when Check again is clicked", async () => {
|
||||
mockConnections.push({ id: "c2", db_type: "mysql", name: "m", database: "db1" });
|
||||
const spy = vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({
|
||||
mysqldumpFound: false,
|
||||
mysqlFound: false,
|
||||
mysqldumpVersion: null,
|
||||
mysqlVersion: null,
|
||||
mysqldumpSource: null,
|
||||
mysqlSource: null,
|
||||
});
|
||||
render(<BackupPage connectionId="c2" />);
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith(false));
|
||||
fireEvent.click(screen.getByRole("button", { name: /check again/i }));
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith(true));
|
||||
});
|
||||
|
||||
it("explains when the bundled tool is present but could not run", async () => {
|
||||
mockConnections.push({ id: "c3", db_type: "mysql", name: "m", database: "db1" });
|
||||
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({
|
||||
mysqldumpFound: false,
|
||||
mysqlFound: false,
|
||||
mysqldumpVersion: null,
|
||||
mysqlVersion: null,
|
||||
mysqldumpSource: null,
|
||||
mysqlSource: null,
|
||||
mysqldumpBundledAvailable: true,
|
||||
mysqlBundledAvailable: false,
|
||||
});
|
||||
render(<BackupPage connectionId="c3" />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/bundled copy is present but could not run/i)).toBeTruthy(),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows install instructions when nothing resolves at all", async () => {
|
||||
mockConnections.push({ id: "c4", db_type: "mysql", name: "m", database: "db1" });
|
||||
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({
|
||||
mysqldumpFound: false,
|
||||
mysqlFound: false,
|
||||
mysqldumpVersion: null,
|
||||
mysqlVersion: null,
|
||||
mysqldumpSource: null,
|
||||
mysqlSource: null,
|
||||
mysqldumpBundledAvailable: false,
|
||||
});
|
||||
render(<BackupPage connectionId="c4" />);
|
||||
await waitFor(() => expect(screen.getByText(/mysql-client/)).toBeTruthy());
|
||||
});
|
||||
|
||||
it("offers a copy button for the install command", async () => {
|
||||
mockConnections.push({ id: "c-copy", db_type: "mysql", name: "m", database: "db1" });
|
||||
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({
|
||||
mysqldumpFound: false,
|
||||
mysqlFound: false,
|
||||
mysqldumpVersion: null,
|
||||
mysqlVersion: null,
|
||||
mysqldumpSource: null,
|
||||
mysqlSource: null,
|
||||
mysqldumpBundledAvailable: false,
|
||||
});
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText: vi.fn() },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
render(<BackupPage connectionId="c-copy" />);
|
||||
const btn = await screen.findByTitle("Copy to clipboard");
|
||||
fireEvent.click(btn);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining("mysql-client"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Download, FolderOpen, HardDrive } from "lucide-react";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { Button } from "../ui/Button";
|
||||
import { CopyButton } from "../ui/CopyButton";
|
||||
import { BackupProgress } from "./BackupProgress";
|
||||
import {
|
||||
FormRow,
|
||||
FormSectionHeader,
|
||||
inputClass,
|
||||
controlClass,
|
||||
} from "./objects/formRow";
|
||||
import { useBackupStore } from "../../stores/backupStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
@@ -98,16 +104,16 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
||||
}
|
||||
}, [activeJob, notify]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkTools = useCallback(
|
||||
async (force: boolean) => {
|
||||
setCheckingTools(true);
|
||||
setPgToolStatus(null);
|
||||
setMysqlToolStatus(null);
|
||||
setAvailableSchemas([]);
|
||||
|
||||
if (isPg) {
|
||||
detectPgTools()
|
||||
.then((status) => setPgToolStatus(status))
|
||||
.catch(() =>
|
||||
try {
|
||||
setPgToolStatus(await detectPgTools(force));
|
||||
} catch {
|
||||
setPgToolStatus({
|
||||
pg_dump_found: false,
|
||||
pg_restore_found: false,
|
||||
@@ -115,17 +121,14 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
||||
pg_restore_version: null,
|
||||
pg_dump_source: null,
|
||||
pg_restore_source: null,
|
||||
}),
|
||||
)
|
||||
.finally(() => setCheckingTools(false));
|
||||
|
||||
getSchemas(connectionId)
|
||||
.then((schemas) => setAvailableSchemas(schemas))
|
||||
.catch(() => setAvailableSchemas([]));
|
||||
});
|
||||
} finally {
|
||||
setCheckingTools(false);
|
||||
}
|
||||
} else if (isMysql) {
|
||||
detectMysqlTools()
|
||||
.then((status) => setMysqlToolStatus(status))
|
||||
.catch(() =>
|
||||
try {
|
||||
setMysqlToolStatus(await detectMysqlTools(force));
|
||||
} catch {
|
||||
setMysqlToolStatus({
|
||||
mysqldumpFound: false,
|
||||
mysqlFound: false,
|
||||
@@ -133,17 +136,26 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
||||
mysqlVersion: null,
|
||||
mysqldumpSource: null,
|
||||
mysqlSource: null,
|
||||
}),
|
||||
)
|
||||
.finally(() => setCheckingTools(false));
|
||||
|
||||
getSchemas(connectionId)
|
||||
.then((schemas) => setAvailableSchemas(schemas))
|
||||
.catch(() => setAvailableSchemas([]));
|
||||
});
|
||||
} finally {
|
||||
setCheckingTools(false);
|
||||
}
|
||||
} else {
|
||||
setCheckingTools(false);
|
||||
}
|
||||
}, [connectionId, isPg, isMysql]);
|
||||
},
|
||||
[isPg, isMysql],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setAvailableSchemas([]);
|
||||
void checkTools(false);
|
||||
if (isPg || isMysql) {
|
||||
getSchemas(connectionId)
|
||||
.then((schemas) => setAvailableSchemas(schemas))
|
||||
.catch(() => setAvailableSchemas([]));
|
||||
}
|
||||
}, [connectionId, isPg, isMysql, checkTools]);
|
||||
|
||||
const handlePickFile = useCallback(async () => {
|
||||
let defaultPath = "backup";
|
||||
@@ -262,6 +274,23 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
||||
? mysqlToolStatus?.mysqldumpSource === "bundled"
|
||||
: false;
|
||||
|
||||
const dumpToolFound = isPg
|
||||
? pgToolStatus?.pg_dump_found
|
||||
: mysqlToolStatus?.mysqldumpFound;
|
||||
const dumpSource = isPg
|
||||
? pgToolStatus?.pg_dump_source
|
||||
: mysqlToolStatus?.mysqldumpSource;
|
||||
const dumpResolvedName = isPg
|
||||
? pgToolStatus?.pg_dump_resolved_name
|
||||
: mysqlToolStatus?.mysqldumpResolvedName;
|
||||
const bundledPresent = isPg
|
||||
? pgToolStatus?.pg_dump_bundled_available
|
||||
: mysqlToolStatus?.mysqldumpBundledAvailable;
|
||||
const dumpFallbackName = isPg ? "pg_dump" : "mysqldump";
|
||||
const installInstructions = getPlatformInstructions(
|
||||
isPg ? PG_INSTALL_INSTRUCTIONS : MYSQL_INSTALL_INSTRUCTIONS,
|
||||
);
|
||||
|
||||
const checkingMessage = isPg
|
||||
? "Checking for pg_dump..."
|
||||
: isMysql
|
||||
@@ -275,128 +304,141 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
||||
: "Create a database backup";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Toolbar header */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<HardDrive size={14} className="text-accent" />
|
||||
<span className="text-xs font-medium text-text">Backup</span>
|
||||
<span className="text-[11px] text-text-muted">
|
||||
{headerDescription}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(isPg || isMysql) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void checkTools(true)}
|
||||
className="text-[11px] text-text-muted hover:text-text transition-colors cursor-pointer"
|
||||
>
|
||||
Check again
|
||||
</button>
|
||||
)}
|
||||
{!checkingTools && !toolsMissing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleStartBackup()}
|
||||
disabled={isRunning || !filePath}
|
||||
className="inline-flex items-center rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<Download size={14} className="mr-1.5" />
|
||||
{isRunning ? "Backing up..." : "Start Backup"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* Tool check */}
|
||||
{checkingTools && checkingMessage && (
|
||||
<div className="glass p-4 text-center">
|
||||
<p className="text-sm text-text-muted">
|
||||
<div className="px-4 py-3 text-sm text-text-muted">
|
||||
{checkingMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolsMissing && !toolsBundled && (
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
|
||||
<div className="border-b border-border bg-amber-500/10 px-4 py-3 space-y-2">
|
||||
<p className="text-amber-300 text-sm font-semibold">
|
||||
{isPg ? "pg_dump not found" : "mysqldump not found"}
|
||||
</p>
|
||||
<p className="text-amber-200/80 text-xs leading-relaxed">
|
||||
The {isPg ? "PostgreSQL" : "MySQL"} client tools are required for
|
||||
backup/restore operations. Install them using:
|
||||
Gridline ships its own {isPg ? "PostgreSQL" : "MySQL"} client
|
||||
tools. This build could not locate or run them, so a manual
|
||||
install is needed:
|
||||
</p>
|
||||
{bundledPresent && (
|
||||
<p className="text-amber-200/80 text-xs leading-relaxed">
|
||||
A bundled copy is present but could not run — reinstall
|
||||
Gridline, or install the tools below.
|
||||
</p>
|
||||
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">
|
||||
{getPlatformInstructions(
|
||||
isPg
|
||||
? PG_INSTALL_INSTRUCTIONS
|
||||
: MYSQL_INSTALL_INSTRUCTIONS,
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-amber-200/60">Install</span>
|
||||
<CopyButton
|
||||
text={installInstructions}
|
||||
className="text-amber-200/70 hover:text-amber-100"
|
||||
/>
|
||||
</div>
|
||||
<pre className="text-xs text-amber-100 bg-amber-500/10 p-3 whitespace-pre-wrap font-mono leading-relaxed">
|
||||
{installInstructions}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingTools && !toolsMissing && (
|
||||
<>
|
||||
{/* Configuration card */}
|
||||
<div className="p-5 space-y-5">
|
||||
{dumpToolFound && (
|
||||
<FormRow label="Tool">
|
||||
<span className="px-3 font-heading text-xs text-text">
|
||||
Using {dumpSource === "bundled" ? "bundled" : "system"}{" "}
|
||||
{dumpResolvedName ?? dumpFallbackName}
|
||||
</span>
|
||||
</FormRow>
|
||||
)}
|
||||
|
||||
{/* Format */}
|
||||
{isPg ? (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Format
|
||||
</label>
|
||||
<FormRow label="Format">
|
||||
<select
|
||||
value={format}
|
||||
onChange={(e) =>
|
||||
setFormat(
|
||||
e.target.value as BackupFormat,
|
||||
)
|
||||
setFormat(e.target.value as BackupFormat)
|
||||
}
|
||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
|
||||
className={`${controlClass} mr-3`}
|
||||
>
|
||||
<option value="custom">
|
||||
Custom Archive
|
||||
</option>
|
||||
<option value="custom">Custom Archive</option>
|
||||
<option value="plain">Plain SQL</option>
|
||||
<option value="tar">Tarball</option>
|
||||
<option value="directory">
|
||||
Directory
|
||||
</option>
|
||||
<option value="directory">Directory</option>
|
||||
</select>
|
||||
</div>
|
||||
</FormRow>
|
||||
) : 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">
|
||||
<FormRow label="Format">
|
||||
<span className="px-3 font-heading text-xs text-text">
|
||||
Plain SQL
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</FormRow>
|
||||
) : null}
|
||||
|
||||
{/* Output file */}
|
||||
<div className="space-y-1 w-full">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Output File
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<FormRow label="Output file">
|
||||
<input
|
||||
type="text"
|
||||
value={filePath}
|
||||
onChange={(e) =>
|
||||
setFilePath(e.target.value)
|
||||
}
|
||||
onChange={(e) => setFilePath(e.target.value)}
|
||||
placeholder="/path/to/backup.dump"
|
||||
className="flex-1 px-4 py-2 text-sm text-text placeholder-text-muted/50 border-b border-border focus:border-accent focus:outline-none transition-colors"
|
||||
className={inputClass}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePickFile}
|
||||
className="flex items-center justify-center w-9 h-9 rounded-lg border border-border bg-surface text-text-muted hover:text-text hover:bg-surface-raised hover:border-border-hover transition-colors cursor-pointer shrink-0"
|
||||
aria-label="Browse for file"
|
||||
className="mr-2 flex h-6 w-6 shrink-0 items-center justify-center border border-border bg-surface text-text-muted hover:text-text hover:bg-surface-raised hover:border-border-hover transition-colors cursor-pointer"
|
||||
>
|
||||
<FolderOpen size={15} />
|
||||
<FolderOpen size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</FormRow>
|
||||
|
||||
{/* Schema (optional) */}
|
||||
{/* Schema */}
|
||||
{(isPg || isMysql) && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Schema{" "}
|
||||
<span className="font-normal normal-case tracking-normal">
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<FormRow label="Schema">
|
||||
<select
|
||||
value={schema}
|
||||
onChange={(e) =>
|
||||
setSchema(e.target.value)
|
||||
}
|
||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
|
||||
onChange={(e) => setSchema(e.target.value)}
|
||||
className={`${controlClass} mr-3`}
|
||||
>
|
||||
<option value="">All schemas</option>
|
||||
{availableSchemas.map((s) => (
|
||||
@@ -405,119 +447,106 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</FormRow>
|
||||
)}
|
||||
|
||||
{(isPg || isMysql) && (
|
||||
<FormSectionHeader label="Options" />
|
||||
)}
|
||||
|
||||
{/* PostgreSQL: no-owner toggle */}
|
||||
{isPg && (
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<FormRow label="No owner">
|
||||
<label className="flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={noOwner}
|
||||
onChange={(e) =>
|
||||
setNoOwner(e.target.checked)
|
||||
}
|
||||
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||
onChange={(e) => setNoOwner(e.target.checked)}
|
||||
className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
|
||||
No Owner{" "}
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
|
||||
--no-owner
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
</FormRow>
|
||||
)}
|
||||
|
||||
{/* MySQL: option toggles */}
|
||||
{isMysql && (
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<>
|
||||
<FormRow label="Single transaction">
|
||||
<label className="flex w-full items-center gap-2.5 px-3 py-2 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"
|
||||
className="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">
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
|
||||
--single-transaction
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
</FormRow>
|
||||
<FormRow label="No data">
|
||||
<label className="flex w-full items-center gap-2.5 px-3 py-2 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"
|
||||
onChange={(e) => setNoData(e.target.checked)}
|
||||
className="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">
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
|
||||
--no-data
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
</FormRow>
|
||||
<FormRow label="Routines">
|
||||
<label className="flex w-full items-center gap-2.5 px-3 py-2 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"
|
||||
onChange={(e) => setRoutines(e.target.checked)}
|
||||
className="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">
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
|
||||
--routines
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
</FormRow>
|
||||
<FormRow label="Triggers">
|
||||
<label className="flex w-full items-center gap-2.5 px-3 py-2 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"
|
||||
onChange={(e) => setTriggers(e.target.checked)}
|
||||
className="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">
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
|
||||
--triggers
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
</FormRow>
|
||||
<FormRow label="Events">
|
||||
<label className="flex w-full items-center gap-2.5 px-3 py-2 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"
|
||||
onChange={(e) => setEvents(e.target.checked)}
|
||||
className="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">
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
|
||||
--events
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</FormRow>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{activeJob && (
|
||||
<div className="px-4">
|
||||
<div className="px-4 py-3">
|
||||
<BackupProgress
|
||||
progress={activeJob.status === "completed" ? 100 : 50}
|
||||
jobType="dump"
|
||||
@@ -526,21 +555,9 @@ export function BackupPage({ connectionId }: BackupPageProps) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end pb-2 pr-2">
|
||||
<Button
|
||||
onClick={handleStartBackup}
|
||||
disabled={isRunning || !filePath}
|
||||
>
|
||||
<Download size={14} className="mr-1.5" />
|
||||
{isRunning ? "Backing up..." : "Start Backup"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -281,8 +281,8 @@ describe("DbViewerScreen", () => {
|
||||
|
||||
it("executes a non-destructive query when Run is clicked", async () => {
|
||||
const executeQuery = vi
|
||||
.spyOn(commands, "executeQuery")
|
||||
.mockResolvedValue(mockQueryResult as any);
|
||||
.spyOn(commands, "executeQueryMulti")
|
||||
.mockResolvedValue({ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any);
|
||||
render(
|
||||
<DbViewerScreen
|
||||
connectionId="c1"
|
||||
@@ -306,13 +306,11 @@ describe("DbViewerScreen", () => {
|
||||
|
||||
it("shows a destructive-query confirmation dialog and executes on confirm", async () => {
|
||||
const executeQuery = vi
|
||||
.spyOn(commands, "executeQuery")
|
||||
.spyOn(commands, "executeQueryMulti")
|
||||
.mockResolvedValue({
|
||||
columns: [],
|
||||
rows: [],
|
||||
total_rows: 0,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
result_sets: [{ columns: [], rows: [], total_rows: 0, page: 1, page_size: 50 }],
|
||||
notices: [],
|
||||
execution_time_ms: 42,
|
||||
} as any);
|
||||
render(
|
||||
<DbViewerScreen
|
||||
@@ -345,8 +343,8 @@ describe("DbViewerScreen", () => {
|
||||
});
|
||||
|
||||
it("refreshes the schema tree after a DDL query runs", async () => {
|
||||
vi.spyOn(commands, "executeQuery").mockResolvedValue(
|
||||
mockQueryResult as any,
|
||||
vi.spyOn(commands, "executeQueryMulti").mockResolvedValue(
|
||||
{ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any,
|
||||
);
|
||||
const getSchemas = vi
|
||||
.spyOn(commands, "getSchemas")
|
||||
@@ -377,8 +375,8 @@ describe("DbViewerScreen", () => {
|
||||
});
|
||||
|
||||
it("does not refresh the schema tree after a SELECT", async () => {
|
||||
vi.spyOn(commands, "executeQuery").mockResolvedValue(
|
||||
mockQueryResult as any,
|
||||
vi.spyOn(commands, "executeQueryMulti").mockResolvedValue(
|
||||
{ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any,
|
||||
);
|
||||
const getSchemas = vi
|
||||
.spyOn(commands, "getSchemas")
|
||||
@@ -396,7 +394,7 @@ describe("DbViewerScreen", () => {
|
||||
);
|
||||
fireEvent.change(textarea, { target: { value: "SELECT 1" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /run query/i }));
|
||||
await waitFor(() => expect(commands.executeQuery).toHaveBeenCalled());
|
||||
await waitFor(() => expect(commands.executeQueryMulti).toHaveBeenCalled());
|
||||
expect(getSchemas).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -423,8 +421,8 @@ describe("DbViewerScreen", () => {
|
||||
|
||||
it("runs the current query when the Cmd+Enter action fires", async () => {
|
||||
const executeQuery = vi
|
||||
.spyOn(commands, "executeQuery")
|
||||
.mockResolvedValue(mockQueryResult as any);
|
||||
.spyOn(commands, "executeQueryMulti")
|
||||
.mockResolvedValue({ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any);
|
||||
render(
|
||||
<DbViewerScreen
|
||||
connectionId="c1"
|
||||
@@ -452,7 +450,7 @@ describe("DbViewerScreen", () => {
|
||||
const pending = new Promise<unknown>((r) => {
|
||||
resolveRun = r;
|
||||
});
|
||||
vi.spyOn(commands, "executeQuery").mockReturnValue(pending as any);
|
||||
vi.spyOn(commands, "executeQueryMulti").mockReturnValue(pending as any);
|
||||
render(
|
||||
<DbViewerScreen
|
||||
connectionId="c1"
|
||||
@@ -469,7 +467,7 @@ describe("DbViewerScreen", () => {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("query-run-pulse")).toBeInTheDocument(),
|
||||
);
|
||||
resolveRun(mockQueryResult);
|
||||
resolveRun({ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 });
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("query-run-pulse")).toBeNull(),
|
||||
);
|
||||
@@ -477,8 +475,8 @@ describe("DbViewerScreen", () => {
|
||||
|
||||
it("collapses and re-expands the query results via the caret", async () => {
|
||||
const executeQuery = vi
|
||||
.spyOn(commands, "executeQuery")
|
||||
.mockResolvedValue(mockQueryResult as any);
|
||||
.spyOn(commands, "executeQueryMulti")
|
||||
.mockResolvedValue({ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any);
|
||||
render(
|
||||
<DbViewerScreen
|
||||
connectionId="c1"
|
||||
@@ -505,8 +503,8 @@ describe("DbViewerScreen", () => {
|
||||
});
|
||||
|
||||
it("resizes the results panel with a drag handle, clamped to min/max", async () => {
|
||||
vi.spyOn(commands, "executeQuery").mockResolvedValue(
|
||||
mockQueryResult as any,
|
||||
vi.spyOn(commands, "executeQueryMulti").mockResolvedValue(
|
||||
{ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any,
|
||||
);
|
||||
render(
|
||||
<DbViewerScreen
|
||||
@@ -930,7 +928,7 @@ describe("DbViewerScreen", () => {
|
||||
});
|
||||
|
||||
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, "executeQueryMulti").mockResolvedValue({ result_sets: [{ columns: [], rows: [], total_rows: 0, page: 1, page_size: 50 }], notices: [], execution_time_ms: 42 } as any);
|
||||
vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
|
||||
vi.spyOn(commands, "getDatabases").mockResolvedValue(["mydb"]);
|
||||
vi.spyOn(commands, "getTables").mockResolvedValue([] as any);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TooltipProvider } from "../ui/Tooltip";
|
||||
import { DbViewerSidebar, NAV_CAPABILITY_KEY } from "./DbViewerSidebar";
|
||||
import { DbViewerToolbar } from "./DbViewerToolbar";
|
||||
import { isDestructiveQuery, isSchemaModifyingQuery } from "../../lib/utils";
|
||||
import { executeQuery, cancelQuery } from "../../lib/commands";
|
||||
import { executeQueryMulti, cancelQuery } from "../../lib/commands";
|
||||
import { getCapabilities } from "../../lib/dbCapabilities";
|
||||
|
||||
const QueryEditor = lazy(() => import("../editor/QueryEditor").then((m) => ({ default: m.QueryEditor })));
|
||||
@@ -19,8 +19,11 @@ import { ObjectDetail, type AnyObject } from "./objects/ObjectDetail";
|
||||
import { ObjectFormTab } from "./objects/ObjectFormTab";
|
||||
import { TabBar } from "./TabBar";
|
||||
import { VirtualDataGrid } from "../grid/VirtualDataGrid";
|
||||
import { ResultSetPanels } from "./ResultSetPanels";
|
||||
import { RowDetailDrawer } from "../grid/RowDetailDrawer";
|
||||
import { TableControls } from "./TableControls";
|
||||
import { StreamExportDialog } from "./StreamExportDialog";
|
||||
import { qualifiedTableSql } from "../../lib/exportData";
|
||||
import { EditConnectionModal } from "./EditConnectionModal";
|
||||
import { PasswordPromptDialog } from "./PasswordPromptDialog";
|
||||
import { useDbConnection } from "../../hooks/useDbConnection";
|
||||
@@ -177,6 +180,7 @@ export function DbViewerScreen({
|
||||
const [rowDetailIdx, setRowDetailIdx] = useState<number | null>(null);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [destructiveQuery, setDestructiveQuery] = useState<string | null>(null);
|
||||
const [streamExport, setStreamExport] = useState<{ sql: string } | null>(null);
|
||||
const connections = useConnectionStore((s) => s.connections);
|
||||
const currentConnection =
|
||||
connections.find((c) => c.id === connectionId) ?? null;
|
||||
@@ -231,6 +235,7 @@ export function DbViewerScreen({
|
||||
const changesQueue = useDbViewerStore((s) => s.changesQueue);
|
||||
const tables = useDbViewerStore((s) => s.tables);
|
||||
const stageCellEdit = useDbViewerStore((s) => s.stageCellEdit);
|
||||
const updateChange = useDbViewerStore((s) => s.updateChange);
|
||||
|
||||
const isRunning = activeTab?.tabType === "query" && !!activeTab?.loading;
|
||||
|
||||
@@ -259,6 +264,7 @@ export function DbViewerScreen({
|
||||
|
||||
const setTabData = useDbViewerStore((s) => s.setTabData);
|
||||
const setTabError = useDbViewerStore((s) => s.setTabError);
|
||||
const setTabMulti = useDbViewerStore((s) => s.setTabMulti);
|
||||
const setTabLoading = useDbViewerStore((s) => s.setTabLoading);
|
||||
const databases = useDbViewerStore((s) => s.databases);
|
||||
const currentDatabase = useDbViewerStore((s) => s.currentDatabase);
|
||||
@@ -310,8 +316,24 @@ export function DbViewerScreen({
|
||||
if (!tab) return;
|
||||
setTabLoading(tabId, true);
|
||||
try {
|
||||
const result = await executeQuery(connectionId, sql, tab.page, tab.pageSize);
|
||||
setTabData(tabId, result);
|
||||
const multi = await executeQueryMulti(connectionId, sql, tab.page, tab.pageSize);
|
||||
const first = multi.result_sets[0] ?? null;
|
||||
// Stamp the run's execution time onto the first set so the toolbar
|
||||
// (TableControls reads activeTab.data.execution_time_ms) still shows it.
|
||||
if (first) {
|
||||
setTabData(tabId, { ...first, execution_time_ms: multi.execution_time_ms });
|
||||
} else {
|
||||
// Lone DML/DDL run with no result set — keep the empty-state behavior.
|
||||
setTabData(tabId, {
|
||||
columns: [],
|
||||
rows: [],
|
||||
total_rows: 0,
|
||||
page: tab.page,
|
||||
page_size: tab.pageSize,
|
||||
execution_time_ms: multi.execution_time_ms,
|
||||
});
|
||||
}
|
||||
setTabMulti(tabId, multi);
|
||||
useQueryStore.getState().invalidateHistory(connectionId);
|
||||
if (isSchemaModifyingQuery(sql)) {
|
||||
const st = useDbViewerStore.getState();
|
||||
@@ -339,6 +361,19 @@ export function DbViewerScreen({
|
||||
[activeTab, stageCellEdit],
|
||||
);
|
||||
|
||||
const handleStageInsertCell = useCallback(
|
||||
(changeId: string, column: string, value: string | null) => {
|
||||
const change = useDbViewerStore
|
||||
.getState()
|
||||
.changesQueue.find((c) => c.id === changeId);
|
||||
if (!change) return;
|
||||
const newData = { ...(change.newData ?? {}) };
|
||||
newData[column] = value;
|
||||
updateChange(changeId, { newData });
|
||||
},
|
||||
[updateChange],
|
||||
);
|
||||
|
||||
const handleOpenRowDetail = useCallback((rowIndex: number) => {
|
||||
setRowDetailIdx(rowIndex);
|
||||
}, []);
|
||||
@@ -832,6 +867,24 @@ export function DbViewerScreen({
|
||||
// Data is already filtered and sorted server-side; no client-side transform needed.
|
||||
const processedRows = rawRows;
|
||||
|
||||
// Pending insert rows (from the changes queue) are prepended to the grid as
|
||||
// editable rows. Only pending inserts for the active table tab count.
|
||||
const pendingInserts =
|
||||
activeTab?.tabType === "table"
|
||||
? changesQueue.filter(
|
||||
(c) =>
|
||||
c.type === "insert" &&
|
||||
c.status === "pending" &&
|
||||
c.schema === activeTab.schema &&
|
||||
c.table === activeTab.table,
|
||||
)
|
||||
: [];
|
||||
const pendingInsertChangeIds = pendingInserts.map((c) => c.id);
|
||||
const pendingInsertRows = pendingInserts.map((c) =>
|
||||
columns.map((col) => c.newData?.[col.name] ?? null),
|
||||
);
|
||||
const displayRows = [...pendingInsertRows, ...processedRows];
|
||||
|
||||
const onPanelResizeStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -985,6 +1038,48 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
)
|
||||
: {};
|
||||
|
||||
// Shared VirtualDataGrid props for the query-tab results region. The
|
||||
// multi-result panels reuse these (overriding rows/columns per set);
|
||||
// single-set runs render the grid directly with today's props.
|
||||
const gridPropsForQueryTab = {
|
||||
connectionId,
|
||||
schema: activeSchema,
|
||||
table: activeTable,
|
||||
rows: displayRows,
|
||||
columns,
|
||||
hiddenColumns,
|
||||
selectedRows,
|
||||
dbType: currentConnection?.db_type ?? "postgresql",
|
||||
tabType: activeTab?.tabType ?? "table",
|
||||
getLocator,
|
||||
onStageEdit: readOnlyTable ? undefined : handleStageEdit,
|
||||
onStageInsertCell: readOnlyTable ? undefined : handleStageInsertCell,
|
||||
onOpenRowDetail: handleOpenRowDetail,
|
||||
readOnly: readOnlyTable,
|
||||
enumValues: editorOptions?.enums,
|
||||
fkOptions: editorOptions?.fks,
|
||||
fkPlaceholders: editorOptions?.fkPlaceholders,
|
||||
stagedValues,
|
||||
pendingKeys,
|
||||
pendingInsertChangeIds,
|
||||
onToggleRow: (rowIndex: number) => {
|
||||
setSelectedRows((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(rowIndex)) next.delete(rowIndex);
|
||||
else next.add(rowIndex);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
onToggleAll: () => {
|
||||
setSelectedRows((prev) => {
|
||||
if (prev.size === processedRows.length && processedRows.length > 0) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(processedRows.map((_, i) => i));
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-hidden">
|
||||
<TabBar onCommitted={handleCommitted} />
|
||||
@@ -1177,72 +1272,31 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
}
|
||||
variant="query"
|
||||
isMatview={readOnlyTable}
|
||||
onExportToFile={() =>
|
||||
setStreamExport({
|
||||
sql:
|
||||
activeTab?.tabType === "query"
|
||||
? activeTab.query ?? ""
|
||||
: qualifiedTableSql(
|
||||
currentConnection?.db_type ?? "postgresql",
|
||||
activeSchema,
|
||||
activeTable,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<VirtualDataGrid
|
||||
connectionId={connectionId}
|
||||
schema={activeSchema}
|
||||
table={activeTable}
|
||||
rows={processedRows}
|
||||
columns={columns}
|
||||
hiddenColumns={hiddenColumns}
|
||||
selectedRows={selectedRows}
|
||||
dbType={currentConnection?.db_type ?? "postgresql"}
|
||||
tabType={activeTab?.tabType ?? "table"}
|
||||
getLocator={getLocator}
|
||||
onStageEdit={readOnlyTable ? undefined : handleStageEdit}
|
||||
onOpenRowDetail={handleOpenRowDetail}
|
||||
readOnly={readOnlyTable}
|
||||
enumValues={editorOptions?.enums}
|
||||
fkOptions={editorOptions?.fks}
|
||||
fkPlaceholders={editorOptions?.fkPlaceholders}
|
||||
stagedValues={stagedValues}
|
||||
pendingKeys={pendingKeys}
|
||||
onToggleRow={(rowIndex) => {
|
||||
setSelectedRows(
|
||||
(prev) => {
|
||||
const next =
|
||||
new Set(
|
||||
prev,
|
||||
);
|
||||
if (
|
||||
next.has(
|
||||
rowIndex,
|
||||
)
|
||||
)
|
||||
next.delete(
|
||||
rowIndex,
|
||||
);
|
||||
else
|
||||
next.add(
|
||||
rowIndex,
|
||||
);
|
||||
return next;
|
||||
},
|
||||
);
|
||||
}}
|
||||
onToggleAll={() => {
|
||||
setSelectedRows(
|
||||
(prev) => {
|
||||
if (
|
||||
prev.size ===
|
||||
processedRows.length &&
|
||||
processedRows.length >
|
||||
0
|
||||
) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(
|
||||
processedRows.map(
|
||||
(_, i) =>
|
||||
i,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}}
|
||||
{activeTab?.tabType === "query" && activeTab.multi && (activeTab.multi.result_sets.length > 1 || activeTab.multi.notices.length > 0) ? (
|
||||
<ResultSetPanels
|
||||
multi={activeTab.multi}
|
||||
renderGrid={(set) => (
|
||||
<VirtualDataGrid {...gridPropsForQueryTab} rows={set.rows} columns={set.columns} />
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<VirtualDataGrid {...gridPropsForQueryTab} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -1341,6 +1395,18 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
setSelectedRows(new Set())
|
||||
}
|
||||
isMatview={readOnlyTable}
|
||||
onExportToFile={() =>
|
||||
setStreamExport({
|
||||
sql:
|
||||
activeTab?.tabType === "query"
|
||||
? activeTab.query ?? ""
|
||||
: qualifiedTableSql(
|
||||
currentConnection?.db_type ?? "postgresql",
|
||||
activeSchema,
|
||||
activeTable,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
@@ -1348,7 +1414,7 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
connectionId={connectionId}
|
||||
schema={activeSchema}
|
||||
table={activeTable}
|
||||
rows={processedRows}
|
||||
rows={displayRows}
|
||||
columns={columns}
|
||||
hiddenColumns={hiddenColumns}
|
||||
selectedRows={selectedRows}
|
||||
@@ -1356,6 +1422,7 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
tabType={activeTab?.tabType ?? "table"}
|
||||
getLocator={getLocator}
|
||||
onStageEdit={readOnlyTable ? undefined : handleStageEdit}
|
||||
onStageInsertCell={readOnlyTable ? undefined : handleStageInsertCell}
|
||||
onOpenRowDetail={handleOpenRowDetail}
|
||||
readOnly={readOnlyTable}
|
||||
enumValues={editorOptions?.enums}
|
||||
@@ -1363,6 +1430,7 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
fkPlaceholders={editorOptions?.fkPlaceholders}
|
||||
stagedValues={stagedValues}
|
||||
pendingKeys={pendingKeys}
|
||||
pendingInsertChangeIds={pendingInsertChangeIds}
|
||||
onToggleRow={(rowIndex) => {
|
||||
setSelectedRows((prev) => {
|
||||
const next = new Set(
|
||||
@@ -1531,6 +1599,13 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
{capabilities.objects && (
|
||||
<ObjectSearchPalette connectionId={connectionId} />
|
||||
)}
|
||||
{streamExport && (
|
||||
<StreamExportDialog
|
||||
connectionId={connectionId}
|
||||
sql={streamExport.sql}
|
||||
onClose={() => setStreamExport(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -10,18 +10,23 @@ const { updateConnection, loadAll } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../stores/connectionStore", () => ({
|
||||
useConnectionStore: (sel: (s: any) => any) =>
|
||||
useConnectionStore: Object.assign(
|
||||
(sel: (s: any) => any) =>
|
||||
sel({ updateConnection, loadAll, folders: [], tags: [] }),
|
||||
{ getState: () => ({ getConnectionPassword: async () => "pw" }) },
|
||||
),
|
||||
}));
|
||||
vi.mock("../../lib/commands", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../lib/commands")>();
|
||||
return {
|
||||
...actual,
|
||||
updateConnection: vi.fn(),
|
||||
testConnection: vi.fn(),
|
||||
testConnection: vi.fn().mockResolvedValue({ ok: true }),
|
||||
saveConnectionPassword: vi.fn(),
|
||||
saveConnectionSshPassword: vi.fn(),
|
||||
saveConnectionSshPassphrase: vi.fn(),
|
||||
getConnectionSshPassword: vi.fn().mockResolvedValue(null),
|
||||
getConnectionSshPassphrase: vi.fn().mockResolvedValue(null),
|
||||
deleteConnectionPassword: vi.fn(),
|
||||
};
|
||||
});
|
||||
@@ -121,4 +126,62 @@ describe("EditConnectionModal", () => {
|
||||
);
|
||||
expect(vi.mocked(commands.saveConnectionPassword)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists the existing SSL settings on save", async () => {
|
||||
render(
|
||||
<EditConnectionModal
|
||||
connection={{
|
||||
...baseConn,
|
||||
ssl_mode: "verify-full",
|
||||
ssl_ca_path: "/tmp/ca.pem",
|
||||
ssl_cert_path: "/tmp/client.pem",
|
||||
ssl_key_path: "/tmp/client.key",
|
||||
}}
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
||||
await waitFor(() => expect(vi.mocked(commands.updateConnection)).toHaveBeenCalled());
|
||||
expect(vi.mocked(commands.updateConnection)).toHaveBeenCalledWith(
|
||||
"c1",
|
||||
expect.objectContaining({
|
||||
ssl_mode: "verify-full",
|
||||
ssl_ca_path: "/tmp/ca.pem",
|
||||
ssl_cert_path: "/tmp/client.pem",
|
||||
ssl_key_path: "/tmp/client.key",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("tests with the same SSL + SSH config it would save", async () => {
|
||||
render(
|
||||
<EditConnectionModal
|
||||
connection={{
|
||||
...baseConn,
|
||||
ssl_mode: "require",
|
||||
ssh_host: "jump.example.com",
|
||||
ssh_port: 22,
|
||||
ssh_user: "tunnel",
|
||||
ssh_auth_method: "key",
|
||||
ssh_private_key_path: "/tmp/id_ed25519",
|
||||
}}
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /^test$/i }));
|
||||
await waitFor(() => expect(vi.mocked(commands.testConnection)).toHaveBeenCalled());
|
||||
expect(vi.mocked(commands.testConnection)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ssl_mode: "require",
|
||||
ssh_host: "jump.example.com",
|
||||
ssh_user: "tunnel",
|
||||
ssh_auth_method: "key",
|
||||
ssh_private_key_path: "/tmp/id_ed25519",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,9 @@ import { Button } from "../ui/Button";
|
||||
import { DetailedConnectionForm } from "../connections/DetailedConnectionForm";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { updateConnection, testConnection, saveConnectionSshPassword, saveConnectionSshPassphrase } from "../../lib/commands";
|
||||
import { updateConnection, testConnection, saveConnectionSshPassword, saveConnectionSshPassphrase, getConnectionSshPassword, getConnectionSshPassphrase } from "../../lib/commands";
|
||||
import { persistDbPassword } from "../../lib/keychain";
|
||||
import { detectProviderFromHost } from "../../lib/connectionString";
|
||||
import { detectProviderFromHost, type SslMode } from "../../lib/connectionString";
|
||||
import type { Connection, ConnectionInput } from "../../lib/types";
|
||||
import type { ConnectionFormData } from "../connections/connectionFormData";
|
||||
|
||||
@@ -47,6 +47,10 @@ export function EditConnectionModal({
|
||||
ssh_private_key: connection.ssh_private_key_path ?? null,
|
||||
ssh_password: null,
|
||||
ssh_passphrase: null,
|
||||
ssl_mode: (connection.ssl_mode as SslMode | null | undefined) ?? null,
|
||||
ssl_ca_path: connection.ssl_ca_path ?? null,
|
||||
ssl_cert_path: connection.ssl_cert_path ?? null,
|
||||
ssl_key_path: connection.ssl_key_path ?? null,
|
||||
}));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
@@ -75,6 +79,10 @@ export function EditConnectionModal({
|
||||
ssh_private_key_path: form.ssh_private_key ?? null,
|
||||
ssh_password: form.ssh_password ?? null,
|
||||
ssh_passphrase: form.ssh_passphrase ?? null,
|
||||
ssl_mode: form.ssl_mode ?? null,
|
||||
ssl_ca_path: form.ssl_ca_path ?? null,
|
||||
ssl_cert_path: form.ssl_cert_path ?? null,
|
||||
ssl_key_path: form.ssl_key_path ?? null,
|
||||
};
|
||||
const updated = await updateConnection(connection.id, input);
|
||||
await persistDbPassword(connection.id, form.use_keychain, form.password).catch(() => {});
|
||||
@@ -104,6 +112,18 @@ export function EditConnectionModal({
|
||||
if (!password) {
|
||||
password = await useConnectionStore.getState().getConnectionPassword(connection.id).catch(() => null);
|
||||
}
|
||||
// SSH secrets live in the keychain only, so a tunneled connection can be
|
||||
// tested without re-entering them.
|
||||
const sshPassword =
|
||||
form.ssh_password ??
|
||||
(form.ssh_host
|
||||
? await getConnectionSshPassword(connection.id).catch(() => null)
|
||||
: null);
|
||||
const sshPassphrase =
|
||||
form.ssh_passphrase ??
|
||||
(form.ssh_host
|
||||
? await getConnectionSshPassphrase(connection.id).catch(() => null)
|
||||
: null);
|
||||
|
||||
const result = await testConnection({
|
||||
name: form.name,
|
||||
@@ -116,7 +136,19 @@ export function EditConnectionModal({
|
||||
folder_id: form.folder_id,
|
||||
environment: form.environment,
|
||||
tag_ids: form.tag_ids,
|
||||
ssh_password: form.ssh_password ?? null,
|
||||
// Test the same config Save would persist — a tunneled/SSL connection
|
||||
// must be probed through its tunnel and TLS mode.
|
||||
ssh_host: form.ssh_host ?? null,
|
||||
ssh_port: form.ssh_port ?? null,
|
||||
ssh_user: form.ssh_user ?? null,
|
||||
ssh_auth_method: form.ssh_auth_method ?? null,
|
||||
ssh_private_key_path: form.ssh_private_key ?? null,
|
||||
ssh_password: sshPassword,
|
||||
ssh_passphrase: sshPassphrase,
|
||||
ssl_mode: form.ssl_mode ?? null,
|
||||
ssl_ca_path: form.ssl_ca_path ?? null,
|
||||
ssl_cert_path: form.ssl_cert_path ?? null,
|
||||
ssl_key_path: form.ssl_key_path ?? null,
|
||||
});
|
||||
if (result.ok) {
|
||||
notify("Connection successful", "success");
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { FilterBuilder } from "./FilterBuilder";
|
||||
import type { ColumnInfo } from "../../lib/types";
|
||||
|
||||
const cols: ColumnInfo[] = [
|
||||
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: false, is_generated: false },
|
||||
{ name: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
||||
];
|
||||
|
||||
describe("FilterBuilder", () => {
|
||||
it("drops a column chip into the drop zone to create a rule with a type-aware operator", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<FilterBuilder columns={cols} rules={[]} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByText("name"));
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ column: "name", operator: "contains" }),
|
||||
]);
|
||||
});
|
||||
it("removing a rule calls onChange without it", () => {
|
||||
const onChange = vi.fn();
|
||||
const rules = [{ id: "r1", column: "name", operator: "contains" as const, value: "Al" }];
|
||||
render(<FilterBuilder columns={cols} rules={rules} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByLabelText("Remove filter name"));
|
||||
expect(onChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { DndContext, useDraggable, useDroppable } from "@dnd-kit/core";
|
||||
import { X } from "lucide-react";
|
||||
import { defaultFilterOperator } from "../grid/gridEditability";
|
||||
import type { ColumnInfo } from "../../lib/types";
|
||||
import type { FilterRule, FilterOperator } from "../../stores/dbViewerStore";
|
||||
|
||||
interface Props {
|
||||
columns: ColumnInfo[];
|
||||
rules: FilterRule[];
|
||||
onChange: (rules: FilterRule[]) => void;
|
||||
}
|
||||
|
||||
function Chip({ col, onAdd }: { col: ColumnInfo; onAdd: () => void }) {
|
||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
||||
id: `col-${col.name}`,
|
||||
data: { column: col },
|
||||
});
|
||||
return (
|
||||
<button
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={onAdd}
|
||||
className={`px-2 py-1 text-xs rounded border border-border bg-surface text-text hover:border-accent ${
|
||||
isDragging ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
{col.name}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const OPERATORS: FilterOperator[] = [
|
||||
"eq",
|
||||
"neq",
|
||||
"contains",
|
||||
"starts",
|
||||
"ends",
|
||||
"gt",
|
||||
"lt",
|
||||
"null",
|
||||
"notnull",
|
||||
];
|
||||
|
||||
export function FilterBuilder({ columns, rules, onChange }: Props) {
|
||||
const [val, setVal] = useState<Record<string, string>>({});
|
||||
|
||||
const addRule = (col: ColumnInfo) => {
|
||||
const op = defaultFilterOperator(col.data_type);
|
||||
onChange([
|
||||
...rules,
|
||||
{
|
||||
id: `f-${Date.now()}-${col.name}`,
|
||||
column: col.name,
|
||||
operator: op,
|
||||
value: "",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const { setNodeRef, isOver } = useDroppable({ id: "filter-dropzone" });
|
||||
|
||||
const remove = (id: string) => onChange(rules.filter((r) => r.id !== id));
|
||||
const update = (id: string, patch: Partial<FilterRule>) =>
|
||||
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
onDragEnd={(e) => {
|
||||
const id = e.active.id as string;
|
||||
const colName = id.replace(/^col-/, "");
|
||||
const col = columns.find((c) => c.name === colName);
|
||||
if (col && e.over?.id === "filter-dropzone") addRule(col);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{columns.map((c) => (
|
||||
<Chip key={c.name} col={c} onAdd={() => addRule(c)} />
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`min-h-[40px] border border-dashed rounded p-2 space-y-1 ${
|
||||
isOver ? "border-accent bg-surface" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{rules.length === 0 && (
|
||||
<span className="text-xs text-text-muted">
|
||||
Drop columns here to add filters
|
||||
</span>
|
||||
)}
|
||||
{rules.map((r) => (
|
||||
<div key={r.id} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-text font-semibold">{r.column}</span>
|
||||
<select
|
||||
value={r.operator}
|
||||
onChange={(e) =>
|
||||
update(r.id, { operator: e.target.value as FilterOperator })
|
||||
}
|
||||
className="bg-surface border border-border rounded px-1"
|
||||
>
|
||||
{OPERATORS.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{!["null", "notnull"].includes(r.operator) && (
|
||||
<input
|
||||
value={val[r.id] ?? r.value}
|
||||
onChange={(e) => {
|
||||
setVal({ ...val, [r.id]: e.target.value });
|
||||
update(r.id, { value: e.target.value });
|
||||
}}
|
||||
className="bg-surface border border-border rounded px-1 flex-1"
|
||||
placeholder="value"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
aria-label={`Remove filter ${r.column}`}
|
||||
onClick={() => remove(r.id)}
|
||||
className="text-text-muted hover:text-red-400"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { HypertableDetail } from "./HypertableDetail";
|
||||
import type { HypertableInfo } from "../../lib/types";
|
||||
|
||||
const item: HypertableInfo = {
|
||||
name: "metrics",
|
||||
schema: "public",
|
||||
num_dimensions: 2,
|
||||
compression_enabled: true,
|
||||
num_chunks: 42,
|
||||
total_size_bytes: 1048576,
|
||||
};
|
||||
|
||||
describe("HypertableDetail", () => {
|
||||
it("renders dimensions, compression, chunk count and human size", () => {
|
||||
render(<HypertableDetail item={item} onOpenTable={vi.fn()} />);
|
||||
expect(screen.getByText("metrics")).toBeTruthy();
|
||||
expect(screen.getByText(/2 dimensions/)).toBeTruthy();
|
||||
expect(screen.getByText(/compression enabled/)).toBeTruthy();
|
||||
expect(screen.getByText(/42 chunks/)).toBeTruthy();
|
||||
expect(screen.getByText(/1\.0 MB/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the DDL/restore caveat", () => {
|
||||
render(<HypertableDetail item={item} onOpenTable={vi.fn()} />);
|
||||
expect(screen.getByText(/create_hypertable/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens the underlying table on click", () => {
|
||||
const onOpenTable = vi.fn();
|
||||
render(<HypertableDetail item={item} onOpenTable={onOpenTable} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /view table/i }));
|
||||
expect(onOpenTable).toHaveBeenCalledWith("public", "metrics");
|
||||
});
|
||||
|
||||
it("marks compression as disabled when off", () => {
|
||||
render(
|
||||
<HypertableDetail item={{ ...item, compression_enabled: false }} onOpenTable={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByText(/compression disabled/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Table2 } from "lucide-react";
|
||||
import type { HypertableInfo } from "../../lib/types";
|
||||
|
||||
function humanBytes(bytes: number | null): string {
|
||||
if (bytes === null) return "unknown size";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let v = bytes;
|
||||
let i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i += 1;
|
||||
}
|
||||
return `${v >= 10 || i === 0 ? v.toFixed(0) : v.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function HypertableDetail({
|
||||
item,
|
||||
onOpenTable,
|
||||
}: {
|
||||
item: HypertableInfo;
|
||||
onOpenTable: (schema: string, table: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-1 space-y-2">
|
||||
<div className="text-sm text-text">{item.name}</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs text-text-muted">
|
||||
<span className="rounded bg-surface-raised px-2 py-1">{item.num_dimensions} dimensions</span>
|
||||
<span className="rounded bg-surface-raised px-2 py-1">
|
||||
{item.compression_enabled ? "compression enabled" : "compression disabled"}
|
||||
</span>
|
||||
<span className="rounded bg-surface-raised px-2 py-1">{item.num_chunks} chunks</span>
|
||||
<span className="rounded bg-surface-raised px-2 py-1">{humanBytes(item.total_size_bytes)}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenTable(item.schema, item.name)}
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-text-muted hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
|
||||
>
|
||||
<Table2 size={13} /> View table
|
||||
</button>
|
||||
<p className="text-xs text-text-subtle">
|
||||
Note: Copy DDL exports the table as plain SQL plus its create_hypertable call. A
|
||||
full-fidelity restore needs TimescaleDB pre/post-restore hooks, which Gridline does
|
||||
not run.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,10 +22,6 @@ interface ObjectExplorerPageProps {
|
||||
sidebarMode?: boolean;
|
||||
}
|
||||
|
||||
const OBJECT_TYPE_OPTIONS = (Object.keys(TYPE_LABELS) as ObjectType[]).map(
|
||||
(t) => ({ value: t, label: TYPE_LABELS[t] }),
|
||||
);
|
||||
|
||||
/** Natural plural for empty-state copy, derived from SINGULAR_LABELS with known irregulars mapped explicitly. */
|
||||
function emptyPlural(type: ObjectType): string {
|
||||
const singular = SINGULAR_LABELS[type];
|
||||
@@ -75,6 +71,7 @@ function typeToDdlType(type: ObjectType): string {
|
||||
sequences: "sequence",
|
||||
enums: "enum",
|
||||
extensions: "extension",
|
||||
hypertables: "table",
|
||||
indexes: "index",
|
||||
constraints: "constraint",
|
||||
roles: "role",
|
||||
@@ -125,6 +122,19 @@ export function ObjectExplorerPage({
|
||||
const changesQueue = useDbViewerStore((s) => s.changesQueue);
|
||||
const openObjectTab = useDbViewerStore((s) => s.openObjectTab);
|
||||
const refreshTree = useDbViewerStore((s) => s.refreshTree);
|
||||
const hypertablesAvailable = useDbViewerStore((s) => s.hypertablesAvailable);
|
||||
const setHypertablesAvailable = useDbViewerStore((s) => s.setHypertablesAvailable);
|
||||
// Build the dropdown from the full object-type union, gating hypertables
|
||||
// on confirmed TimescaleDB availability (visibleObjectTypes in ObjectTree
|
||||
// is scoped to that tree's smaller type set).
|
||||
const objectTypeOptions = useMemo(() => {
|
||||
const all = Object.keys(TYPE_LABELS) as ObjectType[];
|
||||
const visible =
|
||||
hypertablesAvailable === true
|
||||
? all
|
||||
: all.filter((t) => t !== "hypertables");
|
||||
return visible.map((t) => ({ value: t, label: TYPE_LABELS[t] }));
|
||||
}, [hypertablesAvailable]);
|
||||
|
||||
// Use store for persistence, but allow re-fetching when schema changes
|
||||
const [items, setItems] = useState<AnyObject[] | null>(null);
|
||||
@@ -218,6 +228,23 @@ export function ObjectExplorerPage({
|
||||
}
|
||||
}, [currentSchema, fetch]);
|
||||
|
||||
// Probe TimescaleDB availability once per connection so the hypertables
|
||||
// object type only appears in the dropdown when it is actually supported.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await cmd.getHypertables(connectionId);
|
||||
if (!cancelled) setHypertablesAvailable(res.available);
|
||||
} catch {
|
||||
if (!cancelled) setHypertablesAvailable(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [connectionId, setHypertablesAvailable]);
|
||||
|
||||
// After a commit containing ddl items succeeds, refetch the current
|
||||
// object-type list + refresh the schema tree so newly created/dropped
|
||||
// objects show up without a manual refresh.
|
||||
@@ -325,7 +352,7 @@ export function ObjectExplorerPage({
|
||||
<SelectDropdown
|
||||
value={type}
|
||||
onChange={(v) => handleTypeChange(v as ObjectType)}
|
||||
options={OBJECT_TYPE_OPTIONS}
|
||||
options={objectTypeOptions}
|
||||
variant="ghost"
|
||||
aria-label="Object type"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { TYPE_LABELS, visibleObjectTypes } from "./ObjectTree";
|
||||
|
||||
describe("hypertables object type", () => {
|
||||
it("registers label and stays in the type union", () => {
|
||||
expect(TYPE_LABELS.hypertables).toBe("hypertables");
|
||||
expect(Object.keys(TYPE_LABELS)).toContain("hypertables");
|
||||
});
|
||||
|
||||
it("hides hypertables until availability is confirmed true", () => {
|
||||
const base = Object.keys(TYPE_LABELS) as ReturnType<typeof visibleObjectTypes>;
|
||||
expect(visibleObjectTypes(null)).not.toContain("hypertables");
|
||||
expect(visibleObjectTypes(false)).not.toContain("hypertables");
|
||||
expect(visibleObjectTypes(true)).toContain("hypertables");
|
||||
expect(visibleObjectTypes(true).length).toBe(base.length);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,24 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { ChevronRight, ChevronDown, FunctionSquare, GitBranch, ListOrdered, Tag, Puzzle, Search } from "lucide-react";
|
||||
import { ChevronRight, ChevronDown, FunctionSquare, GitBranch, ListOrdered, Tag, Puzzle, Layers, Search } from "lucide-react";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import type { FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo } from "../../lib/types";
|
||||
import type { FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, HypertableInfo } from "../../lib/types";
|
||||
import { HypertableDetail } from "./HypertableDetail";
|
||||
|
||||
type ObjectType = "functions" | "triggers" | "sequences" | "enums" | "extensions";
|
||||
type ObjectType = "functions" | "triggers" | "sequences" | "enums" | "extensions" | "hypertables";
|
||||
|
||||
interface ObjectTreeProps {
|
||||
type: ObjectType;
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<ObjectType, string> = {
|
||||
export const TYPE_LABELS: Record<ObjectType, string> = {
|
||||
functions: "functions",
|
||||
triggers: "triggers",
|
||||
sequences: "sequences",
|
||||
enums: "enums",
|
||||
extensions: "extensions",
|
||||
hypertables: "hypertables",
|
||||
};
|
||||
|
||||
const ICONS: Record<ObjectType, React.ReactNode> = {
|
||||
@@ -25,8 +27,18 @@ const ICONS: Record<ObjectType, React.ReactNode> = {
|
||||
sequences: <ListOrdered size={14} className="text-text-muted shrink-0" />,
|
||||
enums: <Tag size={14} className="text-text-muted shrink-0" />,
|
||||
extensions: <Puzzle size={14} className="text-text-muted shrink-0" />,
|
||||
hypertables: <Layers size={14} className="text-text-muted shrink-0" />,
|
||||
};
|
||||
|
||||
/** Object types visible in the dropdown. Hypertables only show when the
|
||||
* connection confirmed TimescaleDB availability (available === true). */
|
||||
export function visibleObjectTypes(hypertablesAvailable: boolean | null): ObjectType[] {
|
||||
const all = Object.keys(TYPE_LABELS) as ObjectType[];
|
||||
return hypertablesAvailable === true
|
||||
? all
|
||||
: all.filter((t) => t !== "hypertables");
|
||||
}
|
||||
|
||||
function SourceCode({ source }: { source: string }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const maxLen = 500;
|
||||
@@ -59,11 +71,13 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
|
||||
const sequences = useDbViewerStore((s) => s.sequences);
|
||||
const enums = useDbViewerStore((s) => s.enums);
|
||||
const extensions = useDbViewerStore((s) => s.extensions);
|
||||
const hypertables = useDbViewerStore((s) => s.hypertables);
|
||||
const setFunctions = useDbViewerStore((s) => s.setFunctions);
|
||||
const setTriggers = useDbViewerStore((s) => s.setTriggers);
|
||||
const setSequences = useDbViewerStore((s) => s.setSequences);
|
||||
const setEnums = useDbViewerStore((s) => s.setEnums);
|
||||
const setExtensions = useDbViewerStore((s) => s.setExtensions);
|
||||
const setHypertables = useDbViewerStore((s) => s.setHypertables);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
|
||||
@@ -77,8 +91,9 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
|
||||
case "sequences": return sequences;
|
||||
case "enums": return enums;
|
||||
case "extensions": return extensions;
|
||||
case "hypertables": return hypertables;
|
||||
}
|
||||
}, [type, functions, triggers, sequences, enums, extensions]);
|
||||
}, [type, functions, triggers, sequences, enums, extensions, hypertables]);
|
||||
|
||||
const setter = useMemo(() => {
|
||||
switch (type) {
|
||||
@@ -87,8 +102,9 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
|
||||
case "sequences": return setSequences;
|
||||
case "enums": return setEnums;
|
||||
case "extensions": return setExtensions;
|
||||
case "hypertables": return setHypertables;
|
||||
}
|
||||
}, [type, setFunctions, setTriggers, setSequences, setEnums, setExtensions]);
|
||||
}, [type, setFunctions, setTriggers, setSequences, setEnums, setExtensions, setHypertables]);
|
||||
|
||||
// Fetch on mount if not in store
|
||||
useEffect(() => {
|
||||
@@ -113,6 +129,9 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
|
||||
} else if (type === "enums") {
|
||||
const result = await cmd.getEnums(connectionId, currentSchema ?? undefined);
|
||||
if (!cancelled) (setEnums as (v: EnumInfo[]) => void)(result);
|
||||
} else if (type === "hypertables") {
|
||||
const res = await cmd.getHypertables(connectionId, currentSchema ?? undefined);
|
||||
if (!cancelled) (setHypertables as (v: HypertableInfo[]) => void)(res.available ? res.items : []);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail — store remains null, we show the empty state
|
||||
@@ -123,7 +142,7 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
|
||||
|
||||
fetchData();
|
||||
return () => { cancelled = true; };
|
||||
}, [type, connectionId, currentSchema, data, setter, setFunctions, setTriggers, setSequences, setEnums, setExtensions]);
|
||||
}, [type, connectionId, currentSchema, data, setter, setFunctions, setTriggers, setSequences, setEnums, setExtensions, setHypertables]);
|
||||
|
||||
const q = search.toLowerCase().trim();
|
||||
|
||||
@@ -318,6 +337,16 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{type === "hypertables" && (() => {
|
||||
const h = item as HypertableInfo;
|
||||
return (
|
||||
<HypertableDetail
|
||||
item={h}
|
||||
onOpenTable={(schema, table) => useDbViewerStore.getState().openTab(schema, table)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||
import { Button } from "../ui/Button";
|
||||
import { CopyButton } from "../ui/CopyButton";
|
||||
import { BackupProgress } from "./BackupProgress";
|
||||
import { useBackupStore } from "../../stores/backupStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
@@ -94,6 +95,7 @@ export function RestoreDialog({ open, connectionId, onClose }: RestoreDialogProp
|
||||
|
||||
const toolsMissing = toolStatus && !toolStatus.pg_restore_found;
|
||||
const canStart = filePath && confirmed && !running;
|
||||
const installInstructions = getPlatformInstructions();
|
||||
|
||||
return (
|
||||
<AnimatedModal open={open} onClose={onClose}>
|
||||
@@ -110,10 +112,19 @@ export function RestoreDialog({ open, connectionId, onClose }: RestoreDialogProp
|
||||
<p className="text-amber-200/80 text-xs">
|
||||
The PostgreSQL client tools are required for backup/restore operations. Install them using:
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-amber-200/60">Install</span>
|
||||
<CopyButton
|
||||
text={installInstructions}
|
||||
className="text-amber-200/70 hover:text-amber-100"
|
||||
/>
|
||||
</div>
|
||||
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded p-2 whitespace-pre-wrap">
|
||||
{getPlatformInstructions()}
|
||||
{installInstructions}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingTools && !toolsMissing && (
|
||||
|
||||
@@ -64,4 +64,41 @@ describe("RestorePage DB-aware", () => {
|
||||
expect(screen.queryByText(/pg_restore/i)).toBeNull();
|
||||
expect(screen.queryByText(/mysqldump/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the MySQL restore form even when no MySQL client is installed", async () => {
|
||||
// Restore is in-process since Task 7, so a missing mariadb/mysql client must
|
||||
// no longer hide the form.
|
||||
mockConnections.push({ id: "c4", db_type: "mysql", name: "m", database: "db1" });
|
||||
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({
|
||||
mysqldumpFound: false,
|
||||
mysqlFound: false,
|
||||
mysqldumpVersion: null,
|
||||
mysqlVersion: null,
|
||||
mysqldumpSource: null,
|
||||
mysqlSource: null,
|
||||
});
|
||||
render(<RestorePage connectionId="c4" />);
|
||||
|
||||
// The tools check is async; wait until the configuration card is actually up.
|
||||
await waitFor(() => expect(screen.getByText(/Backup File/i)).toBeInTheDocument());
|
||||
expect(screen.getByText(/no MySQL client installation required/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("explains what Clean does for MySQL", async () => {
|
||||
mockConnections.push({ id: "c5", db_type: "mysql", name: "m", database: "db1" });
|
||||
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue(mysqlToolsOk);
|
||||
render(<RestorePage connectionId="c5" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Backup File/i)).toBeInTheDocument());
|
||||
expect(screen.getByText(/only the objects this file defines/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("warns that a failed MySQL restore cannot be rolled back", async () => {
|
||||
mockConnections.push({ id: "c6", db_type: "mysql", name: "m", database: "db1" });
|
||||
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue(mysqlToolsOk);
|
||||
render(<RestorePage connectionId="c6" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Backup File/i)).toBeInTheDocument());
|
||||
expect(screen.getByText(/can't be rolled back/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { FileSearch, Upload } from "lucide-react";
|
||||
import { FolderOpen, Upload } from "lucide-react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { Button } from "../ui/Button";
|
||||
import { CopyButton } from "../ui/CopyButton";
|
||||
import { BackupProgress } from "./BackupProgress";
|
||||
import {
|
||||
FormRow,
|
||||
FormSectionHeader,
|
||||
inputClass,
|
||||
controlClass,
|
||||
} from "./objects/formRow";
|
||||
import { useBackupStore } from "../../stores/backupStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
@@ -10,11 +16,10 @@ import {
|
||||
detectPgTools,
|
||||
pgRestore,
|
||||
getSchemas,
|
||||
detectMysqlTools,
|
||||
mysqlRestore,
|
||||
sqliteRestore,
|
||||
} from "../../lib/commands";
|
||||
import type { PgToolStatus, MySqlToolStatus, BackupJob } from "../../lib/types";
|
||||
import type { PgToolStatus, BackupJob } from "../../lib/types";
|
||||
|
||||
interface RestorePageProps {
|
||||
connectionId: string;
|
||||
@@ -60,7 +65,6 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
const [schema, setSchema] = useState("");
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [pgToolStatus, setPgToolStatus] = useState<PgToolStatus | null>(null);
|
||||
const [mysqlToolStatus, setMysqlToolStatus] = useState<MySqlToolStatus | null>(null);
|
||||
const [checkingTools, setCheckingTools] = useState(true);
|
||||
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
|
||||
|
||||
@@ -93,7 +97,6 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
setCheckingTools(true);
|
||||
setConfirmed(false);
|
||||
setPgToolStatus(null);
|
||||
setMysqlToolStatus(null);
|
||||
setAvailableSchemas([]);
|
||||
|
||||
if (isPg) {
|
||||
@@ -115,19 +118,10 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
.then((schemas) => setAvailableSchemas(schemas))
|
||||
.catch(() => setAvailableSchemas([]));
|
||||
} else if (isMysql) {
|
||||
detectMysqlTools()
|
||||
.then((status) => setMysqlToolStatus(status))
|
||||
.catch(() =>
|
||||
setMysqlToolStatus({
|
||||
mysqldumpFound: false,
|
||||
mysqlFound: false,
|
||||
mysqldumpVersion: null,
|
||||
mysqlVersion: null,
|
||||
mysqldumpSource: null,
|
||||
mysqlSource: null,
|
||||
}),
|
||||
)
|
||||
.finally(() => setCheckingTools(false));
|
||||
// MySQL restore runs in-process over the sqlx driver connection, so
|
||||
// there is no client to probe for or wait on here (Backup/Sync
|
||||
// still probe via detectMysqlTools for mysqldump).
|
||||
setCheckingTools(false);
|
||||
|
||||
getSchemas(connectionId)
|
||||
.then((schemas) => setAvailableSchemas(schemas))
|
||||
@@ -214,17 +208,19 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
runWithProgress,
|
||||
]);
|
||||
|
||||
// pg_restore is still required for PostgreSQL. MySQL restore runs in-process
|
||||
// via the sqlx driver, so a missing mariadb/mysql client must not gate it —
|
||||
// that client could never authenticate to MySQL 8 anyway (#41 follow-up).
|
||||
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 installInstructions = getPlatformInstructions(
|
||||
isPg ? PG_INSTALL_INSTRUCTIONS : MYSQL_INSTALL_INSTRUCTIONS,
|
||||
);
|
||||
|
||||
const checkingMessage = isPg
|
||||
? "Checking for pg_restore..."
|
||||
@@ -238,118 +234,144 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
? "Restore a database from a SQL dump"
|
||||
: "Restore a database from a backup file";
|
||||
|
||||
const cleanDisabled = isPg && format === "plain";
|
||||
const restoreToolResolvedName =
|
||||
pgToolStatus?.pg_restore_resolved_name ?? "pg_restore";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Toolbar header */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Upload size={14} className="text-accent" />
|
||||
<span className="text-xs font-medium text-text">Restore</span>
|
||||
<span className="text-[11px] text-text-muted">
|
||||
{headerDescription}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!checkingTools && !toolsMissing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleStartRestore()}
|
||||
disabled={!canStart}
|
||||
className="inline-flex items-center rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<Upload size={14} className="mr-1.5" />
|
||||
{isRunning ? "Restoring..." : "Start Restore"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* Tool check */}
|
||||
{checkingTools && checkingMessage && (
|
||||
<div className="glass p-4 text-center">
|
||||
<p className="text-sm text-text-muted">
|
||||
<div className="px-4 py-3 text-sm text-text-muted">
|
||||
{checkingMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolsMissing && !toolsBundled && (
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
|
||||
<div className="border-b border-border bg-amber-500/10 px-4 py-3 space-y-2">
|
||||
<p className="text-amber-300 text-sm font-semibold">
|
||||
{isPg ? "pg_restore not found" : "mysql client not found"}
|
||||
</p>
|
||||
<p className="text-amber-200/80 text-xs leading-relaxed">
|
||||
The {isPg ? "PostgreSQL" : "MySQL"} client tools are required for
|
||||
backup/restore operations. Install them using:
|
||||
Gridline ships its own {isPg ? "PostgreSQL" : "MySQL"}{" "}
|
||||
client tools. This build could not locate or run them, so
|
||||
a manual install is needed:
|
||||
</p>
|
||||
{pgToolStatus?.pg_restore_bundled_available && (
|
||||
<p className="text-amber-200/80 text-xs leading-relaxed">
|
||||
A bundled copy is present but could not run — reinstall
|
||||
Gridline, or install the tools below.
|
||||
</p>
|
||||
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">
|
||||
{getPlatformInstructions(
|
||||
isPg
|
||||
? PG_INSTALL_INSTRUCTIONS
|
||||
: MYSQL_INSTALL_INSTRUCTIONS,
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-amber-200/60">
|
||||
Install
|
||||
</span>
|
||||
<CopyButton
|
||||
text={installInstructions}
|
||||
className="text-amber-200/70 hover:text-amber-100"
|
||||
/>
|
||||
</div>
|
||||
<pre className="text-xs text-amber-100 bg-amber-500/10 p-3 whitespace-pre-wrap font-mono leading-relaxed">
|
||||
{installInstructions}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingTools && !toolsMissing && (
|
||||
<>
|
||||
{/* Configuration card */}
|
||||
<div className="p-5 space-y-5">
|
||||
{/* Restore tool */}
|
||||
{isPg && pgToolStatus?.pg_restore_found && (
|
||||
<FormRow label="Tool">
|
||||
<span className="px-3 font-heading text-xs text-text">
|
||||
Using{" "}
|
||||
{pgToolStatus.pg_restore_source === "bundled"
|
||||
? "bundled"
|
||||
: "system"}{" "}
|
||||
{restoreToolResolvedName}
|
||||
</span>
|
||||
</FormRow>
|
||||
)}
|
||||
|
||||
{isMysql && (
|
||||
<FormRow label="Tool">
|
||||
<span className="px-3 font-heading text-xs text-text">
|
||||
Restores run in-process over the driver connection —
|
||||
no MySQL client installation required
|
||||
</span>
|
||||
</FormRow>
|
||||
)}
|
||||
|
||||
{/* Format (PostgreSQL only) */}
|
||||
{isPg && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Format
|
||||
</label>
|
||||
<FormRow label="Format">
|
||||
<select
|
||||
value={format}
|
||||
onChange={(e) =>
|
||||
setFormat(e.target.value)
|
||||
}
|
||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
|
||||
onChange={(e) => setFormat(e.target.value)}
|
||||
className={`${controlClass} mr-3`}
|
||||
>
|
||||
<option value="custom">
|
||||
Custom Archive
|
||||
</option>
|
||||
<option value="custom">Custom Archive</option>
|
||||
<option value="plain">Plain SQL</option>
|
||||
<option value="tar">Tarball</option>
|
||||
<option value="directory">
|
||||
Directory
|
||||
</option>
|
||||
<option value="directory">Directory</option>
|
||||
</select>
|
||||
</div>
|
||||
</FormRow>
|
||||
)}
|
||||
|
||||
{/* Backup file */}
|
||||
<div className="space-y-1 w-full">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Backup File
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<FormRow label="Backup file">
|
||||
<input
|
||||
type="text"
|
||||
value={filePath}
|
||||
onChange={(e) =>
|
||||
setFilePath(e.target.value)
|
||||
}
|
||||
onChange={(e) => setFilePath(e.target.value)}
|
||||
placeholder="/path/to/backup.dump"
|
||||
className="flex-1 px-4 py-2 text-sm text-text placeholder-text-muted/50 border-b border-border focus:border-accent focus:outline-none transition-colors"
|
||||
className={inputClass}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePickFile}
|
||||
className="flex items-center justify-center w-9 h-9 rounded-lg border border-border bg-surface text-text-muted hover:text-text hover:bg-surface-raised hover:border-border-hover transition-colors cursor-pointer shrink-0"
|
||||
aria-label="Browse for file"
|
||||
className="mr-2 flex h-6 w-6 shrink-0 items-center justify-center border border-border bg-surface text-text-muted hover:text-text hover:bg-surface-raised hover:border-border-hover transition-colors cursor-pointer"
|
||||
>
|
||||
<FileSearch size={15} />
|
||||
<FolderOpen size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</FormRow>
|
||||
|
||||
{/* Schema (optional) */}
|
||||
{(isPg || isMysql) && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Schema{" "}
|
||||
<span className="font-normal normal-case tracking-normal">
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<FormRow label="Schema">
|
||||
<select
|
||||
value={schema}
|
||||
onChange={(e) =>
|
||||
setSchema(e.target.value)
|
||||
}
|
||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
|
||||
onChange={(e) => setSchema(e.target.value)}
|
||||
className={`${controlClass} mr-3`}
|
||||
>
|
||||
<option value="">All schemas</option>
|
||||
{availableSchemas.map((s) => (
|
||||
@@ -358,14 +380,16 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</FormRow>
|
||||
)}
|
||||
|
||||
<FormSectionHeader label="Options" />
|
||||
|
||||
{/* Clean toggle */}
|
||||
{(isPg || isMysql || isSqlite) && (
|
||||
<FormRow label="Clean">
|
||||
<label
|
||||
className={`flex items-center gap-2.5 cursor-pointer group ${
|
||||
isPg && format === "plain"
|
||||
className={`flex w-full items-center gap-2.5 px-3 py-2 cursor-pointer group ${
|
||||
cleanDisabled
|
||||
? "opacity-40 pointer-events-none"
|
||||
: ""
|
||||
}`}
|
||||
@@ -373,52 +397,53 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={clean}
|
||||
onChange={(e) =>
|
||||
setClean(e.target.checked)
|
||||
}
|
||||
disabled={isPg && format === "plain"}
|
||||
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer disabled:cursor-not-allowed"
|
||||
onChange={(e) => setClean(e.target.checked)}
|
||||
disabled={cleanDisabled}
|
||||
className="bg-surface border-border accent-accent w-4 h-4 cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
|
||||
Clean{" "}
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
||||
DROP before CREATE
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised px-1.5 py-0.5">
|
||||
{isMysql
|
||||
? "drops only the objects this file defines"
|
||||
: "DROP before CREATE"}
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</FormRow>
|
||||
|
||||
{isPg && format === "plain" && (
|
||||
<p className="text-[11px] text-text-muted/70 -mt-3">
|
||||
Plain SQL restores run via psql and don't
|
||||
support DROP-before-CREATE. Use Custom
|
||||
Archive for clean restores.
|
||||
</p>
|
||||
)}
|
||||
<div className="border-b border-border px-4 py-2 text-[11px] text-text-muted/70">
|
||||
Plain SQL restores run via psql and don't support
|
||||
DROP-before-CREATE. Use Custom Archive for clean
|
||||
restores.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Destructive confirmation */}
|
||||
<div className="bg-red-500/5 border border-red-500/20 px-4 py-3">
|
||||
<div className="border-b border-border bg-red-500/5 px-4 py-3 space-y-2">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(e) =>
|
||||
setConfirmed(e.target.checked)
|
||||
}
|
||||
className="mt-0.5 rounded bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
|
||||
onChange={(e) => setConfirmed(e.target.checked)}
|
||||
className="mt-0.5 bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
|
||||
data-testid="restore-confirm-checkbox"
|
||||
/>
|
||||
<span className="text-sm text-red-300/90 leading-relaxed">
|
||||
I understand this will overwrite data on
|
||||
the target database. This action cannot
|
||||
be undone.
|
||||
I understand this will overwrite data on the target
|
||||
database. This action cannot be undone.
|
||||
</span>
|
||||
</label>
|
||||
{isMysql && (
|
||||
<p className="text-[11px] text-red-400/80">
|
||||
MySQL DDL commits as it runs and can't be rolled
|
||||
back — a failed restore may leave this database
|
||||
partially applied.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{activeJob && (
|
||||
<div className="px-4">
|
||||
<div className="px-4 py-3">
|
||||
<BackupProgress
|
||||
progress={activeJob.status === "completed" ? 100 : 50}
|
||||
jobType="restore"
|
||||
@@ -427,23 +452,9 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end pb-2 pr-2">
|
||||
<Button
|
||||
onClick={handleStartRestore}
|
||||
disabled={!canStart}
|
||||
>
|
||||
<Upload size={14} className="mr-1.5" />
|
||||
{isRunning
|
||||
? "Restoring..."
|
||||
: "Start Restore"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ResultSetPanels } from "./ResultSetPanels";
|
||||
import type { MultiQueryResult } from "../../lib/types";
|
||||
|
||||
const multi: MultiQueryResult = {
|
||||
result_sets: [
|
||||
{ columns: [{ name: "a", data_type: "int", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }], rows: [[1], [2]], total_rows: 2, page: 1, page_size: 50 },
|
||||
{ columns: [{ name: "b", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }], rows: [], total_rows: 0, page: 1, page_size: 50 },
|
||||
],
|
||||
notices: [
|
||||
{ statement_index: 1, kind: "dml", text: "UPDATE 3", affected: 3 },
|
||||
{ statement_index: 3, kind: "error", text: "relation \"nope\" does not exist", affected: null },
|
||||
],
|
||||
execution_time_ms: 12,
|
||||
};
|
||||
|
||||
describe("ResultSetPanels", () => {
|
||||
it("renders one panel header per result set with row counts", () => {
|
||||
render(<ResultSetPanels multi={multi} renderGrid={vi.fn()} />);
|
||||
expect(screen.getByText(/Result 1/)).toBeTruthy();
|
||||
expect(screen.getByText(/Result 2/)).toBeTruthy();
|
||||
expect(screen.getByText(/2 rows/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders notice cards for DML and errors", () => {
|
||||
render(<ResultSetPanels multi={multi} renderGrid={vi.fn()} />);
|
||||
expect(screen.getByText(/UPDATE 3/)).toBeTruthy();
|
||||
expect(screen.getByText(/3 rows affected/)).toBeTruthy();
|
||||
expect(screen.getByText(/relation "nope" does not exist/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("delegates each set to the grid renderer with its index", () => {
|
||||
const renderGrid = vi.fn(() => <div />);
|
||||
render(<ResultSetPanels multi={multi} renderGrid={renderGrid} />);
|
||||
expect(renderGrid).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { AlertCircle, Info } from "lucide-react";
|
||||
import type { MultiQueryResult, QueryResult } from "../../lib/types";
|
||||
|
||||
export function ResultSetPanels({
|
||||
multi,
|
||||
renderGrid,
|
||||
}: {
|
||||
multi: MultiQueryResult;
|
||||
renderGrid: (set: QueryResult, index: number) => React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col">
|
||||
{multi.result_sets.map((set, i) => (
|
||||
<section key={i} className="border-b border-border flex flex-col min-h-0">
|
||||
<header className="px-3 py-1.5 text-xs text-text-muted bg-surface-raised border-b border-border flex items-center gap-2 shrink-0">
|
||||
<span className="font-medium text-text">Result {i + 1}</span>
|
||||
<span>— {set.total_rows} row{set.total_rows === 1 ? "" : "s"}</span>
|
||||
</header>
|
||||
<div className="min-h-0" style={{ height: Math.min(Math.max(set.rows.length * 28 + 32, 160), 480) }}>
|
||||
{renderGrid(set, i)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{multi.notices.map((n, i) => (
|
||||
<div
|
||||
key={i}
|
||||
role={n.kind === "error" ? "alert" : "status"}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 text-xs border-b border-border ${
|
||||
n.kind === "error" ? "text-red-400" : "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{n.kind === "error" ? <AlertCircle size={13} /> : <Info size={13} />}
|
||||
<span className="font-medium">Statement {n.statement_index + 1}</span>
|
||||
<span className="truncate">{n.text}</span>
|
||||
{n.affected !== null && n.affected !== undefined && (
|
||||
<span>({n.affected} rows affected)</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
|
||||
vi.mock("../../lib/commands", () => ({
|
||||
compareSchemas: vi.fn(),
|
||||
getSchemas: vi.fn().mockResolvedValue(["public"]),
|
||||
getDatabases: vi.fn().mockResolvedValue(["app"]),
|
||||
}));
|
||||
vi.mock("../../stores/connectionStore", () => ({
|
||||
useConnectionStore: (sel: (s: unknown) => unknown) =>
|
||||
sel({ connections: [{ id: "src1", name: "Source PG", db_type: "postgresql" }] }),
|
||||
}));
|
||||
|
||||
import { SchemaDiffPage } from "./SchemaDiffPage";
|
||||
import { compareSchemas } from "../../lib/commands";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import type { DiffReport } from "../../lib/types";
|
||||
|
||||
const report: DiffReport = {
|
||||
source_label: "public · src1",
|
||||
target_label: "public · tgt",
|
||||
truncated: false,
|
||||
items: [
|
||||
{
|
||||
object_type: "table", name: "users", kind: "added", detail: [],
|
||||
sync_sql: ['CREATE TABLE "users" (id int);'], destructive: false,
|
||||
},
|
||||
{
|
||||
object_type: "table", name: "old", kind: "removed", detail: [],
|
||||
sync_sql: ["DROP TABLE old;"], destructive: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("SchemaDiffPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useDbViewerStore.getState().clearChanges();
|
||||
(compareSchemas as ReturnType<typeof vi.fn>).mockResolvedValue(report);
|
||||
});
|
||||
|
||||
it("runs the diff and renders grouped items with kind badges", async () => {
|
||||
render(<SchemaDiffPage connectionId="tgt" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /run diff/i }));
|
||||
await waitFor(() => expect(screen.getByText("users")).toBeTruthy());
|
||||
expect(screen.getByText(/added/i)).toBeTruthy();
|
||||
expect(screen.getByText(/removed/i)).toBeTruthy();
|
||||
expect(screen.getByText(/Destructive/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("stages only safe items into the queue", async () => {
|
||||
render(<SchemaDiffPage connectionId="tgt" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /run diff/i }));
|
||||
await waitFor(() => expect(screen.getByText("users")).toBeTruthy());
|
||||
fireEvent.click(screen.getByRole("button", { name: /stage 1 safe item/i }));
|
||||
const queue = useDbViewerStore.getState().changesQueue;
|
||||
expect(queue).toHaveLength(1);
|
||||
expect(queue[0].sql).toContain("CREATE TABLE");
|
||||
});
|
||||
|
||||
it("shows the identical empty state", async () => {
|
||||
(compareSchemas as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
source_label: "a", target_label: "b", truncated: false, items: [],
|
||||
});
|
||||
render(<SchemaDiffPage connectionId="tgt" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /run diff/i }));
|
||||
await waitFor(() => expect(screen.getByText(/identical/i)).toBeTruthy());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Play, Copy, AlertTriangle, GitCompare } from "lucide-react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { compareSchemas, getSchemas, getDatabases } from "../../lib/commands";
|
||||
import { controlClass } from "./objects/formRow";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import type { DiffItem, DiffReport } from "../../lib/types";
|
||||
|
||||
const TYPE_ORDER = ["enum", "sequence", "table", "column", "constraint", "index", "view"];
|
||||
|
||||
const KIND_BADGE: Record<string, string> = {
|
||||
added: "text-emerald-400 border-emerald-400/40",
|
||||
removed: "text-red-400 border-red-400/40",
|
||||
changed: "text-amber-400 border-amber-400/40",
|
||||
};
|
||||
|
||||
/** Threshold above which the diff report is rendered through a virtualizer
|
||||
* (the snapshot cap can reach ~10k cards). Small diffs render plain, which
|
||||
* keeps jsdom tests deterministic and behavior-neutral below the threshold. */
|
||||
const VIRTUALIZE_THRESHOLD = 200;
|
||||
|
||||
function familyOf(dbType: string): string {
|
||||
return dbType === "mysql" || dbType === "mariadb" ? "mysql" : dbType;
|
||||
}
|
||||
|
||||
function SchemaDiffItemCard({ item }: { item: DiffItem }) {
|
||||
return (
|
||||
<div className="border border-border bg-surface p-3 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`border px-1.5 py-0.5 ${KIND_BADGE[item.kind]}`}>{item.kind}</span>
|
||||
<span className="text-text">{item.name}</span>
|
||||
{item.destructive && (
|
||||
<span className="flex items-center gap-1 text-text-muted">
|
||||
<AlertTriangle size={12} className="text-red-400" /> Destructive — copy only
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.detail.map((d, di) => (
|
||||
<div key={di} className="mt-1 text-text-muted">
|
||||
{d.label}: <span className="line-through">{d.old || "∅"}</span> → {d.new || "∅"}
|
||||
</div>
|
||||
))}
|
||||
{item.sync_sql.length > 0 && (
|
||||
<details className="mt-1.5">
|
||||
<summary className="cursor-pointer text-text-muted">SQL</summary>
|
||||
<pre className="mt-1 bg-surface-raised p-2 overflow-x-auto text-[11px]">{item.sync_sql.join("\n")}</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void navigator.clipboard?.writeText(item.sync_sql.join("\n"))}
|
||||
className="mt-1 text-text-muted hover:text-text cursor-pointer"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SchemaDiffPage({ connectionId }: { connectionId: string }) {
|
||||
const connections = useConnectionStore((s) => s.connections);
|
||||
const currentSchema = useDbViewerStore((s) => s.currentSchema);
|
||||
const currentConnection = connections.find((c) => c.id === connectionId);
|
||||
const family = familyOf(currentConnection?.db_type ?? "postgresql");
|
||||
|
||||
const [sourceId, setSourceId] = useState<string>("");
|
||||
const [sourceSchemas, setSourceSchemas] = useState<string[]>([]);
|
||||
const [sourceSchema, setSourceSchema] = useState<string>("");
|
||||
const [targetSchema, setTargetSchema] = useState<string>(currentSchema ?? "public");
|
||||
const [report, setReport] = useState<DiffReport | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const sameFamilySources = useMemo(
|
||||
() => connections.filter((c) => c.id !== connectionId && familyOf(c.db_type) === family),
|
||||
[connections, connectionId, family],
|
||||
);
|
||||
|
||||
const pickSource = async (id: string) => {
|
||||
setSourceId(id);
|
||||
setSourceSchema("");
|
||||
setSourceSchemas([]);
|
||||
if (!id) return;
|
||||
// Set a synchronous default so "Run diff" works before the async schema
|
||||
// fetch resolves (the source must already be connected). The fetch below
|
||||
// refines it to the source's actual first schema.
|
||||
setSourceSchema(family === "mysql" ? "" : family === "sqlite" ? "main" : "public");
|
||||
try {
|
||||
// The source must already be connected (opened in the DB viewer) for
|
||||
// capture — getSchemas/getDatabases and compare_schemas error clearly
|
||||
// if not. We do not auto-connect here.
|
||||
const schemas =
|
||||
family === "mysql"
|
||||
? await getDatabases(id)
|
||||
: family === "sqlite"
|
||||
? ["main"]
|
||||
: await getSchemas(id);
|
||||
setSourceSchemas(schemas);
|
||||
setSourceSchema(schemas[0] ?? "");
|
||||
} catch (e) {
|
||||
setError(`Source connection failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-select the first same-family source on mount so "Run diff" works
|
||||
// without manual selection (the source must already be connected).
|
||||
useEffect(() => {
|
||||
if (sourceId === "" && sameFamilySources.length > 0) {
|
||||
void pickSource(sameFamilySources[0].id);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sameFamilySources, sourceId]);
|
||||
|
||||
const runDiff = async () => {
|
||||
if (!sourceId || !sourceSchema || !targetSchema) return;
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await compareSchemas(sourceId, sourceSchema, connectionId, targetSchema);
|
||||
setReport(r);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stageAll = () => {
|
||||
if (!report) return;
|
||||
useDbViewerStore.getState().addDiffItems(report.items);
|
||||
};
|
||||
|
||||
const copyAll = () => {
|
||||
if (!report) return;
|
||||
const sql = report.items.flatMap((i) => i.sync_sql).join("\n");
|
||||
void navigator.clipboard?.writeText(sql);
|
||||
};
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
if (!report) return [] as [string, DiffItem[]][];
|
||||
const byType = new Map<string, DiffItem[]>();
|
||||
for (const it of report.items) {
|
||||
const list = byType.get(it.object_type) ?? [];
|
||||
list.push(it);
|
||||
byType.set(it.object_type, list);
|
||||
}
|
||||
return [...byType.entries()].sort(
|
||||
(a, b) => TYPE_ORDER.indexOf(a[0]) - TYPE_ORDER.indexOf(b[0]),
|
||||
);
|
||||
}, [report]);
|
||||
|
||||
const flattenedItems = report ? report.items : [];
|
||||
const shouldVirtualize = flattenedItems.length > VIRTUALIZE_THRESHOLD;
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const virtualizer = useVirtualizer({
|
||||
count: flattenedItems.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 96,
|
||||
overscan: 8,
|
||||
});
|
||||
|
||||
const stageableCount = report
|
||||
? report.items.filter((i) => !i.destructive && i.sync_sql.length > 0).length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<div className="border-b border-border px-4 py-2 shrink-0 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<GitCompare size={14} className="text-text-muted" />
|
||||
<span className="text-text-muted">Compare a source connection's schema against this connection (same engine family).</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<select
|
||||
value={sourceId}
|
||||
onChange={(e) => void pickSource(e.target.value)}
|
||||
className={`${controlClass} max-w-56`}
|
||||
aria-label="Source connection"
|
||||
>
|
||||
<option value="">Source connection…</option>
|
||||
{sameFamilySources.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={sourceSchema}
|
||||
onChange={(e) => setSourceSchema(e.target.value)}
|
||||
disabled={!sourceId}
|
||||
className={`${controlClass} max-w-56 disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
aria-label="Source schema"
|
||||
>
|
||||
{sourceSchemas.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<span className="text-text-muted">→</span>
|
||||
<select
|
||||
value={targetSchema}
|
||||
onChange={(e) => setTargetSchema(e.target.value)}
|
||||
className={`${controlClass} max-w-56`}
|
||||
aria-label="Target schema"
|
||||
>
|
||||
<option value={targetSchema}>{targetSchema}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runDiff()}
|
||||
disabled={running || !sourceId || !sourceSchema}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-accent-foreground hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<Play size={13} /> {running ? "Diffing…" : "Run diff"}
|
||||
</button>
|
||||
{report && stageableCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={stageAll}
|
||||
className="bg-surface-raised border border-border px-3 py-1.5 text-xs text-text hover:bg-surface cursor-pointer"
|
||||
>
|
||||
Stage {stageableCount} safe item{stageableCount === 1 ? "" : "s"}
|
||||
</button>
|
||||
)}
|
||||
{report && report.items.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyAll}
|
||||
className="inline-flex items-center gap-1 bg-surface-raised border border-border px-3 py-1.5 text-xs text-text hover:bg-surface cursor-pointer"
|
||||
>
|
||||
<Copy size={13} /> Copy all SQL
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{report?.truncated && (
|
||||
<div className="flex items-center gap-1 text-xs text-amber-400">
|
||||
<AlertTriangle size={13} /> Snapshot capped at 5,000 objects per side — report is truncated.
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="text-xs text-red-400" role="alert">{error}</div>}
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto px-4 py-3 space-y-4">
|
||||
{report && report.items.length === 0 && (
|
||||
<div className="text-xs text-text-muted">Schemas are identical — no differences found.</div>
|
||||
)}
|
||||
{shouldVirtualize ? (
|
||||
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
||||
{virtualizer.getVirtualItems().map((vi) => (
|
||||
<div
|
||||
key={vi.key}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${vi.start}px)`,
|
||||
}}
|
||||
>
|
||||
<SchemaDiffItemCard item={flattenedItems[vi.index]} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
grouped.map(([type, items]) => (
|
||||
<section key={type}>
|
||||
<h3 className="text-xs font-medium text-text-muted uppercase tracking-wide mb-1.5">{type}s</h3>
|
||||
<div className="space-y-1.5">
|
||||
{items.map((it) => (
|
||||
<SchemaDiffItemCard key={`${it.object_type}-${it.name}`} item={it} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -99,7 +99,7 @@ export function SchemaMenu({ connectionId, schema, onRefresh }: SchemaMenuProps)
|
||||
"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}>
|
||||
<div className="relative flex items-center gap-1" ref={menuRef}>
|
||||
<button
|
||||
aria-label="New schema"
|
||||
onClick={() => {
|
||||
@@ -123,7 +123,7 @@ export function SchemaMenu({ connectionId, schema, onRefresh }: SchemaMenuProps)
|
||||
)}
|
||||
|
||||
{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">
|
||||
<div className="absolute left-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={() => {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
save: vi.fn().mockResolvedValue("/tmp/export.csv"),
|
||||
}));
|
||||
vi.mock("../../lib/commands", () => ({
|
||||
exportQueryToFile: vi.fn().mockResolvedValue({ rows_written: 3 }),
|
||||
cancelQuery: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn().mockResolvedValue(() => {}),
|
||||
}));
|
||||
|
||||
import { StreamExportDialog } from "./StreamExportDialog";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { exportQueryToFile, cancelQuery } from "../../lib/commands";
|
||||
|
||||
describe("StreamExportDialog", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("picks a path, invokes the export, and reports the row count", async () => {
|
||||
render(<StreamExportDialog connectionId="c1" sql="SELECT 1" onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||
expect(save).toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: /export/i }));
|
||||
await waitFor(() => expect(exportQueryToFile).toHaveBeenCalledWith("c1", "SELECT 1", "csv", "/tmp/export.csv"));
|
||||
await waitFor(() => expect(screen.getByText(/3 rows written/i)).toBeTruthy());
|
||||
});
|
||||
|
||||
it("cancels via the existing query-cancel wrapper", async () => {
|
||||
render(<StreamExportDialog connectionId="c1" sql="SELECT 1" onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /export/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
|
||||
await waitFor(() => expect(cancelQuery).toHaveBeenCalledWith("c1"));
|
||||
});
|
||||
|
||||
it("surfaces export errors", async () => {
|
||||
(exportQueryToFile as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("boom"));
|
||||
render(<StreamExportDialog connectionId="c1" sql="SELECT 1" onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /export/i }));
|
||||
await waitFor(() => expect(screen.getByText(/boom/i)).toBeTruthy());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Select } from "../ui/Select";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { exportQueryToFile, cancelQuery } from "../../lib/commands";
|
||||
|
||||
interface StreamExportDialogProps {
|
||||
connectionId: string;
|
||||
sql: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const FORMAT_OPTIONS = [
|
||||
{ value: "csv", label: "CSV" },
|
||||
{ value: "jsonl", label: "JSON Lines" },
|
||||
{ value: "json", label: "JSON" },
|
||||
];
|
||||
|
||||
const EXTENSIONS: Record<string, string> = {
|
||||
csv: "csv",
|
||||
jsonl: "jsonl",
|
||||
json: "json",
|
||||
};
|
||||
|
||||
export function StreamExportDialog({ connectionId, sql, onClose }: StreamExportDialogProps) {
|
||||
const [format, setFormat] = useState("csv");
|
||||
const [path, setPath] = useState("");
|
||||
const [running, setRunning] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [done, setDone] = useState(false);
|
||||
const [rowsWritten, setRowsWritten] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const jobIdRef = useRef<string | null>(null);
|
||||
|
||||
// Filter export-progress events to this job so concurrent exports don't
|
||||
// clobber each other's progress bar.
|
||||
useEffect(() => {
|
||||
const unlisten = listen<{ job_id: string; percent: number }>("export-progress", (e) => {
|
||||
if (e.payload.job_id === jobIdRef.current) {
|
||||
setProgress(e.payload.percent);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
unlisten.then((fn) => fn());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleChooseFile = async () => {
|
||||
const ext = EXTENSIONS[format] ?? "csv";
|
||||
const picked = await save({
|
||||
defaultPath: `export.${ext}`,
|
||||
filters: [{ name: format.toUpperCase(), extensions: [ext] }],
|
||||
});
|
||||
if (picked) setPath(picked);
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
setDone(false);
|
||||
setProgress(0);
|
||||
try {
|
||||
let target = path;
|
||||
if (!target) {
|
||||
const ext = EXTENSIONS[format] ?? "csv";
|
||||
target = (await save({
|
||||
defaultPath: `export.${ext}`,
|
||||
filters: [{ name: format.toUpperCase(), extensions: [ext] }],
|
||||
})) ?? "";
|
||||
if (!target) {
|
||||
setRunning(false);
|
||||
return;
|
||||
}
|
||||
setPath(target);
|
||||
}
|
||||
jobIdRef.current = `export-${Date.now()}`;
|
||||
const res = await exportQueryToFile(connectionId, sql, format, target);
|
||||
setDone(true);
|
||||
setProgress(100);
|
||||
setRowsWritten(res.rows_written);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
try {
|
||||
await cancelQuery(connectionId);
|
||||
} catch {
|
||||
// ignore — the export may already have finished
|
||||
}
|
||||
setRunning(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatedModal open onClose={onClose}>
|
||||
<div className="w-[420px] flex flex-col gap-4">
|
||||
<h2 className="text-base font-semibold text-text">Export all rows to file</h2>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Select
|
||||
label="Format"
|
||||
value={format}
|
||||
onChange={setFormat}
|
||||
options={FORMAT_OPTIONS}
|
||||
disabled={running}
|
||||
/>
|
||||
<Button variant="secondary" onClick={handleChooseFile} disabled={running}>
|
||||
Choose file…
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{path && (
|
||||
<p className="text-xs text-text-muted break-all" data-testid="export-path">
|
||||
{path}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{running && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="h-1.5 w-full rounded-full bg-surface-raised overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent transition-all"
|
||||
style={{ width: `${Math.max(progress, 2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-text-muted">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{done && rowsWritten !== null && (
|
||||
<p className="text-xs text-emerald-400" data-testid="export-done">
|
||||
{rowsWritten} rows written
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-400" data-testid="export-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{running && (
|
||||
<Button variant="secondary" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleExport} disabled={running}>
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
);
|
||||
}
|
||||
@@ -60,3 +60,49 @@ describe("SyncPage DB-aware", () => {
|
||||
expect(options).not.toContain("sq1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SyncPage tool re-check", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockConnections.length = 0;
|
||||
useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 });
|
||||
useNotificationStore.setState({ notifications: [] });
|
||||
});
|
||||
|
||||
it("re-checks MySQL tools with force when Check again is clicked", async () => {
|
||||
mockConnections.push(
|
||||
{ id: "my1", db_type: "mysql", name: "MySQL 1", database: "db1" },
|
||||
{ id: "my2", db_type: "mysql", name: "MySQL 2", database: "db2" },
|
||||
);
|
||||
vi.spyOn(commands, "detectPgTools").mockResolvedValue(pgToolsOk);
|
||||
const spy = vi.spyOn(commands, "detectMysqlTools").mockResolvedValue(mysqlToolsOk);
|
||||
vi.spyOn(commands, "getSchemas").mockResolvedValue([]);
|
||||
render(<SyncPage />);
|
||||
const [sourceSelect] = screen.getAllByRole("combobox") as HTMLSelectElement[];
|
||||
fireEvent.change(sourceSelect, { target: { value: "my1" } });
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith(false));
|
||||
fireEvent.click(screen.getByRole("button", { name: /check again/i }));
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith(true));
|
||||
});
|
||||
|
||||
it("lists the specific missing MySQL role", async () => {
|
||||
mockConnections.push(
|
||||
{ id: "my1", db_type: "mysql", name: "MySQL 1", database: "db1" },
|
||||
{ id: "my2", db_type: "mysql", name: "MySQL 2", database: "db2" },
|
||||
);
|
||||
vi.spyOn(commands, "detectPgTools").mockResolvedValue(pgToolsOk);
|
||||
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({
|
||||
mysqldumpFound: false,
|
||||
mysqlFound: true,
|
||||
mysqldumpVersion: null,
|
||||
mysqlVersion: "8.0",
|
||||
mysqldumpSource: null,
|
||||
mysqlSource: "system",
|
||||
});
|
||||
vi.spyOn(commands, "getSchemas").mockResolvedValue([]);
|
||||
render(<SyncPage />);
|
||||
const [sourceSelect] = screen.getAllByRole("combobox") as HTMLSelectElement[];
|
||||
fireEvent.change(sourceSelect, { target: { value: "my1" } });
|
||||
await waitFor(() => expect(screen.getByText(/mysqldump is missing/i)).toBeTruthy());
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { ArrowLeftRight, Database } from "lucide-react";
|
||||
import { Button } from "../ui/Button";
|
||||
import { ArrowLeftRight } from "lucide-react";
|
||||
import { BackupProgress } from "./BackupProgress";
|
||||
import {
|
||||
FormRow,
|
||||
FormSectionHeader,
|
||||
controlClass,
|
||||
} from "./objects/formRow";
|
||||
import { useBackupStore } from "../../stores/backupStore";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
@@ -59,16 +63,15 @@ export function SyncPage() {
|
||||
}, [activeJob, notify]);
|
||||
|
||||
// Tool detection: depends on the selected source connection's DB type
|
||||
useEffect(() => {
|
||||
const checkTools = useCallback(
|
||||
async (force: boolean) => {
|
||||
setPgToolStatus(null);
|
||||
setMysqlToolStatus(null);
|
||||
setConfirmed(false);
|
||||
|
||||
if (isPg) {
|
||||
setCheckingTools(true);
|
||||
detectPgTools()
|
||||
.then((status) => setPgToolStatus(status))
|
||||
.catch(() =>
|
||||
try {
|
||||
setPgToolStatus(await detectPgTools(force));
|
||||
} catch {
|
||||
setPgToolStatus({
|
||||
pg_dump_found: false,
|
||||
pg_restore_found: false,
|
||||
@@ -76,14 +79,15 @@ export function SyncPage() {
|
||||
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(() =>
|
||||
try {
|
||||
setMysqlToolStatus(await detectMysqlTools(force));
|
||||
} catch {
|
||||
setMysqlToolStatus({
|
||||
mysqldumpFound: false,
|
||||
mysqlFound: false,
|
||||
@@ -91,11 +95,19 @@ export function SyncPage() {
|
||||
mysqlVersion: null,
|
||||
mysqldumpSource: null,
|
||||
mysqlSource: null,
|
||||
}),
|
||||
)
|
||||
.finally(() => setCheckingTools(false));
|
||||
});
|
||||
} finally {
|
||||
setCheckingTools(false);
|
||||
}
|
||||
}, [isPg, isMysql]);
|
||||
}
|
||||
},
|
||||
[isPg, isMysql],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setConfirmed(false);
|
||||
void checkTools(false);
|
||||
}, [checkTools]);
|
||||
|
||||
// Fetch schemas from the source connection when it changes
|
||||
useEffect(() => {
|
||||
@@ -195,31 +207,59 @@ export function SyncPage() {
|
||||
? connections.filter((c) => c.db_type === dbType)
|
||||
: [];
|
||||
|
||||
const sourceName =
|
||||
connections.find((c) => c.id === sourceConnectionId)?.name ??
|
||||
sourceConnectionId;
|
||||
const targetName =
|
||||
connections.find((c) => c.id === targetConnectionId)?.name ??
|
||||
targetConnectionId;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Toolbar header */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ArrowLeftRight size={14} className="text-accent" />
|
||||
<span className="text-xs font-medium text-text">DB Sync</span>
|
||||
<span className="text-[11px] text-text-muted">
|
||||
Transfer data between databases via pipe
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(isPg || isMysql) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void checkTools(true)}
|
||||
className="text-[11px] text-text-muted hover:text-text transition-colors cursor-pointer"
|
||||
>
|
||||
Check again
|
||||
</button>
|
||||
)}
|
||||
{!checkingTools && !toolsMissing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleStartSync()}
|
||||
disabled={!canStart}
|
||||
className="inline-flex items-center rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<ArrowLeftRight size={14} className="mr-1.5" />
|
||||
{isRunning ? "Syncing..." : "Start Sync"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* Tool check */}
|
||||
{checkingTools && checkingMessage && (
|
||||
<div className="glass p-4 text-center">
|
||||
<p className="text-sm text-text-muted">
|
||||
<div className="px-4 py-3 text-sm text-text-muted">
|
||||
{checkingMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolsMissing && !toolsBundled && (
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
|
||||
<div className="border-b border-border bg-amber-500/10 px-4 py-3 space-y-2">
|
||||
<p className="text-amber-300 text-sm font-semibold">
|
||||
{isPg
|
||||
? "PostgreSQL tools not found"
|
||||
@@ -242,58 +282,45 @@ export function SyncPage() {
|
||||
{isMysql && !mysqlToolStatus?.mysqlFound && (
|
||||
<li>mysql is missing.</li>
|
||||
)}
|
||||
{isMysql && mysqlToolStatus?.mysqlResolvedName && (
|
||||
<li className="text-amber-200/50">
|
||||
Resolved MySQL client: {mysqlToolStatus.mysqlResolvedName}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingTools && !toolsMissing && (
|
||||
<>
|
||||
{/* Configuration card */}
|
||||
<div className="p-5 space-y-5">
|
||||
{/* Source & Target connection pickers */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium flex items-center gap-1">
|
||||
<Database size={11} />
|
||||
Source
|
||||
</label>
|
||||
<FormSectionHeader label="Connections" />
|
||||
|
||||
{/* Source connection */}
|
||||
<FormRow label="Source">
|
||||
<select
|
||||
value={sourceConnectionId}
|
||||
onChange={(e) => {
|
||||
setSourceConnectionId(
|
||||
e.target.value,
|
||||
);
|
||||
setSourceConnectionId(e.target.value);
|
||||
setTargetConnectionId("");
|
||||
}}
|
||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
|
||||
className={`${controlClass} mr-3`}
|
||||
>
|
||||
<option value="">
|
||||
Select source...
|
||||
</option>
|
||||
<option value="">Select source...</option>
|
||||
{connections.map((c) => (
|
||||
<option
|
||||
key={c.id}
|
||||
value={c.id}
|
||||
>
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium flex items-center gap-1">
|
||||
<Database size={11} />
|
||||
Target
|
||||
</label>
|
||||
</FormRow>
|
||||
|
||||
{/* Target connection — filtered to the source's db type */}
|
||||
<FormRow label="Target">
|
||||
<select
|
||||
value={targetConnectionId}
|
||||
onChange={(e) =>
|
||||
setTargetConnectionId(
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
onChange={(e) => setTargetConnectionId(e.target.value)}
|
||||
disabled={!sourceConnectionId}
|
||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
className={`${controlClass} mr-3 disabled:opacity-40 disabled:cursor-not-allowed`}
|
||||
>
|
||||
<option value="">
|
||||
{sourceConnectionId
|
||||
@@ -304,81 +331,58 @@ export function SyncPage() {
|
||||
<option
|
||||
key={c.id}
|
||||
value={c.id}
|
||||
disabled={
|
||||
c.id === sourceConnectionId
|
||||
}
|
||||
disabled={c.id === sourceConnectionId}
|
||||
>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schema (optional) */}
|
||||
{(isPg || isMysql) && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Schema{" "}
|
||||
<span className="font-normal normal-case tracking-normal">
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
value={schema}
|
||||
onChange={(e) =>
|
||||
setSchema(e.target.value)
|
||||
}
|
||||
disabled={!sourceConnectionId}
|
||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value="">
|
||||
{sourceConnectionId
|
||||
? "All schemas"
|
||||
: "Select a source first"}
|
||||
</option>
|
||||
{availableSchemas.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</FormRow>
|
||||
|
||||
{/* Flow indicator */}
|
||||
{sourceConnectionId && targetConnectionId && (
|
||||
<div className="flex items-center gap-3 text-[11px] text-text-muted">
|
||||
<FormRow label="Flow">
|
||||
<div className="flex items-center gap-3 px-3 py-2 text-[11px] text-text-muted">
|
||||
<span className="font-medium text-text">
|
||||
{connections.find(
|
||||
(c) =>
|
||||
c.id === sourceConnectionId,
|
||||
)?.name ?? sourceConnectionId}
|
||||
{sourceName}
|
||||
</span>
|
||||
<ArrowLeftRight
|
||||
size={12}
|
||||
className="text-accent shrink-0"
|
||||
/>
|
||||
<span className="font-medium text-text">
|
||||
{connections.find(
|
||||
(c) =>
|
||||
c.id === targetConnectionId,
|
||||
)?.name ?? targetConnectionId}
|
||||
{targetName}
|
||||
</span>
|
||||
</div>
|
||||
</FormRow>
|
||||
)}
|
||||
|
||||
{/* Schema (optional) */}
|
||||
{(isPg || isMysql) && (
|
||||
<FormRow label="Schema">
|
||||
<select
|
||||
value={schema}
|
||||
onChange={(e) => setSchema(e.target.value)}
|
||||
className={`${controlClass} mr-3`}
|
||||
>
|
||||
<option value="">All schemas</option>
|
||||
{availableSchemas.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Destructive confirmation */}
|
||||
<div className="bg-red-500/5 border border-red-500/20 px-4 py-3">
|
||||
<div className="border-b border-border bg-red-500/5 px-4 py-3 space-y-2">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(e) =>
|
||||
setConfirmed(e.target.checked)
|
||||
}
|
||||
className="mt-0.5 rounded bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
|
||||
onChange={(e) => setConfirmed(e.target.checked)}
|
||||
className="mt-0.5 bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
|
||||
data-testid="sync-confirm-checkbox"
|
||||
/>
|
||||
<span className="text-sm text-red-300/90 leading-relaxed">
|
||||
@@ -391,7 +395,7 @@ export function SyncPage() {
|
||||
|
||||
{/* Progress */}
|
||||
{activeJob && (
|
||||
<div className="px-4">
|
||||
<div className="px-4 py-3">
|
||||
<BackupProgress
|
||||
progress={activeJob.status === "completed" ? 100 : 50}
|
||||
jobType="sync"
|
||||
@@ -400,24 +404,9 @@ export function SyncPage() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end pb-2 pr-2">
|
||||
<Button
|
||||
onClick={handleStartSync}
|
||||
disabled={!canStart}
|
||||
>
|
||||
<ArrowLeftRight
|
||||
size={14}
|
||||
className="mr-1.5"
|
||||
/>
|
||||
{isRunning ? "Syncing..." : "Start Sync"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -251,7 +251,7 @@ export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) {
|
||||
<div className="flex shrink-0 items-center gap-1.5 border-l border-border px-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openQueryTab}
|
||||
onClick={() => openQueryTab()}
|
||||
aria-label="New query tab"
|
||||
className="flex items-center gap-1.5 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white transition-colors hover:bg-accent-hover cursor-pointer"
|
||||
>
|
||||
|
||||
@@ -325,13 +325,115 @@ describe("TableControls", () => {
|
||||
expect(screen.queryByLabelText(/insert row/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the FilterBuilder inside the filter popover", () => {
|
||||
it("stages an insert with only editable columns when Insert Row is clicked", () => {
|
||||
const cols = [
|
||||
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: "nextval('users_id_seq')", editable: false, is_generated: false },
|
||||
{ name: "name", data_type: "text", is_nullable: false, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
||||
{ name: "created_at", data_type: "timestamp", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: "now()", editable: true, is_generated: false },
|
||||
];
|
||||
seed([makeTab({ data: { columns: cols, rows: [], total_rows: 0, page: 1, page_size: 50 } })], "tab-1");
|
||||
renderControls({ columns: cols, rows: [] });
|
||||
fireEvent.click(screen.getByLabelText(/insert row/i));
|
||||
const queue = useDbViewerStore.getState().changesQueue;
|
||||
expect(queue).toHaveLength(1);
|
||||
expect(queue[0].type).toBe("insert");
|
||||
expect(queue[0].newData).toEqual({ name: null, created_at: null });
|
||||
});
|
||||
|
||||
it("renders the column filter popover with the + Add filter action", () => {
|
||||
seed([makeTab()], "tab-1");
|
||||
renderControls();
|
||||
fireEvent.click(screen.getByLabelText(/column filters/i));
|
||||
expect(
|
||||
screen.getByText("Drop columns here to add filters"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Column Filters")).toBeInTheDocument();
|
||||
expect(screen.getByText("+ Add filter")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("debounces filter value edits so a query only fires after the user stops typing", () => {
|
||||
vi.useFakeTimers();
|
||||
seed([makeTab()], "tab-1");
|
||||
const onFilterChange = vi.fn();
|
||||
renderControls({
|
||||
onFilterChange,
|
||||
filterRules: [
|
||||
{ id: "r1", column: "id", operator: "contains", value: "" },
|
||||
],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/column filters/i));
|
||||
|
||||
const input = screen.getByPlaceholderText("value");
|
||||
fireEvent.change(input, { target: { value: "a" } });
|
||||
fireEvent.change(input, { target: { value: "ab" } });
|
||||
fireEvent.change(input, { target: { value: "abc" } });
|
||||
|
||||
// No commit yet — the debounce hasn't elapsed.
|
||||
expect(onFilterChange).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(400);
|
||||
});
|
||||
|
||||
// Only the final value is committed, once.
|
||||
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||
expect(onFilterChange).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ value: "abc" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("closes the filter popover when the button is clicked while it is open", () => {
|
||||
seed([makeTab()], "tab-1");
|
||||
renderControls();
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/column filters/i));
|
||||
expect(screen.getByText("Column Filters")).toBeInTheDocument();
|
||||
|
||||
// Clicking the button while open closes it (plain toggle).
|
||||
fireEvent.click(screen.getByLabelText(/column filters/i));
|
||||
expect(screen.queryByText("Column Filters")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles the sort rules popover open and closed", () => {
|
||||
seed([makeTab()], "tab-1");
|
||||
renderControls();
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/sort rules/i));
|
||||
expect(screen.getByText("Sort Rules")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/sort rules/i));
|
||||
expect(screen.queryByText("Sort Rules")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles the auto-refresh dropdown open and closed", () => {
|
||||
seed([makeTab()], "tab-1");
|
||||
renderControls();
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/auto-refresh/i));
|
||||
expect(screen.getByText("Off")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/auto-refresh/i));
|
||||
expect(screen.queryByText("Off")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles the show/hide columns dropdown open and closed", () => {
|
||||
seed([makeTab()], "tab-1");
|
||||
renderControls();
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/toggle columns/i));
|
||||
expect(screen.getByText("Visible columns")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/toggle columns/i));
|
||||
expect(screen.queryByText("Visible columns")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles the export dropdown open and closed", () => {
|
||||
seed([makeTab()], "tab-1");
|
||||
renderControls();
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/export/i));
|
||||
expect(screen.getByText("JSON")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/export/i));
|
||||
expect(screen.queryByText("JSON")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("export dropdown includes the Excel option", () => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { FilterBuilder } from "./FilterBuilder";
|
||||
import { Tooltip } from "../ui/Tooltip";
|
||||
import { exportData } from "../../lib/exportData";
|
||||
import type { ColumnInfo } from "../../lib/types";
|
||||
@@ -49,22 +48,28 @@ function DropdownMenu({
|
||||
open,
|
||||
setOpen,
|
||||
align,
|
||||
wrapRef,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
align?: "left" | "right";
|
||||
/** When provided, outside-click detection uses this wrapper (which should
|
||||
* contain both the toggle button and the menu) so the button never fights
|
||||
* the outside-click handler. */
|
||||
wrapRef?: React.RefObject<HTMLDivElement | null>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
const container = wrapRef?.current ?? ref.current;
|
||||
if (container && !container.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", close);
|
||||
return () => document.removeEventListener("mousedown", close);
|
||||
}, [open, setOpen]);
|
||||
}, [open, setOpen, wrapRef]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
@@ -79,28 +84,84 @@ function DropdownMenu({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced free-text input for filter values. The value is committed to the
|
||||
* rules (and thus triggers a server-side refetch) only after the user stops
|
||||
* typing for `DEBOUNCE_MS`. Without this, every keystroke fires a query and
|
||||
* the in-flight guard in `fetchData` silently drops intermediate fetches, so
|
||||
* the final filter value can end up not being applied.
|
||||
*/
|
||||
const FILTER_DEBOUNCE_MS = 400;
|
||||
|
||||
function DebouncedValueInput({
|
||||
value,
|
||||
onCommit,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
onCommit: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(value);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Keep the draft in sync when the committed value changes externally
|
||||
// (e.g. a rule is replaced or removed while this input is mounted).
|
||||
useEffect(() => {
|
||||
setDraft(value);
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = e.target.value;
|
||||
setDraft(v);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => onCommit(v), FILTER_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={draft}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterModal({
|
||||
columns,
|
||||
rules,
|
||||
onChange,
|
||||
open,
|
||||
setOpen,
|
||||
wrapRef,
|
||||
}: {
|
||||
columns: ColumnInfo[];
|
||||
rules: FilterRule[];
|
||||
onChange: (rules: FilterRule[]) => void;
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
wrapRef: React.RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
// Close on outside click, but treat the toggle button (which lives inside
|
||||
// the same wrapper) as part of the modal so it never fights the toggle.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", close);
|
||||
return () => document.removeEventListener("mousedown", close);
|
||||
}, [open, setOpen]);
|
||||
}, [open, setOpen, wrapRef]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -116,17 +177,13 @@ function FilterModal({
|
||||
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute top-full left-0 mt-1 z-30 w-96 rounded-lg bg-surface border border-border shadow-lg p-3"
|
||||
>
|
||||
<div className="absolute top-full left-0 mt-1 z-30 w-96 rounded-lg bg-surface border border-border shadow-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-text">Column Filters</span>
|
||||
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<FilterBuilder columns={columns} rules={rules} onChange={onChange} />
|
||||
{rules.map((rule) => (
|
||||
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
|
||||
<select
|
||||
@@ -152,12 +209,10 @@ function FilterModal({
|
||||
<option value="notnull">not null</option>
|
||||
</select>
|
||||
{rule.operator !== "null" && rule.operator !== "notnull" && (
|
||||
<input
|
||||
type="text"
|
||||
<DebouncedValueInput
|
||||
value={rule.value}
|
||||
onChange={(e) => updateRule(rule.id, { value: e.target.value })}
|
||||
onCommit={(v) => updateRule(rule.id, { value: v })}
|
||||
placeholder="value"
|
||||
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0"
|
||||
/>
|
||||
)}
|
||||
<button type="button" onClick={() => removeRule(rule.id)} className="text-text-muted hover:text-red-400 shrink-0 cursor-pointer">
|
||||
@@ -182,22 +237,27 @@ function SortModal({
|
||||
onChange,
|
||||
open,
|
||||
setOpen,
|
||||
wrapRef,
|
||||
}: {
|
||||
columns: ColumnInfo[];
|
||||
rules: SortRule[];
|
||||
onChange: (rules: SortRule[]) => void;
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
wrapRef: React.RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
// Close on outside click, but treat the toggle button (which lives inside
|
||||
// the same wrapper) as part of the modal so it never fights the toggle.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", close);
|
||||
return () => document.removeEventListener("mousedown", close);
|
||||
}, [open, setOpen]);
|
||||
}, [open, setOpen, wrapRef]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -213,10 +273,7 @@ function SortModal({
|
||||
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute top-full left-0 mt-1 z-30 w-72 rounded-lg bg-surface border border-border shadow-lg p-3"
|
||||
>
|
||||
<div className="absolute top-full left-0 mt-1 z-30 w-72 rounded-lg bg-surface border border-border shadow-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-text">Sort Rules</span>
|
||||
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
|
||||
@@ -272,6 +329,7 @@ function BulkActionsDropdown({
|
||||
onClearSelection: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const addChange = useDbViewerStore((s) => s.addChange);
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
@@ -338,7 +396,7 @@ function BulkActionsDropdown({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative" ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
@@ -347,7 +405,7 @@ function BulkActionsDropdown({
|
||||
<span className="text-xs font-medium">Actions</span>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
<DropdownMenu open={open} setOpen={setOpen} align="right">
|
||||
<DropdownMenu open={open} setOpen={setOpen} align="right" wrapRef={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyJSON}
|
||||
@@ -409,6 +467,8 @@ interface TableControlsProps {
|
||||
isMatview?: boolean;
|
||||
/** "table" = full table toolbar; "query" = export/refresh/columns + timing */
|
||||
variant?: "table" | "query";
|
||||
/** Open the full-result streamed export dialog. */
|
||||
onExportToFile?: () => void;
|
||||
}
|
||||
|
||||
export function TableControls({
|
||||
@@ -430,6 +490,7 @@ export function TableControls({
|
||||
defaultRefreshRate = 0,
|
||||
isMatview = false,
|
||||
variant = "table",
|
||||
onExportToFile,
|
||||
}: TableControlsProps) {
|
||||
const isQuery = variant === "query";
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
@@ -445,11 +506,16 @@ export function TableControls({
|
||||
|
||||
// local state
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const filterWrapRef = useRef<HTMLDivElement>(null);
|
||||
const [sortOpen, setSortOpen] = useState(false);
|
||||
const sortWrapRef = useRef<HTMLDivElement>(null);
|
||||
const [columnMenuOpen, setColumnMenuOpen] = useState(false);
|
||||
const columnMenuWrapRef = useRef<HTMLDivElement>(null);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const exportWrapRef = useRef<HTMLDivElement>(null);
|
||||
const [autoRefresh, setAutoRefresh] = useState(defaultRefreshRate);
|
||||
const [autoRefreshOpen, setAutoRefreshOpen] = useState(false);
|
||||
const autoRefreshWrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-refresh: a self-restarting timer that only counts down while the tab
|
||||
// is idle. Fires a refresh, waits for it to complete (loading → false),
|
||||
@@ -484,8 +550,14 @@ export function TableControls({
|
||||
};
|
||||
|
||||
const handleInsertRow = () => {
|
||||
// Build the insert template from editable columns only. PK / generated /
|
||||
// identity columns are omitted so their defaults (serial, identity,
|
||||
// generated) apply on commit — an explicit NULL would bypass them and
|
||||
// fail with a NOT NULL violation.
|
||||
const newData: Record<string, unknown> = {};
|
||||
columns.forEach((c) => { newData[c.name] = null; });
|
||||
columns.forEach((c) => {
|
||||
if (c.editable && !c.is_pk && !c.is_generated) newData[c.name] = null;
|
||||
});
|
||||
addChange({
|
||||
type: "insert",
|
||||
schema,
|
||||
@@ -545,7 +617,7 @@ export function TableControls({
|
||||
);
|
||||
|
||||
const exportControl = (
|
||||
<div className="relative">
|
||||
<div className="relative" ref={exportWrapRef}>
|
||||
<Tooltip content="Export" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
@@ -556,7 +628,7 @@ export function TableControls({
|
||||
<Download size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<DropdownMenu open={exportOpen} setOpen={setExportOpen}>
|
||||
<DropdownMenu open={exportOpen} setOpen={setExportOpen} wrapRef={exportWrapRef}>
|
||||
{EXPORT_FORMATS.map((fmt) => (
|
||||
<button
|
||||
key={fmt.ext}
|
||||
@@ -567,12 +639,27 @@ export function TableControls({
|
||||
{fmt.label}
|
||||
</button>
|
||||
))}
|
||||
{onExportToFile && (
|
||||
<>
|
||||
<div className="my-1 h-px bg-border" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setExportOpen(false);
|
||||
onExportToFile();
|
||||
}}
|
||||
className="w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
Export all rows to file…
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
|
||||
const columnsControl = (
|
||||
<div className="relative">
|
||||
<div className="relative" ref={columnMenuWrapRef}>
|
||||
<Tooltip content="Show/hide columns" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
@@ -587,6 +674,7 @@ export function TableControls({
|
||||
open={columnMenuOpen}
|
||||
setOpen={setColumnMenuOpen}
|
||||
align={isQuery ? "left" : "right"}
|
||||
wrapRef={columnMenuWrapRef}
|
||||
>
|
||||
<div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider">
|
||||
Visible columns
|
||||
@@ -658,7 +746,7 @@ export function TableControls({
|
||||
{refreshControl}
|
||||
|
||||
{/* Auto-refresh */}
|
||||
<div className="relative">
|
||||
<div className="relative" ref={autoRefreshWrapRef}>
|
||||
<Tooltip content={`Auto-refresh: ${autoRefresh > 0 ? `${autoRefresh / 1000}s` : "Off"}`} side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
@@ -672,7 +760,7 @@ export function TableControls({
|
||||
{autoRefresh > 0 && <span className="text-[10px] font-medium">{autoRefresh / 1000}s</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<DropdownMenu open={autoRefreshOpen} setOpen={setAutoRefreshOpen}>
|
||||
<DropdownMenu open={autoRefreshOpen} setOpen={setAutoRefreshOpen} wrapRef={autoRefreshWrapRef}>
|
||||
{AUTO_REFRESH_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
@@ -692,7 +780,7 @@ export function TableControls({
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
|
||||
{/* Filter */}
|
||||
<div className="relative">
|
||||
<div className="relative" ref={filterWrapRef}>
|
||||
<Tooltip content="Column filters" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
@@ -716,11 +804,12 @@ export function TableControls({
|
||||
onChange={onFilterChange}
|
||||
open={filterOpen}
|
||||
setOpen={setFilterOpen}
|
||||
wrapRef={filterWrapRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<div className="relative">
|
||||
<div className="relative" ref={sortWrapRef}>
|
||||
<Tooltip content="Sort rules" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
@@ -744,6 +833,7 @@ export function TableControls({
|
||||
onChange={onSortChange}
|
||||
open={sortOpen}
|
||||
setOpen={setSortOpen}
|
||||
wrapRef={sortWrapRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,13 +3,15 @@ import { SelectDropdown } from "../ui/SelectDropdown";
|
||||
import { BackupPage } from "./BackupPage";
|
||||
import { RestorePage } from "./RestorePage";
|
||||
import { SyncPage } from "./SyncPage";
|
||||
import { SchemaDiffPage } from "./SchemaDiffPage";
|
||||
|
||||
type ToolOperation = "backup" | "restore" | "sync";
|
||||
type ToolOperation = "backup" | "restore" | "sync" | "diff";
|
||||
|
||||
const OPERATION_OPTIONS = [
|
||||
{ value: "backup", label: "Backup" },
|
||||
{ value: "restore", label: "Restore" },
|
||||
{ value: "sync", label: "DB Sync" },
|
||||
{ value: "diff", label: "Schema Diff" },
|
||||
];
|
||||
|
||||
export function ToolsPage({ connectionId }: { connectionId: string }) {
|
||||
@@ -33,6 +35,7 @@ export function ToolsPage({ connectionId }: { connectionId: string }) {
|
||||
{operation === "backup" && <BackupPage connectionId={connectionId} />}
|
||||
{operation === "restore" && <RestorePage connectionId={connectionId} />}
|
||||
{operation === "sync" && <SyncPage />}
|
||||
{operation === "diff" && <SchemaDiffPage connectionId={connectionId} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ListChecks,
|
||||
ListOrdered,
|
||||
Puzzle,
|
||||
Layers,
|
||||
ShieldCheck,
|
||||
SquareFunction,
|
||||
Tag,
|
||||
@@ -29,6 +30,7 @@ export const TYPE_LABELS: Record<ObjectType, string> = {
|
||||
sequences: "Sequences",
|
||||
enums: "Enums",
|
||||
extensions: "Extensions",
|
||||
hypertables: "Hypertables",
|
||||
indexes: "Indexes",
|
||||
constraints: "Constraints",
|
||||
procedures: "Procedures",
|
||||
@@ -41,6 +43,7 @@ export const SINGULAR_LABELS: Record<ObjectType, string> = {
|
||||
sequences: "sequence",
|
||||
enums: "enum",
|
||||
extensions: "extension",
|
||||
hypertables: "hypertable",
|
||||
indexes: "index",
|
||||
constraints: "constraint",
|
||||
procedures: "procedure",
|
||||
@@ -55,6 +58,7 @@ export const OBJECT_ICONS: Record<ObjectType, React.ReactNode> = {
|
||||
sequences: <ListOrdered size={14} className="text-text-muted shrink-0" />,
|
||||
enums: <Tag size={14} className="text-text-muted shrink-0" />,
|
||||
extensions: <Puzzle size={14} className="text-text-muted shrink-0" />,
|
||||
hypertables: <Layers size={14} className="text-text-muted shrink-0" />,
|
||||
indexes: <BookMarked size={14} className="text-text-muted shrink-0" />,
|
||||
constraints: <ListChecks size={14} className="text-text-muted shrink-0" />,
|
||||
procedures: <SquareFunction size={14} className="text-text-muted shrink-0" />,
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { DbType } from "../../lib/types";
|
||||
const DB_TYPE_LABELS: Record<DbType, string> = {
|
||||
postgresql: "PostgreSQL",
|
||||
mysql: "MySQL",
|
||||
mariadb: "MariaDB",
|
||||
sqlite: "SQLite",
|
||||
redis: "Redis",
|
||||
};
|
||||
|
||||
@@ -6,16 +6,18 @@ interface Props {
|
||||
isJson: boolean;
|
||||
isFk: boolean;
|
||||
nullable: boolean;
|
||||
/** When true, hide row-scoped actions (View Row / Select Row) — pending insert rows have no backing row. */
|
||||
isInsertRow?: boolean;
|
||||
onCopy: () => void; onCopyJson: () => void; onEdit: () => void;
|
||||
onSetNull: () => void; onOpenFk: () => void; onClose: () => void;
|
||||
onViewRow: () => void; onSelectRow: () => void;
|
||||
}
|
||||
export function CellContextMenu({ anchorRect, editable, isJson, isFk, nullable, onCopy, onCopyJson, onEdit, onSetNull, onOpenFk, onClose, onViewRow, onSelectRow }: Props) {
|
||||
export function CellContextMenu({ anchorRect, editable, isJson, isFk, nullable, isInsertRow = false, onCopy, onCopyJson, onEdit, onSetNull, onOpenFk, onClose, onViewRow, onSelectRow }: Props) {
|
||||
const items: { label: string; icon: React.ReactNode; onClick: () => void; show: boolean }[] = [
|
||||
{ label: "Copy", icon: <Copy size={12} />, onClick: () => { onCopy(); onClose(); }, show: true },
|
||||
{ label: "Copy JSON", icon: <Braces size={12} />, onClick: () => { onCopyJson(); onClose(); }, show: isJson },
|
||||
{ label: "View Row", icon: <Eye size={12} />, onClick: () => { onViewRow(); onClose(); }, show: true },
|
||||
{ label: "Select Row", icon: <MousePointerClick size={12} />, onClick: () => { onSelectRow(); onClose(); }, show: true },
|
||||
{ label: "View Row", icon: <Eye size={12} />, onClick: () => { onViewRow(); onClose(); }, show: !isInsertRow },
|
||||
{ label: "Select Row", icon: <MousePointerClick size={12} />, onClick: () => { onSelectRow(); onClose(); }, show: !isInsertRow },
|
||||
{ label: "Edit", icon: <Pencil size={12} />, onClick: () => { onEdit(); onClose(); }, show: editable },
|
||||
{ label: "Set NULL", icon: <CircleSlash size={12} />, onClick: () => { onSetNull(); onClose(); }, show: editable && nullable },
|
||||
{ label: "Open FK reference", icon: <Link2 size={12} />, onClick: () => { onOpenFk(); onClose(); }, show: isFk },
|
||||
|
||||
@@ -13,6 +13,42 @@ describe("CellEditor", () => {
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onCommit).toHaveBeenCalledWith("Alicia");
|
||||
});
|
||||
|
||||
it("renders a native date input for date columns", () => {
|
||||
render(<CellEditor initialValue="2024-01-15" dataType="date" onCommit={vi.fn()} onCancel={vi.fn()} />);
|
||||
const input = screen.getByDisplayValue("2024-01-15");
|
||||
expect(input).toHaveAttribute("type", "date");
|
||||
});
|
||||
|
||||
it("renders a datetime-local input for timestamp columns", () => {
|
||||
render(<CellEditor initialValue="2024-01-15 10:30:00" dataType="timestamp without time zone" onCommit={vi.fn()} onCancel={vi.fn()} />);
|
||||
const input = screen.getByDisplayValue("2024-01-15T10:30");
|
||||
expect(input).toHaveAttribute("type", "datetime-local");
|
||||
});
|
||||
|
||||
it("commits a datetime-local value in canonical DB format (space separator + seconds)", () => {
|
||||
const onCommit = vi.fn();
|
||||
render(<CellEditor initialValue="2024-01-15 10:30:00" dataType="timestamp without time zone" onCommit={onCommit} onCancel={vi.fn()} />);
|
||||
const input = screen.getByDisplayValue("2024-01-15T10:30");
|
||||
fireEvent.change(input, { target: { value: "2024-01-15T11:45" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onCommit).toHaveBeenCalledWith("2024-01-15 11:45:00");
|
||||
});
|
||||
|
||||
it("renders a boolean select with true/false options", () => {
|
||||
render(<CellEditor initialValue="true" dataType="boolean" onCommit={vi.fn()} onCancel={vi.fn()} />);
|
||||
const select = screen.getByRole("combobox");
|
||||
expect(select).toHaveValue("true");
|
||||
expect(screen.getByRole("option", { name: "true" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "false" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a number input for integer columns", () => {
|
||||
render(<CellEditor initialValue="42" dataType="integer" onCommit={vi.fn()} onCancel={vi.fn()} />);
|
||||
const input = screen.getByRole("spinbutton");
|
||||
expect(input).toHaveAttribute("type", "number");
|
||||
expect(input).toHaveAttribute("step", "1");
|
||||
});
|
||||
it("commits null when a nullable cell is emptied", () => {
|
||||
const onCommit = vi.fn();
|
||||
render(<CellEditor initialValue="Alice" dataType="text" onCommit={onCommit} onCancel={vi.fn()} nullable />);
|
||||
@@ -40,7 +76,7 @@ describe("CellEditor", () => {
|
||||
it("blocks empty commits on non-nullable non-text columns and shows an error", () => {
|
||||
const onCommit = vi.fn();
|
||||
render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />);
|
||||
const input = screen.getByRole("textbox");
|
||||
const input = screen.getByRole("spinbutton");
|
||||
fireEvent.change(input, { target: { value: "" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onCommit).not.toHaveBeenCalled();
|
||||
@@ -57,7 +93,7 @@ describe("CellEditor", () => {
|
||||
it("clears the validation error as soon as the user types again", () => {
|
||||
const onCommit = vi.fn();
|
||||
render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />);
|
||||
const input = screen.getByRole("textbox");
|
||||
const input = screen.getByRole("spinbutton");
|
||||
fireEvent.change(input, { target: { value: "" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByTestId("cell-editor-error")).toBeInTheDocument();
|
||||
|
||||
@@ -30,6 +30,45 @@ const controlClass =
|
||||
/** Types that tolerate an empty string when NOT NULL ('' is a valid value). */
|
||||
const TEXT_LIKE = ["char", "text", "uuid", "bit"];
|
||||
|
||||
/** Map a column data type to a "smart" input kind. */
|
||||
type InputKind = "date" | "datetime" | "time" | "boolean" | "number" | "text";
|
||||
|
||||
const NUMERIC_TYPES = new Set([
|
||||
"integer", "bigint", "smallint", "int", "int2", "int4", "int8",
|
||||
"tinyint", "mediumint", "numeric", "decimal", "real", "double",
|
||||
"double precision", "float", "float4", "float8", "money",
|
||||
"serial", "bigserial", "smallserial",
|
||||
]);
|
||||
|
||||
const INTEGER_TYPES = new Set([
|
||||
"integer", "bigint", "smallint", "int", "int2", "int4", "int8",
|
||||
"tinyint", "mediumint", "serial", "bigserial", "smallserial",
|
||||
]);
|
||||
|
||||
function inputKindForType(dataType: string): InputKind {
|
||||
const t = dataType.toLowerCase().trim();
|
||||
if (t === "date") return "date";
|
||||
if (t.includes("timestamp") || t === "datetime") return "datetime";
|
||||
if (t === "time" || t === "timetz" || t.startsWith("time ")) return "time";
|
||||
if (t === "boolean" || t === "bool") return "boolean";
|
||||
if (NUMERIC_TYPES.has(t)) return "number";
|
||||
return "text";
|
||||
}
|
||||
|
||||
/** Convert a DB timestamp ("2024-01-15 10:30:00") to datetime-local ("2024-01-15T10:30"). */
|
||||
function toDatetimeLocal(v: string): string {
|
||||
const m = v.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/);
|
||||
return m ? `${m[1]}T${m[2]}` : v;
|
||||
}
|
||||
|
||||
/** Convert a datetime-local value ("2024-01-15T10:30") to the canonical DB
|
||||
* format ("2024-01-15 10:30:00") accepted by both PostgreSQL and MySQL. */
|
||||
function fromDatetimeLocal(v: string): string {
|
||||
const m = v.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::(\d{2}))?/);
|
||||
if (m) return `${m[1]} ${m[2]}:${m[3] ?? "00"}`;
|
||||
return v;
|
||||
}
|
||||
|
||||
export function CellEditor({
|
||||
initialValue,
|
||||
dataType,
|
||||
@@ -40,7 +79,11 @@ export function CellEditor({
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: CellEditorProps) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const kind = inputKindForType(dataType);
|
||||
const isInteger = INTEGER_TYPES.has(dataType.toLowerCase().trim());
|
||||
const [value, setValue] = useState(
|
||||
kind === "datetime" ? toDatetimeLocal(initialValue) : initialValue,
|
||||
);
|
||||
const [query, setQuery] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
@@ -73,6 +116,8 @@ export function CellEditor({
|
||||
useEffect(() => {
|
||||
if (enumValues && enumValues.length > 0) {
|
||||
enumRef.current?.focus();
|
||||
} else if (kind === "boolean") {
|
||||
enumRef.current?.focus();
|
||||
} else if (fkOptions && fkOptions.length > 0) {
|
||||
searchRef.current?.focus();
|
||||
searchRef.current?.select();
|
||||
@@ -258,6 +303,63 @@ export function CellEditor({
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "boolean") {
|
||||
return (
|
||||
<div ref={rootRef} className={`flex h-full w-full items-center gap-1.5 px-1.5 ${error ? "ring-1 ring-inset ring-red-500/60" : ""}`}>
|
||||
<select
|
||||
ref={enumRef}
|
||||
className={controlClass}
|
||||
value={initialValue}
|
||||
onChange={(e) => commitRaw(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">{nullable ? "NULL" : "—"}</option>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
{errorBubble}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "date" || kind === "datetime" || kind === "time" || kind === "number") {
|
||||
const inputType = kind === "datetime" ? "datetime-local" : kind;
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-testid="cell-editor"
|
||||
className={`flex h-full w-full items-center gap-2 ${error ? "ring-1 ring-inset ring-red-500/60" : ""}`}
|
||||
>
|
||||
<input
|
||||
ref={ref as any}
|
||||
type={inputType}
|
||||
className={inputClass}
|
||||
value={value}
|
||||
step={kind === "number" ? (isInteger ? "1" : "any") : undefined}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commitRaw(kind === "datetime" ? fromDatetimeLocal(value) : value);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{errorBubble}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cls = large
|
||||
? `${inputClass} h-4 resize-none overflow-y-auto whitespace-pre leading-none py-0.5`
|
||||
: inputClass;
|
||||
|
||||
@@ -48,6 +48,21 @@ describe("VirtualDataGrid", () => {
|
||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a pending insert row with an 'auto' placeholder and no selection checkbox", () => {
|
||||
const rows = [[null, null], ...mockRows];
|
||||
mockGetTotalSize.mockReturnValue(rows.length * 36);
|
||||
mockGetVirtualItems.mockReturnValue(rows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
|
||||
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={rows} columns={mockColumns}
|
||||
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
|
||||
dbType="postgresql" tabType="table" pendingInsertChangeIds={["ch-1"]} />);
|
||||
// Non-editable PK column in the insert row shows "auto" instead of NULL.
|
||||
expect(screen.getByText("auto")).toBeInTheDocument();
|
||||
// The insert row shows a "+" marker instead of a selection checkbox.
|
||||
expect(screen.getByTitle("New row")).toBeInTheDocument();
|
||||
// Real rows still render their checkboxes (2 real rows).
|
||||
expect(screen.getAllByRole("checkbox")).toHaveLength(3); // 1 select-all + 2 row checkboxes
|
||||
});
|
||||
|
||||
it("shows staged values passed from the parent + a pending outline", () => {
|
||||
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
|
||||
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
|
||||
|
||||
@@ -45,6 +45,10 @@ interface VirtualDataGridProps {
|
||||
stagedValues?: Record<string, string | null>;
|
||||
/** Keys of cells with a PENDING (not yet committed) update → drives the pulsing orange outline. */
|
||||
pendingKeys?: Record<string, boolean>;
|
||||
/** Ordered change ids of pending insert rows prepended to `rows`. */
|
||||
pendingInsertChangeIds?: string[];
|
||||
/** Stage a cell edit on a pending insert row (updates the insert's newData). */
|
||||
onStageInsertCell?: (changeId: string, column: string, value: string | null) => void;
|
||||
}
|
||||
|
||||
const ROW_HEIGHT = 36;
|
||||
@@ -74,20 +78,24 @@ export function VirtualDataGrid({
|
||||
fkPlaceholders,
|
||||
stagedValues,
|
||||
pendingKeys,
|
||||
pendingInsertChangeIds,
|
||||
onStageInsertCell,
|
||||
}: VirtualDataGridProps) {
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const visibleColumns = columns.filter((c) => !hiddenColumns.has(c.name));
|
||||
const hasColumns = columns.length > 0;
|
||||
const allSelected = rows.length > 0 && selectedRows.size === rows.length;
|
||||
const pendingInsertCount = pendingInsertChangeIds?.length ?? 0;
|
||||
const realRowCount = rows.length - pendingInsertCount;
|
||||
const allSelected = realRowCount > 0 && selectedRows.size === realRowCount;
|
||||
const selectAllRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Indeterminate state for partial selection
|
||||
useEffect(() => {
|
||||
if (selectAllRef.current) {
|
||||
selectAllRef.current.indeterminate = selectedRows.size > 0 && selectedRows.size < rows.length;
|
||||
selectAllRef.current.indeterminate = selectedRows.size > 0 && selectedRows.size < realRowCount;
|
||||
}
|
||||
}, [selectedRows.size, rows.length]);
|
||||
}, [selectedRows.size, realRowCount]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
@@ -282,6 +290,8 @@ export function VirtualDataGrid({
|
||||
const isJson = !isNull && (col.data_type === "jsonb" || col.data_type === "json");
|
||||
const jp = isJson ? jsonPreview(displayCell) : { label: "", isJson: false };
|
||||
const editable = isCellEditable(col, tabType, dbType, readOnly);
|
||||
const isInsertRow = rowIndex < pendingInsertCount;
|
||||
const insertChangeId = isInsertRow ? pendingInsertChangeIds?.[rowIndex] : undefined;
|
||||
const isActive = activeCell?.row === rowIndex && activeCell?.col === colIndex;
|
||||
const isEditing = editingCell?.row === rowIndex && editingCell?.col === colIndex;
|
||||
const isPending =
|
||||
@@ -307,6 +317,9 @@ export function VirtualDataGrid({
|
||||
// revert/display stays correct even after repeated edits of the same cell.
|
||||
const dbValue = ci >= 0 ? row[ci] : undefined;
|
||||
if (committed !== (dbValue === null || dbValue === undefined ? null : dbValue)) {
|
||||
if (isInsertRow && insertChangeId) {
|
||||
onStageInsertCell?.(insertChangeId, col.name, committed);
|
||||
} else {
|
||||
const locator = getLocator?.(row) ?? {};
|
||||
onStageEdit?.(
|
||||
cellToUpdateChange({
|
||||
@@ -325,6 +338,7 @@ export function VirtualDataGrid({
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
setPendingCellKey(cellKey);
|
||||
setEditingCell(null);
|
||||
};
|
||||
@@ -355,7 +369,9 @@ export function VirtualDataGrid({
|
||||
}
|
||||
style={{ width: getWidth(col.name), flexShrink: 0 }}
|
||||
title={
|
||||
isNull
|
||||
isInsertRow && !editable
|
||||
? "auto (default)"
|
||||
: isNull
|
||||
? "NULL"
|
||||
: isFk
|
||||
? `FK → ${col.fk_ref![0]}.${col.fk_ref![1]}: ${String(displayCell)}`
|
||||
@@ -390,6 +406,8 @@ export function VirtualDataGrid({
|
||||
onCancel={() => setEditingCell(null)}
|
||||
/>
|
||||
</div>
|
||||
) : isInsertRow && !editable ? (
|
||||
<span className="italic text-text-muted/60">auto</span>
|
||||
) : isNull ? (
|
||||
<span className="italic text-text-muted">NULL</span>
|
||||
) : isJson ? (
|
||||
@@ -420,7 +438,7 @@ export function VirtualDataGrid({
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[activeCell, columns, dbType, editingCell, enumValues, fkOptions, fkPlaceholders, getLocator, handleFkClick, onStageEdit, schema, table, tabType, getWidth, pendingCell, stagedValues, pendingKeys, pendingCellKey],
|
||||
[activeCell, columns, dbType, editingCell, enumValues, fkOptions, fkPlaceholders, getLocator, handleFkClick, onStageEdit, onStageInsertCell, schema, table, tabType, getWidth, pendingCell, stagedValues, pendingKeys, pendingCellKey, pendingInsertCount, pendingInsertChangeIds],
|
||||
);
|
||||
|
||||
// ── context menu helpers ──────────────────────────────
|
||||
@@ -446,6 +464,11 @@ export function VirtualDataGrid({
|
||||
const ci = columns.findIndex((c) => c.name === column.name);
|
||||
const value = rows[row]?.[ci];
|
||||
if (value === null || value === undefined) return;
|
||||
if (row < pendingInsertCount) {
|
||||
const changeId = pendingInsertChangeIds?.[row];
|
||||
if (changeId) onStageInsertCell?.(changeId, column.name, null);
|
||||
return;
|
||||
}
|
||||
const locator = getLocator?.(rows[row]) ?? {};
|
||||
onStageEdit?.(
|
||||
cellToUpdateChange({
|
||||
@@ -464,7 +487,7 @@ export function VirtualDataGrid({
|
||||
},
|
||||
);
|
||||
},
|
||||
[columns, dbType, getLocator, onStageEdit, readOnly, rows, schema, table, tabType, visibleColumns],
|
||||
[columns, dbType, getLocator, onStageEdit, onStageInsertCell, readOnly, rows, schema, table, tabType, visibleColumns, pendingInsertCount, pendingInsertChangeIds],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -528,15 +551,18 @@ export function VirtualDataGrid({
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const row = rows[virtualRow.index];
|
||||
const isSelected = selectedRows.has(virtualRow.index);
|
||||
const gridIndex = virtualRow.index;
|
||||
const isInsertRow = gridIndex < pendingInsertCount;
|
||||
const realIndex = isInsertRow ? -1 : gridIndex - pendingInsertCount;
|
||||
const row = rows[gridIndex];
|
||||
const isSelected = !isInsertRow && selectedRows.has(realIndex);
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
data-index={gridIndex}
|
||||
className={`flex items-center border-b border-border ${
|
||||
isSelected ? "bg-accent/5" : ""
|
||||
} hover:bg-surface/50`}
|
||||
} ${isInsertRow ? "bg-accent/[0.03] ring-1 ring-inset ring-accent/60" : ""} hover:bg-surface/50`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
@@ -546,7 +572,15 @@ export function VirtualDataGrid({
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{hasColumns && (
|
||||
{hasColumns &&
|
||||
(isInsertRow ? (
|
||||
<div
|
||||
style={{ width: 40, minWidth: 40 }}
|
||||
className="flex items-center justify-center border-r border-border self-stretch"
|
||||
>
|
||||
<span className="text-[10px] text-accent/70" title="New row">+</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{ width: 40, minWidth: 40 }}
|
||||
className="flex items-center justify-center border-r border-border self-stretch"
|
||||
@@ -554,12 +588,12 @@ export function VirtualDataGrid({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleRow(virtualRow.index)}
|
||||
onChange={() => onToggleRow(realIndex)}
|
||||
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{visibleColumns.map((col, i) => renderCell(col, row, virtualRow.index, i))}
|
||||
))}
|
||||
{visibleColumns.map((col, i) => renderCell(col, row, gridIndex, i))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -601,6 +635,7 @@ export function VirtualDataGrid({
|
||||
isJson={ctxCol.data_type === "jsonb" || ctxCol.data_type === "json"}
|
||||
isFk={ctxCol.is_fk && ctxCol.fk_ref != null}
|
||||
nullable={ctxCol.is_nullable}
|
||||
isInsertRow={ctxMenu.row < pendingInsertCount}
|
||||
onCopy={() => {
|
||||
void copyCellValue(ctxMenu.row, ctxMenu.col);
|
||||
setCtxMenu(null);
|
||||
@@ -610,11 +645,11 @@ export function VirtualDataGrid({
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
onViewRow={() => {
|
||||
onOpenRowDetail?.(ctxMenu.row);
|
||||
onOpenRowDetail?.(ctxMenu.row - pendingInsertCount);
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
onSelectRow={() => {
|
||||
onToggleRow(ctxMenu.row);
|
||||
onToggleRow(ctxMenu.row - pendingInsertCount);
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
onEdit={() => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { DbType } from "../../lib/types";
|
||||
const DB_TYPES: { value: DbType; label: string }[] = [
|
||||
{ value: "postgresql", label: "PostgreSQL" },
|
||||
{ value: "mysql", label: "MySQL" },
|
||||
{ value: "mariadb", label: "MariaDB" },
|
||||
{ value: "sqlite", label: "SQLite" },
|
||||
{ value: "redis", label: "Redis" },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { PendingSqlFileBanner } from "./PendingSqlFileBanner";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
|
||||
beforeEach(() => {
|
||||
useUiStore.setState({ pendingSqlFile: null });
|
||||
});
|
||||
|
||||
describe("PendingSqlFileBanner", () => {
|
||||
it("renders nothing when there is no pending file", () => {
|
||||
const { container } = render(<PendingSqlFileBanner />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("names the file and tells the user to open a connection", () => {
|
||||
useUiStore.setState({
|
||||
pendingSqlFile: { path: "/tmp/a.sql", name: "student_db.sql", content: "SELECT 1;" },
|
||||
});
|
||||
render(<PendingSqlFileBanner />);
|
||||
expect(screen.getByText(/student_db\.sql/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/open a connection/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { FileCode2, X } from "lucide-react";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
|
||||
/**
|
||||
* Shown when a `.sql` file was opened from the OS but no database connection is
|
||||
* active. The file is already loaded in memory; picking a connection in the
|
||||
* Home screen delivers it (see `useSqlFileOpen`).
|
||||
*/
|
||||
export function PendingSqlFileBanner() {
|
||||
const pending = useUiStore((s) => s.pendingSqlFile);
|
||||
const clear = useUiStore((s) => s.clearPendingSqlFile);
|
||||
if (!pending) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-center gap-2 border-b border-border bg-surface-raised px-3 py-1.5 text-xs text-text-muted"
|
||||
>
|
||||
<FileCode2 className="h-3.5 w-3.5 shrink-0 text-accent" />
|
||||
<span className="truncate">
|
||||
<span className="font-medium text-text">{pending.name}</span> is ready — open a connection
|
||||
to use it.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
aria-label="Dismiss pending SQL file"
|
||||
className="ml-auto flex items-center rounded px-1 py-0.5 hover:bg-surface hover:text-text transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
|
||||
function mockClipboard(writeText: (text: string) => Promise<void>) {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture Node-level unhandled rejections. jsdom does not dispatch
|
||||
* `window.unhandledrejection` for rejections originating in the environment, so
|
||||
* the process event is the only reliable signal that a failed clipboard write
|
||||
* escaped the click handler.
|
||||
*
|
||||
* `match` narrows the capture to this test's own error. It is required, not a
|
||||
* convenience: the listener is process-global, and Vitest runs several test
|
||||
* files in the same worker process — without a filter another file's rejection
|
||||
* would be recorded here and fail this test. Filtering keeps the assertion
|
||||
* precise ("did *our* rejection escape?") instead of order-dependent.
|
||||
*/
|
||||
interface UnhandledRejectionEmitter {
|
||||
on(event: "unhandledRejection", listener: (reason: unknown) => void): void;
|
||||
off(event: "unhandledRejection", listener: (reason: unknown) => void): void;
|
||||
}
|
||||
|
||||
const nodeProcess = (
|
||||
globalThis as unknown as { process?: UnhandledRejectionEmitter }
|
||||
).process;
|
||||
|
||||
function captureUnhandledRejections(match: string) {
|
||||
const seen: unknown[] = [];
|
||||
const listener = (reason: unknown) => {
|
||||
if (String(reason).includes(match)) seen.push(reason);
|
||||
};
|
||||
nodeProcess?.on("unhandledRejection", listener);
|
||||
return {
|
||||
seen,
|
||||
stop: () => nodeProcess?.off("unhandledRejection", listener),
|
||||
};
|
||||
}
|
||||
|
||||
/** Let queued microtasks and Node's unhandled-rejection check drain. */
|
||||
async function drain() {
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
describe("CopyButton", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockClipboard(vi.fn().mockResolvedValue(undefined));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("copies the given text to the clipboard", async () => {
|
||||
render(<CopyButton text="brew install mysql-client" />);
|
||||
fireEvent.click(screen.getByTitle("Copy to clipboard"));
|
||||
await waitFor(() =>
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
"brew install mysql-client",
|
||||
),
|
||||
);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows a transient Copied confirmation", async () => {
|
||||
render(<CopyButton text="brew install libpq" />);
|
||||
const btn = screen.getByTitle("Copy to clipboard");
|
||||
expect(btn.textContent).toBe("Copy");
|
||||
|
||||
fireEvent.click(btn);
|
||||
|
||||
await waitFor(() => expect(screen.getByTitle("Copy to clipboard").textContent).toBe("Copied"));
|
||||
});
|
||||
|
||||
it("reverts to Copy after the confirmation delay", async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<CopyButton text="brew install libpq" />);
|
||||
const btn = screen.getByTitle("Copy to clipboard");
|
||||
|
||||
fireEvent.click(btn);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.getByTitle("Copy to clipboard").textContent).toBe("Copied");
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
});
|
||||
expect(screen.getByTitle("Copy to clipboard").textContent).toBe("Copy");
|
||||
});
|
||||
|
||||
it("stays quiet when the clipboard rejects", async () => {
|
||||
const rejected = vi.fn().mockRejectedValue(new Error("NotAllowedError"));
|
||||
mockClipboard(rejected);
|
||||
const unhandled = captureUnhandledRejections("NotAllowedError");
|
||||
|
||||
try {
|
||||
render(<CopyButton text="brew install libpq" />);
|
||||
fireEvent.click(screen.getByTitle("Copy to clipboard"));
|
||||
|
||||
await waitFor(() => expect(rejected).toHaveBeenCalled());
|
||||
await drain();
|
||||
|
||||
expect(screen.getByTitle("Copy to clipboard").textContent).toBe("Copy");
|
||||
expect(unhandled.seen).toEqual([]);
|
||||
} finally {
|
||||
unhandled.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("stays quiet when the clipboard API is unavailable", async () => {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
const unhandled = captureUnhandledRejections("writeText");
|
||||
|
||||
try {
|
||||
render(<CopyButton text="brew install libpq" />);
|
||||
expect(() =>
|
||||
fireEvent.click(screen.getByTitle("Copy to clipboard")),
|
||||
).not.toThrow();
|
||||
|
||||
await drain();
|
||||
|
||||
expect(screen.getByTitle("Copy to clipboard").textContent).toBe("Copy");
|
||||
expect(unhandled.seen).toEqual([]);
|
||||
} finally {
|
||||
unhandled.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses a custom label for the accessible name", () => {
|
||||
render(<CopyButton text="x" label="Copy install command" />);
|
||||
expect(screen.getByTitle("Copy install command")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Copy install command")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
|
||||
interface CopyButtonProps {
|
||||
/** Text written to the clipboard. */
|
||||
text: string;
|
||||
/** Accessible name; also the tooltip. */
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Small inline copy control with a transient "Copied" confirmation.
|
||||
*
|
||||
* Clipboard writes are best-effort: `navigator.clipboard` is absent in some
|
||||
* webview contexts and can reject on permission grounds, so a failure leaves
|
||||
* the label unchanged instead of surfacing an error the user cannot act on.
|
||||
*/
|
||||
export function CopyButton({
|
||||
text,
|
||||
label = "Copy to clipboard",
|
||||
className = "",
|
||||
}: CopyButtonProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setCopied(true);
|
||||
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
|
||||
timerRef.current = window.setTimeout(() => setCopied(false), 1500);
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCopy()}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 text-[11px] transition-colors cursor-pointer text-text-muted hover:text-text ${className}`}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check size={11} className="text-emerald-400" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={11} />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
|
||||
const listeners: Record<string, (e: unknown) => void> = {};
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(async (name: string, cb: (e: unknown) => void) => {
|
||||
listeners[name] = cb;
|
||||
return () => {};
|
||||
}),
|
||||
}));
|
||||
|
||||
const takePendingSqlFiles = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../lib/commands", () => ({
|
||||
takePendingSqlFiles: () => takePendingSqlFiles(),
|
||||
}));
|
||||
|
||||
import { useSqlFileOpen } from "./useSqlFileOpen";
|
||||
import { useUiStore } from "../stores/uiStore";
|
||||
import { useDbViewerStore } from "../stores/dbViewerStore";
|
||||
import { useNotificationStore } from "../stores/notificationStore";
|
||||
|
||||
const file = { path: "/tmp/student_db.sql", name: "student_db.sql", content: "SELECT 1;" };
|
||||
|
||||
beforeEach(() => {
|
||||
takePendingSqlFiles.mockReset();
|
||||
useUiStore.setState({ activeConnectionId: null, pendingSqlFile: null, activeView: "home" });
|
||||
useDbViewerStore.setState({ tabs: [], activeTabId: null });
|
||||
});
|
||||
|
||||
describe("useSqlFileOpen", () => {
|
||||
it("opens a tab when a connection is active", async () => {
|
||||
takePendingSqlFiles.mockResolvedValue([file]);
|
||||
useUiStore.setState({ activeConnectionId: "conn-1" });
|
||||
|
||||
renderHook(() => useSqlFileOpen());
|
||||
|
||||
await waitFor(() => {
|
||||
const tab = useDbViewerStore.getState().tabs.slice(-1)[0];
|
||||
expect(tab?.query).toBe("SELECT 1;");
|
||||
});
|
||||
expect(useDbViewerStore.getState().tabs.slice(-1)[0]?.table).toBe("student_db.sql");
|
||||
expect(useUiStore.getState().pendingSqlFile).toBeNull();
|
||||
});
|
||||
|
||||
it("stashes the file and prompts when no connection is active", async () => {
|
||||
takePendingSqlFiles.mockResolvedValue([file]);
|
||||
|
||||
renderHook(() => useSqlFileOpen());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useUiStore.getState().pendingSqlFile).toEqual(file);
|
||||
});
|
||||
expect(useDbViewerStore.getState().tabs).toHaveLength(0);
|
||||
expect(useUiStore.getState().activeView).toBe("home");
|
||||
});
|
||||
|
||||
it("drains the buffer again when the nudge event fires", async () => {
|
||||
takePendingSqlFiles.mockResolvedValue([]);
|
||||
useUiStore.setState({ activeConnectionId: "conn-1" });
|
||||
|
||||
renderHook(() => useSqlFileOpen());
|
||||
await waitFor(() => expect(takePendingSqlFiles).toHaveBeenCalledTimes(1));
|
||||
|
||||
takePendingSqlFiles.mockResolvedValue([file]);
|
||||
listeners["sql-file-opened"]?.({});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useDbViewerStore.getState().tabs.slice(-1)[0]?.query).toBe("SELECT 1;");
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the stashed file once a connection becomes active", async () => {
|
||||
takePendingSqlFiles.mockResolvedValue([file]);
|
||||
renderHook(() => useSqlFileOpen());
|
||||
|
||||
await waitFor(() => expect(useUiStore.getState().pendingSqlFile).toEqual(file));
|
||||
|
||||
useUiStore.setState({ activeConnectionId: "conn-2" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useDbViewerStore.getState().tabs.slice(-1)[0]?.query).toBe("SELECT 1;");
|
||||
});
|
||||
expect(useUiStore.getState().pendingSqlFile).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces a read failure as a notification instead of throwing", async () => {
|
||||
takePendingSqlFiles.mockRejectedValue(new Error("a.sql is not valid UTF-8"));
|
||||
useUiStore.setState({ activeConnectionId: "conn-1" });
|
||||
|
||||
renderHook(() => useSqlFileOpen());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useNotificationStore.getState().notifications.slice(-1)[0]?.message).toContain("UTF-8");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { takePendingSqlFiles } from "../lib/commands";
|
||||
import { useUiStore } from "../stores/uiStore";
|
||||
import { useDbViewerStore } from "../stores/dbViewerStore";
|
||||
import { useNotificationStore } from "../stores/notificationStore";
|
||||
import type { PendingSqlFile } from "../lib/types";
|
||||
|
||||
/**
|
||||
* Turn OS "open this .sql file with Gridline" requests into editor tabs.
|
||||
*
|
||||
* Every platform path (macOS RunEvent::Opened, Windows/Linux argv, the
|
||||
* single-instance forward) ends up in a Rust buffer; this hook only ever
|
||||
* *drains* that buffer. That is deliberate: draining is idempotent, so a cold
|
||||
* start, a warm start, and a webview reload all converge on the same code path
|
||||
* and there is no cold-start race to handle.
|
||||
*
|
||||
* Opening never executes: the script lands in a query tab and runs only when
|
||||
* the user presses Run, which passes through the destructive-query guard.
|
||||
*/
|
||||
export function useSqlFileOpen(): void {
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const deliver = async () => {
|
||||
let files: PendingSqlFile[];
|
||||
try {
|
||||
files = await takePendingSqlFiles();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
useNotificationStore.getState().notify(`Could not open SQL file: ${msg}`, "error");
|
||||
return;
|
||||
}
|
||||
if (disposed) return;
|
||||
for (const file of files) openOrStash(file);
|
||||
};
|
||||
|
||||
void deliver();
|
||||
void listen("sql-file-opened", () => {
|
||||
void deliver();
|
||||
})
|
||||
.then((un) => {
|
||||
unlisten = un;
|
||||
})
|
||||
.catch(() => {
|
||||
// No Tauri event bridge (browser / tests): `listen` rejects there. The
|
||||
// mount-time drain already ran, and the Rust buffer survives until a
|
||||
// real webview asks for it, so there is nothing to recover here. The
|
||||
// drain is idempotent, so a missed nudge is not a correctness problem —
|
||||
// a warm webview still drains on mount and on the next event.
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// A stashed file has no target until the user picks a connection, so
|
||||
// deliver it as soon as one becomes active.
|
||||
const pending = useUiStore((s) => s.pendingSqlFile);
|
||||
const activeConnectionId = useUiStore((s) => s.activeConnectionId);
|
||||
useEffect(() => {
|
||||
if (pending && activeConnectionId) {
|
||||
openQueryTabFor(pending);
|
||||
useUiStore.getState().clearPendingSqlFile();
|
||||
}
|
||||
}, [pending, activeConnectionId]);
|
||||
}
|
||||
|
||||
function openOrStash(file: PendingSqlFile): void {
|
||||
const { activeConnectionId } = useUiStore.getState();
|
||||
if (!activeConnectionId) {
|
||||
// Guide rather than error: a Finder double-click should not dead-end.
|
||||
useUiStore.getState().setPendingSqlFile(file);
|
||||
useUiStore.getState().setActiveView("home");
|
||||
useNotificationStore
|
||||
.getState()
|
||||
.notify(`${file.name} is ready — open a connection to use it.`, "info");
|
||||
return;
|
||||
}
|
||||
openQueryTabFor(file);
|
||||
}
|
||||
|
||||
function openQueryTabFor(file: PendingSqlFile): void {
|
||||
useDbViewerStore.getState().openQueryTab({
|
||||
sql: file.content,
|
||||
label: file.name,
|
||||
filePath: file.path,
|
||||
});
|
||||
useUiStore.getState().setActiveView("db-viewer");
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user