Compare commits

...
Author SHA1 Message Date
github-actions[bot] 8f54385b97 chore(packages): release 0.4.75 [skip node-packages-release] 2026-08-28 10:27:48 +00:00
github-actions[bot] cb9f87031f chore(jdbc): bump plugin version [skip ci] 2026-08-28 09:35:31 +00:00
t8y2 5c738f5bf2 chore(release): prepare v0.5.98 2026-08-28 17:34:50 +08:00
二丫讲梵 038ff4e773 fix(editor): align wrapped line numbers 2026-08-28 17:19:40 +08:00
t8y2 9c56de2e9f chore(query): gate test-only execution helper 2026-08-28 09:17:46 +00:00
zipg ef87e45cbc fix(windows): recover hidden startup window 2026-08-28 17:16:55 +08:00
t8y2 f83f707fbc fix(kingbase): honor driver identifier quote for schema context
Closes #7505
2026-08-28 09:13:09 +00:00
zipg 7a4edb6c19 fix(postgres): preserve configured search path 2026-08-28 16:55:04 +08:00
二丫讲梵 b4787a63af feat(editor): configure multi-statement result view 2026-08-28 16:52:02 +08:00
Guoyu Su fd17037a2a fix(linux): bundle AppImage input method modules 2026-08-28 16:51:33 +08:00
二丫讲梵 41db375ee6 style(editor): align line number gutter items 2026-08-28 16:51:03 +08:00
t8y2 1498b27314 fix(query): avoid IRIS derived count aliases
Closes #7486
2026-08-28 08:25:31 +00:00
jearhe 6d8e667bed feat(structure): support search in DDL view 2026-08-28 15:30:08 +08:00
StaravenX df88d7baa7 fix(jdbc): fall back to DatabaseMetaData when oracle dictionary views are missing 2026-08-28 15:29:06 +08:00
t8y2 659fb7f330 feat(mcp): expose message queue send tool
Closes #7236
2026-08-28 07:27:51 +00:00
二丫讲梵 c237ecbfca fix(nacos): allow manual scope when namespace directory is missing 2026-08-28 15:10:25 +08:00
t8y2 bcc64274e8 feat(ci): sign arm64 Windows builds and NSIS uninstallers 2026-08-28 15:05:45 +08:00
Mo-Moore 802019daa3 feat(editor): execute custom sql template by shortcut on selection 2026-08-28 14:27:55 +08:00
jearhe 60bfb41f13 feat(editor): add clear-all action for sql parameter values 2026-08-28 14:25:42 +08:00
t8y2 17e2573fbe fix(kafka): fall back to topic names when metadata times out 2026-08-28 06:11:26 +00:00
t8y2 2467d212d5 fix(dameng): normalize legacy DBX connection URLs
Closes #7482
2026-08-28 14:07:10 +08:00
t8y2 80224ab1d9 fix(oracle): support 11g data compare pagination
Closes #7470
2026-08-28 05:42:17 +00:00
t8y2 89086cc22b fix(schema): list HighGo PostgreSQL extensions
Closes #7448
2026-08-28 05:02:43 +00:00
Abeautifulsnow fdfad88590 feat(editor): add global default transaction commit mode 2026-08-28 12:37:11 +08:00
李子凡 d3a97164aa fix(sql): preserve empty raw placeholders 2026-08-28 12:36:37 +08:00
t8y2 d4245f0d58 fix(dameng): open java.net module for multi-NIC hosts 2026-08-28 12:36:00 +08:00
李子凡 2c5f926bd8 fix(grid): pad short timestamp fractions 2026-08-28 12:35:47 +08:00
amwps290 20a2994fa1 feat(structure): show table constraints for postgres-compatible dbs 2026-08-28 12:14:04 +08:00
t8y2 7ad05b178f fix(mysql): support DRDS index expression fallback
Closes #7363
2026-08-28 03:42:29 +00:00
t8y2 be307ba2d7 fix(query): collapse duplicated external-driver query branch 2026-08-28 11:41:24 +08:00
zipg 10bce698ae fix(mcp): install rustls crypto provider for TLS connections 2026-08-28 11:13:14 +08:00
Diego Fabricio c0abc26fe8 fix(grid): allow rerunning exact total row count from status 2026-08-28 11:12:41 +08:00
zipg 425ca055ea fix(mysql): keep pool when health probe times out during export 2026-08-28 11:12:06 +08:00
dadong 99a9961712 fix(export): harden config export passphrase handling 2026-08-28 11:11:35 +08:00
zipg d8ec9bb8d0 feat(connection): choose group when creating a connection 2026-08-28 11:11:00 +08:00
t8y2 4636788f49 fix(export): fall back when Doris rejects read-only snapshot
Closes #7446
2026-08-28 02:36:06 +00:00
t8y2 22305b7d0f fix(jdbc): retry invalid UTF-8 table previews without left wrappers
Closes #7268
2026-08-28 02:05:30 +00:00
zipg 71d07348ad perf(oracle): stop slow index metadata from blocking first query 2026-08-27 22:25:39 +08:00
Melody Ruch b198dd1327 fix(postgres): affected rows for WITH-wrapped DML and CTE completion 2026-08-27 22:25:09 +08:00
Melody Ruch 9290391985 fix(structure): emit NULL keyword for nullable MySQL timestamp columns 2026-08-27 22:24:40 +08:00
Melody Ruch 1016ccc352 fix(backup): match db.schema.table excludes when database equals schema 2026-08-27 22:24:09 +08:00
Abeautifulsnow ec0b6768be feat(grid): add row/column crosshair highlight setting 2026-08-27 22:23:13 +08:00
t8y2 b09c6f4f21 fix(export): stabilize Kingbase large result pagination
Closes #7377
2026-08-27 19:08:05 +08:00
t8y2 74ffab29ca fix(transfer): enable identity insert for SQL Server
Closes #7335
2026-08-27 18:52:15 +08:00
t8y2 e996f8aefb fix(transfer): use rownum pagination for OceanBase Oracle
Closes #7209
2026-08-27 18:47:57 +08:00
zipg 4fed29fba2 fix(structure): improve mysql auto-increment editing and field shortcuts 2026-08-27 18:21:52 +08:00
二丫讲梵 214bf86a6f feat(desktop): add zen mode toggle for data tabs 2026-08-27 17:59:10 +08:00
Farahani 955558c81f feat(safety): temporary write-unlock window for read-only connections 2026-08-27 17:37:54 +08:00
Mo-Moore 7933b7656f feat(jdbc,desktop): browse Hive/Inceptor routines from system views 2026-08-27 17:32:56 +08:00
t8y2 e5572ead7a fix(grid): parse pg timestamps deterministically for local date sort
Closes #7281
2026-08-27 17:32:09 +08:00
t8y2 bd6c780aba fix(core): uppercase dameng column types in structure ddl
Closes #7343
2026-08-27 17:23:40 +08:00
dadong dfd4d3b7ff feat(import): add SQL script import with dialect-aware parsing 2026-08-27 17:17:41 +08:00
Yang Fei ab4e747bf9 feat(editor): drag result grid columns into the SQL editor 2026-08-27 17:16:57 +08:00
Mo-Moore 9e4fe8ce56 fix(jdbc): connect Hive2 JDBC through registered driver 2026-08-27 17:16:32 +08:00
t8y2 6a6e5fbad9 fix(core): use row_number pagination for sqlserver table transfers
Closes #7356
2026-08-27 17:10:10 +08:00
t8y2 e8d6370b1e fix(grid): reset scroll to top on external result replacement
Results replaced without an internal grid action (SQL re-executed from
the editor or a grid-embedded run button) kept the previous viewport, so
DOM-mode grids stayed stranded at the old scroll offset after running an
ORDER BY query. The result watch now resets to the first row unless the
replacement is an in-place refresh (prepareFullReload markers) or an
infinite-scroll append completion. A remounted grid also no longer
adopts a scroll-only pending-changes snapshot; cached scroll is only
replayed alongside unsaved edit state, while the KeepAlive reactivate
path still restores pure scroll.

Closes #7341
2026-08-27 16:33:23 +08:00
t8y2 4eeeafa858 fix(sidebar): expand collapsed groups when locating tab
Closes #7387
2026-08-27 16:33:23 +08:00
t8y2 e4dc5bace8 fix(core): use drop user cascade for oceanbase oracle schema drop
Closes #7351
2026-08-27 16:33:23 +08:00
zipg 920ab62cf1 feat(grid): support deleting rows from JOIN query results 2026-08-27 16:28:14 +08:00
t8y2 36a7b7bd3a feat(ai): localize custom provider label 2026-08-27 16:23:51 +08:00
t8y2 565feb8745 feat(ci): cache native c deps through sccache 2026-08-27 16:14:42 +08:00
Abeautifulsnow 40549c9573 fix(dialog): clamp grid track and wrap footer so dialogs stop clipping 2026-08-27 16:06:25 +08:00
zipg eb0994e980 feat(grid): support composite unique reference keys 2026-08-27 16:00:00 +08:00
t8y2 cf03de122a fix(ci): keep sccache server alive for post-build stats 2026-08-27 15:56:48 +08:00
t8y2 d9beaaaa1c fix(agents): raise gradle daemon heap for parallel builds 2026-08-27 14:38:53 +08:00
261 changed files with 13355 additions and 1095 deletions
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
die() {
echo "AppImage input-method verification failed: $*" >&2
exit 1
}
if [[ $# -ne 1 ]]; then
die "usage: $0 <AppImage file or bundle directory>"
fi
input_path=$1
if [[ -f "$input_path" ]]; then
appimage=$input_path
elif [[ -d "$input_path" ]]; then
appimages=()
while IFS= read -r -d '' candidate; do
appimages+=("$candidate")
done < <(find "$input_path" -maxdepth 1 -type f -name '*.AppImage' -print0)
[[ ${#appimages[@]} -eq 1 ]] \
|| die "expected exactly one AppImage in $input_path, found ${#appimages[@]}"
appimage=${appimages[0]}
else
die "path does not exist: $input_path"
fi
appimage=$(realpath "$appimage")
work_dir=$(mktemp -d "${TMPDIR:-/tmp}/dbx-appimage-input-methods.XXXXXX")
trap 'rm -rf "$work_dir"' EXIT
(
cd "$work_dir"
"$appimage" --appimage-extract >/dev/null
)
appdir="$work_dir/squashfs-root"
[[ -d "$appdir" ]] || die "AppImage extraction did not create squashfs-root"
appdir=$(realpath "$appdir")
require_artifact() {
local basename=$1
local match
local resolved
match=$(find "$appdir" \( -type f -o -type l \) -name "$basename" -print -quit)
[[ -n "$match" ]] || die "missing bundled artifact: $basename"
[[ -e "$match" ]] || die "bundled artifact is a broken symbolic link: ${match#"$appdir"/}"
resolved=$(realpath "$match")
[[ "$resolved" == "$appdir"/* ]] \
|| die "bundled artifact resolves outside the AppImage: ${match#"$appdir"/}"
echo "Found $basename at ${match#"$appdir"/}"
}
require_artifact im-fcitx5.so
require_artifact im-ibus.so
require_artifact libFcitx5GClient.so.2
require_artifact libibus-1.0.so.5
cache=''
while IFS= read -r -d '' candidate; do
if grep -Fq 'im-fcitx5.so' "$candidate" && grep -Fq 'im-ibus.so' "$candidate"; then
cache=$candidate
break
fi
done < <(find "$appdir" -type f -path '*/gtk-3.0/*/immodules.cache' -print0)
[[ -n "$cache" ]] || die "no private GTK 3 immodules.cache registers both Fcitx 5 and IBus"
echo "Verified GTK input-method cache at ${cache#"$appdir"/}"
+5 -1
View File
@@ -147,7 +147,11 @@ jobs:
for module in "${MODULES[@]}"; do
TASKS+=(":${module}:shadowJar")
done
./gradlew "${TASKS[@]}" --parallel
# agents/common changes trigger a parallel mass rebuild of every
# packaged module; the Gradle daemon default heap is too small for
# ~25 shadowJar fat jars at once on a 16 GB runner.
./gradlew "${TASKS[@]}" --parallel \
-Dorg.gradle.jvmargs="-Xmx4g -XX:MaxMetaspaceSize=1g"
working-directory: agents
- run: python3 scripts/validate_agent_jars.py
working-directory: agents
+21
View File
@@ -71,6 +71,13 @@ jobs:
echo "SCCACHE_S3_USE_SSL=true"
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
# Keep the server alive through post-compile test/lint phases so
# "Show sccache stats" reflects the real counters.
echo "SCCACHE_IDLE_TIMEOUT=0"
# Also cache the C/C++ compilation of -sys crates. These jobs are
# native Linux builds, so plain CC/CXX select the target compiler.
echo "CC=${SCCACHE_PATH} cc"
echo "CXX=${SCCACHE_PATH} c++"
} >> "$GITHUB_ENV"
- name: Rust cache
@@ -378,6 +385,13 @@ jobs:
echo "SCCACHE_S3_USE_SSL=true"
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
# Keep the server alive through post-compile test/lint phases so
# "Show sccache stats" reflects the real counters.
echo "SCCACHE_IDLE_TIMEOUT=0"
# Also cache the C/C++ compilation of -sys crates. These jobs are
# native Linux builds, so plain CC/CXX select the target compiler.
echo "CC=${SCCACHE_PATH} cc"
echo "CXX=${SCCACHE_PATH} c++"
} >> "$GITHUB_ENV"
- name: Rust cache
@@ -455,6 +469,13 @@ jobs:
echo "SCCACHE_S3_USE_SSL=true"
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
# Keep the server alive through post-compile test/lint phases so
# "Show sccache stats" reflects the real counters.
echo "SCCACHE_IDLE_TIMEOUT=0"
# Also cache the C/C++ compilation of -sys crates. These jobs are
# native Linux builds, so plain CC/CXX select the target compiler.
echo "CC=${SCCACHE_PATH} cc"
echo "CXX=${SCCACHE_PATH} c++"
} >> "$GITHUB_ENV"
- name: Rust cache
+32
View File
@@ -343,6 +343,22 @@ jobs:
echo "SCCACHE_S3_USE_SSL=true"
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
# Keep the server alive through the post-compile packaging tail so
# "Show sccache stats" reflects the real counters.
echo "SCCACHE_IDLE_TIMEOUT=0"
# Also cache the C/C++ compilation of -sys crates. All matrix legs
# are native builds, so plain CC/CXX select the target compiler;
# MSVC stays unwrapped. SCCACHE_PATH avoids depending on PATH.
case "${RUNNER_OS}" in
Linux)
echo "CC=${SCCACHE_PATH} cc"
echo "CXX=${SCCACHE_PATH} c++"
;;
macOS)
echo "CC=${SCCACHE_PATH} clang"
echo "CXX=${SCCACHE_PATH} clang++"
;;
esac
} >> "$GITHUB_ENV"
- name: Rust cache
@@ -561,6 +577,22 @@ jobs:
echo "SCCACHE_S3_USE_SSL=true"
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
# Keep the server alive through the post-compile packaging tail so
# "Show sccache stats" reflects the real counters.
echo "SCCACHE_IDLE_TIMEOUT=0"
# Also cache the C/C++ compilation of -sys crates. All matrix legs
# are native builds, so plain CC/CXX select the target compiler;
# MSVC stays unwrapped. SCCACHE_PATH avoids depending on PATH.
case "${RUNNER_OS}" in
Linux)
echo "CC=${SCCACHE_PATH} cc"
echo "CXX=${SCCACHE_PATH} c++"
;;
macOS)
echo "CC=${SCCACHE_PATH} clang"
echo "CXX=${SCCACHE_PATH} clang++"
;;
esac
} >> "$GITHUB_ENV"
- name: Rust cache
+57 -41
View File
@@ -180,6 +180,23 @@ jobs:
echo "SCCACHE_S3_USE_SSL=true"
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
# Linking, bundling, and signing run for 30+ minutes after the last
# rustc call; the server's 10-minute default idle timeout would
# exit first and zero the counters shown by "Show sccache stats".
echo "SCCACHE_IDLE_TIMEOUT=0"
# Also cache the C/C++ compilation of -sys crates. All matrix legs
# are native builds, so plain CC/CXX select the target compiler;
# MSVC stays unwrapped. SCCACHE_PATH avoids depending on PATH.
case "${RUNNER_OS}" in
Linux)
echo "CC=${SCCACHE_PATH} cc"
echo "CXX=${SCCACHE_PATH} c++"
;;
macOS)
echo "CC=${SCCACHE_PATH} clang"
echo "CXX=${SCCACHE_PATH} clang++"
;;
esac
} >> "$GITHUB_ENV"
- name: Compute Rust dependency hash
@@ -226,7 +243,9 @@ jobs:
if: startsWith(matrix.platform, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev xdg-utils
# Bundle GTK input-method modules from the same Jammy ABI baseline
# instead of loading potentially incompatible modules from the host.
sudo apt-get install -y fcitx5-frontend-gtk3 ibus-gtk3 libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev xdg-utils
- name: Setup Tauri signing key
shell: bash
@@ -251,6 +270,11 @@ jobs:
releaseDraft: true
args: --target ${{ matrix.target }}
- name: Verify AppImage input methods
if: startsWith(matrix.platform, 'ubuntu')
shell: bash
run: ./.github/scripts/verify-appimage-input-methods.sh "target/${{ matrix.target }}/release/bundle/appimage"
- name: Show sccache stats
if: always()
continue-on-error: true
@@ -266,10 +290,8 @@ jobs:
include:
- target: x86_64-pc-windows-msvc
arch: x64
sign_windows: true
- target: aarch64-pc-windows-msvc
arch: arm64
sign_windows: false
runs-on: windows-2022
env:
CARGO_INCREMENTAL: "0"
@@ -322,6 +344,23 @@ jobs:
echo "SCCACHE_S3_USE_SSL=true"
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
# Linking, bundling, and signing run for 30+ minutes after the last
# rustc call; the server's 10-minute default idle timeout would
# exit first and zero the counters shown by "Show sccache stats".
echo "SCCACHE_IDLE_TIMEOUT=0"
# Also cache the C/C++ compilation of -sys crates. All matrix legs
# are native builds, so plain CC/CXX select the target compiler;
# MSVC stays unwrapped. SCCACHE_PATH avoids depending on PATH.
case "${RUNNER_OS}" in
Linux)
echo "CC=${SCCACHE_PATH} cc"
echo "CXX=${SCCACHE_PATH} c++"
;;
macOS)
echo "CC=${SCCACHE_PATH} clang"
echo "CXX=${SCCACHE_PATH} clang++"
;;
esac
} >> "$GITHUB_ENV"
- name: Compute Rust dependency hash
@@ -358,7 +397,6 @@ jobs:
fi
- name: Setup TrustAsia VSign
if: matrix.sign_windows
shell: pwsh
env:
VSIGN_OPERATOR_PFX_B64: ${{ secrets.VSIGN_OPERATOR_PFX_B64 }}
@@ -366,7 +404,6 @@ jobs:
run: ./.github/scripts/setup-vsign.ps1
- name: Build and sign Tauri app
if: matrix.sign_windows
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -377,16 +414,6 @@ jobs:
releaseDraft: true
args: --target ${{ matrix.target }} --config src-tauri/tauri.vsign.conf.json
- name: Build unsigned Tauri app
if: ${{ !matrix.sign_windows }}
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: ${{ github.ref_name }}
releaseDraft: true
args: --target ${{ matrix.target }}
- name: Verify Windows bundles
shell: pwsh
run: |
@@ -403,19 +430,17 @@ jobs:
if ($msiInstallers.Count -eq 0) {
throw "No MSI installer was produced for ${{ matrix.target }}"
}
if ("${{ matrix.sign_windows }}" -eq "true") {
./.github/scripts/assert-authenticode.ps1 -Path @(
$nsisInstallers.FullName
$msiInstallers.FullName
)
}
./.github/scripts/assert-authenticode.ps1 -Path @(
$nsisInstallers.FullName
$msiInstallers.FullName
)
- name: Upload Windows WebView2 offline installer
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SSIGNCODE_OPERATOR_PWD: ${{ matrix.sign_windows && secrets.VSIGN_OPERATOR_PWD || '' }}
VSIGN_KEY_PIN: ${{ matrix.sign_windows && secrets.VSIGN_KEY_PIN || '' }}
SSIGNCODE_OPERATOR_PWD: ${{ secrets.VSIGN_OPERATOR_PWD }}
VSIGN_KEY_PIN: ${{ secrets.VSIGN_KEY_PIN }}
run: |
$version = "${env:GITHUB_REF_NAME}".TrimStart("v")
$arch = "${{ matrix.arch }}"
@@ -427,14 +452,9 @@ jobs:
Get-ChildItem $bundleDir -Filter "*.exe" | ForEach-Object { $before[$_.FullName] = $_.LastWriteTimeUtc }
}
if ("${{ matrix.sign_windows }}" -eq "true") {
pnpm tauri bundle --bundles nsis --target ${{ matrix.target }} `
--config src-tauri/tauri.webview2-offline.conf.json `
--config src-tauri/tauri.vsign.conf.json
} else {
pnpm tauri bundle --bundles nsis --target ${{ matrix.target }} `
--config src-tauri/tauri.webview2-offline.conf.json
}
pnpm tauri bundle --bundles nsis --target ${{ matrix.target }} `
--config src-tauri/tauri.webview2-offline.conf.json `
--config src-tauri/tauri.vsign.conf.json
$installer = Get-ChildItem $bundleDir -Filter "*.exe" |
Where-Object { !$before.ContainsKey($_.FullName) -or $_.LastWriteTimeUtc -gt $before[$_.FullName] } |
@@ -445,9 +465,7 @@ jobs:
throw "Missing WebView2 offline installer in ${bundleDir}"
}
if ("${{ matrix.sign_windows }}" -eq "true") {
./.github/scripts/assert-authenticode.ps1 -Path $installer.FullName
}
./.github/scripts/assert-authenticode.ps1 -Path $installer.FullName
Copy-Item $installer.FullName $offlineName -Force
gh release upload "${env:GITHUB_REF_NAME}" $offlineName --repo "${env:GITHUB_REPOSITORY}" --clobber
@@ -456,8 +474,8 @@ jobs:
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SSIGNCODE_OPERATOR_PWD: ${{ matrix.sign_windows && secrets.VSIGN_OPERATOR_PWD || '' }}
VSIGN_KEY_PIN: ${{ matrix.sign_windows && secrets.VSIGN_KEY_PIN || '' }}
SSIGNCODE_OPERATOR_PWD: ${{ secrets.VSIGN_OPERATOR_PWD }}
VSIGN_KEY_PIN: ${{ secrets.VSIGN_KEY_PIN }}
run: |
$version = "${env:GITHUB_REF_NAME}".TrimStart("v")
$arch = "${{ matrix.arch }}"
@@ -475,10 +493,8 @@ jobs:
throw "Release tag version $version does not match the built DBX package version $appVersion"
}
if ("${{ matrix.sign_windows }}" -eq "true") {
./.github/scripts/vsign-sign.ps1 -FilePath $exePath
./.github/scripts/assert-authenticode.ps1 -Path $exePath
}
./.github/scripts/vsign-sign.ps1 -FilePath $exePath
./.github/scripts/assert-authenticode.ps1 -Path $exePath
New-Item -ItemType Directory -Force -Path $portableDir | Out-Null
Copy-Item $exePath (Join-Path $portableDir "DBX.exe") -Force
@@ -519,7 +535,7 @@ jobs:
run: ${SCCACHE_PATH} --show-stats
- name: Remove temporary VSign files
if: ${{ always() && matrix.sign_windows }}
if: always()
shell: pwsh
run: |
$workDir = if ([string]::IsNullOrWhiteSpace($env:VSIGN_WORK_DIR)) {
Generated
+4 -4
View File
@@ -2002,7 +2002,7 @@ dependencies = [
[[package]]
name = "dbx"
version = "0.5.97"
version = "0.5.98"
dependencies = [
"anyhow",
"axum",
@@ -2058,7 +2058,7 @@ dependencies = [
[[package]]
name = "dbx-cli"
version = "0.4.74"
version = "0.4.75"
dependencies = [
"async-trait",
"dbx-core",
@@ -2152,7 +2152,7 @@ dependencies = [
[[package]]
name = "dbx-mcp"
version = "0.4.74"
version = "0.4.75"
dependencies = [
"async-trait",
"dbx-core",
@@ -2173,7 +2173,7 @@ dependencies = [
[[package]]
name = "dbx-web"
version = "0.5.97"
version = "0.5.98"
dependencies = [
"aes-gcm 0.10.3",
"argon2",
@@ -2098,7 +2098,8 @@ public final class DamengAgent extends AbstractJdbcAgent {
private static String buildUrl(ConnectParams params) {
String connectionString = params.getConnection_string() == null ? "" : params.getConnection_string().trim();
if (!connectionString.isEmpty()) {
if (!connectionString.isEmpty()
&& !connectionString.regionMatches(true, 0, "dm://", 0, "dm://".length())) {
return connectionString;
}
String database = params.getDatabase() == null ? "" : params.getDatabase().trim();
@@ -21,6 +21,24 @@ class DamengAgentUrlTest {
Assertions.assertEquals("jdbc:dm://127.0.0.1:5236/MAIN", url);
}
@Test
void rebuildsLegacyDbxUrlFromConnectionFields() throws Exception {
ConnectParams params = new ConnectParams(
"127.0.0.1",
5236,
"MAIN",
"SYSDBA",
"pwd",
"",
"dm://SYSDBA:SYSDBA@legacy-host:5236/OLD",
false
);
String url = invokeBuildUrl(params);
Assertions.assertEquals("jdbc:dm://127.0.0.1:5236/MAIN", url);
}
@Test
void usesCompleteCustomJdbcUrlWithoutRewritingIt() throws Exception {
ConnectParams params = new ConnectParams(
@@ -650,9 +650,15 @@ public final class KafkaAgent {
topics.add(topic);
}
} catch (Exception error) {
if (!isUnsupportedVersionError(error)) {
if (!isUnsupportedVersionError(error) && !isTimeoutError(error)) {
throw error;
}
if (isTimeoutError(error)) {
logger().warn(
"Kafka topic descriptions timed out; returning topic names without partition metadata",
error
);
}
for (TopicListing listing : listings) {
Map<String, Object> topic = new LinkedHashMap<>();
topic.put("name", listing.name());
@@ -2582,6 +2588,21 @@ public final class KafkaAgent {
return false;
}
/**
* A topic listing is still useful when the broker cannot describe every topic before the
* request deadline. Keep timeout detection narrow so authorization, transport, and unknown
* topic failures remain visible to the caller instead of being mistaken for a partial result.
*/
private static boolean isTimeoutError(Throwable error) {
for (Throwable current : causeChain(error)) {
if (current instanceof java.util.concurrent.TimeoutException
|| current instanceof org.apache.kafka.common.errors.TimeoutException) {
return true;
}
}
return false;
}
static boolean hasUnsupportedVersionException(Throwable error) {
return causeChain(error).stream().anyMatch(UnsupportedVersionException.class::isInstance);
}
@@ -255,6 +255,25 @@ class KafkaAgentTest {
assertFalse(topics.get(1).containsKey("partitions"));
}
@Test
void topicListingFallsBackWhenDescriptionsTimeOut() throws Exception {
Object result = KafkaAgent.topicListResult(
Arrays.asList(
new TopicListing("orders", Uuid.randomUuid(), false),
new TopicListing("payments", Uuid.randomUuid(), false)
),
names -> {
throw new java.util.concurrent.TimeoutException("describeTopics timed out");
}
);
@SuppressWarnings("unchecked")
List<Map<String, Object>> topics = (List<Map<String, Object>>) ((Map<String, Object>) result).get("topics");
assertEquals(Arrays.asList("orders", "payments"), topics.stream().map(topic -> topic.get("name")).toList());
assertFalse(topics.get(0).containsKey("partitions"));
assertFalse(topics.get(1).containsKey("replicationFactor"));
}
@Test
void topicListingPreservesNonVersionDescriptionErrors() {
IllegalStateException failure = new IllegalStateException("metadata authorization failed");
+143 -6
View File
@@ -79,21 +79,30 @@ func TestKingbaseIntegration(t *testing.T) {
if err != nil || len(parentColumns) != 3 || parentColumns[0].Comment == nil || *parentColumns[0].Comment != "主键编号" || parentColumns[1].Comment == nil || *parentColumns[1].Comment != "客户'名称" {
t.Fatalf("get commented columns failed: columns=%v err=%v", parentColumns, err)
}
if parentColumns[1].DataType != "character varying(64)" || parentColumns[1].CharacterMaximumLength == nil || *parentColumns[1].CharacterMaximumLength != 64 {
t.Fatalf("varchar length metadata mismatch: column=%+v", parentColumns[1])
parentVarcharType := strings.ToLower(strings.TrimSpace(parentColumns[1].DataType))
if !strings.Contains(parentVarcharType, "varchar") && !strings.Contains(parentVarcharType, "character varying") {
t.Fatalf("varchar type metadata mismatch: column=%+v", parentColumns[1])
}
if parentColumns[2].DataType != "character(32)" || parentColumns[2].CharacterMaximumLength == nil || *parentColumns[2].CharacterMaximumLength != 32 {
if parentColumns[1].CharacterMaximumLength == nil || *parentColumns[1].CharacterMaximumLength != 64 {
actualLength := -1
if parentColumns[1].CharacterMaximumLength != nil {
actualLength = *parentColumns[1].CharacterMaximumLength
}
t.Fatalf("varchar length metadata mismatch: length=%d column=%+v", actualLength, parentColumns[1])
}
parentCharacterType := strings.ToLower(strings.TrimSpace(parentColumns[2].DataType))
if ((!strings.HasPrefix(parentCharacterType, "char") && !strings.HasPrefix(parentCharacterType, "character") && parentCharacterType != "bpchar") || strings.Contains(parentCharacterType, "varying")) || parentColumns[2].CharacterMaximumLength == nil || *parentColumns[2].CharacterMaximumLength != 32 {
t.Fatalf("character length metadata mismatch: column=%+v", parentColumns[2])
}
ddl, err := server.getTableDDL(schema, parent)
if err != nil {
t.Fatalf("get table DDL failed: %v", err)
}
qualifiedParent := qualifiedSchema + "." + quoteIdentifier(parent)
qualifiedParent := server.quoteDDLIdentifier(schema) + "." + server.quoteDDLIdentifier(parent)
for _, expected := range []string{
"COMMENT ON TABLE " + qualifiedParent + " IS '订单父表';",
"COMMENT ON COLUMN " + qualifiedParent + "." + quoteIdentifier(parentColumns[0].Name) + " IS '主键编号';",
"COMMENT ON COLUMN " + qualifiedParent + "." + quoteIdentifier(parentColumns[1].Name) + " IS '客户''名称';",
"COMMENT ON COLUMN " + qualifiedParent + "." + server.quoteDDLIdentifier(parentColumns[0].Name) + " IS '主键编号';",
"COMMENT ON COLUMN " + qualifiedParent + "." + server.quoteDDLIdentifier(parentColumns[1].Name) + " IS '客户''名称';",
} {
if !strings.Contains(ddl, expected) {
t.Fatalf("table DDL missing %q:\n%s", expected, ddl)
@@ -155,6 +164,134 @@ func TestKingbaseIntegration(t *testing.T) {
}
}
func TestKingbaseConstraintsIntegration(t *testing.T) {
host := os.Getenv("KINGBASE_TEST_HOST")
portText := os.Getenv("KINGBASE_TEST_PORT")
username := os.Getenv("KINGBASE_TEST_USERNAME")
password := os.Getenv("KINGBASE_TEST_PASSWORD")
if host == "" || portText == "" || username == "" || password == "" {
t.Skip("Kingbase integration environment is not configured")
}
port, err := strconv.Atoi(portText)
if err != nil {
t.Fatal(err)
}
database := os.Getenv("KINGBASE_TEST_DATABASE")
if database == "" {
database = "test"
}
suffix := strconv.FormatInt(time.Now().UnixNano(), 36)
schema := "dbx_constraints_" + suffix
schemaIdent := quoteIdentifier(schema)
parent := "parent"
child := "child"
parentTable := schemaIdent + "." + quoteIdentifier(parent)
childTable := schemaIdent + "." + quoteIdentifier(child)
server := newServer()
cp := connectParams{
Host: host, Port: port, Database: database, Username: username, Password: password,
ConnectionString: fmt.Sprintf("jdbc:kingbase8://%s:%d/%s", host, port, database),
}
if err := server.connect(cp); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = server.disconnect() })
t.Cleanup(func() {
_, _ = server.executeQuery(queryOptions{SQL: "DROP SCHEMA IF EXISTS " + schemaIdent + " CASCADE"})
})
mustExecute(t, server, "CREATE SCHEMA "+schemaIdent)
mustExecute(t, server, "CREATE TABLE "+parentTable+" (a bigint NOT NULL, b bigint NOT NULL, CONSTRAINT parent_pk PRIMARY KEY (a, b))")
mustExecute(t, server, "CREATE TABLE "+childTable+" (id bigint PRIMARY KEY, parent_a bigint, parent_b bigint, code text NOT NULL, amount int, CONSTRAINT child_parent_fk FOREIGN KEY (parent_a, parent_b) REFERENCES "+parentTable+" (a, b) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, CONSTRAINT child_code_unique UNIQUE (code), CONSTRAINT child_code_check CHECK (length(code) > 0))")
constraints, err := server.listConstraints(schema, child)
if err != nil {
t.Fatalf("list constraints failed in %s mode: %v", server.mode.compatibilityMode, err)
}
byName := map[string]constraintInfo{}
for _, constraint := range constraints {
byName[constraint.Name] = constraint
}
pk := byName["child_pkey"]
if pk.ConstraintType != "PRIMARY KEY" || len(pk.Columns) != 1 || pk.Columns[0] != "id" {
t.Fatalf("primary key metadata mismatch in %s mode: %+v", server.mode.compatibilityMode, pk)
}
fk := byName["child_parent_fk"]
if fk.ConstraintType != "FOREIGN KEY" || !equalStringSlices(fk.Columns, []string{"parent_a", "parent_b"}) || !equalStringSlices(fk.RefColumns, []string{"a", "b"}) || fk.RefSchema == nil || *fk.RefSchema != schema || fk.RefTable == nil || *fk.RefTable != parent || fk.OnDelete == nil || *fk.OnDelete != "CASCADE" || !fk.Deferrable || !fk.InitiallyDeferred {
t.Fatalf("foreign key metadata mismatch in %s mode: %+v", server.mode.compatibilityMode, fk)
}
unique := byName["child_code_unique"]
if unique.ConstraintType != "UNIQUE" || !equalStringSlices(unique.Columns, []string{"code"}) {
t.Fatalf("unique metadata mismatch in %s mode: %+v", server.mode.compatibilityMode, unique)
}
check := byName["child_code_check"]
if check.ConstraintType != "CHECK" || !equalStringSlices(check.Columns, []string{"code"}) || !strings.Contains(check.Definition, "length(code) > 0") || !check.Enabled {
t.Fatalf("check metadata mismatch in %s mode: %+v", server.mode.compatibilityMode, check)
}
foreignKeys, err := server.listForeignKeys(schema, child)
if err != nil {
t.Fatalf("list foreign keys failed in %s mode: %v", server.mode.compatibilityMode, err)
}
foreignKeyNames := map[string]struct{}{}
for _, foreignKey := range foreignKeys {
foreignKeyNames[foreignKey.Name] = struct{}{}
}
constraintForeignKeyNames := map[string]struct{}{}
for _, constraint := range constraints {
if constraint.ConstraintType == "FOREIGN KEY" {
constraintForeignKeyNames[constraint.Name] = struct{}{}
}
}
if len(foreignKeyNames) != len(constraintForeignKeyNames) {
t.Fatalf("foreign-key metadata sources disagree in %s mode: constraints=%v foreign_keys=%v", server.mode.compatibilityMode, constraintForeignKeyNames, foreignKeyNames)
}
for name := range foreignKeyNames {
if _, ok := constraintForeignKeyNames[name]; !ok {
t.Fatalf("foreign-key %q missing from list_constraints in %s mode", name, server.mode.compatibilityMode)
}
}
if server.mode.compatibilityMode == "oracle" {
mustExecute(t, server, "ALTER TABLE "+childTable+" DISABLE CONSTRAINT child_code_check")
disabled, err := server.listConstraints(schema, child)
if err != nil {
t.Fatalf("list disabled constraints failed in %s mode: %v", server.mode.compatibilityMode, err)
}
var disabledCheck constraintInfo
found := false
for _, constraint := range disabled {
if constraint.Name == "child_code_check" {
disabledCheck = constraint
found = true
break
}
}
if !found || disabledCheck.Enabled {
t.Fatalf("disabled constraint state mismatch in %s mode: %+v", server.mode.compatibilityMode, disabledCheck)
}
mustExecute(t, server, "ALTER TABLE "+childTable+" ENABLE CONSTRAINT child_code_check")
}
parentConstraints, err := server.listConstraints(schema, parent)
if err != nil {
t.Fatalf("list parent constraints failed in %s mode: %v", server.mode.compatibilityMode, err)
}
if len(parentConstraints) != 1 || parentConstraints[0].Name != "parent_pk" || !equalStringSlices(parentConstraints[0].Columns, []string{"a", "b"}) {
t.Fatalf("composite primary key order mismatch in %s mode: %+v", server.mode.compatibilityMode, parentConstraints)
}
}
func equalStringSlices(left, right []string) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
func TestKingbaseCustomTypesIntegration(t *testing.T) {
host := os.Getenv("KINGBASE_TEST_HOST")
portText := os.Getenv("KINGBASE_TEST_PORT")
+273 -10
View File
@@ -130,6 +130,23 @@ type foreignKeyInfo struct {
RefColumn string `json:"ref_column"`
}
type constraintInfo struct {
Name string `json:"name"`
ConstraintType string `json:"constraint_type"`
Definition string `json:"definition"`
Columns []string `json:"columns"`
RefSchema *string `json:"ref_schema,omitempty"`
RefTable *string `json:"ref_table,omitempty"`
RefColumns []string `json:"ref_columns"`
MatchType *string `json:"match_type,omitempty"`
OnUpdate *string `json:"on_update,omitempty"`
OnDelete *string `json:"on_delete,omitempty"`
Deferrable bool `json:"deferrable"`
InitiallyDeferred bool `json:"initially_deferred"`
Enabled bool `json:"enabled"`
Valid bool `json:"valid"`
}
type triggerInfo struct {
Name string `json:"name"`
Event string `json:"event"`
@@ -205,10 +222,7 @@ func (s *server) identifierQuote() string {
// schema/table names containing hyphens or other special characters render as
// valid SQL instead of being parsed as operators or bare tokens.
func (s *server) quoteDDLIdentifier(value string) string {
if s.mode.mysqlCompat {
return "`" + strings.ReplaceAll(value, "`", "``") + "`"
}
return quoteIdentifier(value)
return s.quoteIdentifier(value)
}
func (s *server) connectionInfo() (map[string]any, error) {
@@ -1453,8 +1467,12 @@ func (s *server) queryInformationSchemaColumns(schema, table string, primary map
if err := rows.Scan(&name, &dataType, &fullDataType, &nullable, &defaultValue, &comment, &precision, &scale, &length); err != nil {
return nil, err
}
if parsed := boundedVarcharLength(dataType); parsed != nil && !length.Valid {
length = sql.NullInt64{Int64: int64(*parsed), Valid: true}
if !length.Valid || length.Int64 < 0 {
if parsed := boundedVarcharLength(dataType); parsed != nil {
length = sql.NullInt64{Int64: int64(*parsed), Valid: true}
} else if parsed := boundedVarcharLength(fullDataType.String); parsed != nil {
length = sql.NullInt64{Int64: int64(*parsed), Valid: true}
}
}
result = append(result, columnInfo{Name: name, DataType: resolvedInformationSchemaDataType(dataType, fullDataType.String), ResolvedSchema: stringPtr(schema), FullDataType: fullDataType.String, IsNullable: strings.EqualFold(nullable, "YES"), ColumnDefault: nullStringPtr(defaultValue), IsPrimaryKey: primary[strings.ToLower(name)], Comment: nullStringPtr(comment), NumericPrecision: nullIntPtr(precision), NumericScale: nullIntPtr(scale), CharacterMaximumLength: nullIntPtr(length)})
}
@@ -1600,7 +1618,7 @@ func parseCatalogAttributeNumbers(raw any) ([]int, error) {
default:
value = fmt.Sprint(typed)
}
value = strings.TrimSpace(strings.Trim(value, "{}"))
value = strings.TrimSpace(strings.Trim(value, "{}[]"))
if value == "" {
return []int{}, nil
}
@@ -1761,6 +1779,248 @@ WHERE c.contype = 'f' AND n.nspname = %s AND t.relname = %s ORDER BY c.conname`,
return result, nil
}
func kingbaseConstraintFunctionName(catalog string) string {
if catalog == "pg_catalog" {
return "pg_get_constraintdef"
}
return "sys_get_constraintdef"
}
func kingbaseConstraintsQuery(catalog, prefix, schema, table string, definitionUnsupported, validatedUnsupported, statusUnsupported bool) string {
definitionExpression := fmt.Sprintf("COALESCE(%s(c.oid, true), '')", kingbaseCatalogFunction(catalog, "sys_get_constraintdef", "pg_get_constraintdef"))
validExpression := "COALESCE(CAST(c.convalidated AS text), 'T')"
statusExpression := "COALESCE(CAST(c.constatus AS text), 'E')"
if definitionUnsupported {
definitionExpression = "''"
}
if validatedUnsupported {
validExpression = "true"
}
if statusUnsupported {
statusExpression = "'E'"
}
return fmt.Sprintf(`SELECT COALESCE(c.conname, ''), c.contype::text, %s, c.conkey,
rn.nspname, rt.relname, c.confkey, c.confmatchtype::text, c.confupdtype::text, c.confdeltype::text,
c.condeferrable, c.condeferred, %s, %s
FROM %s.%s_constraint c
JOIN %s.%s_class t ON t.oid = c.conrelid
JOIN %s.%s_namespace n ON n.oid = t.relnamespace
LEFT JOIN %s.%s_class rt ON rt.oid = c.confrelid
LEFT JOIN %s.%s_namespace rn ON rn.oid = rt.relnamespace
WHERE n.nspname = %s AND t.relname = %s AND t.relkind IN ('r', 'p', 'f')
ORDER BY COALESCE(c.conname, '')`, definitionExpression, validExpression, statusExpression, catalog, prefix, catalog, prefix, catalog, prefix, catalog, prefix, catalog, prefix, quoteLiteral(schema), quoteLiteral(table))
}
func (s *server) listConstraints(schema, table string) ([]constraintInfo, error) {
effective, err := s.effectiveSchema(schema)
if err != nil {
return nil, err
}
catalog, prefix := "sys_catalog", "sys"
if s.mode.postgresCatalog {
catalog, prefix = "pg_catalog", "pg"
}
definitionUnsupported := s.constraintDefinitionUnsupported
validatedUnsupported := s.mode.legacyV7 || s.constraintValidatedUnsupported
statusUnsupported := s.mode.legacyV7 || s.constraintStatusUnsupported
var rows *sql.Rows
for {
query := kingbaseConstraintsQuery(catalog, prefix, effective, table, definitionUnsupported, validatedUnsupported, statusUnsupported)
rows, err = s.metadataQuery(query)
if err == nil {
break
}
changed := false
if !definitionUnsupported && isUndefinedFunction(err, kingbaseConstraintFunctionName(catalog)) {
definitionUnsupported = true
s.constraintDefinitionUnsupported = true
changed = true
}
if !validatedUnsupported && isUndefinedColumn(err, "convalidated") {
validatedUnsupported = true
s.constraintValidatedUnsupported = true
changed = true
}
if !statusUnsupported && isUndefinedColumn(err, "constatus") {
statusUnsupported = true
s.constraintStatusUnsupported = true
changed = true
}
if !changed {
return nil, err
}
}
defer rows.Close()
type rawConstraint struct {
name, kind, definition string
columns, refColumns []int
refSchema, refTable sql.NullString
matchType, onUpdate, onDelete sql.NullString
deferrable, initiallyDeferred bool
valid, enabled bool
}
raw := []rawConstraint{}
for rows.Next() {
var item rawConstraint
var columnsRaw, refColumnsRaw, validRaw, statusRaw any
if err := rows.Scan(&item.name, &item.kind, &item.definition, &columnsRaw, &item.refSchema, &item.refTable, &refColumnsRaw, &item.matchType, &item.onUpdate, &item.onDelete, &item.deferrable, &item.initiallyDeferred, &validRaw, &statusRaw); err != nil {
return nil, err
}
item.valid = parseConstraintEnabled(validRaw)
item.enabled = parseConstraintEnabled(statusRaw)
item.columns, err = parseCatalogAttributeNumbers(columnsRaw)
if err != nil {
return nil, fmt.Errorf("failed to parse constraint %s columns: %w", item.name, err)
}
item.refColumns, err = parseCatalogAttributeNumbers(refColumnsRaw)
if err != nil {
return nil, fmt.Errorf("failed to parse constraint %s referenced columns: %w", item.name, err)
}
raw = append(raw, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
attributes, err := s.relationAttributesByNumber(catalog, prefix, effective, table)
if err != nil {
return nil, err
}
refAttributes := map[string]map[int]string{}
result := make([]constraintInfo, 0, len(raw))
for _, item := range raw {
constraint := constraintInfo{
Name: item.name, ConstraintType: kingbaseConstraintTypeName(item.kind), Definition: item.definition,
Columns: []string{}, RefColumns: []string{}, Deferrable: item.deferrable,
// FK details are retained for API completeness and future unified
// constraint/FK presentations. The current UI renders FK rows through
// list_foreign_keys, so these fields are not currently displayed in the
// Constraints tab.
InitiallyDeferred: item.initiallyDeferred, Enabled: item.enabled, Valid: item.valid,
}
for _, number := range item.columns {
if name := attributes[number]; name != "" {
constraint.Columns = append(constraint.Columns, name)
}
}
if item.refSchema.Valid {
constraint.RefSchema = stringPtr(item.refSchema.String)
}
if item.refTable.Valid {
constraint.RefTable = stringPtr(item.refTable.String)
}
if strings.EqualFold(strings.TrimSpace(item.kind), "f") && item.refSchema.Valid && item.refTable.Valid {
key := item.refSchema.String + "\x00" + item.refTable.String
ref := refAttributes[key]
if ref == nil {
ref, err = s.relationAttributesByNumber(catalog, prefix, item.refSchema.String, item.refTable.String)
if err != nil {
return nil, err
}
refAttributes[key] = ref
}
for _, number := range item.refColumns {
if name := ref[number]; name != "" {
constraint.RefColumns = append(constraint.RefColumns, name)
}
}
constraint.MatchType = kingbaseConstraintMatchType(item.matchType)
constraint.OnUpdate = kingbaseConstraintAction(item.onUpdate)
constraint.OnDelete = kingbaseConstraintAction(item.onDelete)
}
result = append(result, constraint)
}
return result, nil
}
func parseConstraintEnabled(raw any) bool {
if raw == nil {
return true
}
if enabled, ok := raw.(bool); ok {
return enabled
}
if bytes, ok := raw.([]byte); ok {
raw = string(bytes)
}
value := strings.ToLower(strings.TrimSpace(fmt.Sprint(raw)))
if value == "" {
return true
}
switch value {
case "1", "t", "true", "y", "yes", "e", "enabled", "enable", "on":
return true
case "0", "f", "false", "d", "disabled", "disable", "off":
return false
default:
// Unknown catalog states should not make the whole metadata request fail.
return true
}
}
func kingbaseConstraintTypeName(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "p":
return "PRIMARY KEY"
case "f":
return "FOREIGN KEY"
case "u":
return "UNIQUE"
case "c":
return "CHECK"
case "t":
return "CONSTRAINT TRIGGER"
case "x":
return "EXCLUDE"
case "n":
return "NOT NULL"
default:
return strings.TrimSpace(value)
}
}
func kingbaseConstraintMatchType(value sql.NullString) *string {
if !value.Valid {
return nil
}
var result string
switch strings.ToLower(strings.TrimSpace(value.String)) {
case "f":
result = "FULL"
case "p":
result = "PARTIAL"
case "s":
result = "SIMPLE"
default:
return nil
}
return &result
}
func kingbaseConstraintAction(value sql.NullString) *string {
if !value.Valid {
return nil
}
var result string
switch strings.ToLower(strings.TrimSpace(value.String)) {
case "a":
result = "NO ACTION"
case "r":
result = "RESTRICT"
case "c":
result = "CASCADE"
case "n":
result = "SET NULL"
case "d":
result = "SET DEFAULT"
default:
return nil
}
return &result
}
func (s *server) listTriggers(schema, table string) ([]triggerInfo, error) {
effective, err := s.effectiveSchema(schema)
if err != nil {
@@ -2315,12 +2575,15 @@ func decodeTriggerTiming(triggerType int) string {
func boundedVarcharLength(dataType string) *int {
lower := strings.ToLower(strings.TrimSpace(dataType))
for _, prefix := range []string{"varchar", "character varying"} {
for _, prefix := range []string{"character varying", "varchar", "bpchar", "character", "char"} {
if strings.HasPrefix(lower, prefix) {
value := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(lower, prefix), ")"))
value = strings.TrimPrefix(value, "(")
if number, err := strconv.Atoi(strings.TrimSpace(value)); err == nil && number >= 0 {
return &number
fields := strings.Fields(value)
if len(fields) > 0 {
if number, err := strconv.Atoi(fields[0]); err == nil && number >= 0 {
return &number
}
}
}
}
+41 -22
View File
@@ -129,27 +129,30 @@ type querySession struct {
}
type server struct {
db *sql.DB
openDatabase kingbaseDBOpener
params connectParams
mode kingbaseMode
usePgDefaultExpression bool
usePgViewDefinition bool
usePgFunctionDefinition bool
useLegacyRoutineDefinition bool
catalogIdentityUnsupported bool
catalogOIDUnsupported bool
infoColumnTypeUnsupported bool
infoUdtNameUnsupported bool
indexOrdinalityUnsupported bool
triggerPrettyUnsupported bool
triggerInternalUnsupported bool
currentSchema string
schemaSet bool
sessions map[string]*querySession
nextSessionID uint64
activeCancelMu sync.Mutex
activeCancel context.CancelFunc
db *sql.DB
openDatabase kingbaseDBOpener
params connectParams
mode kingbaseMode
usePgDefaultExpression bool
usePgViewDefinition bool
usePgFunctionDefinition bool
useLegacyRoutineDefinition bool
catalogIdentityUnsupported bool
catalogOIDUnsupported bool
infoColumnTypeUnsupported bool
infoUdtNameUnsupported bool
indexOrdinalityUnsupported bool
triggerPrettyUnsupported bool
triggerInternalUnsupported bool
constraintDefinitionUnsupported bool
constraintValidatedUnsupported bool
constraintStatusUnsupported bool
currentSchema string
schemaSet bool
sessions map[string]*querySession
nextSessionID uint64
activeCancelMu sync.Mutex
activeCancel context.CancelFunc
}
type agentSession struct {
@@ -407,6 +410,9 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any
case "list_foreign_keys":
result, err := s.listForeignKeys(stringParam(params, "schema"), stringParam(params, "table"))
return result, false, err
case "list_constraints":
result, err := s.listConstraints(stringParam(params, "schema"), stringParam(params, "table"))
return result, false, err
case "list_triggers":
result, err := s.listTriggers(stringParam(params, "schema"), stringParam(params, "table"))
return result, false, err
@@ -477,6 +483,9 @@ func (s *server) connect(cp connectParams) error {
s.indexOrdinalityUnsupported = false
s.triggerPrettyUnsupported = false
s.triggerInternalUnsupported = false
s.constraintDefinitionUnsupported = false
s.constraintValidatedUnsupported = false
s.constraintStatusUnsupported = false
return nil
}
@@ -561,6 +570,9 @@ func (s *server) disconnect() error {
s.indexOrdinalityUnsupported = false
s.triggerPrettyUnsupported = false
s.triggerInternalUnsupported = false
s.constraintDefinitionUnsupported = false
s.constraintValidatedUnsupported = false
s.constraintStatusUnsupported = false
s.currentSchema = ""
s.schemaSet = false
if s.db == nil {
@@ -922,7 +934,7 @@ func (s *server) setSchema(ctx context.Context, conn *sql.Conn, schema string) e
if schema != "" {
// Kingbase implicitly prioritizes its system catalog when it is not
// listed explicitly, matching the JDBC agent and DBeaver behavior.
statement = "SET search_path TO " + quoteIdentifier(schema)
statement = "SET search_path TO " + s.quoteIdentifier(schema)
}
if _, err := conn.ExecContext(ctx, statement); err != nil {
return err
@@ -1881,6 +1893,13 @@ func quoteIdentifier(value string) string {
return `"` + strings.ReplaceAll(value, `"`, `""`) + `"`
}
func (s *server) quoteIdentifier(value string) string {
if s.mode.mysqlCompat {
return "`" + strings.ReplaceAll(value, "`", "``") + "`"
}
return quoteIdentifier(value)
}
func quoteLiteral(value string) string {
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}
+343
View File
@@ -1006,6 +1006,8 @@ func TestParseCatalogAttributeNumbers(t *testing.T) {
{name: "int2vector string", raw: "1 2 4", expected: "1,2,4"},
{name: "array string", raw: "{3,5}", expected: "3,5"},
{name: "bytes", raw: []byte("6 7"), expected: "6,7"},
{name: "bracketed array string", raw: "[8 9]", expected: "8,9"},
{name: "int16 slice", raw: []int16{10, 11}, expected: "10,11"},
{name: "empty", raw: nil, expected: ""},
{name: "invalid", raw: "1 bad", wantErr: true},
}
@@ -1032,6 +1034,32 @@ func TestParseCatalogAttributeNumbers(t *testing.T) {
}
}
func TestParseConstraintEnabled(t *testing.T) {
for _, test := range []struct {
name string
raw any
want bool
}{
{name: "boolean true", raw: true, want: true},
{name: "boolean false", raw: false, want: false},
{name: "enabled string", raw: "E", want: true},
{name: "disabled string", raw: "D", want: false},
{name: "enabled bytes", raw: []byte("enabled"), want: true},
{name: "disabled bytes", raw: []byte("disabled"), want: false},
{name: "one", raw: int64(1), want: true},
{name: "zero", raw: int64(0), want: false},
{name: "null", raw: nil, want: true},
{name: "unknown", raw: "future-state", want: true},
{name: "not validated label defaults enabled", raw: "N", want: true},
} {
t.Run(test.name, func(t *testing.T) {
if actual := parseConstraintEnabled(test.raw); actual != test.want {
t.Fatalf("parseConstraintEnabled(%#v) = %v, want %v", test.raw, actual, test.want)
}
})
}
}
func TestListIndexesFallsBackWhenWithOrdinalityIsUnsupported(t *testing.T) {
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
switch {
@@ -1210,6 +1238,262 @@ func TestListForeignKeysKeepsEmptyInformationSchemaResultOnV8(t *testing.T) {
}
}
func TestListConstraintsResolvesColumnsAndForeignKeyDetails(t *testing.T) {
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
switch {
case strings.Contains(query, "FROM sys_catalog.sys_constraint c"):
return &valueRows{
columns: []string{"conname", "contype", "definition", "conkey", "ref_schema", "ref_table", "confkey", "match_type", "on_update", "on_delete", "condeferrable", "condeferred", "convalidated", "constatus"},
rows: [][]driver.Value{
{"orders_amount_check", "c", "CHECK (amount > 0)", "{4}", nil, nil, nil, " ", " ", " ", false, false, []byte("f"), []byte("D")},
{"orders_customer_fkey", "f", "FOREIGN KEY (customer_id, customer_region) REFERENCES customers(id, region) ON DELETE CASCADE", "{2,3}", "PUBLIC", "customers", "{1,2}", "s", "a", "c", true, true, "t", "E"},
},
}, nil
case strings.Contains(query, "SELECT a.attnum, a.attname") && strings.Contains(query, "c.relname = 'orders'"):
return &valueRows{columns: []string{"attnum", "attname"}, rows: [][]driver.Value{{int64(2), "customer_id"}, {int64(3), "customer_region"}, {int64(4), "amount"}}}, nil
case strings.Contains(query, "SELECT a.attnum, a.attname") && strings.Contains(query, "c.relname = 'customers'"):
return &valueRows{columns: []string{"attnum", "attname"}, rows: [][]driver.Value{{int64(1), "id"}, {int64(2), "region"}}}, nil
default:
return nil, errors.New("unexpected query: " + query)
}
}}
server := newServer()
server.db = openMetadataDB(t, state)
constraints, err := server.listConstraints("PUBLIC", "orders")
if err != nil {
t.Fatal(err)
}
if len(constraints) != 2 {
t.Fatalf("unexpected constraints: %#v", constraints)
}
check, foreignKey := constraints[0], constraints[1]
if check.ConstraintType != "CHECK" || !equalStringSlices(check.Columns, []string{"amount"}) || check.Valid || check.Enabled {
t.Fatalf("unexpected check constraint: %#v", check)
}
if foreignKey.ConstraintType != "FOREIGN KEY" || !foreignKey.Valid || !foreignKey.Enabled || !equalStringSlices(foreignKey.Columns, []string{"customer_id", "customer_region"}) || !equalStringSlices(foreignKey.RefColumns, []string{"id", "region"}) {
t.Fatalf("unexpected foreign key columns: %#v", foreignKey)
}
if foreignKey.RefSchema == nil || *foreignKey.RefSchema != "PUBLIC" || foreignKey.RefTable == nil || *foreignKey.RefTable != "customers" || foreignKey.MatchType == nil || *foreignKey.MatchType != "SIMPLE" || foreignKey.OnUpdate == nil || *foreignKey.OnUpdate != "NO ACTION" || foreignKey.OnDelete == nil || *foreignKey.OnDelete != "CASCADE" || !foreignKey.Deferrable || !foreignKey.InitiallyDeferred {
t.Fatalf("unexpected foreign key details: %#v", foreignKey)
}
queries := strings.Join(state.snapshotQueries(), "\n")
if !strings.Contains(queries, "sys_catalog.sys_get_constraintdef") {
t.Fatalf("constraints must use the active catalog deparser: %s", queries)
}
}
func TestKingbaseConstraintQueryHasLegacyV7Fallback(t *testing.T) {
modern := kingbaseConstraintsQuery("sys_catalog", "sys", "public", "orders", false, false, false)
if !strings.Contains(modern, "sys_catalog.sys_get_constraintdef") || !strings.Contains(modern, "c.convalidated") || !strings.Contains(modern, "COALESCE(c.conname, '')") {
t.Fatalf("modern constraint query missing metadata fields: %s", modern)
}
if !strings.Contains(modern, "COALESCE(CAST(c.convalidated AS text), 'T')") {
t.Fatalf("modern constraint query must normalize convalidated to text: %s", modern)
}
if !strings.Contains(modern, "COALESCE(CAST(c.constatus AS text), 'E')") {
t.Fatalf("modern constraint query must normalize constatus to text: %s", modern)
}
legacy := kingbaseConstraintsQuery("sys_catalog", "sys", "public", "orders", true, true, true)
if strings.Contains(legacy, "sys_get_constraintdef") || strings.Contains(legacy, "c.convalidated") || !strings.Contains(legacy, "''") || !strings.Contains(legacy, "COALESCE(c.conname, '')") {
t.Fatalf("legacy V7 constraint query is not safe: %s", legacy)
}
}
func TestListConstraintsFallsBackWhenValidatedColumnIsUnsupported(t *testing.T) {
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
if strings.Contains(query, "c.convalidated") {
return nil, &gokb.Error{Code: gokb.ErrorCode("42703"), Message: "column c.convalidated does not exist"}
}
if strings.Contains(query, "FROM sys_catalog.sys_constraint c") {
return &valueRows{
columns: []string{"conname", "contype", "definition", "conkey", "ref_schema", "ref_table", "confkey", "match_type", "on_update", "on_delete", "condeferrable", "condeferred", "valid", "enabled"},
rows: [][]driver.Value{{"orders_pkey", "p", "PRIMARY KEY (id)", "{1}", nil, nil, nil, nil, nil, nil, false, false, true, true}},
}, nil
}
if strings.Contains(query, "SELECT a.attnum, a.attname") {
return &valueRows{columns: []string{"attnum", "attname"}, rows: [][]driver.Value{{int64(1), "id"}}}, nil
}
return nil, errors.New("unexpected query: " + query)
}}
server := newServer()
server.db = openMetadataDB(t, state)
constraints, err := server.listConstraints("PUBLIC", "orders")
if err != nil {
t.Fatal(err)
}
if len(constraints) != 1 || constraints[0].Definition != "PRIMARY KEY (id)" || !constraints[0].Valid {
t.Fatalf("unexpected fallback constraints: %#v", constraints)
}
if !server.constraintValidatedUnsupported {
t.Fatal("validated-column fallback was not cached")
}
if len(state.snapshotQueries()) != 3 {
t.Fatalf("expected failed modern query, fallback query and attribute query: %v", state.snapshotQueries())
}
if _, err := server.listConstraints("PUBLIC", "orders"); err != nil {
t.Fatal(err)
}
queries := state.snapshotQueries()
for _, query := range queries[3:] {
if strings.Contains(query, "c.convalidated") {
t.Fatalf("cached fallback queried unsupported convalidated column: %s", query)
}
}
}
func TestListConstraintsLegacyV7KeepsStructuralMetadata(t *testing.T) {
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
switch {
case strings.Contains(query, "FROM sys_catalog.sys_constraint c"):
if strings.Contains(query, "c.convalidated") {
return nil, errors.New("V7 query used unsupported constraint metadata: " + query)
}
if strings.Contains(query, "sys_get_constraintdef") {
return nil, &gokb.Error{Code: gokb.ErrorCode("42883"), Message: "function sys_get_constraintdef does not exist"}
}
return &valueRows{
columns: []string{"conname", "contype", "definition", "conkey", "ref_schema", "ref_table", "confkey", "match_type", "on_update", "on_delete", "condeferrable", "condeferred", "valid", "enabled"},
rows: [][]driver.Value{{"orders_pkey", "p", "", "[1]", nil, nil, nil, nil, nil, nil, false, false, true, true}},
}, nil
case strings.Contains(query, "SELECT a.attnum, a.attname"):
return &valueRows{columns: []string{"attnum", "attname"}, rows: [][]driver.Value{{int64(1), "id"}}}, nil
default:
return nil, errors.New("unexpected query: " + query)
}
}}
server := newServer()
server.db = openMetadataDB(t, state)
server.mode.legacyV7 = true
constraints, err := server.listConstraints("PUBLIC", "orders")
if err != nil {
t.Fatal(err)
}
if len(constraints) != 1 || constraints[0].Name != "orders_pkey" || constraints[0].Definition != "" || !constraints[0].Valid || !equalStringSlices(constraints[0].Columns, []string{"id"}) || !server.constraintDefinitionUnsupported {
t.Fatalf("unexpected V7 constraints: %#v", constraints)
}
}
func TestListConstraintsLegacyV7UsesSupportedDeparser(t *testing.T) {
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
switch {
case strings.Contains(query, "FROM sys_catalog.sys_constraint c"):
if !strings.Contains(query, "sys_catalog.sys_get_constraintdef") || strings.Contains(query, "COALESCE(CAST(c.convalidated AS text)") || strings.Contains(query, "c.constatus") {
return nil, errors.New("unexpected V7 constraint query: " + query)
}
return &valueRows{
columns: []string{"conname", "contype", "definition", "conkey", "ref_schema", "ref_table", "confkey", "match_type", "on_update", "on_delete", "condeferrable", "condeferred", "valid", "enabled"},
rows: [][]driver.Value{{"orders_pkey", "p", "PRIMARY KEY (id)", "[1]", nil, nil, nil, nil, nil, nil, false, false, true, "E"}},
}, nil
case strings.Contains(query, "SELECT a.attnum, a.attname"):
return &valueRows{columns: []string{"attnum", "attname"}, rows: [][]driver.Value{{int64(1), "id"}}}, nil
default:
return nil, errors.New("unexpected query: " + query)
}
}}
server := newServer()
server.db = openMetadataDB(t, state)
server.mode.legacyV7 = true
constraints, err := server.listConstraints("PUBLIC", "orders")
if err != nil {
t.Fatal(err)
}
if len(constraints) != 1 || constraints[0].Definition != "PRIMARY KEY (id)" || !constraints[0].Enabled || server.constraintDefinitionUnsupported {
t.Fatalf("unexpected V7 constraints: %#v", constraints)
}
}
func TestKingbaseConstraintQueryUsesPostgresCatalog(t *testing.T) {
query := kingbaseConstraintsQuery("pg_catalog", "pg", "public", "orders", false, false, false)
if !strings.Contains(query, "pg_catalog.pg_get_constraintdef") || strings.Contains(query, "sys_get_constraintdef") {
t.Fatalf("PostgreSQL catalog query used the wrong deparser: %s", query)
}
if !strings.Contains(query, "FROM pg_catalog.pg_constraint") || !strings.Contains(query, "pg_catalog.pg_namespace") {
t.Fatalf("PostgreSQL catalog query used the wrong catalog tables: %s", query)
}
}
func TestListConstraintsHandlesNullAttributeVectors(t *testing.T) {
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
switch {
case strings.Contains(query, "FROM sys_catalog.sys_constraint c"):
return &valueRows{
columns: []string{"conname", "contype", "definition", "conkey", "ref_schema", "ref_table", "confkey", "match_type", "on_update", "on_delete", "condeferrable", "condeferred", "valid", "enabled"},
rows: [][]driver.Value{{"table_check", "c", "CHECK (true)", nil, nil, nil, nil, nil, nil, nil, false, false, true, true}},
}, nil
case strings.Contains(query, "SELECT a.attnum, a.attname"):
return &valueRows{columns: []string{"attnum", "attname"}}, nil
default:
return nil, errors.New("unexpected query: " + query)
}
}}
server := newServer()
server.db = openMetadataDB(t, state)
constraints, err := server.listConstraints("PUBLIC", "orders")
if err != nil {
t.Fatal(err)
}
if len(constraints) != 1 || len(constraints[0].Columns) != 0 || len(constraints[0].RefColumns) != 0 {
t.Fatalf("NULL attribute vectors should decode as empty lists: %#v", constraints)
}
}
func TestListConstraintsCachesReferencedAttributes(t *testing.T) {
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
switch {
case strings.Contains(query, "FROM sys_catalog.sys_constraint c"):
return &valueRows{
columns: []string{"conname", "contype", "definition", "conkey", "ref_schema", "ref_table", "confkey", "match_type", "on_update", "on_delete", "condeferrable", "condeferred", "valid", "enabled"},
rows: [][]driver.Value{
{"orders_customer_fkey", "f", "FOREIGN KEY (customer_id) REFERENCES customers(id)", "{2}", "PUBLIC", "customers", "{1}", "s", "a", "a", false, false, true, true},
{"orders_region_fkey", "f", "FOREIGN KEY (customer_region) REFERENCES customers(region)", "{3}", "PUBLIC", "customers", "{2}", "s", "a", "a", false, false, true, true},
},
}, nil
case strings.Contains(query, "SELECT a.attnum, a.attname") && strings.Contains(query, "c.relname = 'orders'"):
return &valueRows{columns: []string{"attnum", "attname"}, rows: [][]driver.Value{{int64(2), "customer_id"}, {int64(3), "customer_region"}}}, nil
case strings.Contains(query, "SELECT a.attnum, a.attname") && strings.Contains(query, "c.relname = 'customers'"):
return &valueRows{columns: []string{"attnum", "attname"}, rows: [][]driver.Value{{int64(1), "id"}, {int64(2), "region"}}}, nil
default:
return nil, errors.New("unexpected query: " + query)
}
}}
server := newServer()
server.db = openMetadataDB(t, state)
constraints, err := server.listConstraints("PUBLIC", "orders")
if err != nil || len(constraints) != 2 {
t.Fatalf("unexpected constraints: %v %#v", err, constraints)
}
var referencedAttributeQueries int
for _, query := range state.snapshotQueries() {
if strings.Contains(query, "SELECT a.attnum, a.attname") && strings.Contains(query, "c.relname = 'customers'") {
referencedAttributeQueries++
}
}
if referencedAttributeQueries != 1 {
t.Fatalf("referenced relation attributes were not cached: %v", state.snapshotQueries())
}
}
func TestKingbaseConstraintLabels(t *testing.T) {
for input, expected := range map[string]string{"p": "PRIMARY KEY", "f": "FOREIGN KEY", "u": "UNIQUE", "c": "CHECK", "t": "CONSTRAINT TRIGGER", "x": "EXCLUDE", "n": "NOT NULL", "custom": "custom"} {
if actual := kingbaseConstraintTypeName(input); actual != expected {
t.Fatalf("type %q: expected %q, got %q", input, expected, actual)
}
}
for input, expected := range map[string]string{"a": "NO ACTION", "r": "RESTRICT", "c": "CASCADE", "n": "SET NULL", "d": "SET DEFAULT"} {
actual := kingbaseConstraintAction(sql.NullString{String: input, Valid: true})
if actual == nil || *actual != expected {
t.Fatalf("action %q: expected %q, got %v", input, expected, actual)
}
}
}
func TestKingbaseV7VersionPattern(t *testing.T) {
for _, test := range []struct {
version string
@@ -3322,6 +3606,32 @@ func TestConnectionLifecycleResetsCatalogOIDCapability(t *testing.T) {
}
}
func TestConnectionLifecycleResetsConstraintCapabilityCache(t *testing.T) {
state := &connectionAttemptState{pingErrors: map[string]error{}}
server := newServer()
server.openDatabase = state.open
server.constraintDefinitionUnsupported = true
server.constraintValidatedUnsupported = true
server.constraintStatusUnsupported = true
if err := server.connect(connectParams{MySQLCompatMode: true, URLParams: "sslmode=disable"}); err != nil {
t.Fatal(err)
}
if server.constraintDefinitionUnsupported || server.constraintValidatedUnsupported || server.constraintStatusUnsupported {
t.Fatal("connect must reset cached constraint capabilities")
}
server.constraintDefinitionUnsupported = true
server.constraintValidatedUnsupported = true
server.constraintStatusUnsupported = true
if err := server.disconnect(); err != nil {
t.Fatal(err)
}
if server.constraintDefinitionUnsupported || server.constraintValidatedUnsupported || server.constraintStatusUnsupported {
t.Fatal("disconnect must reset cached constraint capabilities")
}
}
func TestAppendDDLStatementEnsuresSingleTerminator(t *testing.T) {
got := appendDDLStatement("CREATE TABLE \"public\".\"orders\" (\n \"id\" integer\n)\n", "CREATE INDEX orders_id_idx ON public.orders (id)")
want := "CREATE TABLE \"public\".\"orders\" (\n \"id\" integer\n);\n\nCREATE INDEX orders_id_idx ON public.orders (id);"
@@ -3644,6 +3954,39 @@ func TestSchemaConnResetsOnceAfterExplicitSchema(t *testing.T) {
}
}
func TestSchemaConnUsesBackticksInMySQLCompatMode(t *testing.T) {
db, state := openFakeDB(t, 0)
server := newServer()
server.db = db
server.mode.mysqlCompat = true
conn, err := server.schemaConn(context.Background(), "audit-schema")
if err != nil {
t.Fatal(err)
}
if err := conn.Close(); err != nil {
t.Fatal(err)
}
state.mu.Lock()
defer state.mu.Unlock()
if len(state.execStatements) != 1 || state.execStatements[0] != "SET search_path TO `audit-schema`" {
t.Fatalf("unexpected MySQL compatibility schema setup: %v", state.execStatements)
}
}
func TestKingbaseIdentifierQuoteEscapesModeSpecificDelimiter(t *testing.T) {
server := newServer()
server.mode.mysqlCompat = true
if got := server.quoteIdentifier("audit`schema"); got != "`audit``schema`" {
t.Fatalf("unexpected MySQL compatibility identifier: %s", got)
}
server.mode.mysqlCompat = false
if got := server.quoteIdentifier(`audit"schema`); got != `"audit""schema"` {
t.Fatalf("unexpected PostgreSQL-compatible identifier: %s", got)
}
}
func TestSchemaConnPropagatesSchemaErrors(t *testing.T) {
tests := []struct {
name string
+27 -1
View File
@@ -29,8 +29,34 @@ Set `VASTBASE_TEST_HOST`, `VASTBASE_TEST_PORT`, `VASTBASE_TEST_DATABASE`,
`VASTBASE_TEST_USERNAME`, and `VASTBASE_TEST_PASSWORD`, then run:
```bash
go test -run '^TestVastbaseIntegration$' -count=1 ./...
go test -count=1 ./...
```
`TestVastbaseConstraintsIntegration` validates structured primary-key,
foreign-key, unique, check, deferrability, validation, enabled state, and
deparser metadata. To qualify multiple real database compatibility modes,
create one test database per mode and pass all names as a comma-separated list:
```bash
VASTBASE_TEST_DATABASES=vastbase_a,vastbase_b,vastbase_pg go test -run '^TestVastbaseConstraintsIntegration$' -count=1
```
The agent reads each database's `pg_catalog.pg_database.datcompatibility` value
and reports both a normalized `compatibilityMode` (`oracle`, `mysql`,
`postgres`, or `sqlserver`) and the server's original
`compatibilityModeRaw` value. `B`, `M`, and `MYSQL` use MySQL identifier rules.
Catalog availability and SQL Server identity metadata are probed separately
from the normalized mode. Vastbase `A` is normalized to Oracle compatibility,
but the tested A release rejects `DISABLE CONSTRAINT`, so that DDL assertion is
only enabled for server values that explicitly report `O`, `ORA`, or `ORACLE`. For a full compatibility
qualification, run the complete suite separately against A (Oracle), B
(MySQL), PG (PostgreSQL), and MSSQL (SQL Server) instances. Supply credentials
through the `VASTBASE_TEST_*` environment variables; do not commit them.
MySQL-compatible instances intentionally skip the PostgreSQL-only SQL-function
fixture and `search_path` visibility assertion, while still running the table,
column, index, view, transaction, paging, cancellation, and structured
constraint coverage.
The benchmark harness under `bench/` compares this native agent with the
Vastbase JDBC 2.11v and 2.15v agents.
+74 -4
View File
@@ -1,9 +1,11 @@
package main
import (
"context"
"database/sql"
"net/url"
"strings"
"time"
_ "gitcode.com/opengauss/openGauss-connector-go-pq"
)
@@ -29,11 +31,79 @@ func agentDataTypes() []string {
return vastbaseDataTypes
}
func detectAgentMode(_ *sql.DB, configuredMySQL bool) vastbaseMode {
if configuredMySQL {
return vastbaseMode{compatibilityMode: "mysql", mysqlCompat: true, postgresCatalog: true}
type vastbaseCompatibility struct {
mode string
raw string
mysqlCompat bool
sqlServer bool
supportsDisableConstraint bool
}
func normalizeVastbaseCompatibility(raw string) vastbaseCompatibility {
normalizedRaw := strings.ToUpper(strings.TrimSpace(raw))
compact := strings.NewReplacer("_", "", "-", "", " ", "").Replace(normalizedRaw)
result := vastbaseCompatibility{mode: "postgres", raw: normalizedRaw}
switch compact {
case "A":
result.mode = "oracle"
case "O", "ORA", "ORACLE":
result.mode = "oracle"
result.supportsDisableConstraint = true
case "B", "M", "MYSQL":
result.mode = "mysql"
result.mysqlCompat = true
case "MSSQL", "SQLSERVER":
result.mode = "sqlserver"
result.sqlServer = true
case "", "P", "PG", "POSTGRES", "POSTGRESQL":
result.mode = "postgres"
default:
result.mode = strings.ToLower(strings.TrimSpace(raw))
}
return vastbaseMode{compatibilityMode: "postgres", postgresCatalog: true}
return result
}
func detectAgentMode(db *sql.DB, configuredMySQL bool) vastbaseMode {
raw := "PG"
if db != nil {
var detected string
if err := db.QueryRow("SELECT datcompatibility FROM pg_catalog.pg_database WHERE datname = current_database()").Scan(&detected); err == nil && strings.TrimSpace(detected) != "" {
raw = detected
}
}
compatibility := normalizeVastbaseCompatibility(raw)
if configuredMySQL {
compatibility = normalizeVastbaseCompatibility("MYSQL")
}
mode := vastbaseMode{
compatibilityMode: compatibility.mode,
compatibilityModeRaw: compatibility.raw,
mysqlCompat: compatibility.mysqlCompat,
sqlServerIdentity: compatibility.sqlServer,
supportsDisableConstraint: compatibility.supportsDisableConstraint,
}
if db == nil {
mode.postgresCatalog = true
return mode
}
postgresCatalog := catalogExists(db, "pg_catalog.pg_namespace")
systemCatalog := catalogExists(db, "sys_catalog.sys_namespace")
mode.postgresCatalog = postgresCatalog || !systemCatalog
return mode
}
func catalogExists(db *sql.DB, catalog string) bool {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, "SELECT 1 FROM "+catalog+" WHERE 1 = 0")
if err != nil {
return false
}
return rows.Close() == nil
}
func vastbaseSupportsDisableConstraint(mode vastbaseMode) bool {
return mode.supportsDisableConstraint
}
func agentSSLModeAttempts(sslMode string) []string {
+136 -10
View File
@@ -65,17 +65,21 @@ func TestVastbaseIntegration(t *testing.T) {
mustExecute(t, server, "CREATE TABLE public."+quoteIdentifier(child)+" (id integer PRIMARY KEY, parent_id integer REFERENCES public."+quoteIdentifier(parent)+"(id))")
mustExecute(t, server, "CREATE INDEX "+quoteIdentifier(child+"_parent_idx")+" ON public."+quoteIdentifier(child)+"(parent_id)")
mustExecute(t, server, "CREATE VIEW public."+quoteIdentifier(view)+" AS SELECT id, name FROM public."+quoteIdentifier(parent))
mustExecute(t, server, "CREATE FUNCTION public."+quoteIdentifier(function)+"() RETURNS text AS $$ SELECT 'dbx'; $$ LANGUAGE SQL")
if !server.mode.mysqlCompat {
mustExecute(t, server, "CREATE FUNCTION public."+quoteIdentifier(function)+"() RETURNS text AS $$ SELECT 'dbx'; $$ LANGUAGE SQL")
}
mustExecute(t, server, "CREATE SCHEMA "+quoteIdentifier(searchFirst))
mustExecute(t, server, "CREATE SCHEMA "+quoteIdentifier(searchSecond))
mustExecute(t, server, "CREATE TABLE "+quoteIdentifier(searchSecond)+"."+quoteIdentifier(searchTable)+" (id integer PRIMARY KEY, name varchar(64))")
mustExecute(t, server, "SET search_path TO "+quoteIdentifier(searchFirst)+", "+quoteIdentifier(searchSecond))
visibleColumns, err := server.getColumns("", searchTable)
if err != nil || len(visibleColumns) != 2 || visibleColumns[0].ResolvedSchema == nil || *visibleColumns[0].ResolvedSchema != searchSecond {
t.Fatalf("unqualified metadata did not resolve the non-first search_path schema: columns=%#v err=%v", visibleColumns, err)
if !server.mode.mysqlCompat {
mustExecute(t, server, "SET search_path TO "+quoteIdentifier(searchFirst)+", "+quoteIdentifier(searchSecond))
visibleColumns, err := server.getColumns("", searchTable)
if err != nil || len(visibleColumns) != 2 || visibleColumns[0].ResolvedSchema == nil || *visibleColumns[0].ResolvedSchema != searchSecond {
t.Fatalf("unqualified metadata did not resolve the non-first search_path schema: columns=%#v err=%v", visibleColumns, err)
}
mustExecute(t, server, "INSERT INTO "+quoteIdentifier(searchTable)+" VALUES (1, 'visible')")
mustExecute(t, server, "SET search_path TO public")
}
mustExecute(t, server, "INSERT INTO "+quoteIdentifier(searchTable)+" VALUES (1, 'visible')")
mustExecute(t, server, "SET search_path TO public")
tables, err := server.listTables("public", metadataListConstraints{Filter: suffix})
if err != nil || len(tables) < 3 {
@@ -111,9 +115,11 @@ func TestVastbaseIntegration(t *testing.T) {
if err != nil || len(foreignKeys) != 1 || foreignKeys[0].RefTable != parent {
t.Fatalf("list foreign keys failed: keys=%v err=%v", foreignKeys, err)
}
source, err := server.getObjectSource("public", function, "FUNCTION")
if err != nil || !strings.Contains(fmt.Sprint(source["source"]), function) {
t.Fatalf("get function source failed: source=%v err=%v", source, err)
if !server.mode.mysqlCompat {
source, err := server.getObjectSource("public", function, "FUNCTION")
if err != nil || !strings.Contains(fmt.Sprint(source["source"]), function) {
t.Fatalf("get function source failed: source=%v err=%v", source, err)
}
}
transactionParams := map[string]json.RawMessage{
@@ -155,6 +161,126 @@ func TestVastbaseIntegration(t *testing.T) {
}
}
func TestVastbaseConstraintsIntegration(t *testing.T) {
host := os.Getenv("VASTBASE_TEST_HOST")
portText := os.Getenv("VASTBASE_TEST_PORT")
username := os.Getenv("VASTBASE_TEST_USERNAME")
password := os.Getenv("VASTBASE_TEST_PASSWORD")
if host == "" || portText == "" || username == "" || password == "" {
t.Skip("Vastbase integration environment is not configured")
}
port, err := strconv.Atoi(portText)
if err != nil {
t.Fatal(err)
}
databases := []string{os.Getenv("VASTBASE_TEST_DATABASE")}
if databases[0] == "" {
databases[0] = "postgres"
}
if raw := os.Getenv("VASTBASE_TEST_DATABASES"); raw != "" {
databases = strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ';' })
}
for _, database := range databases {
database = strings.TrimSpace(database)
if database == "" {
continue
}
t.Run(database, func(t *testing.T) {
suffix := strconv.FormatInt(time.Now().UnixNano(), 36)
schema := "dbx_constraints_" + suffix
parent := schema + ".parent"
child := schema + ".child"
server := newServer()
cp := connectParams{
Host: host, Port: port, Database: database, Username: username, Password: password,
ConnectionString: fmt.Sprintf("jdbc:vastbase://%s:%d/%s", host, port, database),
}
if err := server.connect(cp); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = server.disconnect() })
t.Cleanup(func() { _, _ = server.executeQuery(queryOptions{SQL: "DROP SCHEMA IF EXISTS " + schema + " CASCADE"}) })
expectedModes := map[string]string{"A": "oracle", "B": "mysql", "PG": "postgres", "MSSQL": "sqlserver"}
if expected, ok := expectedModes[server.mode.compatibilityModeRaw]; ok && server.mode.compatibilityMode != expected {
t.Fatalf("compatibility mode %q normalized to %q, want %q", server.mode.compatibilityModeRaw, server.mode.compatibilityMode, expected)
}
mustExecute(t, server, "CREATE SCHEMA "+schema)
mustExecute(t, server, "CREATE TABLE "+parent+" (id integer PRIMARY KEY, region integer, CONSTRAINT parent_pair_unique UNIQUE (id, region))")
childID := "id integer PRIMARY KEY"
if server.mode.sqlServerIdentity {
childID = "id integer IDENTITY(10,2) PRIMARY KEY"
}
mustExecute(t, server, "CREATE TABLE "+child+" ("+childID+", parent_id integer, parent_region integer, amount integer, CONSTRAINT child_parent_fk FOREIGN KEY (parent_id, parent_region) REFERENCES "+parent+" (id, region) DEFERRABLE INITIALLY DEFERRED, CONSTRAINT child_amount_check CHECK (amount > 0))")
constraints, err := server.listConstraints(schema, "child")
if err != nil {
t.Fatalf("list constraints failed in %s mode: %v", server.mode.compatibilityMode, err)
}
byName := map[string]constraintInfo{}
for _, constraint := range constraints {
byName[constraint.Name] = constraint
}
fk := byName["child_parent_fk"]
if fk.ConstraintType != "FOREIGN KEY" || !fk.Enabled || !fk.Valid || !fk.Deferrable || !fk.InitiallyDeferred || !equalVastbaseStrings(fk.Columns, []string{"parent_id", "parent_region"}) || !equalVastbaseStrings(fk.RefColumns, []string{"id", "region"}) {
t.Fatalf("foreign key metadata mismatch in %s mode: %+v", server.mode.compatibilityMode, fk)
}
check := byName["child_amount_check"]
if check.ConstraintType != "CHECK" || !check.Enabled || !check.Valid || !strings.Contains(check.Definition, "amount") {
t.Fatalf("check metadata mismatch in %s mode: %+v", server.mode.compatibilityMode, check)
}
if server.mode.sqlServerIdentity {
columns, err := server.getColumns(schema, "child")
if err != nil || len(columns) == 0 || columns[0].Extra == nil || *columns[0].Extra != "IDENTITY(10,2)" {
t.Fatalf("SQL Server identity metadata mismatch: columns=%+v err=%v", columns, err)
}
}
foreignKeys, err := server.listForeignKeys(schema, "child")
if err != nil || len(foreignKeys) != 2 {
t.Fatalf("foreign key listing failed in %s mode: keys=%v err=%v", server.mode.compatibilityMode, foreignKeys, err)
}
foreignKeyNames := map[string]struct{}{}
for _, foreignKey := range foreignKeys {
foreignKeyNames[foreignKey.Name] = struct{}{}
}
constraintForeignKeyNames := map[string]struct{}{}
for _, constraint := range constraints {
if constraint.ConstraintType == "FOREIGN KEY" {
constraintForeignKeyNames[constraint.Name] = struct{}{}
}
}
if len(foreignKeyNames) != len(constraintForeignKeyNames) {
t.Fatalf("foreign-key metadata sources disagree in %s mode: constraints=%v foreign_keys=%v", server.mode.compatibilityMode, constraintForeignKeyNames, foreignKeyNames)
}
for name := range foreignKeyNames {
if _, ok := constraintForeignKeyNames[name]; !ok {
t.Fatalf("foreign-key %q missing from list_constraints in %s mode", name, server.mode.compatibilityMode)
}
}
if vastbaseSupportsDisableConstraint(server.mode) {
mustExecute(t, server, "ALTER TABLE "+child+" DISABLE CONSTRAINT child_amount_check")
disabled, err := server.listConstraints(schema, "child")
if err != nil {
t.Fatalf("list disabled constraints failed in %s mode: %v", server.mode.compatibilityMode, err)
}
var disabledCheck constraintInfo
found := false
for _, constraint := range disabled {
if constraint.Name == "child_amount_check" {
disabledCheck = constraint
found = true
break
}
}
if !found || disabledCheck.Enabled {
t.Fatalf("disabled constraint state mismatch in %s mode: %+v", server.mode.compatibilityMode, disabledCheck)
}
mustExecute(t, server, "ALTER TABLE "+child+" ENABLE CONSTRAINT child_amount_check")
}
})
}
}
func TestVastbaseCustomTypesIntegration(t *testing.T) {
host := os.Getenv("VASTBASE_TEST_HOST")
portText := os.Getenv("VASTBASE_TEST_PORT")
+34 -19
View File
@@ -136,24 +136,27 @@ type rowScanner struct {
}
type server struct {
db *sql.DB
openDatabase agentDBOpener
params connectParams
mode vastbaseMode
usePgDefaultExpression bool
catalogIdentityUnsupported bool
infoColumnTypeUnsupported bool
infoUdtNameUnsupported bool
listTablesStatement *sql.Stmt
connectionRuntime *connectionRuntime
sessionAffinity bool
currentSchema string
schemaInitialized bool
schemaConnectionID uintptr
sessions map[string]*querySession
nextSessionID uint64
activeCancelMu sync.Mutex
activeCancel context.CancelFunc
db *sql.DB
openDatabase agentDBOpener
params connectParams
mode vastbaseMode
usePgDefaultExpression bool
catalogIdentityUnsupported bool
infoColumnTypeUnsupported bool
infoUdtNameUnsupported bool
constraintDefinitionUnsupported bool
constraintValidatedUnsupported bool
constraintEnabledUnsupported bool
listTablesStatement *sql.Stmt
connectionRuntime *connectionRuntime
sessionAffinity bool
currentSchema string
schemaInitialized bool
schemaConnectionID uintptr
sessions map[string]*querySession
nextSessionID uint64
activeCancelMu sync.Mutex
activeCancel context.CancelFunc
}
type agentSession struct {
@@ -433,6 +436,9 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any
case "list_foreign_keys":
result, err := s.listForeignKeys(stringParam(params, "schema"), stringParam(params, "table"))
return result, false, err
case "list_constraints":
result, err := s.listConstraints(stringParam(params, "schema"), stringParam(params, "table"))
return result, false, err
case "list_triggers":
result, err := s.listTriggers(stringParam(params, "schema"), stringParam(params, "table"))
return result, false, err
@@ -489,6 +495,9 @@ func (s *server) connect(cp connectParams) error {
s.catalogIdentityUnsupported = false
s.infoColumnTypeUnsupported = false
s.infoUdtNameUnsupported = false
s.constraintDefinitionUnsupported = false
s.constraintValidatedUnsupported = false
s.constraintEnabledUnsupported = false
s.sessionAffinity = false
return nil
}
@@ -505,11 +514,14 @@ func (s *server) connectWithRuntime(cp connectParams, connectionRuntime *connect
s.db = db
s.connectionRuntime = connectionRuntime
s.params = cp
s.mode = detectAgentMode(db, cp.MySQLCompatMode)
s.mode = detectAgentMode(connectionRuntime.database(), cp.MySQLCompatMode)
s.usePgDefaultExpression = false
s.catalogIdentityUnsupported = false
s.infoColumnTypeUnsupported = false
s.infoUdtNameUnsupported = false
s.constraintDefinitionUnsupported = false
s.constraintValidatedUnsupported = false
s.constraintEnabledUnsupported = false
s.sessionAffinity = false
return nil
}
@@ -584,6 +596,9 @@ func (s *server) disconnect() error {
s.catalogIdentityUnsupported = false
s.infoColumnTypeUnsupported = false
s.infoUdtNameUnsupported = false
s.constraintDefinitionUnsupported = false
s.constraintValidatedUnsupported = false
s.constraintEnabledUnsupported = false
s.connectionRuntime = nil
s.sessionAffinity = false
s.resetSchemaCache()
+68
View File
@@ -149,6 +149,34 @@ func TestVastbaseMetadataErrorClassificationUsesOpenGaussCodes(t *testing.T) {
t.Fatal("undefined Vastbase function was not recognized")
}
}
func TestVastbaseCompatibilityNormalization(t *testing.T) {
for _, test := range []struct {
raw string
mode string
mysql bool
sqlServer bool
disable bool
}{
{raw: "A", mode: "oracle"},
{raw: "O", mode: "oracle", disable: true},
{raw: "ORA", mode: "oracle", disable: true},
{raw: "ORACLE", mode: "oracle", disable: true},
{raw: "B", mode: "mysql", mysql: true},
{raw: "M", mode: "mysql", mysql: true},
{raw: "MYSQL", mode: "mysql", mysql: true},
{raw: "PG", mode: "postgres"},
{raw: "POSTGRESQL", mode: "postgres"},
{raw: "MSSQL", mode: "sqlserver", sqlServer: true},
{raw: "SQL_SERVER", mode: "sqlserver", sqlServer: true},
} {
t.Run(test.raw, func(t *testing.T) {
actual := normalizeVastbaseCompatibility(test.raw)
if actual.mode != test.mode || actual.mysqlCompat != test.mysql || actual.sqlServer != test.sqlServer || actual.supportsDisableConstraint != test.disable {
t.Fatalf("normalizeVastbaseCompatibility(%q) = %+v", test.raw, actual)
}
})
}
}
func TestVastbaseModeUsesPostgresCatalog(t *testing.T) {
mode := detectAgentMode(nil, false)
@@ -159,6 +187,34 @@ func TestVastbaseModeUsesPostgresCatalog(t *testing.T) {
if mysqlMode.compatibilityMode != "mysql" || !mysqlMode.postgresCatalog || !mysqlMode.mysqlCompat {
t.Fatalf("unexpected MySQL-compatible Vastbase mode: %+v", mysqlMode)
}
for _, compatibility := range []string{"M", "B", "MYSQL"} {
resolved := vastbaseMode{compatibilityMode: strings.ToLower(compatibility), postgresCatalog: true}
resolved.mysqlCompat = resolved.compatibilityMode == "m" || resolved.compatibilityMode == "b" || resolved.compatibilityMode == "mysql"
if !resolved.mysqlCompat {
t.Fatalf("compatibility %q must use MySQL identifier rules: %+v", compatibility, resolved)
}
}
}
func TestVastbaseDisableConstraintMode(t *testing.T) {
for _, mode := range []struct {
raw string
want bool
}{
{raw: "A", want: false},
{raw: "O", want: true},
{raw: "ORA", want: true},
{raw: "oracle", want: true},
{raw: "B", want: false},
{raw: "M", want: false},
{raw: "mysql", want: false},
{raw: "C", want: false},
} {
compatibility := normalizeVastbaseCompatibility(mode.raw)
if actual := vastbaseSupportsDisableConstraint(vastbaseMode{supportsDisableConstraint: compatibility.supportsDisableConstraint}); actual != mode.want {
t.Fatalf("vastbaseSupportsDisableConstraint(%q) = %v, want %v", mode.raw, actual, mode.want)
}
}
}
func TestSchemaConnectionRecoversAfterCanceledDriverConnection(t *testing.T) {
@@ -222,6 +278,18 @@ func TestDisconnectResetsInformationSchemaCapabilityCache(t *testing.T) {
}
}
func TestDisconnectResetsConstraintCapabilityCache(t *testing.T) {
server := newServer()
server.constraintDefinitionUnsupported = true
server.constraintValidatedUnsupported = true
if err := server.disconnect(); err != nil {
t.Fatal(err)
}
if server.constraintDefinitionUnsupported || server.constraintValidatedUnsupported {
t.Fatal("disconnect must reset cached constraint capabilities")
}
}
var (
registerVastbaseSchemaRetryDriver sync.Once
registerVastbasePingRetryDriver sync.Once
+1 -1
View File
@@ -175,7 +175,7 @@ func (s *server) metadataDatabase() (*sql.DB, error) {
func isMetadataOperation(method string) bool {
switch method {
case "connection_info", "list_databases", "list_schemas", "list_tables", "get_table_comment", "list_objects",
"list_data_types", "completion_assistant_search_v1", "get_columns", "list_indexes", "list_foreign_keys",
"list_data_types", "completion_assistant_search_v1", "get_columns", "list_indexes", "list_foreign_keys", "list_constraints",
"list_triggers", "get_object_source", "get_table_ddl", "get_explain_info":
return true
default:
@@ -219,6 +219,25 @@ func (conn *runtimePoolTestConn) Ping(context.Context) error {
return nil
}
func (*runtimePoolTestConn) QueryContext(context.Context, string, []driver.NamedValue) (driver.Rows, error) {
return &runtimePoolTestModeRows{}, nil
}
type runtimePoolTestModeRows struct {
read bool
}
func (*runtimePoolTestModeRows) Columns() []string { return []string{"datcompatibility"} }
func (*runtimePoolTestModeRows) Close() error { return nil }
func (rows *runtimePoolTestModeRows) Next(destination []driver.Value) error {
if rows.read {
return io.EOF
}
rows.read = true
destination[0] = "A"
return nil
}
type runtimePoolTestStmt struct {
state *runtimePoolTestState
}
+394 -64
View File
@@ -28,10 +28,12 @@ var postgresDataTypes = []string{
}
type vastbaseMode struct {
compatibilityMode string
postgresCatalog bool
mysqlCompat bool
sqlServerIdentity bool
compatibilityMode string
compatibilityModeRaw string
postgresCatalog bool
mysqlCompat bool
sqlServerIdentity bool
supportsDisableConstraint bool
}
type databaseInfo struct {
@@ -114,66 +116,29 @@ type foreignKeyInfo struct {
RefColumn string `json:"ref_column"`
}
type constraintInfo struct {
Name string `json:"name"`
ConstraintType string `json:"constraint_type"`
Definition string `json:"definition"`
Columns []string `json:"columns"`
RefSchema *string `json:"ref_schema,omitempty"`
RefTable *string `json:"ref_table,omitempty"`
RefColumns []string `json:"ref_columns"`
MatchType *string `json:"match_type,omitempty"`
OnUpdate *string `json:"on_update,omitempty"`
OnDelete *string `json:"on_delete,omitempty"`
Deferrable bool `json:"deferrable"`
InitiallyDeferred bool `json:"initially_deferred"`
Enabled bool `json:"enabled"`
Valid bool `json:"valid"`
}
type triggerInfo struct {
Name string `json:"name"`
Event string `json:"event"`
Timing string `json:"timing"`
}
func detectVastbaseMode(db *sql.DB, configuredMySQL bool) vastbaseMode {
if configuredMySQL {
return vastbaseMode{compatibilityMode: "mysql", mysqlCompat: true}
}
mode := vastbaseMode{compatibilityMode: detectDatabaseMode(db)}
mode.postgresCatalog = !catalogExists(db, "sys_catalog.sys_namespace") && catalogExists(db, "pg_catalog.pg_namespace")
if !mode.postgresCatalog {
if mode.compatibilityMode != "" {
mode.mysqlCompat = mode.compatibilityMode == "mysql"
} else {
mode.mysqlCompat = supportsBacktickIdentifiers(db)
}
mode.sqlServerIdentity = !mode.mysqlCompat && catalogExists(db, "sys.identity_columns")
}
return mode
}
func catalogExists(db *sql.DB, catalog string) bool {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, "SELECT 1 FROM "+catalog+" WHERE 1 = 0")
if err != nil {
return false
}
return rows.Close() == nil
}
func detectMySQLCompatMode(db *sql.DB) bool {
if databaseMode := detectDatabaseMode(db); databaseMode != "" {
return databaseMode == "mysql"
}
return supportsBacktickIdentifiers(db)
}
func detectDatabaseMode(db *sql.DB) string {
var databaseMode string
switch err := db.QueryRow("SELECT setting FROM sys_catalog.sys_settings WHERE LOWER(name) = 'database_mode'").Scan(&databaseMode); {
case err == nil:
// Treat database_mode as authoritative when the server exposes it. This
// avoids misclassifying Oracle-compatible servers that also publish
// sql_mode for MySQL syntax toggles such as ANSI_QUOTES.
return strings.ToLower(strings.TrimSpace(databaseMode))
case errors.Is(err, sql.ErrNoRows):
return ""
default:
return ""
}
}
func supportsBacktickIdentifiers(db *sql.DB) bool {
var value int
return db.QueryRow("SELECT 1 AS `dbx_identifier_probe`").Scan(&value) == nil
}
func (s *server) identifierQuote() string {
// Vastbase MySQL compatibility mode follows MySQL identifier quoting;
// other modes retain the PostgreSQL-compatible double quote.
@@ -195,8 +160,9 @@ func (s *server) connectionInfo() (map[string]any, error) {
}
return map[string]any{
"database": database, "username": username, "version": version, "schema": schema,
"compatibilityMode": s.mode.compatibilityMode, "mysql_compat_mode": s.mode.mysqlCompat,
"identifierQuote": s.identifierQuote(),
"compatibilityMode": s.mode.compatibilityMode, "compatibilityModeRaw": s.mode.compatibilityModeRaw,
"mysql_compat_mode": s.mode.mysqlCompat,
"identifierQuote": s.identifierQuote(),
"databaseInfo": map[string]string{
"productName": "Vastbase",
"productVersion": version,
@@ -1350,13 +1316,17 @@ func (s *server) queryInformationSchemaColumns(schema, table string, primary map
default:
fullDataTypeExpression = "NULL AS column_type"
}
catalog, prefix := "sys_catalog", "sys"
if s.mode.postgresCatalog {
catalog, prefix = "pg_catalog", "pg"
}
query := fmt.Sprintf(`SELECT c.column_name, c.data_type, %s, c.is_nullable, c.column_default,
col_description(a.attrelid, a.attnum), c.numeric_precision, c.numeric_scale, c.character_maximum_length
FROM information_schema.columns c
LEFT JOIN sys_catalog.sys_namespace n ON n.nspname = c.table_schema
LEFT JOIN sys_catalog.sys_class rel ON rel.relnamespace = n.oid AND rel.relname = c.table_name
LEFT JOIN sys_catalog.sys_attribute a ON a.attrelid = rel.oid AND a.attname = c.column_name AND a.attnum > 0 AND NOT a.attisdropped
WHERE c.table_schema = %s AND c.table_name = %s ORDER BY c.ordinal_position`, fullDataTypeExpression, quoteLiteral(schema), quoteLiteral(table))
LEFT JOIN %s.%s_namespace n ON n.nspname = c.table_schema
LEFT JOIN %s.%s_class rel ON rel.relnamespace = n.oid AND rel.relname = c.table_name
LEFT JOIN %s.%s_attribute a ON a.attrelid = rel.oid AND a.attname = c.column_name AND a.attnum > 0 AND NOT a.attisdropped
WHERE c.table_schema = %s AND c.table_name = %s ORDER BY c.ordinal_position`, fullDataTypeExpression, catalog, prefix, catalog, prefix, catalog, prefix, quoteLiteral(schema), quoteLiteral(table))
rows, err := s.metadataQuery(query)
if err != nil {
return nil, err
@@ -1518,6 +1488,314 @@ WHERE tc.table_schema = ` + quoteLiteral(effective) + ` AND tc.table_name = ` +
return result, rows.Err()
}
func vastbaseConstraintFunctionName(catalog string) string {
if catalog == "pg_catalog" {
return "pg_get_constraintdef"
}
return "sys_get_constraintdef"
}
func vastbaseConstraintsQuery(catalog, prefix, schema, table string, definitionUnsupported, validatedUnsupported, enabledUnsupported bool) string {
definitionExpression := fmt.Sprintf("COALESCE(%s(c.oid, true), '')", vastbaseCatalogFunction(catalog, "sys_get_constraintdef", "pg_get_constraintdef"))
validExpression := "COALESCE(CAST(c.convalidated AS text), 'T')"
enabledExpression := "COALESCE(CAST(c.conenable AS text), 'T')"
if definitionUnsupported {
definitionExpression = "''"
}
if validatedUnsupported {
validExpression = "'T'"
}
if enabledUnsupported {
enabledExpression = "'T'"
}
return fmt.Sprintf(`SELECT COALESCE(c.conname, ''), CAST(c.contype AS text), %s,
CAST(c.conkey AS text), rn.nspname, rt.relname, CAST(c.confkey AS text),
CAST(c.confmatchtype AS text), CAST(c.confupdtype AS text), CAST(c.confdeltype AS text),
c.condeferrable, c.condeferred, %s, %s
FROM %s.%s_constraint c
JOIN %s.%s_class t ON t.oid = c.conrelid
JOIN %s.%s_namespace n ON n.oid = t.relnamespace
LEFT JOIN %s.%s_class rt ON rt.oid = c.confrelid
LEFT JOIN %s.%s_namespace rn ON rn.oid = rt.relnamespace
WHERE n.nspname = %s AND t.relname = %s AND t.relkind IN ('r', 'p', 'f')
ORDER BY COALESCE(c.conname, '')`, definitionExpression, validExpression, enabledExpression, catalog, prefix, catalog, prefix, catalog, prefix, catalog, prefix, catalog, prefix, quoteLiteral(schema), quoteLiteral(table))
}
func (s *server) listConstraints(schema, table string) ([]constraintInfo, error) {
effective, err := s.effectiveSchema(schema)
if err != nil {
return nil, err
}
catalog, prefix := "sys_catalog", "sys"
if s.mode.postgresCatalog {
catalog, prefix = "pg_catalog", "pg"
}
effective, table, err = s.resolveConstraintRelation(catalog, prefix, effective, table)
if err != nil {
return nil, err
}
definitionUnsupported := s.constraintDefinitionUnsupported
validatedUnsupported := s.constraintValidatedUnsupported
enabledUnsupported := s.constraintEnabledUnsupported
var rows *sql.Rows
for {
rows, err = s.metadataQuery(vastbaseConstraintsQuery(catalog, prefix, effective, table, definitionUnsupported, validatedUnsupported, enabledUnsupported))
if err == nil {
break
}
changed := false
if !definitionUnsupported && isUndefinedFunction(err, vastbaseConstraintFunctionName(catalog)) {
definitionUnsupported = true
s.constraintDefinitionUnsupported = true
changed = true
}
if !validatedUnsupported && isUndefinedColumn(err, "convalidated") {
validatedUnsupported = true
s.constraintValidatedUnsupported = true
changed = true
}
if !enabledUnsupported && isUndefinedColumn(err, "conenable") {
enabledUnsupported = true
s.constraintEnabledUnsupported = true
changed = true
}
if !changed {
return nil, err
}
}
defer rows.Close()
type rawConstraint struct {
name, kind, definition string
columnNumbers, refColumnNumbers []int
refSchema, refTable sql.NullString
matchType, onUpdate, onDelete sql.NullString
deferrable, initiallyDeferred, valid, enabled bool
}
raw := []rawConstraint{}
for rows.Next() {
var item rawConstraint
var columnsRaw, refColumnsRaw sql.NullString
var validRaw, enabledRaw any
if err := rows.Scan(&item.name, &item.kind, &item.definition, &columnsRaw, &item.refSchema, &item.refTable, &refColumnsRaw, &item.matchType, &item.onUpdate, &item.onDelete, &item.deferrable, &item.initiallyDeferred, &validRaw, &enabledRaw); err != nil {
return nil, err
}
item.columnNumbers, err = parseVastbaseConstraintAttributeNumbers(columnsRaw.String)
if err != nil {
return nil, fmt.Errorf("failed to parse constraint %s columns: %w", item.name, err)
}
item.refColumnNumbers, err = parseVastbaseConstraintAttributeNumbers(refColumnsRaw.String)
if err != nil {
return nil, fmt.Errorf("failed to parse constraint %s referenced columns: %w", item.name, err)
}
item.valid = parseVastbaseConstraintValid(validRaw)
item.enabled = parseVastbaseConstraintEnabled(enabledRaw)
raw = append(raw, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
attributes, err := s.relationAttributesByNumber(catalog, prefix, effective, table)
if err != nil {
return nil, err
}
refAttributes := map[string]map[int]string{}
result := make([]constraintInfo, 0, len(raw))
for _, item := range raw {
constraint := constraintInfo{
Name: item.name, ConstraintType: vastbaseConstraintTypeName(item.kind), Definition: item.definition,
Columns: []string{}, RefColumns: []string{}, Deferrable: item.deferrable,
InitiallyDeferred: item.initiallyDeferred, Enabled: item.enabled, Valid: item.valid,
}
for _, number := range item.columnNumbers {
if name := attributes[number]; name != "" {
constraint.Columns = append(constraint.Columns, name)
}
}
if item.refSchema.Valid {
constraint.RefSchema = stringPtr(item.refSchema.String)
}
if item.refTable.Valid {
constraint.RefTable = stringPtr(item.refTable.String)
}
if strings.EqualFold(strings.TrimSpace(item.kind), "f") && item.refSchema.Valid && item.refTable.Valid {
key := item.refSchema.String + "\x00" + item.refTable.String
ref := refAttributes[key]
if ref == nil {
ref, err = s.relationAttributesByNumber(catalog, prefix, item.refSchema.String, item.refTable.String)
if err != nil {
return nil, err
}
refAttributes[key] = ref
}
for _, number := range item.refColumnNumbers {
if name := ref[number]; name != "" {
constraint.RefColumns = append(constraint.RefColumns, name)
}
}
constraint.MatchType = vastbaseConstraintMatchType(item.matchType)
constraint.OnUpdate = vastbaseConstraintAction(item.onUpdate)
constraint.OnDelete = vastbaseConstraintAction(item.onDelete)
}
result = append(result, constraint)
}
return result, nil
}
func (s *server) resolveConstraintRelation(catalog, prefix, schema, table string) (string, string, error) {
query := fmt.Sprintf(`SELECT n.nspname, c.relname
FROM %s.%s_class c JOIN %s.%s_namespace n ON n.oid = c.relnamespace
WHERE LOWER(n.nspname) = LOWER(%s) AND LOWER(c.relname) = LOWER(%s)
AND c.relkind IN ('r', 'p', 'f')
ORDER BY CASE WHEN n.nspname = %s AND c.relname = %s THEN 0 ELSE 1 END`, catalog, prefix, catalog, prefix, quoteLiteral(schema), quoteLiteral(table), quoteLiteral(schema), quoteLiteral(table))
rows, err := s.metadataQuery(query)
if err != nil {
return "", "", err
}
defer rows.Close()
type relationName struct{ schema, table string }
matches := []relationName{}
for rows.Next() {
var match relationName
if err := rows.Scan(&match.schema, &match.table); err != nil {
return "", "", err
}
matches = append(matches, match)
}
if err := rows.Err(); err != nil {
return "", "", err
}
if len(matches) == 0 {
return "", "", fmt.Errorf("Vastbase relation not found: %s.%s", schema, table)
}
if matches[0].schema == schema && matches[0].table == table {
return schema, table, nil
}
if len(matches) > 1 {
return "", "", fmt.Errorf("ambiguous Vastbase relation name %s.%s under case-insensitive matching", schema, table)
}
return matches[0].schema, matches[0].table, nil
}
func (s *server) relationAttributesByNumber(catalog, prefix, schema, table string) (map[int]string, error) {
query := fmt.Sprintf(`SELECT a.attnum, a.attname
FROM %s.%s_attribute a JOIN %s.%s_class c ON c.oid = a.attrelid
JOIN %s.%s_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = %s AND c.relname = %s AND a.attnum > 0 AND NOT a.attisdropped`, catalog, prefix, catalog, prefix, catalog, prefix, quoteLiteral(schema), quoteLiteral(table))
rows, err := s.metadataQuery(query)
if err != nil {
return nil, err
}
defer rows.Close()
result := map[int]string{}
for rows.Next() {
var number int
var name string
if err := rows.Scan(&number, &name); err != nil {
return nil, err
}
result[number] = name
}
return result, rows.Err()
}
func parseVastbaseConstraintAttributeNumbers(raw string) ([]int, error) {
value := strings.TrimSpace(strings.Trim(raw, "{}[]"))
if value == "" {
return []int{}, nil
}
parts := strings.FieldsFunc(value, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' })
result := make([]int, 0, len(parts))
for _, part := range parts {
number, err := strconv.Atoi(strings.TrimSpace(part))
if err != nil {
return nil, fmt.Errorf("invalid attribute number %q", part)
}
result = append(result, number)
}
return result, nil
}
func parseVastbaseConstraintValue(raw any) string {
if raw == nil {
return ""
}
if bytes, ok := raw.([]byte); ok {
return strings.ToLower(strings.TrimSpace(string(bytes)))
}
return strings.ToLower(strings.TrimSpace(fmt.Sprint(raw)))
}
func parseVastbaseConstraintValid(raw any) bool {
if value, ok := raw.(bool); ok {
return value
}
switch parseVastbaseConstraintValue(raw) {
case "0", "f", "false", "n", "no", "not validated", "not_validated", "invalid":
return false
default:
return true
}
}
func parseVastbaseConstraintEnabled(raw any) bool {
if value, ok := raw.(bool); ok {
return value
}
switch parseVastbaseConstraintValue(raw) {
case "0", "f", "false", "d", "disabled", "disable", "off":
return false
default:
return true
}
}
func vastbaseConstraintTypeName(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "p":
return "PRIMARY KEY"
case "f":
return "FOREIGN KEY"
case "u":
return "UNIQUE"
case "c":
return "CHECK"
case "t":
return "CONSTRAINT TRIGGER"
case "x":
return "EXCLUDE"
case "n":
return "NOT NULL"
default:
return strings.TrimSpace(value)
}
}
func vastbaseConstraintMatchType(value sql.NullString) *string {
if !value.Valid {
return nil
}
labels := map[string]string{"f": "FULL", "p": "PARTIAL", "s": "SIMPLE"}
result, ok := labels[strings.ToLower(strings.TrimSpace(value.String))]
if !ok {
return nil
}
return &result
}
func vastbaseConstraintAction(value sql.NullString) *string {
if !value.Valid {
return nil
}
labels := map[string]string{"a": "NO ACTION", "r": "RESTRICT", "c": "CASCADE", "n": "SET NULL", "d": "SET DEFAULT"}
result, ok := labels[strings.ToLower(strings.TrimSpace(value.String))]
if !ok {
return nil
}
return &result
}
func (s *server) listTriggers(schema, table string) ([]triggerInfo, error) {
effective, err := s.effectiveSchema(schema)
if err != nil {
@@ -1791,6 +2069,8 @@ func vastbaseIdentityClause(code string) *string {
clause = "GENERATED ALWAYS AS IDENTITY"
case "d":
clause = "GENERATED BY DEFAULT AS IDENTITY"
case "m":
clause = "IDENTITY"
default:
return nil
}
@@ -1869,6 +2149,10 @@ WHERE tc.table_schema=` + quoteLiteral(schema) + ` AND tc.table_name=` + quoteLi
}
func (s *server) applyIdentityMetadata(schema, table string, columns []columnInfo) {
if s.mode.postgresCatalog {
s.applyPostgresIdentityMetadata(schema, table, columns)
return
}
query := `SELECT a.attname, ic.seed_value, ic.increment_value FROM sys.identity_columns ic
JOIN sys_catalog.sys_class c ON c.oid=ic.object_id JOIN sys_catalog.sys_namespace n ON n.oid=c.relnamespace
JOIN sys_catalog.sys_attribute a ON a.attrelid=c.oid AND a.attnum=ic.column_id
@@ -1898,6 +2182,52 @@ WHERE n.nspname=` + quoteLiteral(schema) + ` AND c.relname=` + quoteLiteral(tabl
}
}
func (s *server) applyPostgresIdentityMetadata(schema, table string, columns []columnInfo) {
query := `SELECT a.attname, pg_get_serial_sequence(quote_ident(n.nspname) || '.' || quote_ident(c.relname), a.attname), a.attidentity
FROM pg_catalog.pg_attribute a
JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = ` + quoteLiteral(schema) + ` AND c.relname = ` + quoteLiteral(table) + ` AND a.attidentity <> ''`
rows, err := s.metadataQuery(query)
if err != nil {
return
}
type identityColumn struct {
name, sequence, identity string
}
identityColumns := []identityColumn{}
for rows.Next() {
var item identityColumn
if rows.Scan(&item.name, &item.sequence, &item.identity) == nil {
identityColumns = append(identityColumns, item)
}
}
_ = rows.Close()
byName := map[string]*columnInfo{}
for i := range columns {
byName[strings.ToLower(columns[i].Name)] = &columns[i]
}
for _, item := range identityColumns {
column := byName[strings.ToLower(item.name)]
if column == nil {
continue
}
extra := vastbaseIdentityClause(item.identity)
if extra == nil {
extra = stringPtr("IDENTITY")
}
if item.sequence != "" {
var start, increment sql.NullInt64
sequenceQuery := "SELECT start_value, increment_by FROM " + quoteCatalogIdentifier(item.sequence)
if err := s.requireDBQueryRow(sequenceQuery, &start, &increment); err == nil && start.Valid && increment.Valid {
value := fmt.Sprintf("IDENTITY(%d,%d)", start.Int64, increment.Int64)
extra = &value
}
}
column.Extra = extra
}
}
func catalogPrefix(catalog string) string {
if catalog == "pg_catalog" {
return "pg"
@@ -197,6 +197,156 @@ func openVastbaseCustomTypesDB(t *testing.T, state *vastbaseCustomTypesTestState
return db
}
func TestVastbaseConstraintStatusParsing(t *testing.T) {
for _, test := range []struct {
name string
raw any
valid bool
enabled bool
}{
{name: "boolean true", raw: true, valid: true, enabled: true},
{name: "boolean false", raw: false, valid: false, enabled: false},
{name: "text true", raw: "t", valid: true, enabled: true},
{name: "text false", raw: "f", valid: false, enabled: false},
{name: "one", raw: "1", valid: true, enabled: true},
{name: "zero", raw: "0", valid: false, enabled: false},
{name: "oracle enabled", raw: "E", valid: true, enabled: true},
{name: "oracle disabled", raw: "D", valid: true, enabled: false},
{name: "enabled label", raw: "enabled", valid: true, enabled: true},
{name: "disabled label", raw: "disabled", valid: true, enabled: false},
{name: "not validated", raw: "N", valid: false, enabled: true},
{name: "no", raw: "no", valid: false, enabled: true},
{name: "bytes false", raw: []byte("false"), valid: false, enabled: false},
{name: "null defaults true", raw: nil, valid: true, enabled: true},
{name: "unknown defaults true", raw: "future-state", valid: true, enabled: true},
} {
t.Run(test.name, func(t *testing.T) {
if actual := parseVastbaseConstraintValid(test.raw); actual != test.valid {
t.Fatalf("parseVastbaseConstraintValid(%#v) = %v, want %v", test.raw, actual, test.valid)
}
if actual := parseVastbaseConstraintEnabled(test.raw); actual != test.enabled {
t.Fatalf("parseVastbaseConstraintEnabled(%#v) = %v, want %v", test.raw, actual, test.enabled)
}
})
}
}
func TestVastbaseConstraintAttributeNumbersAreStrict(t *testing.T) {
for _, raw := range []string{"1 2", "{3,2}", "[4, 5]", ""} {
if _, err := parseVastbaseConstraintAttributeNumbers(raw); err != nil {
t.Fatalf("valid vector %q failed: %v", raw, err)
}
}
if _, err := parseVastbaseConstraintAttributeNumbers("1 bad 3"); err == nil {
t.Fatal("invalid constraint vector must return an error")
}
}
func TestVastbaseConstraintQueryNormalizesCatalogCapabilities(t *testing.T) {
query := vastbaseConstraintsQuery("pg_catalog", "pg", "public", "orders", false, false, false)
for _, expected := range []string{
"pg_catalog.pg_get_constraintdef",
"COALESCE(CAST(c.convalidated AS text), 'T')",
"CAST(c.conkey AS text)",
"CAST(c.confkey AS text)",
} {
if !strings.Contains(query, expected) {
t.Fatalf("constraint query missing %q: %s", expected, query)
}
}
fallback := vastbaseConstraintsQuery("pg_catalog", "pg", "public", "orders", true, true, true)
if strings.Contains(fallback, "pg_get_constraintdef") || strings.Contains(fallback, "convalidated") || strings.Contains(fallback, "conenable") {
t.Fatalf("fallback constraint query still contains unsupported fields: %s", fallback)
}
}
func TestVastbaseConstraintRelationResolvesFoldedNames(t *testing.T) {
state := &vastbaseCustomTypesTestState{query: func(query string) (driver.Rows, error) {
if !strings.Contains(query, "LOWER(n.nspname) = LOWER('app')") || !strings.Contains(query, "LOWER(c.relname) = LOWER('orders')") {
return nil, fmt.Errorf("unexpected query: %s", query)
}
return &valueRows{columns: []string{"nspname", "relname"}, rows: [][]driver.Value{{"APP", "ORDERS"}}}, nil
}}
server := newServer()
server.db = openVastbaseCustomTypesDB(t, state)
schema, table, err := server.resolveConstraintRelation("pg_catalog", "pg", "app", "orders")
if err != nil {
t.Fatal(err)
}
if schema != "APP" || table != "ORDERS" {
t.Fatalf("folded relation was not resolved: %s.%s", schema, table)
}
}
func TestVastbaseConstraintRelationRejectsAmbiguousFoldedNames(t *testing.T) {
state := &vastbaseCustomTypesTestState{query: func(string) (driver.Rows, error) {
return &valueRows{columns: []string{"nspname", "relname"}, rows: [][]driver.Value{{"APP", "ORDERS"}, {"App", "Orders"}}}, nil
}}
server := newServer()
server.db = openVastbaseCustomTypesDB(t, state)
if _, _, err := server.resolveConstraintRelation("pg_catalog", "pg", "app", "orders"); err == nil || !strings.Contains(err.Error(), "ambiguous") {
t.Fatalf("ambiguous folded relation must fail, got %v", err)
}
}
func TestVastbaseListConstraintsResolvesCatalogVectors(t *testing.T) {
state := &vastbaseCustomTypesTestState{query: func(query string) (driver.Rows, error) {
switch {
case strings.Contains(query, "LOWER(n.nspname) = LOWER('public')") && strings.Contains(query, "LOWER(c.relname) = LOWER('orders')"):
return &valueRows{columns: []string{"nspname", "relname"}, rows: [][]driver.Value{{"public", "orders"}}}, nil
case strings.Contains(query, "FROM pg_catalog.pg_constraint c"):
return &valueRows{
columns: []string{"conname", "contype", "definition", "conkey", "ref_schema", "ref_table", "confkey", "match_type", "on_update", "on_delete", "condeferrable", "condeferred", "valid", "enabled"},
rows: [][]driver.Value{
{"orders_check", "c", "CHECK (amount > 0)", "{4}", nil, nil, nil, nil, nil, nil, false, false, []byte("t"), []byte("D")},
{"orders_customer_fkey", "f", "FOREIGN KEY (customer_id, region_id) REFERENCES customers(id, region_id)", "{2,3}", "public", "customers", "{1,2}", "s", "a", "c", true, true, "f", true},
},
}, nil
case strings.Contains(query, "SELECT a.attnum, a.attname") && strings.Contains(query, "c.relname = 'orders'"):
return &valueRows{columns: []string{"attnum", "attname"}, rows: [][]driver.Value{{int64(2), "customer_id"}, {int64(3), "region_id"}, {int64(4), "amount"}}}, nil
case strings.Contains(query, "SELECT a.attnum, a.attname") && strings.Contains(query, "c.relname = 'customers'"):
return &valueRows{columns: []string{"attnum", "attname"}, rows: [][]driver.Value{{int64(1), "id"}, {int64(2), "region_id"}}}, nil
default:
return nil, fmt.Errorf("unexpected query: %s", query)
}
}}
server := newServer()
server.db = openVastbaseCustomTypesDB(t, state)
server.mode.postgresCatalog = true
constraints, err := server.listConstraints("public", "orders")
if err != nil {
t.Fatal(err)
}
if len(constraints) != 2 {
t.Fatalf("unexpected constraints: %#v", constraints)
}
check, fk := constraints[0], constraints[1]
if check.ConstraintType != "CHECK" || !check.Valid || check.Enabled || !equalVastbaseStrings(check.Columns, []string{"amount"}) {
t.Fatalf("unexpected check constraint: %#v", check)
}
if fk.ConstraintType != "FOREIGN KEY" || fk.Valid || !fk.Enabled || !fk.Deferrable || !fk.InitiallyDeferred || !equalVastbaseStrings(fk.Columns, []string{"customer_id", "region_id"}) || !equalVastbaseStrings(fk.RefColumns, []string{"id", "region_id"}) {
t.Fatalf("unexpected foreign key constraint: %#v", fk)
}
if fk.MatchType == nil || *fk.MatchType != "SIMPLE" || fk.OnUpdate == nil || *fk.OnUpdate != "NO ACTION" || fk.OnDelete == nil || *fk.OnDelete != "CASCADE" {
t.Fatalf("unexpected foreign key actions: %#v", fk)
}
}
func equalVastbaseStrings(left, right []string) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
func TestVastbaseGetColumnsResolvesVisibleRelationSchemaInCatalogQuery(t *testing.T) {
queries := []string{}
state := &vastbaseCustomTypesTestState{query: func(query string) (driver.Rows, error) {
+28 -6
View File
@@ -88,6 +88,7 @@ import {
isSwitchToPreviousTabShortcut,
isToggleResultsPaneShortcut,
isToggleSidebarShortcut,
isToggleZenModeShortcut,
isZoomInShortcut,
isZoomOutShortcut,
switchToTabIndexFromShortcut,
@@ -254,6 +255,7 @@ const agentDriverUpdateCount = ref(0);
const showHistory = ref(false);
const showAiPanel = ref(safeLocalStorageGet("dbx-ai-panel-open") === "true");
const isAiPanelMaximized = ref(false);
const isZenMode = ref(false);
const showSqlLibraryPanel = ref(safeLocalStorageGet("dbx-sql-library-open") === "true");
const showSqlFilePanel = ref(safeLocalStorageGet("dbx-sql-file-panel-open") === "true");
const rightSidebarPanelRefs: Record<RightSidebarPanelId, typeof showAiPanel> = {
@@ -323,6 +325,18 @@ const activeTab = computed(() => queryStore.tabs.find((t) => t.id === queryStore
const tabNavigationHistory = ref(createTabNavigationHistory());
let pendingTabHistoryNavigationId: string | null = null;
watch(
() => activeTab.value?.mode,
(mode) => {
if (mode !== "data") isZenMode.value = false;
},
);
function toggleZenMode() {
if (activeTab.value?.mode !== "data") return;
isZenMode.value = !isZenMode.value;
}
const externalSqlFileChanges = useExternalSqlFileChanges({
activeTab,
recreateFile: async (tab) => {
@@ -2610,6 +2624,12 @@ async function handleKeydown(e: KeyboardEvent) {
setSidebarOpen(!sidebarOpen.value);
return;
}
if (isToggleZenModeShortcut(e, shortcuts) && activeTab.value?.mode === "data") {
e.preventDefault();
e.stopPropagation();
toggleZenMode();
return;
}
if (switchTabIndex != null) {
if (activateTabByIndex(switchTabIndex)) {
e.preventDefault();
@@ -2977,7 +2997,7 @@ onUnmounted(() => {
<div :class="isClassicLayout ? 'app-layout-classic flex-1 flex min-h-0' : 'app-panel-gutter flex-1 flex min-h-0 gap-1 p-1'">
<AppSidebar
v-show="sidebarOpen"
v-show="sidebarOpen && !isZenMode"
ref="appSidebarRef"
:sidebar-width="sidebarWidth"
:classic-layout="isClassicLayout"
@@ -2988,13 +3008,13 @@ onUnmounted(() => {
@open-settings="(initialTab) => openSettings(initialTab ?? 'appearance')"
@add-to-ai="addToAi"
/>
<div v-show="!sidebarOpen" class="flex h-full w-8 shrink-0 items-start justify-center border-r bg-background/80 pt-2" :class="isClassicLayout ? '' : 'rounded-md border border-border/80'">
<div v-show="!sidebarOpen && !isZenMode" class="flex h-full w-8 shrink-0 items-start justify-center border-r bg-background/80 pt-2" :class="isClassicLayout ? '' : 'rounded-md border border-border/80'">
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('sidebar.expand')" :aria-label="t('sidebar.expand')" @click="setSidebarOpen(true)">
<ChevronsRight class="h-4 w-4" />
</Button>
</div>
<div v-show="!isAiPanelMaximized" :class="isClassicLayout ? 'flex-1 min-w-0 overflow-hidden' : 'flex-1 min-w-0 overflow-hidden rounded-md border border-border/80 bg-background'">
<div v-show="!isAiPanelMaximized || isZenMode" :class="isClassicLayout ? 'flex-1 min-w-0 overflow-hidden' : 'flex-1 min-w-0 overflow-hidden rounded-md border border-border/80 bg-background'">
<div class="h-full flex flex-col min-w-0">
<AppTabBar
ref="appTabBarRef"
@@ -3003,6 +3023,7 @@ onUnmounted(() => {
:settings-page-open="settingsPageTabOpen"
:settings-page-active="settingsStore.settingsPageActive"
:agent-driver-update-count="toolbarAgentDriverUpdateCount"
@toggle-zen-mode="toggleZenMode"
@activate-driver-store="openDriverStorePage"
@activate-settings-page="activateSettingsPage"
@locate-tab="locateTabInSidebar"
@@ -3168,6 +3189,7 @@ onUnmounted(() => {
<div
v-if="showAiPanel"
v-show="!isZenMode"
:class="[isClassicLayout ? 'h-full relative z-30 isolate bg-background' : 'h-full relative z-30 isolate rounded-md border border-border/80 bg-background', isAiPanelMaximized ? 'min-w-0 flex-1' : 'min-w-[180px] max-w-full']"
:style="isAiPanelMaximized ? {} : { width: aiPanelWidth + 'px' }"
>
@@ -3192,21 +3214,21 @@ onUnmounted(() => {
</div>
</div>
<div v-if="showHistory" v-show="!isAiPanelMaximized" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: historyWidth + 'px' }">
<div v-if="showHistory" v-show="!isAiPanelMaximized && !isZenMode" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: historyWidth + 'px' }">
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startHistoryResize" />
<div class="h-full min-h-0 overflow-hidden rounded-[inherit]">
<QueryHistory :current-connection-id="activeTab?.connectionId" :current-database="activeTab?.database" @restore="restoreHistorySql" @analyze-ai="analyzeHistoryWithAi" @close="closeRightSidebarPanel('history')" />
</div>
</div>
<div v-if="showSqlLibraryPanel" v-show="!isAiPanelMaximized" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: sqlLibraryWidth + 'px' }">
<div v-if="showSqlLibraryPanel" v-show="!isAiPanelMaximized && !isZenMode" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: sqlLibraryWidth + 'px' }">
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startSqlLibraryResize" />
<div class="h-full min-h-0 overflow-hidden rounded-[inherit]">
<SqlLibraryPanel @close="closeRightSidebarPanel('sqlLibrary')" />
</div>
</div>
<div v-if="showSqlFilePanel" v-show="!isAiPanelMaximized" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: sqlFilePanelWidth + 'px' }">
<div v-if="showSqlFilePanel" v-show="!isAiPanelMaximized && !isZenMode" :class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'" :style="{ width: sqlFilePanelWidth + 'px' }">
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startSqlFilePanelResize" />
<div class="h-full min-h-0 overflow-hidden rounded-[inherit]">
<SqlFilePanel @close="closeRightSidebarPanel('sqlFile')" />
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { computed, nextTick, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Lock } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import PasswordInput from "@/components/ui/PasswordInput.vue";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { getRememberedExportPassphrase } from "@/lib/backend/exportPassphraseSession";
const props = defineProps<{
open: boolean;
@@ -29,15 +30,24 @@ const dialogOpen = computed({
const passphrase = ref("");
const passphraseConfirm = ref("");
const error = ref("");
// 密码短语输入框组件引用,打开对话框时用于手动聚焦
const passphraseInput = ref<InstanceType<typeof PasswordInput> | null>(null);
watch(
dialogOpen,
(open) => {
if (open) {
passphrase.value = "";
passphraseConfirm.value = "";
error.value = "";
}
async (open) => {
if (!open) return;
// 导出模式回显本次会话上次使用的密码短语(仅内存保存,不落盘);导入模式始终从空开始
const remembered = props.mode === "export" ? getRememberedExportPassphrase() : "";
passphrase.value = remembered;
passphraseConfirm.value = remembered;
error.value = "";
await nextTick();
// 手动聚焦并把光标定位到末尾,避免新输入插到回显内容前面
const inputEl = passphraseInput.value?.$el?.querySelector("input");
inputEl?.focus();
const length = String(inputEl?.value ?? "").length;
inputEl?.setSelectionRange(length, length);
},
{ immediate: true },
);
@@ -64,7 +74,8 @@ const displayError = computed(() => error.value || props.externalError || "");
<template>
<Dialog v-model:open="dialogOpen">
<DialogContent class="sm:max-w-[440px]">
<!-- initial-focus=false:禁用默认自动聚焦,改由打开对话框时的手动聚焦把光标定位到回显内容末尾 -->
<DialogContent class="sm:max-w-[440px]" :initial-focus="false">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Lock class="h-5 w-5" />
@@ -79,7 +90,7 @@ const displayError = computed(() => error.value || props.externalError || "");
<div class="grid gap-2">
<Label>{{ t("configExport.passphrase") }}</Label>
<PasswordInput v-model="passphrase" :placeholder="t('configExport.passphrasePlaceholder')" :toggle-tab-index="-1" :disabled="busy" @keydown.enter="mode === 'import' ? confirm() : undefined" />
<PasswordInput ref="passphraseInput" v-model="passphrase" :placeholder="t('configExport.passphrasePlaceholder')" :toggle-tab-index="-1" :disabled="busy" @keydown.enter="mode === 'import' ? confirm() : undefined" />
</div>
<div v-if="mode === 'export'" class="grid gap-2">
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
vi.mock("@/components/ui/dialog", async () => {
@@ -63,9 +63,14 @@ vi.mock("@lucide/vue", async () => {
});
import ConfigPassphraseDialog from "@/components/config/ConfigPassphraseDialog.vue";
import { clearRememberedExportPassphrase, rememberExportPassphrase } from "@/lib/backend/exportPassphraseSession";
const mountedApps: App[] = [];
beforeEach(() => {
clearRememberedExportPassphrase();
});
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
while (document.body.firstChild) document.body.removeChild(document.body.firstChild);
@@ -113,6 +118,55 @@ describe("ConfigPassphraseDialog", () => {
expect(document.body.textContent).toContain("Passphrase is required");
});
it("blocks encrypted export when the confirmation passphrase does not match", async () => {
const confirm = vi.fn();
await mountDialog({ open: true, mode: "export", onConfirm: confirm });
const [passphraseInput, confirmInput] = [...document.body.querySelectorAll("input")];
passphraseInput.value = "correct-pass";
passphraseInput.dispatchEvent(new Event("input"));
confirmInput.value = "mismatched-pass";
confirmInput.dispatchEvent(new Event("input"));
const encrypted = [...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Export encrypted"));
encrypted?.click();
await nextTick();
expect(confirm).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Passphrases do not match");
});
it("confirms encrypted export when both passphrase entries match", async () => {
const confirm = vi.fn();
await mountDialog({ open: true, mode: "export", onConfirm: confirm });
const [passphraseInput, confirmInput] = [...document.body.querySelectorAll("input")];
passphraseInput.value = "matching-pass";
passphraseInput.dispatchEvent(new Event("input"));
confirmInput.value = "matching-pass";
confirmInput.dispatchEvent(new Event("input"));
const encrypted = [...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Export encrypted"));
encrypted?.click();
await nextTick();
expect(confirm).toHaveBeenCalledWith("matching-pass");
});
it("prefills the session-remembered passphrase into both export fields", async () => {
rememberExportPassphrase("session-pass");
await mountDialog({ open: true, mode: "export", onConfirm: vi.fn() });
expect([...document.body.querySelectorAll("input")].map((input) => input.value)).toEqual(["session-pass", "session-pass"]);
});
it("never prefills the passphrase in import mode", async () => {
rememberExportPassphrase("session-pass");
await mountDialog({ open: true, mode: "import", onConfirm: vi.fn() });
expect([...document.body.querySelectorAll("input")].map((input) => input.value)).toEqual([""]);
});
it("keeps import mode free of the unencrypted export action", async () => {
await mountDialog({ open: true, mode: "import", onConfirm: vi.fn() });
@@ -260,6 +260,20 @@ const emit = defineEmits<{
}>();
const store = useConnectionStore();
const UNGROUPED_CONNECTION_GROUP = "__dbx_ungrouped_connection_group__";
const selectedConnectionGroupId = ref<string | null>(null);
const connectionGroupSelectValue = computed({
get: () => selectedConnectionGroupId.value ?? UNGROUPED_CONNECTION_GROUP,
set: (value: string) => {
selectedConnectionGroupId.value = value === UNGROUPED_CONNECTION_GROUP ? null : value;
},
});
const connectionGroupOptions = computed(() => store.connectionGroupOptions.map((group) => ({ id: group.id, label: group.path.join(" / ") })));
function initialConnectionGroupId(): string | null {
const preferred = store.newConnectionGroupId ?? store.selectedConnectionGroupId;
return preferred && connectionGroupOptions.value.some((group) => group.id === preferred) ? preferred : null;
}
const tunnelProfileStore = useTunnelProfileStore();
const isTesting = ref(false);
const isSaving = ref(false);
@@ -2582,6 +2596,7 @@ watch(
} else {
clearSavedDatabaseInfo();
editingId.value = null;
selectedConnectionGroupId.value = initialConnectionGroupId();
form.value = defaultForm();
redisKeyTemplatesText.value = "";
productionProtectionEnabled.value = false;
@@ -3094,7 +3109,7 @@ const agentDriverFocus = computed<DriverStoreFocus>(() => ({ target: "driver", d
const canChooseVisibleNacosNamespaces = computed(() => form.value.db_type === "nacos");
const isNacosV3AdminPlane = computed(() => nacosImplementation.value === "nacos" && nacosVersionMode.value === "v3" && nacosApiPlane.value === "admin");
const isNacosV3ConsolePlane = computed(() => nacosImplementation.value === "nacos" && nacosVersionMode.value === "v3" && nacosApiPlane.value === "console");
const canDetectNacosNamespaceAccess = computed(() => form.value.db_type === "nacos" && nacosImplementation.value === "nacos" && nacosAuthKind.value === "usernamePassword");
const canDetectNacosNamespaceAccess = computed(() => form.value.db_type === "nacos" && nacosAuthKind.value === "usernamePassword");
const nacosManualNamespaceLabelKey = computed(() => (isNacosV3AdminPlane.value ? "nacos.nacosManagedNamespaces" : "nacos.nacosManagedNamespacesNameOrId"));
const nacosManualNamespacePlaceholderKey = computed(() => (isNacosV3AdminPlane.value ? "nacos.nacosManagedNamespacesIdPlaceholder" : "nacos.nacosManagedNamespacesPlaceholder"));
const nacosManualNamespaceHintKey = computed(() => {
@@ -3308,7 +3323,8 @@ const shouldUseWideConnectionDialog = computed(() => dialogStep.value === "confi
const connectionDialogContentClass = computed(() => {
if (dialogStep.value === "select") return "connection-dialog-content--picker sm:h-[720px] sm:max-w-[880px]";
const widthClass = shouldUseWideConnectionDialog.value ? "connection-dialog-content--wide sm:max-w-[660px]" : "connection-dialog-content--standard sm:max-w-[560px]";
return `${widthClass} connection-dialog-content--config`;
const scrollableAdvancedNacos = form.value.db_type === "nacos" && configTab.value === "advanced";
return `${widthClass} connection-dialog-content--config${scrollableAdvancedNacos ? " connection-dialog-content--scrollable" : ""}`;
});
const connectionLabelClass = "justify-self-start text-left";
const connectionLabelSmallClass = `${connectionLabelClass} text-xs`;
@@ -4456,6 +4472,7 @@ async function resolveManualNacosNamespaceNames(namespaces: string[]): Promise<s
function isNacosNamespaceListingPermissionError(error: unknown): boolean {
const message = errorMessage(error);
if (/NACOS_ERROR\[rnacosNamespaceDirectoryUnavailable\]/.test(message)) return true;
if (/NACOS_ERROR\[(?:v3ManagedNamespacesRequired|managedNamespacesRequired)\]/.test(message)) return true;
return /\/v3\/(?:admin|console)\/core\/namespace\/list/.test(message) && /\b403\b/.test(message) && /authorization failed/i.test(message);
}
@@ -4541,7 +4558,10 @@ async function saveVisibleNacosNamespaceSelection() {
if (visibleNacosNamespaceAccessMode.value === "manual") {
const manualNamespaces = parseNacosManagedNamespaces(nacosManagedNamespacesText.value);
isResolvingManualNacosNamespaces.value = true;
const resolvedNamespaces = isNacosV3AdminPlane.value ? manualNamespaces : await resolveManualNacosNamespaceNames(manualNamespaces);
// r-nacos does not promise a namespace directory on its client OpenAPI.
// Preserve user-provided IDs directly so scoped config access does not
// require a separate console login.
const resolvedNamespaces = isNacosV3AdminPlane.value || nacosImplementation.value === "rnacos" ? manualNamespaces : await resolveManualNacosNamespaceNames(manualNamespaces);
isResolvingManualNacosNamespaces.value = false;
nacosManagedNamespacesText.value = resolvedNamespaces.join("\n");
form.value.visible_databases = resolvedNamespaces;
@@ -4818,6 +4838,7 @@ function openJdbcDriverManagerFromError() {
function resetForm(options: { preservePickerState?: boolean } = {}) {
editingId.value = null;
selectedConnectionGroupId.value = initialConnectionGroupId();
form.value = defaultForm();
redisKeyTemplatesText.value = "";
resetConnectionNoteVisibilityDraft(connectionNoteVisibilityDraft, settingsStore.editorSettings.sidebarShowConnectionNotes);
@@ -4976,6 +4997,10 @@ watch(
{ immediate: true },
);
watch(connectionGroupOptions, (groups) => {
if (selectedConnectionGroupId.value && !groups.some((group) => group.id === selectedConnectionGroupId.value)) selectedConnectionGroupId.value = null;
});
watch(
() => props.prefillConfig,
(draft) => {
@@ -5231,7 +5256,7 @@ async function save() {
await ensureRequiredAgentDriverInstalled(config);
await ensureRequiredGaussdbMJdbcRuntime(config);
await persistGlobalTimeoutDrafts();
await store.addConnection(config);
await store.addConnection(config, selectedConnectionGroupId.value);
connectionSaved = true;
await persistConnectionNoteVisibilityDraft();
draftTestConnectionId.value = uuid();
@@ -5846,35 +5871,49 @@ function openExternalUrl(url: string) {
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.color") }}</Label>
<div class="col-span-3 flex items-center gap-1.5">
<button
v-for="color in colorOptions"
:key="color.value || 'none'"
type="button"
class="h-6 w-6 rounded-full border ring-offset-background transition hover:scale-105"
:class="[color.class, form.color === color.value ? 'ring-2 ring-ring ring-offset-2' : 'border-border']"
:title="t(color.labelKey)"
@click="handlePresetClick(color.value)"
/>
<Popover v-model:open="customColorOpen">
<PopoverTrigger as-child>
<button
type="button"
class="h-6 w-6 rounded-full border flex items-center justify-center hover:scale-105 transition"
:class="[!isPresetColor(form.color) && form.color ? 'border-border ring-2 ring-ring ring-offset-2' : 'border-dashed border-border']"
:style="!isPresetColor(form.color) && form.color ? { backgroundColor: form.color } : {}"
:title="t('connection.colorCustom')"
>
<Pipette class="h-3.5 w-3.5" :class="!isPresetColor(form.color) && form.color ? 'text-white' : 'text-muted-foreground'" />
</button>
</PopoverTrigger>
<PopoverContent class="w-auto p-2">
<div class="flex items-center gap-2">
<input type="color" :value="form.color" @input="handleCustomColorPicked(($event.target as HTMLInputElement).value)" class="h-6 w-6 cursor-pointer rounded border-0 p-0" />
<Input type="text" :value="customColorInput || form.color" @input="handleCustomColorInput(($event.target as HTMLInputElement).value)" class="w-28 h-7 text-xs font-mono" :placeholder="t('connection.customColorPlaceholder')" />
</div>
</PopoverContent>
</Popover>
<div class="col-span-3 flex min-w-0 items-center">
<div class="flex shrink-0 items-center gap-1.5">
<button
v-for="color in colorOptions"
:key="color.value || 'none'"
type="button"
class="h-6 w-6 rounded-full border ring-offset-background transition hover:scale-105"
:class="[color.class, form.color === color.value ? 'ring-2 ring-ring ring-offset-2' : 'border-border']"
:title="t(color.labelKey)"
@click="handlePresetClick(color.value)"
/>
<Popover v-model:open="customColorOpen">
<PopoverTrigger as-child>
<button
type="button"
class="h-6 w-6 rounded-full border flex items-center justify-center hover:scale-105 transition"
:class="[!isPresetColor(form.color) && form.color ? 'border-border ring-2 ring-ring ring-offset-2' : 'border-dashed border-border']"
:style="!isPresetColor(form.color) && form.color ? { backgroundColor: form.color } : {}"
:title="t('connection.colorCustom')"
>
<Pipette class="h-3.5 w-3.5" :class="!isPresetColor(form.color) && form.color ? 'text-white' : 'text-muted-foreground'" />
</button>
</PopoverTrigger>
<PopoverContent class="w-auto p-2">
<div class="flex items-center gap-2">
<input type="color" :value="form.color" @input="handleCustomColorPicked(($event.target as HTMLInputElement).value)" class="h-6 w-6 cursor-pointer rounded border-0 p-0" />
<Input type="text" :value="customColorInput || form.color" @input="handleCustomColorInput(($event.target as HTMLInputElement).value)" class="w-28 h-7 text-xs font-mono" :placeholder="t('connection.customColorPlaceholder')" />
</div>
</PopoverContent>
</Popover>
</div>
<div v-if="!editingId && connectionGroupOptions.length > 0" class="ml-3 flex min-w-0 flex-1 items-center gap-2 border-l pl-3">
<Label class="shrink-0 text-xs text-muted-foreground">{{ t("connectionGroup.group") }}</Label>
<Select v-model="connectionGroupSelectValue">
<SelectTrigger class="h-8 min-w-0 flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem :value="UNGROUPED_CONNECTION_GROUP">{{ t("connectionGroup.ungroupedLabel") }}</SelectItem>
<SelectItem v-for="group in connectionGroupOptions" :key="group.id" :value="group.id">{{ group.label }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
@@ -7939,7 +7978,7 @@ function openExternalUrl(url: string) {
</TabsContent>
<TabsContent value="advanced" class="m-0 flex min-h-0 flex-1 flex-col overflow-hidden">
<div class="connection-form-body grid min-h-0 flex-1 scroll-pb-6 gap-4 overflow-y-auto pt-4 pr-2 pb-6">
<div class="connection-form-body grid min-h-0 flex-1 scroll-pb-6 gap-4 overflow-y-auto pt-4 pr-2 pb-6" :class="{ 'connection-form-body--nacos': form.db_type === 'nacos' }">
<div v-if="form.db_type === 'elasticsearch'" class="grid grid-cols-4 items-center gap-4">
<div class="flex items-center gap-1">
<Label :class="connectionLabelSmallClass">{{ t("connection.elasticsearchConnectivityCheckDisabled") }}</Label>
@@ -8945,6 +8984,10 @@ function openExternalUrl(url: string) {
min-height: 0;
}
.connection-dialog-content--scrollable {
height: min(720px, calc(var(--dbx-viewport-height) - 2rem));
}
.connection-dialog-content--config .connection-form-body {
/* Preserve every form section's natural height; the form viewport owns
* scrolling and must never shrink cards into collapsed grid rows. */
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { computed, onMounted, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Lock, Unlock } from "@lucide/vue";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useConnectionStore } from "@/stores/connectionStore";
import { useReadOnlyUnlockStore } from "@/stores/readOnlyUnlockStore";
import { formatWriteUnlockRemaining } from "@/lib/database/readOnlyWriteAccess";
const props = defineProps<{
connectionId: string;
compact?: boolean;
showLabel?: boolean;
}>();
const { t } = useI18n();
const connectionStore = useConnectionStore();
const unlockStore = useReadOnlyUnlockStore();
const persistentlyReadOnly = computed(() => connectionStore.getConfig(props.connectionId)?.read_only === true);
const remainingMs = computed(() => unlockStore.remainingMs(props.connectionId));
const unlocked = computed(() => remainingMs.value > 0);
const remainingLabel = computed(() => formatWriteUnlockRemaining(remainingMs.value));
const tooltip = computed(() => {
if (unlocked.value) return `${t("readOnlyUnlock.unlockedBadge")} · ${t("readOnlyUnlock.remaining", { time: remainingLabel.value })}`;
return t("connection.readOnlyBadge");
});
async function onActivate() {
if (!persistentlyReadOnly.value) return;
if (unlocked.value) {
await unlockStore.lockNow(props.connectionId);
return;
}
await unlockStore.requestUnlock({
connectionId: props.connectionId,
connectionName: connectionStore.getConfig(props.connectionId)?.name,
source: t("readOnlyUnlock.sourceStatus"),
});
}
watch(
() => props.connectionId,
(connectionId) => {
if (persistentlyReadOnly.value) void unlockStore.refreshFromBackend(connectionId);
},
);
onMounted(() => {
if (persistentlyReadOnly.value) void unlockStore.refreshFromBackend(props.connectionId);
});
</script>
<template>
<Tooltip v-if="persistentlyReadOnly">
<TooltipTrigger as-child>
<button
type="button"
class="inline-flex items-center gap-0.5 rounded text-muted-foreground hover:bg-muted-foreground/15 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:class="compact ? 'h-4 px-0.5' : 'h-4 px-1'"
:aria-label="unlocked ? t('readOnlyUnlock.lockNow') : t('readOnlyUnlock.unlockAction')"
:title="unlocked ? t('readOnlyUnlock.lockNow') : t('readOnlyUnlock.unlockAction')"
@click.stop.prevent="onActivate"
>
<Unlock v-if="unlocked" class="h-3 w-3 shrink-0 text-amber-600 dark:text-amber-400" />
<Lock v-else class="h-3 w-3 shrink-0" />
<span v-if="showLabel && !unlocked" class="text-[10px] leading-none">{{ t("connection.readOnlyBadge") }}</span>
<span v-if="unlocked" class="text-[10px] tabular-nums leading-none">{{ remainingLabel }}</span>
</button>
</TooltipTrigger>
<TooltipContent>{{ tooltip }}</TooltipContent>
</Tooltip>
</template>
@@ -6,6 +6,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import NacosConfigDiffDialog from "@/components/nacos/NacosConfigDiffDialog.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import * as api from "@/lib/backend/api";
import type { ConsulAclItem, ConsulAclKind, ConsulAclReferences, ConsulAclWrite, ConsulCapabilities } from "@/types/consul";
import { useI18n } from "vue-i18n";
@@ -13,7 +14,7 @@ import { useI18n } from "vue-i18n";
const props = defineProps<{ connectionId: string; capabilities: ConsulCapabilities | null }>();
const { t } = useI18n();
const store = useConnectionStore();
const readOnly = computed(() => Boolean(store.getConfig(props.connectionId)?.read_only));
const readOnly = computed(() => connectionIsEffectivelyReadOnly(store.getConfig(props.connectionId)));
const active = ref<ConsulAclKind>("token");
const items = ref<Record<string, unknown>[]>([]);
const loading = ref(false);
@@ -15,6 +15,7 @@ import * as api from "@/lib/backend/api";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { kvExportFilenameStem, type KvExportScopeRequest } from "@/lib/kv/kvExportScope";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { useConsulStore } from "@/stores/consulStore";
import { isCurrentWatchEvent, nextWatchIndex } from "@/lib/consul/watchState";
import type { ConsulDeletePrefixPreview, ConsulDeletePrefixReport, ConsulImportConflictPolicy, ConsulImportPreview, ConsulImportReport, ConsulKvBundle, ConsulSearchMatch, ConsulScope, ConsulWatchEvent } from "@/types/consul";
@@ -26,7 +27,7 @@ const connectionStore = useConnectionStore();
const consulStore = useConsulStore();
const browserRef = ref<InstanceType<typeof KvKeyBrowser> | null>(null);
const fileInputRef = ref<HTMLInputElement>();
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
const consulConnections = computed(() => connectionStore.connections.filter((connection) => connection.db_type === "consul"));
const transferTargetConnections = computed(() => (transferMode.value === "migration" ? consulConnections.value.filter((connection) => connection.id !== props.connectionId) : consulConnections.value));
@@ -66,7 +67,7 @@ const transferReport = ref<ConsulImportReport | null>(null);
const transferLoading = ref(false);
const transferApplying = ref(false);
const transferError = ref("");
const transferTargetReadOnly = computed(() => Boolean(connectionStore.getConfig(transferTargetId.value)?.read_only));
const transferTargetReadOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(transferTargetId.value)));
const deleteOpen = ref(false);
const deletePrefix = ref("");
@@ -10,13 +10,14 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import NacosConfigDiffDialog from "@/components/nacos/NacosConfigDiffDialog.vue";
import * as api from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import type { ConsulCapabilities, ConsulConfigEntry, ConsulDiscoveryChain, ConsulExportedService, ConsulIntention, ConsulPeering } from "@/types/consul";
import { useI18n } from "vue-i18n";
type MeshTab = "config" | "intentions" | "discovery" | "peering" | "exported";
const props = defineProps<{ connectionId: string; capabilities: ConsulCapabilities | null }>();
const store = useConnectionStore();
const readOnly = computed(() => Boolean(store.getConfig(props.connectionId)?.read_only));
const readOnly = computed(() => connectionIsEffectivelyReadOnly(store.getConfig(props.connectionId)));
const { t } = useI18n();
function capabilityLabel(value?: string) {
if (value === "supported") return t("consul.ui.capabilitySupported");
@@ -7,6 +7,7 @@ import { Label } from "@/components/ui/label";
import * as api from "@/lib/backend/api";
import type { ConsulCapabilities, ConsulOperatorDocument, ConsulOperatorReadKind } from "@/types/consul";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { useI18n } from "vue-i18n";
const props = defineProps<{ connectionId: string; capabilities: ConsulCapabilities | null }>();
@@ -26,7 +27,7 @@ const settings = computed(() => {
const external = connectionStore.getConfig(props.connectionId)?.external_config;
return external && typeof external === "object" && !Array.isArray(external) ? (external as Record<string, unknown>) : {};
});
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
const autopilotWriteVisible = computed(() => settings.value.consulOperatorAutopilotWriteEnabled && props.capabilities?.operatorAutopilot === "supported");
const raftWriteVisible = computed(() => settings.value.consulOperatorRaftWriteEnabled && props.capabilities?.operatorRaft === "supported");
const keyringWriteVisible = computed(() => settings.value.consulOperatorKeyringWriteEnabled && props.capabilities?.operatorKeyring === "supported");
@@ -6,13 +6,14 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import * as api from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { useI18n } from "vue-i18n";
import type { ConsulCapabilities, ConsulEnterpriseKind, ConsulEnterpriseWrite, ConsulScopeImpact } from "@/types/consul";
const props = defineProps<{ connectionId: string; capabilities: ConsulCapabilities | null }>();
const { t } = useI18n();
const store = useConnectionStore();
const readOnly = computed(() => Boolean(store.getConfig(props.connectionId)?.read_only));
const readOnly = computed(() => connectionIsEffectivelyReadOnly(store.getConfig(props.connectionId)));
const active = ref<ConsulEnterpriseKind>("namespace");
const items = ref<Record<string, unknown>[]>([]);
const loading = ref(false);
@@ -11,6 +11,7 @@ import { clampConsulPage, CONSUL_LIST_PAGE_SIZE, paginateConsulItems } from "@/l
import { consulSessionRenewDelayMs, parseConsulDurationMs } from "@/lib/consul/sessionRenew";
import { normalizeConsulSession } from "@/lib/consul/sessionModel";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { useConsulStore } from "@/stores/consulStore";
import { useI18n } from "vue-i18n";
import type { ConsulResponseMetadata, ConsulSession, ConsulSessionKeysResponse, ConsulSessionServiceCheck } from "@/types/consul";
@@ -19,7 +20,7 @@ const props = defineProps<{ connectionId: string }>();
const { t } = useI18n();
const store = useConnectionStore();
const consulStore = useConsulStore();
const readOnly = computed(() => Boolean(store.getConfig(props.connectionId)?.read_only));
const readOnly = computed(() => connectionIsEffectivelyReadOnly(store.getConfig(props.connectionId)));
function behaviorLabel(behavior: string) {
if (behavior === "delete") return t("consul.ui.behaviorDelete");
if (behavior === "release") return t("consul.ui.behaviorRelease");
@@ -7,6 +7,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import * as api from "@/lib/backend/api";
import type { ConsulCapabilities, ConsulCoordinate, ConsulEvent, ConsulPreparedQuery, ConsulPreparedQueryExecuteResponse, ConsulPreparedQueryNode } from "@/types/consul";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { useI18n } from "vue-i18n";
type ToolTab = "queries" | "events" | "coordinates";
@@ -21,7 +22,7 @@ function capabilityLabel(value?: string) {
return t("consul.ui.capabilityUnknown");
}
const connectionStore = useConnectionStore();
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
const canWriteQueries = computed(() => !readOnly.value && props.capabilities?.preparedQueries === "supported");
const canFireEvents = computed(() => !readOnly.value && props.capabilities?.events === "supported");
const queryAvailable = computed(() => !["unsupported", "disabled", "forbidden"].includes(props.capabilities?.preparedQueries || ""));
@@ -18,6 +18,7 @@ import { buildGridFsFilesStructuredFilter, createGridFsFileFilterRule, currentGr
import { buildGridFsDownloadArchive, defaultGridFsArchiveFileName, formatGridFsUploadDate } from "@/lib/document/gridfsFiles";
import { clampGridFsPage, gridFsTotalPages, paginateGridFsItems } from "@/lib/document/gridfsPagination";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { useSettingsStore } from "@/stores/settingsStore";
const props = defineProps<{
@@ -62,7 +63,7 @@ const partiallyChecked = computed(() => checkedFileIds.value.size > 0 && checked
const downloadTargets = computed(() => (checkedFileCount.value > 0 ? checkedFiles.value : selectedFile.value ? [selectedFile.value] : []));
const downloadButtonLabel = computed(() => (checkedFileCount.value > 0 ? t("gridfsBrowser.downloadSelected") : t("gridfsBrowser.downloadFile")));
const selectedMetadata = computed(() => (selectedFile.value?.metadata ? JSON.stringify(selectedFile.value.metadata, null, 2) : ""));
const isReadonly = computed(() => connectionStore.getConfig(props.connectionId)?.read_only ?? false);
const isReadonly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
const mongoProvider = documentStoreProviderFor("mongodb");
const activeStructuredRuleCount = computed(() => {
if (!appliedStructuredFilter.value) return 0;
@@ -11,6 +11,7 @@ import { useToast } from "@/composables/useToast";
import * as api from "@/lib/backend/api";
import { currentGridFsBucketFilter, currentGridFsBucketSort, currentGridFsBucketSortDirection, gridFsBucketSortInputForColumn } from "@/lib/document/gridFsBrowser";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { useQueryStore } from "@/stores/queryStore";
const props = defineProps<{
@@ -39,7 +40,7 @@ const filterBuilderOpen = ref(false);
const selectedBucket = computed(() => buckets.value.find((bucket) => bucket.name === selectedBucketName.value) || null);
const totalFiles = computed(() => buckets.value.reduce((sum, bucket) => sum + bucket.fileCount, 0));
const totalBytes = computed(() => buckets.value.reduce((sum, bucket) => sum + bucket.totalBytes, 0));
const isReadonly = computed(() => connectionStore.getConfig(props.connectionId)?.read_only ?? false);
const isReadonly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
@@ -60,6 +60,7 @@ import {
type DoltClientSessionScope,
} from "@/lib/dolt/doltVersionControl";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { useQueryStore } from "@/stores/queryStore";
import { useToast } from "@/composables/useToast";
import { copyToClipboard } from "@/lib/common/clipboard";
@@ -240,7 +241,7 @@ const tableDiffPageSize = ref(DOLT_DIFF_DEFAULT_PAGE_SIZE);
const tableDiffTotalRows = ref(0);
const tableDiffMaximumPage = computed(() => Math.max(1, Math.ceil(tableDiffTotalRows.value / tableDiffPageSize.value)));
const selectedRevisionKeySet = computed(() => new Set(selectedRevisionKeys.value));
const connectionReadOnly = computed(() => connectionStore.getConfig(props.connectionId)?.read_only ?? false);
const connectionReadOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
const createsNamedRef = computed(() => branchDialog.value === "create" || branchDialog.value === "create-tag");
const mutationDialogTitle = computed(() => {
if (branchDialog.value === "create") return t("doltVersionControl.createBranch");
@@ -55,7 +55,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { useTheme } from "@/composables/useTheme";
import CodeSnapshotDialog from "@/components/codeSnapshot/CodeSnapshotDialog.vue";
import type { CodeSnapshotSource } from "@/lib/codeSnapshot/codeSnapshot";
import { useSettingsStore, AI_PROVIDER_PRESETS, normalizeAiConfig } from "@/stores/settingsStore";
import { useSettingsStore, AI_PROVIDER_PRESETS, aiProviderLabel, normalizeAiConfig } from "@/stores/settingsStore";
import AiProviderLogo from "@/components/icons/AiProviderLogo.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
@@ -725,7 +725,7 @@ function getModelsForConfig(configId: string) {
}
function configMatchesModelQuery(config: AiConfigItem, query: string): boolean {
return config.name.toLowerCase().includes(query) || config.provider.toLowerCase().includes(query) || AI_PROVIDER_PRESETS[config.provider].label.toLowerCase().includes(query);
return config.name.toLowerCase().includes(query) || config.provider.toLowerCase().includes(query) || aiProviderLabel(config.provider, t).toLowerCase().includes(query) || AI_PROVIDER_PRESETS[config.provider].label.toLowerCase().includes(query);
}
function getConfigModelOptions(config: AiConfigItem) {
@@ -5001,12 +5001,7 @@ async function openExternalUrl(url: string) {
<Popover v-model:open="providerSelectorOpen">
<PopoverTrigger as-child>
<button type="button" class="min-w-0 flex shrink items-center gap-1.5 max-w-[220px] rounded-[6px] border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground">
<AiProviderLogo
:provider="activeFullConfig?.provider ?? 'claude'"
:label="AI_PROVIDER_PRESETS[activeFullConfig?.provider ?? 'claude']?.label ?? activeFullConfig?.provider ?? 'claude'"
:icon-slug="AI_PROVIDER_PRESETS[activeFullConfig?.provider ?? 'claude']?.iconSlug"
class="h-3 w-3 shrink-0"
/>
<AiProviderLogo :provider="activeFullConfig?.provider ?? 'claude'" :label="aiProviderLabel(activeFullConfig?.provider ?? 'claude', t)" :icon-slug="AI_PROVIDER_PRESETS[activeFullConfig?.provider ?? 'claude']?.iconSlug" class="h-3 w-3 shrink-0" />
<span class="min-w-0 truncate">{{ activeFullConfig?.model || t("ai.selectModel") }}</span>
<svg class="h-3 w-3 shrink-0 opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6" /></svg>
</button>
@@ -14,6 +14,11 @@ const props = defineProps<{
tone?: "app" | "editor";
}>();
const emit = defineEmits<{
open: [];
close: [];
}>();
const { t } = useI18n();
const settingsStore = useSettingsStore();
@@ -271,6 +276,7 @@ function scheduleDocumentSearchUpdate() {
function openSearch(): boolean {
searchVisible.value = true;
emit("open");
const v = props.view;
if (v) {
cmOpenSearchPanel(v);
@@ -322,6 +328,7 @@ function closeSearch() {
clearSearchQuery();
v.focus();
}
if (wasVisible) emit("close");
return wasVisible;
}
@@ -51,6 +51,7 @@ import { HelpTooltip, Tooltip, TooltipContent, TooltipTrigger, TooltipProvider }
import {
useSettingsStore,
AI_PROVIDER_PRESETS,
aiProviderLabel,
EDITOR_THEMES,
DEFAULT_EDITOR_SETTINGS,
DEFAULT_DESKTOP_SETTINGS,
@@ -70,6 +71,7 @@ import {
type DisconnectTabHandlingMode,
type DataTabReuseMode,
type DataGridFilterEditorView,
type MultiStatementDefaultView,
type OpenTabsRestoreMode,
type AppCloseUnsavedTabsMode,
type SidebarObjectInfoMode,
@@ -153,8 +155,9 @@ import { isMcpPolicyMutationBlocked, MCP_CAPABILITY_ROWS, MCP_EXECUTION_MODE_COL
import { isMacOS, isWindows } from "@/lib/backend/platform";
import { combineDataTypeForDatabase, dataTypeLengthInputValue, getDataTypeOptions, getDefaultLengthForType, isDataTypeLengthDisabled, splitDataType } from "@/lib/table/tableStructureEditorState";
import { useToast } from "@/composables/useToast";
import type { DatabaseType, SqlSnippet } from "@/types/database";
import type { DatabaseType, SqlShortcutAction, SqlSnippet } from "@/types/database";
import { uuid } from "@/lib/common/utils";
import { findSqlShortcutConflicts, hasSqlShortcutConflicts as sqlShortcutsHaveConflicts, SQL_SHORTCUT_TABLE_TOKEN } from "@/lib/sql/sqlShortcutActions";
import { DEFAULT_SQL_SNIPPETS } from "@/lib/sql/sqlCompletion";
import AiProviderLogo from "@/components/icons/AiProviderLogo.vue";
import AppLogo from "@/components/icons/AppLogo.vue";
@@ -350,6 +353,7 @@ const editActiveDataGridTypeColorSchemeId = ref(settingsStore.editorSettings.act
const showThemeCustomizer = ref(false);
const showDataGridTypeColorScheme = ref(false);
const editExecuteMode = ref(settingsStore.editorSettings.executeMode);
const editDefaultTransactionMode = ref(settingsStore.editorSettings.defaultTransactionMode);
const editShortcuts = ref(normalizeShortcutSettings(settingsStore.editorSettings.shortcuts));
function translateWithExecuteShortcut(key: string): string {
return t(key, { shortcut: formatShortcutDisplay(editShortcuts.value.executeSql) });
@@ -412,8 +416,10 @@ const editDataGridQuickEntry = ref(settingsStore.editorSettings.dataGridQuickEnt
const editDataGridFilterEditorView = ref<DataGridFilterEditorView>(settingsStore.editorSettings.dataGridFilterEditorView);
const dataGridFilterViewPreviewExpanded = ref(true);
const editDataGridTextFilterPanelHeight = ref(settingsStore.editorSettings.dataGridTextFilterPanelHeight);
const editMultiStatementDefaultView = ref<MultiStatementDefaultView>(settingsStore.editorSettings.multiStatementDefaultView);
const editDataGridAutoTransposeSingleRow = ref(settingsStore.editorSettings.dataGridAutoTransposeSingleRow);
const editDataGridCellDetailButtonVisible = ref(settingsStore.editorSettings.dataGridCellDetailButtonVisible);
const editDataGridCrosshairHighlight = ref(settingsStore.editorSettings.dataGridCrosshairHighlight);
const editPageSize = ref(settingsStore.editorSettings.pageSize);
const editTableOpenPageSize = ref(settingsStore.editorSettings.tableOpenPageSize);
const editQueryResultMaxRowsEnabled = ref(settingsStore.editorSettings.queryResultMaxRowsEnabled);
@@ -560,6 +566,12 @@ function editableSnippet(snippet: SqlSnippet): SqlSnippet {
const editSnippets = ref<SqlSnippet[]>(settingsStore.editorSettings.snippets.map(editableSnippet));
function editableSqlShortcut(action: SqlShortcutAction): SqlShortcutAction {
return { ...action, enabled: action.enabled !== false };
}
const editSqlShortcuts = ref<SqlShortcutAction[]>(settingsStore.editorSettings.sqlShortcuts.map(editableSqlShortcut));
function currentEditorSettingsDraft(): EditorSettingsDraft {
return {
fontFamily: editFontFamily.value,
@@ -571,6 +583,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
customThemes: editCustomThemes.value,
activeCustomThemeId: editActiveCustomThemeId.value,
executeMode: editExecuteMode.value,
defaultTransactionMode: editDefaultTransactionMode.value,
executeAllOnBlankLine: editExecuteAllOnBlankLine.value,
showExecutionTargetPicker: editShowExecutionTargetPicker.value,
showStatementRunButtons: editShowStatementRunButtons.value,
@@ -604,8 +617,10 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
dataGridQuickEntry: editDataGridQuickEntry.value,
dataGridFilterEditorView: editDataGridFilterEditorView.value,
dataGridTextFilterPanelHeight: editDataGridTextFilterPanelHeight.value,
multiStatementDefaultView: editMultiStatementDefaultView.value,
dataGridAutoTransposeSingleRow: editDataGridAutoTransposeSingleRow.value,
dataGridCellDetailButtonVisible: editDataGridCellDetailButtonVisible.value,
dataGridCrosshairHighlight: editDataGridCrosshairHighlight.value,
flatteningMultiLineText: editFlatteningMultiLineText.value,
pageSize: editPageSize.value,
tableOpenPageSize: editTableOpenPageSize.value,
@@ -647,6 +662,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
updateDownloadSource: editUpdateDownloadSource.value,
toolbarItems: { ...editToolbarItems.value },
snippets: editSnippets.value,
sqlShortcuts: editSqlShortcuts.value,
sqlVariableSubstitutionEnabled: editSqlVariableSubstitutionEnabled.value,
sqlVariableSyntaxOverrides: editSqlVariableSyntaxOverrides.value,
clickTableNavigationTarget: editClickTableNavigationTarget.value,
@@ -660,6 +676,12 @@ const snippetDialogOpen = ref(false);
const snippetEditingId = ref<string | null>(null);
const snippetForm = ref({ label: "", prefix: "", body: "" });
const snippetFormPrefixError = ref("");
const sqlShortcutDialogOpen = ref(false);
const sqlShortcutEditingId = ref<string | null>(null);
const sqlShortcutForm = ref({ label: "", shortcut: "", sql: "" });
const sqlShortcutFormLabelError = ref("");
const editingSqlShortcutInputId = ref<string | null>(null);
const iconThemeBlackDescriptionText = computed(() => (isMacOS() ? t("settings.iconThemeBlackDescriptionMac") : t("settings.iconThemeBlackDescription")));
const layoutDescTruncated = {
separated: ref<boolean>(false),
@@ -787,6 +809,127 @@ function confirmDeleteSnippet(snippet: SqlSnippet) {
}
}
function openAddSqlShortcutDialog() {
sqlShortcutEditingId.value = null;
sqlShortcutForm.value = { label: "", shortcut: "", sql: `SELECT * FROM ${SQL_SHORTCUT_TABLE_TOKEN}` };
sqlShortcutFormLabelError.value = "";
editingSqlShortcutInputId.value = null;
sqlShortcutDialogOpen.value = true;
}
function openEditSqlShortcutDialog(action: SqlShortcutAction) {
sqlShortcutEditingId.value = action.id;
sqlShortcutForm.value = {
label: action.label,
shortcut: action.shortcut,
sql: action.sql,
};
sqlShortcutFormLabelError.value = "";
editingSqlShortcutInputId.value = null;
sqlShortcutDialogOpen.value = true;
}
function saveSqlShortcut() {
const label = sqlShortcutForm.value.label.trim();
if (!label) {
sqlShortcutFormLabelError.value = t("settings.sqlShortcutsLabelRequired");
return;
}
const payload: SqlShortcutAction = {
id: sqlShortcutEditingId.value ?? uuid(),
label,
shortcut: sqlShortcutForm.value.shortcut.trim(),
sql: sqlShortcutForm.value.sql,
enabled: true,
};
const nextShortcuts = sqlShortcutEditingId.value
? editSqlShortcuts.value.map((item) =>
item.id === sqlShortcutEditingId.value
? {
...payload,
enabled: item.enabled !== false,
}
: item,
)
: [...editSqlShortcuts.value, payload];
if (sqlShortcutsHaveConflicts(nextShortcuts, editShortcuts.value)) {
toast(t("settings.shortcutConflict"), 3000);
return;
}
editSqlShortcuts.value = nextShortcuts;
sqlShortcutDialogOpen.value = false;
void commitSqlShortcuts();
}
function setSqlShortcutEnabled(id: string, enabled: boolean) {
const idx = editSqlShortcuts.value.findIndex((item) => item.id === id);
if (idx === -1) return;
if (enabled) {
const next = editSqlShortcuts.value.map((item) => (item.id === id ? { ...item, enabled } : item));
if (sqlShortcutsHaveConflicts(next, editShortcuts.value)) {
toast(t("settings.shortcutConflict"), 3000);
return;
}
}
editSqlShortcuts.value[idx] = { ...editSqlShortcuts.value[idx], enabled };
void commitSqlShortcuts();
}
function deleteSqlShortcut(id: string) {
editSqlShortcuts.value = editSqlShortcuts.value.filter((item) => item.id !== id);
void commitSqlShortcuts();
}
function confirmDeleteSqlShortcut(action: SqlShortcutAction) {
if (window.confirm(t("settings.sqlShortcutsDeleteConfirm", { name: action.label }))) {
deleteSqlShortcut(action.id);
}
}
function onSqlShortcutBindingKeydown(id: string, event: KeyboardEvent) {
event.preventDefault();
event.stopPropagation();
if (editingSqlShortcutInputId.value !== id) return;
if (event.key === "Escape") {
editingSqlShortcutInputId.value = null;
return;
}
const shortcut = eventToShortcut(event);
if (!shortcut) return;
sqlShortcutForm.value = { ...sqlShortcutForm.value, shortcut };
editingSqlShortcutInputId.value = null;
}
function focusSqlShortcutInput(id: string) {
editingSqlShortcutInputId.value = id;
const input = document.querySelector<HTMLInputElement>(`[data-sql-shortcut-input="${id}"]`);
requestAnimationFrame(() => {
input?.focus();
input?.select();
});
}
function cancelSqlShortcutInputEdit() {
editingSqlShortcutInputId.value = null;
}
function clearSqlShortcutBinding() {
sqlShortcutForm.value = { ...sqlShortcutForm.value, shortcut: "" };
}
async function commitSqlShortcuts() {
const sqlShortcuts = editSqlShortcuts.value.map(editableSqlShortcut);
try {
await settingsStore.updateEditorSettingsAndPersist({ sqlShortcuts });
editEditorSettingsBase.value = {
...editEditorSettingsBase.value,
sqlShortcuts: JSON.parse(JSON.stringify(sqlShortcuts)) as SqlShortcutAction[],
};
} catch (error) {
applySettingsErrorToast(error);
}
}
const uiFontPreviewValues = new Set([DEFAULT_UI_FONT_FAMILY, SYSTEM_UI_FONT_FAMILY]);
const systemFontOptions = computed(() => {
@@ -834,6 +977,7 @@ function syncEditorSettingsDraftFromStore() {
editCustomThemes.value = [...settingsStore.editorSettings.customThemes];
editActiveCustomThemeId.value = settingsStore.editorSettings.activeCustomThemeId;
editExecuteMode.value = settingsStore.editorSettings.executeMode;
editDefaultTransactionMode.value = settingsStore.editorSettings.defaultTransactionMode;
editExecuteAllOnBlankLine.value = settingsStore.editorSettings.executeAllOnBlankLine;
editShowExecutionTargetPicker.value = settingsStore.editorSettings.showExecutionTargetPicker;
editShowStatementRunButtons.value = settingsStore.editorSettings.showStatementRunButtons;
@@ -868,8 +1012,10 @@ function syncEditorSettingsDraftFromStore() {
editDataGridQuickEntry.value = settingsStore.editorSettings.dataGridQuickEntry;
editDataGridFilterEditorView.value = settingsStore.editorSettings.dataGridFilterEditorView;
editDataGridTextFilterPanelHeight.value = settingsStore.editorSettings.dataGridTextFilterPanelHeight;
editMultiStatementDefaultView.value = settingsStore.editorSettings.multiStatementDefaultView;
editDataGridAutoTransposeSingleRow.value = settingsStore.editorSettings.dataGridAutoTransposeSingleRow;
editDataGridCellDetailButtonVisible.value = settingsStore.editorSettings.dataGridCellDetailButtonVisible;
editDataGridCrosshairHighlight.value = settingsStore.editorSettings.dataGridCrosshairHighlight;
editFlatteningMultiLineText.value = settingsStore.editorSettings.flatteningMultiLineText;
editPageSize.value = settingsStore.editorSettings.pageSize;
editTableOpenPageSize.value = settingsStore.editorSettings.tableOpenPageSize;
@@ -913,6 +1059,7 @@ function syncEditorSettingsDraftFromStore() {
editUpdateDownloadSource.value = settingsStore.editorSettings.updateDownloadSource;
editToolbarItems.value = { ...settingsStore.editorSettings.toolbarItems };
editSnippets.value = settingsStore.editorSettings.snippets.map(editableSnippet);
editSqlShortcuts.value = settingsStore.editorSettings.sqlShortcuts.map(editableSqlShortcut);
editSqlVariableSubstitutionEnabled.value = settingsStore.editorSettings.sqlVariableSubstitutionEnabled;
editSqlVariableSyntaxOverrides.value = normalizeSqlVariableSyntaxOverrides(settingsStore.editorSettings.sqlVariableSyntaxOverrides);
editClickTableNavigationTarget.value = settingsStore.editorSettings.clickTableNavigationTarget;
@@ -953,6 +1100,8 @@ const shortcutConflicts = computed(() =>
return conflict ? [definition.id] : [];
}),
);
const sqlShortcutConflicts = computed(() => findSqlShortcutConflicts(editSqlShortcuts.value, editShortcuts.value));
const hasSqlShortcutConflicts = computed(() => sqlShortcutConflicts.value.length > 0);
const shortcutSearchQuery = ref("");
const formatterEditorShortcutIds: ShortcutActionId[] = [
"formatSql",
@@ -990,8 +1139,13 @@ const filteredShortcutDefinitions = computed(() => {
});
const hasShortcutConflicts = computed(() => shortcutConflicts.value.length > 0);
const shortcutsChanged = computed(() => JSON.stringify(editShortcuts.value) !== JSON.stringify(editEditorSettingsBase.value.shortcuts));
const sqlShortcutsChanged = computed(() => JSON.stringify(editSqlShortcuts.value) !== JSON.stringify(editEditorSettingsBase.value.sqlShortcuts));
const duckDbWorkerSettingsRequireRestart = computed(() => editDuckDbWorkerProcessIsolation.value !== startupDuckDbWorkerProcessIsolation.value || normalizeDuckDbWorkerMaxProcesses(editDuckDbWorkerMaxProcesses.value) !== startupDuckDbWorkerMaxProcesses.value);
const hasBlockingShortcutConflicts = computed(() => shortcutsChanged.value && hasShortcutConflicts.value);
const hasBlockingShortcutConflicts = computed(() => {
const shortcutDraftTouched = shortcutsChanged.value || sqlShortcutsChanged.value;
if (!shortcutDraftTouched) return false;
return hasShortcutConflicts.value || hasSqlShortcutConflicts.value;
});
const hasBlockingFormatterConfig = computed(() => activeSettingsTab.value === "formatter" && !sqlFormatterConfigValid.value);
const hasBlockingQueryResultRowLimit = computed(() => editQueryResultMaxRowsEnabled.value && editQueryResultMaxRows.value < editPageSize.value);
const hasApplyBlocker = computed(() => hasBlockingShortcutConflicts.value || hasBlockingFormatterConfig.value || hasBlockingQueryResultRowLimit.value);
@@ -1090,6 +1244,7 @@ function resetDefaultsForTab(tab: SettingsCategory) {
editFontFamily.value = DEFAULT_EDITOR_SETTINGS.fontFamily;
editFontSize.value = DEFAULT_EDITOR_SETTINGS.fontSize;
editExecuteMode.value = DEFAULT_EDITOR_SETTINGS.executeMode;
editDefaultTransactionMode.value = DEFAULT_EDITOR_SETTINGS.defaultTransactionMode;
editExecuteAllOnBlankLine.value = DEFAULT_EDITOR_SETTINGS.executeAllOnBlankLine;
editShowExecutionTargetPicker.value = DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker;
editShowStatementRunButtons.value = DEFAULT_EDITOR_SETTINGS.showStatementRunButtons;
@@ -1167,8 +1322,10 @@ function resetDefaultsForTab(tab: SettingsCategory) {
editDataGridQuickEntry.value = DEFAULT_EDITOR_SETTINGS.dataGridQuickEntry;
editDataGridFilterEditorView.value = DEFAULT_EDITOR_SETTINGS.dataGridFilterEditorView;
editDataGridTextFilterPanelHeight.value = DEFAULT_EDITOR_SETTINGS.dataGridTextFilterPanelHeight;
editMultiStatementDefaultView.value = DEFAULT_EDITOR_SETTINGS.multiStatementDefaultView;
editDataGridAutoTransposeSingleRow.value = DEFAULT_EDITOR_SETTINGS.dataGridAutoTransposeSingleRow;
editDataGridCellDetailButtonVisible.value = DEFAULT_EDITOR_SETTINGS.dataGridCellDetailButtonVisible;
editDataGridCrosshairHighlight.value = DEFAULT_EDITOR_SETTINGS.dataGridCrosshairHighlight;
editFlatteningMultiLineText.value = DEFAULT_EDITOR_SETTINGS.flatteningMultiLineText;
editPageSize.value = DEFAULT_EDITOR_SETTINGS.pageSize;
editTableOpenPageSize.value = DEFAULT_EDITOR_SETTINGS.tableOpenPageSize;
@@ -1207,6 +1364,7 @@ function resetAllDefaults() {
editCustomThemes.value = [...DEFAULT_EDITOR_SETTINGS.customThemes];
editActiveCustomThemeId.value = DEFAULT_EDITOR_SETTINGS.activeCustomThemeId;
editExecuteMode.value = DEFAULT_EDITOR_SETTINGS.executeMode;
editDefaultTransactionMode.value = DEFAULT_EDITOR_SETTINGS.defaultTransactionMode;
editExecuteAllOnBlankLine.value = DEFAULT_EDITOR_SETTINGS.executeAllOnBlankLine;
editShowExecutionTargetPicker.value = DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker;
editShowStatementRunButtons.value = DEFAULT_EDITOR_SETTINGS.showStatementRunButtons;
@@ -1249,8 +1407,10 @@ function resetAllDefaults() {
editDataGridQuickEntry.value = DEFAULT_EDITOR_SETTINGS.dataGridQuickEntry;
editDataGridFilterEditorView.value = DEFAULT_EDITOR_SETTINGS.dataGridFilterEditorView;
editDataGridTextFilterPanelHeight.value = DEFAULT_EDITOR_SETTINGS.dataGridTextFilterPanelHeight;
editMultiStatementDefaultView.value = DEFAULT_EDITOR_SETTINGS.multiStatementDefaultView;
editDataGridAutoTransposeSingleRow.value = DEFAULT_EDITOR_SETTINGS.dataGridAutoTransposeSingleRow;
editDataGridCellDetailButtonVisible.value = DEFAULT_EDITOR_SETTINGS.dataGridCellDetailButtonVisible;
editDataGridCrosshairHighlight.value = DEFAULT_EDITOR_SETTINGS.dataGridCrosshairHighlight;
editFlatteningMultiLineText.value = DEFAULT_EDITOR_SETTINGS.flatteningMultiLineText;
editPageSize.value = DEFAULT_EDITOR_SETTINGS.pageSize;
editTableOpenPageSize.value = DEFAULT_EDITOR_SETTINGS.tableOpenPageSize;
@@ -1293,6 +1453,7 @@ function resetAllDefaults() {
editUpdateDownloadSource.value = DEFAULT_EDITOR_SETTINGS.updateDownloadSource;
editToolbarItems.value = { ...DEFAULT_EDITOR_SETTINGS.toolbarItems };
editSnippets.value = DEFAULT_SQL_SNIPPETS.map((s) => ({ ...s }));
editSqlShortcuts.value = [];
}
function addTableColumnTemplateRow() {
@@ -1423,6 +1584,10 @@ function onExecuteModeChange(v: any) {
if (v === "all" || v === "current") editExecuteMode.value = v;
}
function onDefaultTransactionModeChange(v: any) {
if (v === "auto" || v === "manual") editDefaultTransactionMode.value = v;
}
function onCompletionTriggerModeChange(v: any) {
if (v === "manual" || v === "require-prefix" || v === "positional") {
editCompletionTriggerMode.value = v;
@@ -4025,6 +4190,24 @@ onUnmounted(() => {
</Select>
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2" data-editor-default-transaction-mode>
<div class="min-w-0 space-y-1">
<Label for="editor-default-transaction-mode">{{ t("settings.defaultTransactionMode") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.defaultTransactionModeDescription") }}
</p>
</div>
<Select :model-value="editDefaultTransactionMode" @update:model-value="onDefaultTransactionModeChange">
<SelectTrigger id="editor-default-transaction-mode" class="h-8 w-48 shrink-0">
<SelectValue :placeholder="t('settings.defaultTransactionMode')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{{ t("settings.defaultTransactionModeAuto") }}</SelectItem>
<SelectItem value="manual">{{ t("settings.defaultTransactionModeManual") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2" :class="{ 'opacity-50': editExecuteMode !== 'current' }">
<div class="space-y-1">
<Label for="editor-execute-all-on-blank-line">{{ t("settings.executeAllOnBlankLine") }}</Label>
@@ -5362,6 +5545,21 @@ onUnmounted(() => {
@update:model-value="updatePageSizeDraft"
/>
</div>
<div data-settings-search-id="multi-statement-default-view" :class="['flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2', settingsSearchTargetClass('multi-statement-default-view')]">
<div class="min-w-0 space-y-1">
<Label for="multi-statement-default-view">{{ t("settings.multiStatementDefaultView") }}</Label>
<p class="text-xs text-muted-foreground">{{ t("settings.multiStatementDefaultViewDescription") }}</p>
</div>
<Select v-model="editMultiStatementDefaultView">
<SelectTrigger id="multi-statement-default-view" class="h-8 w-32 shrink-0">
<SelectValue :placeholder="t('settings.multiStatementDefaultView')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="result">{{ t("tabs.tableData") }}</SelectItem>
<SelectItem value="summary">{{ t("tabs.executionSummary") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="query-result-max-rows-enabled">
@@ -5508,6 +5706,17 @@ onUnmounted(() => {
</div>
<Switch id="data-grid-cell-detail-button-visible" v-model="editDataGridCellDetailButtonVisible" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="data-grid-crosshair-highlight">
{{ t("settings.dataGridCrosshairHighlight") }}
</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.dataGridCrosshairHighlightDescription") }}
</p>
</div>
<Switch id="data-grid-crosshair-highlight" v-model="editDataGridCrosshairHighlight" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="flattening-multi-line">
@@ -5913,6 +6122,70 @@ onUnmounted(() => {
</div>
</div>
</div>
<div class="mt-6 border-t border-border/70 pt-6" data-settings-search-id="sql-shortcuts">
<div class="mb-4 flex items-center justify-between gap-3">
<div class="min-w-0">
<h3 class="text-sm font-medium">{{ t("settings.sqlShortcutsTitle") }}</h3>
<p class="mt-1 text-sm text-muted-foreground">
{{ t("settings.sqlShortcutsDescription") }}
</p>
</div>
<Button variant="outline" size="sm" class="shrink-0" @click="openAddSqlShortcutDialog">
<Plus class="mr-2 h-4 w-4" />
{{ t("settings.sqlShortcutsAdd") }}
</Button>
</div>
<div v-if="editSqlShortcuts.length === 0" class="rounded-md border border-dashed border-border/70 px-3 py-8 text-center text-sm text-muted-foreground">
{{ t("settings.sqlShortcutsEmpty") }}
</div>
<div v-else class="overflow-x-auto rounded-md border">
<table class="w-full min-w-[720px] text-sm">
<thead>
<tr class="border-b bg-muted/50">
<th class="px-3 py-2 text-left font-medium whitespace-nowrap">{{ t("settings.sqlShortcutsLabel") }}</th>
<th class="px-3 py-2 text-left font-medium whitespace-nowrap">{{ t("settings.shortcutPressShortcut") }}</th>
<th class="px-3 py-2 text-left font-medium whitespace-nowrap">{{ t("settings.snippetsStatus") }}</th>
<th class="px-3 py-2 text-left font-medium whitespace-nowrap">{{ t("settings.sqlShortcutsSql") }}</th>
<th class="px-3 py-2 w-20"></th>
</tr>
</thead>
<tbody>
<tr v-for="action in editSqlShortcuts" :key="action.id" class="border-b last:border-b-0 hover:bg-muted/30" :class="action.enabled === false ? 'text-muted-foreground' : ''">
<td class="px-3 py-2">{{ action.label }}</td>
<td class="px-3 py-2">
<Badge variant="outline" class="h-5 rounded-md px-1.5 font-mono text-[11px] text-muted-foreground">
{{ action.shortcut ? formatShortcutPill(action.shortcut) : t("settings.sqlShortcutsUnbound") }}
</Badge>
</td>
<td class="px-3 py-2">
<div class="flex items-center gap-2">
<Switch :id="`sql-shortcut-enabled-${action.id}`" :model-value="action.enabled !== false" size="sm" :aria-label="t('settings.sqlShortcutsToggle')" @update:model-value="(value: boolean) => setSqlShortcutEnabled(action.id, value)" />
<Label :for="`sql-shortcut-enabled-${action.id}`" class="text-xs font-normal text-muted-foreground">
{{ action.enabled === false ? t("settings.snippetsDisabled") : t("settings.snippetsEnabled") }}
</Label>
</div>
</td>
<td class="max-w-[300px] truncate px-3 py-2 font-mono text-xs text-muted-foreground">{{ action.sql }}</td>
<td class="px-3 py-2">
<div class="flex items-center gap-1">
<Button variant="ghost" size="icon-xs" @click="openEditSqlShortcutDialog(action)">
<Pencil class="size-3.5" />
</Button>
<Button variant="ghost" size="icon-xs" @click="confirmDeleteSqlShortcut(action)">
<Trash2 class="size-3.5" />
</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="hasSqlShortcutConflicts" class="mt-2 text-xs text-destructive">
{{ t("settings.shortcutConflict") }}
</p>
</div>
</section>
<!-- Snippets Tab -->
@@ -6282,7 +6555,7 @@ onUnmounted(() => {
</Badge>
</div>
<div class="text-xs text-muted-foreground">
{{ AI_PROVIDER_PRESETS[config.provider].label }}
{{ aiProviderLabel(config.provider, t) }}
</div>
</div>
</div>
@@ -6531,8 +6804,8 @@ onUnmounted(() => {
<SelectTrigger class="col-span-2" inputClass="h-8 text-xs">
<SelectValue>
<span class="flex items-center gap-2">
<AiProviderLogo :provider="selectedAiProviderPreset.provider" :label="selectedAiProviderPreset.label" :icon-slug="selectedAiProviderPreset.iconSlug" />
<span>{{ selectedAiProviderPreset.label }}</span>
<AiProviderLogo :provider="selectedAiProviderPreset.provider" :label="aiProviderLabel(selectedAiProviderPreset.provider, t)" :icon-slug="selectedAiProviderPreset.iconSlug" />
<span>{{ aiProviderLabel(selectedAiProviderPreset.provider, t) }}</span>
</span>
</SelectValue>
</SelectTrigger>
@@ -6540,8 +6813,8 @@ onUnmounted(() => {
<SelectItem v-for="provider in aiProviderOptions" :key="provider.provider" :value="provider.provider">
<span class="flex w-full items-center justify-between gap-4">
<span class="flex items-center gap-2">
<AiProviderLogo :provider="provider.provider" :label="provider.label" :icon-slug="provider.iconSlug" />
<span>{{ provider.label }}</span>
<AiProviderLogo :provider="provider.provider" :label="aiProviderLabel(provider.provider, t)" :icon-slug="provider.iconSlug" />
<span>{{ aiProviderLabel(provider.provider, t) }}</span>
</span>
</span>
</SelectItem>
@@ -7483,6 +7756,68 @@ onUnmounted(() => {
</DialogContent>
</Dialog>
<!-- SQL Shortcut Add/Edit Dialog -->
<Dialog :open="sqlShortcutDialogOpen" @update:open="sqlShortcutDialogOpen = $event">
<DialogContent class="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>
{{ sqlShortcutEditingId ? t("settings.sqlShortcutsEditTitle") : t("settings.sqlShortcutsAddTitle") }}
</DialogTitle>
</DialogHeader>
<div class="flex flex-col gap-4 py-2">
<div class="flex flex-col gap-1.5">
<Label for="sql-shortcut-label">{{ t("settings.sqlShortcutsLabel") }}</Label>
<Input id="sql-shortcut-label" v-model="sqlShortcutForm.label" :placeholder="t('settings.sqlShortcutsLabelPlaceholder')" />
<p v-if="sqlShortcutFormLabelError" class="text-xs text-destructive">
{{ sqlShortcutFormLabelError }}
</p>
</div>
<div class="flex flex-col gap-1.5">
<Label for="sql-shortcut-binding">{{ t("settings.shortcutPressShortcut") }}</Label>
<div class="flex items-center gap-2">
<input
id="sql-shortcut-binding"
data-sql-shortcut-input="dialog"
:value="editingSqlShortcutInputId === 'dialog' ? '' : formatShortcutPill(sqlShortcutForm.shortcut)"
:style="{ width: editingSqlShortcutInputId === 'dialog' ? shortcutPressShortcutInputWidth : `${Math.max(4, formatShortcutPill(sqlShortcutForm.shortcut).length + 3)}ch` }"
readonly
:placeholder="t('settings.shortcutPressShortcut')"
class="h-8 w-auto min-w-12 max-w-64 shrink-0 cursor-default rounded-[6px] border border-input bg-muted px-2.5 text-center font-mono text-[13px] font-semibold text-foreground/75 shadow-inner outline-none selection:bg-transparent placeholder:text-muted-foreground"
:class="editingSqlShortcutInputId === 'dialog' ? 'max-w-64 cursor-text border-border/80 bg-background text-left text-foreground shadow-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35' : ''"
@keydown="(event: KeyboardEvent) => onSqlShortcutBindingKeydown('dialog', event)"
/>
<Button v-if="editingSqlShortcutInputId !== 'dialog'" type="button" variant="ghost" size="icon" class="h-8 w-8 shrink-0" :aria-label="t('settings.shortcutPressShortcut')" @click="focusSqlShortcutInput('dialog')">
<Pencil class="h-4 w-4" />
</Button>
<Button v-else type="button" variant="ghost" size="sm" class="h-8 shrink-0 px-2 text-sm font-medium text-muted-foreground hover:text-foreground" @click="cancelSqlShortcutInputEdit">
{{ t("settings.cancel") }}
</Button>
<Button v-if="sqlShortcutForm.shortcut" type="button" variant="ghost" size="icon" class="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive" :aria-label="t('settings.shortcutClear')" @click="clearSqlShortcutBinding">
<X class="h-4 w-4" />
</Button>
</div>
</div>
<div class="flex flex-col gap-1.5">
<Label for="sql-shortcut-sql">{{ t("settings.sqlShortcutsSql") }}</Label>
<textarea
id="sql-shortcut-sql"
v-model="sqlShortcutForm.sql"
:placeholder="t('settings.sqlShortcutsSqlPlaceholder')"
rows="6"
class="flex min-h-[120px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm font-mono shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<p class="text-xs text-muted-foreground">
{{ t("settings.sqlShortcutsVariableHint", { token: SQL_SHORTCUT_TABLE_TOKEN }) }}
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="sqlShortcutDialogOpen = false">{{ t("settings.cancel") }}</Button>
<Button @click="saveSqlShortcut">{{ t("settings.save") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- AI Config Delete Confirmation -->
<DangerConfirmDialog v-model:open="aiDeleteConfirmOpen" :title="t('ai.deleteConfigTitle')" :message="t('ai.deleteConfigConfirm')" :confirm-label="t('common.delete')" @confirm="aiConfirmDeleteConfig" />
<DangerConfirmDialog
@@ -97,24 +97,30 @@ import { analyzeMysqlRoutineSyntax, supportsMysqlRoutineSyntaxDiagnostics } from
import {
DBX_TABLE_REFERENCE_MIME,
DBX_TABLE_REFERENCE_DROP_EVENT,
DBX_TABLE_REFERENCE_HOVER_EVENT,
DBX_TABLE_REFERENCE_DRAG_END_EVENT,
activeTableReferencePayloadValue,
clearActiveTableReferencePayload,
hasTableReferencePayloadType,
parseTableReferencePayload,
tableReferenceInsertText,
type QueryEditorTableReferenceDropDetail,
type QueryEditorTableReferenceHoverDetail,
type QueryEditorTableReferencePayload,
} from "@/lib/editor/queryEditorTableDrop";
import { isPointOverElementRoot } from "@/lib/editor/tableReferenceDragFeedback";
import type { SqlHighlighter } from "@/lib/sql/sqlHighlighter";
import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, editorDiagnosticColors, editorThemeAppearanceFor, loadEditorTheme, editorFontTheme, shellLineCommentTheme, sqlCompletionTheme, sqlSemanticHighlightTheme } from "@/lib/editor/editorThemes";
import { createStatementGutterMarkerDom, shouldShowStatementGutter } from "@/lib/editor/codemirrorStatementGutter";
import { createQueryEditorSqlShortcutDomHandler, isCharacterProducingShortcut } from "@/lib/editor/queryEditorSqlShortcut";
import { createQueryEditorReplaceShortcutBindings, createQueryEditorReplaceShortcutHandler, createQueryEditorSearchKeymap } from "@/lib/editor/queryEditorSearchKeymap";
import { buildQueryEditorLineNumbersExtension } from "@/lib/editor/queryEditorLineNumbers";
import { buildQueryEditorLineNumbersExtension, createQueryEditorLineNumberAlignmentExtension } from "@/lib/editor/queryEditorLineNumbers";
import { searchKeymapWithoutModD } from "@/lib/editor/codemirrorSearchKeymap";
import { defaultKeymapForGlobalShortcuts } from "@/lib/editor/codemirrorDefaultKeymap";
import { appendSqlCompletionSpace } from "@/lib/editor/sqlCompletionInsertion";
import { compareSqlCompletions, completionLabelPresentation } from "@/lib/editor/sqlCompletionPresentation";
import { clampEditorFontSize, createEditorWheelZoomGestureGuard, createEditorZoomCommitScheduler, fontSizeFromGestureScale, fontSizeFromWheelDelta } from "@/lib/editor/editorZoom";
import { enabledSqlShortcutActions, resolveSqlShortcutTemplate } from "@/lib/sql/sqlShortcutActions";
import { normalizeShortcutSettings, shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry";
import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer";
import { currentStatementFrameLayer } from "@/lib/editor/codemirrorCurrentStatementFrameLayer";
@@ -1923,6 +1929,18 @@ function handleSqlIntentionActions(currentView: EditorViewType): boolean {
}
}
function runSqlShortcutAction(action: ReturnType<typeof enabledSqlShortcutActions>[number], currentView: EditorViewType, event?: KeyboardEvent): boolean {
if (shouldBlockExecutionShortcut(event, currentView)) return true;
if (props.readOnly) return true;
const { from, to, empty } = currentView.state.selection.main;
if (empty) return false;
const selected = currentView.state.sliceDoc(from, to).trim();
if (!selected) return false;
const sql = resolveSqlShortcutTemplate(action.sql, selected);
emitExecutionRequest(sql);
return true;
}
function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view"))["keymap"]) {
const shortcuts = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts);
const Prec = codeMirrorPrec;
@@ -1943,11 +1961,22 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view")
openReplace,
isReadOnly: () => !!props.readOnly,
});
const sqlShortcutActions = enabledSqlShortcutActions(settingsStore.editorSettings.sqlShortcuts);
const sqlShortcutKeymapActions = sqlShortcutActions.filter((action) => !isCharacterProducingShortcut(action.shortcut));
const sqlShortcutBindings = sqlShortcutKeymapActions.flatMap((action) => binding(action.shortcut, (currentView) => runSqlShortcutAction(action, currentView)));
const sqlShortcutDomHandler = createQueryEditorSqlShortcutDomHandler(
() => settingsStore.editorSettings.sqlShortcuts,
(action, currentView, event) => runSqlShortcutAction(action, currentView, event),
);
const combinedDomKeydownHandler = (event: KeyboardEvent, view: EditorViewType) => {
if (replaceShortcutHandler(event)) return true;
return sqlShortcutDomHandler(event, view);
};
return [
editorViewModule
? (Prec?.high(
editorViewModule.EditorView.domEventHandlers({
keydown: replaceShortcutHandler,
keydown: combinedDomKeydownHandler,
}),
) ?? [])
: [],
@@ -2014,6 +2043,7 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view")
openReplace,
isReadOnly: () => !!props.readOnly,
}),
...sqlShortcutBindings,
]),
) ?? [],
codeMirrorKeymap.of(
@@ -3295,6 +3325,7 @@ function insertTableReferencePayload(currentView: EditorViewType, payload: Query
userEvent: "input.drop",
});
clearActiveTableReferencePayload(payload);
hideQueryEditorDropCaret();
currentView.focus();
return true;
}
@@ -3316,21 +3347,77 @@ function onTableReferenceDropEvent(event: Event) {
if (!currentView || props.readOnly || !(event instanceof CustomEvent)) return;
const detail = event.detail as QueryEditorTableReferenceDropDetail | undefined;
if (!detail?.payload) return;
const target = document.elementFromPoint(detail.clientX, detail.clientY);
if (target instanceof Element && editorRef.value?.contains(target)) {
// elementFromPoint 被透明覆盖层拦截时回退为编辑器根节点包围盒判定(见 isPointOverElementRoot)。
if (isPointOverElementRoot(detail.clientX, detail.clientY, editorRef.value)) {
insertTableReferencePayload(currentView, detail.payload, detail);
}
}
// --- 表引用拖拽悬停时的插入光标线(指针模拟拖拽经 window 事件驱动) ---
const queryEditorDropCaret = ref<{ left: number; top: number; height: number } | null>(null);
const queryEditorDropCaretStyle = computed(() => {
const caret = queryEditorDropCaret.value;
return caret ? { left: `${caret.left}px`, top: `${caret.top}px`, height: `${caret.height}px` } : {};
});
function showQueryEditorDropCaretAt(clientX: number, clientY: number) {
const currentView = view.value;
if (!currentView || props.readOnly || !editorRef.value) {
hideQueryEditorDropCaret();
return;
}
let dropPos: number | null = null;
try {
dropPos = currentView.posAtCoords({ x: clientX, y: clientY });
} catch {
dropPos = null;
}
if (dropPos == null) {
hideQueryEditorDropCaret();
return;
}
const coords = currentView.coordsAtPos(dropPos);
if (!coords) {
hideQueryEditorDropCaret();
return;
}
const rect = editorRef.value.getBoundingClientRect();
queryEditorDropCaret.value = { left: coords.left - rect.left, top: coords.top - rect.top, height: Math.max(coords.bottom - coords.top, 0) };
}
function hideQueryEditorDropCaret() {
queryEditorDropCaret.value = null;
}
function onTableReferenceHoverEvent(event: Event) {
if (!(event instanceof CustomEvent)) return;
const detail = event.detail as QueryEditorTableReferenceHoverDetail | undefined;
if (!detail) return;
// elementFromPoint 被透明覆盖层拦截时回退为编辑器根节点包围盒判定(见 isPointOverElementRoot)。
if (!isPointOverElementRoot(detail.clientX, detail.clientY, editorRef.value)) {
hideQueryEditorDropCaret();
return;
}
showQueryEditorDropCaretAt(detail.clientX, detail.clientY);
}
function onTableReferenceDragEndEvent() {
hideQueryEditorDropCaret();
}
function registerTableReferenceDropListener() {
if (tableReferenceDropListenerRegistered) return;
window.addEventListener(DBX_TABLE_REFERENCE_DROP_EVENT, onTableReferenceDropEvent);
window.addEventListener(DBX_TABLE_REFERENCE_HOVER_EVENT, onTableReferenceHoverEvent);
window.addEventListener(DBX_TABLE_REFERENCE_DRAG_END_EVENT, onTableReferenceDragEndEvent);
tableReferenceDropListenerRegistered = true;
}
function unregisterTableReferenceDropListener() {
if (!tableReferenceDropListenerRegistered) return;
window.removeEventListener(DBX_TABLE_REFERENCE_DROP_EVENT, onTableReferenceDropEvent);
window.removeEventListener(DBX_TABLE_REFERENCE_HOVER_EVENT, onTableReferenceHoverEvent);
window.removeEventListener(DBX_TABLE_REFERENCE_DRAG_END_EVENT, onTableReferenceDragEndEvent);
tableReferenceDropListenerRegistered = false;
}
@@ -5214,6 +5301,7 @@ onMounted(async () => {
}),
runGutterComp.of(runStatementGutterExtension()),
lineNumbersComp.of(lineNumbersExtension(initialSettings.showLineNumbers)),
createQueryEditorLineNumberAlignmentExtension(ViewPlugin),
currentStatementFrameExtension,
highlightActiveLineGutter(),
highlightSpecialChars(),
@@ -5374,12 +5462,21 @@ onMounted(async () => {
return recoverLargeTauriPaste(event, currentView);
},
dragover(event) {
if (props.readOnly || !hasDroppedTableReference(event)) return false;
if (props.readOnly || !hasDroppedTableReference(event)) {
hideQueryEditorDropCaret();
return false;
}
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
showQueryEditorDropCaretAt(event.clientX, event.clientY);
return true;
},
dragleave() {
hideQueryEditorDropCaret();
return false;
},
drop(event, currentView) {
hideQueryEditorDropCaret();
return insertDroppedTableReference(currentView, event);
},
blur(_event, currentView) {
@@ -5897,10 +5994,19 @@ watch(
);
watch(
() => settingsStore.editorSettings.shortcuts,
() => [settingsStore.editorSettings.shortcuts, settingsStore.editorSettings.sqlShortcuts],
() => {
if (!view.value || !defaultKeymapComp) return;
view.value.dispatch({ effects: defaultKeymapComp.reconfigure(defaultKeymapExtension()) });
if (!view.value || !editorViewModule) return;
const effects = [];
if (defaultKeymapComp) {
effects.push(defaultKeymapComp.reconfigure(defaultKeymapExtension()));
}
if (runKeymapComp) {
effects.push(runKeymapComp.reconfigure(runKeymapExtension(editorViewModule.keymap)));
}
if (effects.length > 0) {
view.value.dispatch({ effects });
}
},
{ deep: true },
);
@@ -6187,6 +6293,7 @@ defineExpose({
"
/>
</CustomContextMenu>
<div v-show="queryEditorDropCaret" data-query-editor-drop-caret class="pointer-events-none absolute z-20 w-0.5 rounded-full bg-primary/70" :style="queryEditorDropCaretStyle" />
<EditorSearchPanel ref="searchPanelRef" :view="view" />
<SqlExecutionTargetPicker v-if="pickerVisible" :candidates="pickerCandidates" :active-index="pickerActiveIndex" :anchor="pickerAnchor" @update:active-index="onPickerActiveIndexChange" @confirm="onPickerConfirm" @cancel="closePicker" />
<DelimitedListDialog v-model:open="delimitedListOpen" :selected-text="delimitedListSelectedText" @confirm="applyDelimitedListResult" />
@@ -86,6 +86,16 @@ function setAllParametersToRaw() {
values.value = next;
}
function clearParameterValues() {
const next = { ...values.value };
for (const parameter of props.parameters) {
const current = next[parameter.key] ?? { kind: "string", value: "" };
next[parameter.key] = { ...current, value: "" };
}
activeHistoryName.value = "";
values.value = next;
}
function updateValue(name: string, value: string) {
const matchedHistory = histories.value[name]?.find((entry) => entry.value === value);
values.value[name] = { ...(values.value[name] ?? { kind: "string" }), ...(matchedHistory ? { kind: matchedHistory.kind } : {}), value };
@@ -146,6 +156,9 @@ async function copyResolvedSql() {
<div class="grid max-h-[calc(86vh-8rem)] gap-4 overflow-y-auto pr-1">
<div class="flex flex-wrap items-center gap-2">
<p class="min-w-0 flex-1 text-sm text-muted-foreground">{{ t("sqlParameters.description") }}</p>
<Button type="button" size="sm" variant="outline" class="shrink-0" data-testid="sql-parameters-clear-values" @click="clearParameterValues">
{{ t("sqlParameters.clearValues") }}
</Button>
<Button type="button" size="sm" variant="outline" class="ml-auto shrink-0" data-testid="sql-parameters-use-raw-all" @click="setAllParametersToRaw">
{{ t("sqlParameters.useRawForAll") }}
</Button>
@@ -36,7 +36,15 @@ const MIXED_VALUES: Record<string, SqlParameterInput> = {
empty_value: { kind: "boolean", value: "" },
};
const RAW_VALUES = Object.fromEntries(Object.entries(MIXED_VALUES).map(([key, input]) => [key, { ...input, kind: "raw" as const }]));
const RAW_SQL = "SELECT alpha beta, 42, NULL, current_date, NULL, alpha beta";
const RAW_SQL = "SELECT alpha beta, 42, NULL, current_date, ${empty_value}, alpha beta";
const CLEARED_VALUES: Record<string, SqlParameterInput> = {
text_value: { kind: "string", value: "" },
numeric_value: { kind: "number", value: "" },
null_value: { kind: "null", value: "" },
raw_value: { kind: "raw", value: "" },
empty_value: { kind: "boolean", value: "" },
};
const CLEARED_SQL = "SELECT '', NULL, NULL, ${raw_value}, '', ''";
const mountedApps: App[] = [];
@@ -74,12 +82,22 @@ function actionButton(): HTMLButtonElement | null {
return document.body.querySelector('[data-testid="sql-parameters-use-raw-all"]');
}
function clearActionButton(): HTMLButtonElement | null {
return document.body.querySelector('[data-testid="sql-parameters-clear-values"]');
}
function clickRawAction() {
const button = actionButton();
expect(button).not.toBeNull();
button!.click();
}
function clickClearAction() {
const button = clearActionButton();
expect(button).not.toBeNull();
button!.click();
}
function buttonWithText(text: string): HTMLButtonElement | undefined {
return Array.from(document.body.querySelectorAll("button")).find((button) => button.textContent?.trim() === text);
}
@@ -164,3 +182,87 @@ describe("SqlParameterDialog raw parameter action", () => {
expect(state.open).toBe(false);
});
});
describe("SqlParameterDialog clear parameter values action", () => {
it("clears every displayed value while preserving kinds and updating the preview", async () => {
const { state, onExecute } = await mountDialog();
clickClearAction();
await nextTick();
expect(parameterInputs().map((input) => input.value)).toEqual(Array(PARAMETERS.length).fill(""));
expect(Array.from(document.body.querySelectorAll('[role="combobox"]')).map((trigger) => trigger.textContent?.trim())).toEqual(["String", "Number", "NULL", "Raw SQL", "Boolean"]);
expect(parameterInputs()[2]?.disabled).toBe(true);
expect(document.body.querySelector("pre")?.textContent).toBe(CLEARED_SQL);
expect(state.open).toBe(true);
expect(onExecute).not.toHaveBeenCalled();
expect(historyMocks.remember).not.toHaveBeenCalled();
});
it("preserves parameter history after clearing current values", async () => {
const { onExecute } = await mountDialog();
clickClearAction();
await nextTick();
const input = parameterInputs()[0];
expect(input).not.toBeUndefined();
input?.focus();
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 0));
const historyEntry = Array.from(document.body.querySelectorAll("button")).find((button) => button.textContent?.includes("alpha beta"));
expect(historyEntry).toBeDefined();
historyEntry?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
await nextTick();
expect(parameterInputs()[0]?.value).toBe("alpha beta");
expect(onExecute).not.toHaveBeenCalled();
expect(historyMocks.remember).not.toHaveBeenCalled();
});
it("keeps cancel side-effect free after clearing values", async () => {
const { state, onExecute } = await mountDialog();
clickClearAction();
await nextTick();
buttonWithText("Cancel")?.click();
await nextTick();
expect(state.open).toBe(false);
expect(onExecute).not.toHaveBeenCalled();
expect(historyMocks.remember).not.toHaveBeenCalled();
});
it("remembers and emits cleared values only when Execute is clicked", async () => {
const { state, onExecute } = await mountDialog();
clickClearAction();
await nextTick();
expect(historyMocks.remember).not.toHaveBeenCalled();
buttonWithText("Execute")?.click();
await nextTick();
expect(historyMocks.remember).toHaveBeenCalledOnce();
expect(historyMocks.remember).toHaveBeenCalledWith(CLEARED_VALUES);
expect(onExecute).toHaveBeenCalledOnce();
expect(onExecute).toHaveBeenCalledWith(CLEARED_SQL);
expect(state.open).toBe(false);
});
it("is idempotent when the current parameter values are already empty", async () => {
const { state, onExecute } = await mountDialog(CLEARED_VALUES);
const beforePreview = document.body.querySelector("pre")?.textContent;
clickClearAction();
await nextTick();
expect(parameterInputs().map((input) => input.value)).toEqual(Array(PARAMETERS.length).fill(""));
expect(Array.from(document.body.querySelectorAll('[role="combobox"]')).map((trigger) => trigger.textContent?.trim())).toEqual(["String", "Number", "NULL", "Raw SQL", "Boolean"]);
expect(document.body.querySelector("pre")?.textContent).toBe(beforePreview);
expect(state.open).toBe(true);
expect(onExecute).not.toHaveBeenCalled();
expect(historyMocks.remember).not.toHaveBeenCalled();
});
});
@@ -8,6 +8,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
import { Input } from "@/components/ui/input";
import * as api from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
type AccessView = "users" | "roles";
type PermissionAccess = "read" | "write" | "readwrite";
@@ -52,7 +53,7 @@ const approvalExpected = ref("");
let pendingApproval: (() => Promise<void>) | null = null;
let detailRequest = 0;
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
const selectedUserRoles = computed(() => userDetail.value?.roles ?? []);
const grantableRoles = computed(() => roles.value.filter((role) => !selectedUserRoles.value.includes(role)));
const initialUserRoleLabel = computed(() => (newUserRoles.value.length ? t("etcd.access.createAndAssignRoles", { count: newUserRoles.value.length }) : t("etcd.access.createUserAction")));
@@ -17,6 +17,7 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { isKeyInKvExportScope, kvExportFilenameStem, kvValueByteIdentity, type KvExportScopeKind, type KvExportScopeRequest } from "@/lib/kv/kvExportScope";
import { detectKvValueFormat } from "@/lib/kv/kvValueFormat";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
type WorkbenchMode = "keys" | "search" | "maintenance" | "watch" | "lease";
type SearchScope = "key" | "value" | "all";
@@ -153,9 +154,9 @@ const transferLoadingDetail = ref("");
const transferPreviewLoaded = ref(false);
const syncConfigurationExpanded = ref(true);
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
const etcdConnections = computed(() => connectionStore.connections.filter((connection) => connection.db_type === "etcd"));
const targetReadOnly = computed(() => Boolean(connectionStore.getConfig(targetConnectionId.value)?.read_only));
const targetReadOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(targetConnectionId.value)));
const selectedTransferRows = computed(() => transferRows.value.filter((row) => row.selected && isTransferRowSelectable(row)));
const selectableTransferRows = computed(() => transferRows.value.filter(isTransferRowSelectable));
const filteredTransferRows = computed(() => {
+293 -20
View File
@@ -16,6 +16,7 @@ import {
Search,
Inbox,
SearchX,
ShieldCheck,
Code2,
Copy,
Loader2,
@@ -38,6 +39,7 @@ import {
PanelBottom,
PanelRight,
RefreshCw,
RefreshCcw,
TableProperties,
UserRound,
Database,
@@ -77,7 +79,7 @@ import TemporalCellEditor from "@/components/grid/TemporalCellEditor.vue";
import EnumCellEditor from "@/components/grid/EnumCellEditor.vue";
import DataGridReadonlyTextSelection from "@/components/grid/DataGridReadonlyTextSelection.vue";
import GridSnapshotDialog from "@/components/grid/GridSnapshotDialog.vue";
import type { QueryResult, ColumnInfo, DatabaseType, ForeignKeyInfo, IndexInfo, TriggerInfo, TableInfoTab, QueryResultSourceColumnRef } from "@/types/database";
import type { QueryResult, ColumnInfo, ConstraintInfo, DatabaseType, ForeignKeyInfo, IndexInfo, ReferenceKeyInfo, TriggerInfo, TableInfoTab, QueryResultSourceColumnRef } from "@/types/database";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import { tableObjectSourceKind } from "@/lib/table/tableObjectSourceKind";
import { tableColumnDefaultDisplayValue } from "@/lib/table/tableColumnDefaultPresentation";
@@ -180,6 +182,7 @@ import {
resolveColumnFormatter,
type ColumnFormatterConfig,
type DateTimeFormatterUnit,
type ForeignKeyDisplayFilterConfig,
DateTimePatterns,
} from "@/lib/dataGrid/columnFormatter";
import { temporalCellEditorConfig, type TemporalCellEditorConfig } from "@/lib/dataGrid/dataGridTemporalEditor";
@@ -202,7 +205,7 @@ import {
isToggleTransposeShortcut,
} from "@/lib/editor/keyboardShortcuts";
import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid/dataGridScrollGutter";
import { canFetchNextDataGridSegment, canGoNextDataGridPage, dataGridTotalRowCountLabelKey, dataGridTruncationHintKey, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal, type DataGridInexactTotalRowCountMode } from "@/lib/dataGrid/dataGridPagination";
import { canFetchNextDataGridSegment, canGoNextDataGridPage, dataGridTotalRowCountLabelKey, dataGridTruncationHintKey, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal, showDataGridRerunTotalCountAction, type DataGridInexactTotalRowCountMode } from "@/lib/dataGrid/dataGridPagination";
import { dataGridCountQueryOptions } from "@/lib/dataGrid/dataGridQueryOptions";
import {
createResultScopedPendingRequests,
@@ -230,6 +233,7 @@ import {
} from "@/lib/dataGrid/dataGridInfiniteScroll";
import { resolveDataGridWheelScroll } from "@/lib/dataGrid/dataGridWheel";
import { CANVAS_DATA_GRID_ROW_HEIGHT, MAX_CANVAS_DATA_GRID_PIXEL_RATIO, canvasDataGridActionOverlayWidth, canvasDataGridActionReservedWidth, dataGridSearchMatchKey, drawCanvasDataGrid, type CanvasDevicePixelSize } from "@/lib/dataGrid/canvasDataGridRenderer";
import { resolveCrosshairTarget, type CrosshairTarget } from "@/lib/dataGrid/crosshairHighlight";
import { DATA_GRID_DARK_STRIPED_ROW_BG, DATA_GRID_LIGHT_STRIPED_ROW_BG, dataGridActiveRowBackground } from "@/lib/dataGrid/dataGridPaintTheme";
import { createRowLowerTextCache } from "@/lib/dataGrid/dataGridRowLowerText";
import { dataGridPreviewLabelKey, dataGridSaveActionMode, dataGridSaveToolbarState } from "@/lib/dataGrid/dataGridSaveUi";
@@ -302,7 +306,9 @@ import { DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS, DATA_GRID_COPY_EXTRACTOR_IDS, DAT
import { columnNamesForCopy } from "@/lib/dataGrid/dataGridColumnNameCopy";
import { DATA_GRID_ROW_NUM_WIDTH, dataGridRowNumberColumnWidth, resolveDataGridMaxRowNumber, useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
import { createDataGridColumnStructureSignature } from "@/lib/dataGrid/dataGridColumnWidthState";
import { useDataGridColumnLayout, useDataGridColumnLayoutState } from "@/composables/useDataGridColumnLayout";
import { useDataGridColumnLayout, useDataGridColumnLayoutState, type ColumnHeaderReferenceDragController } from "@/composables/useDataGridColumnLayout";
import { createColumnReferencePayload, createTableReferenceDragEndEvent, createTableReferenceDropEvent, createTableReferenceHoverEvent } from "@/lib/editor/queryEditorTableDrop";
import { beginTableReferenceDragFeedback, isOverSqlEditorTarget, type TableReferenceDragFeedback } from "@/lib/editor/tableReferenceDragFeedback";
import { dataGridCanvasDevicePixelSize, useDataGridCanvasRuntime, type DataGridCanvasRuntime } from "@/composables/useDataGridCanvasRuntime";
import { useDataGridScrollbars, type DataGridScrollbarsRuntime } from "@/composables/useDataGridScrollbars";
import { useDataGridSelection } from "@/composables/useDataGridSelection";
@@ -340,6 +346,7 @@ import {
} from "@/lib/dataGrid/dataGridToolbar";
import { getTableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities";
import { getTableStructureCapabilities } from "@/lib/table/tableStructureCapabilities";
import { constraintsForConstraintsTab } from "@/lib/table/constraintPresentation";
import { filterObjectBrowserTableColumns } from "@/lib/table/objectBrowserTableInfo";
import { gaussdbMTypeDisplayName } from "@/lib/table/postgresDataTypeHelp";
import { reserveDataGridHeaderLine } from "@/lib/dataGrid/dataGridHeaderLayout";
@@ -384,6 +391,7 @@ const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const cellDetailButtonEnabled = computed(() => settingsStore.editorSettings.dataGridCellDetailButtonVisible);
const dataGridCrosshairHighlight = computed(() => settingsStore.editorSettings.dataGridCrosshairHighlight);
const tableFontSize = computed(() => settingsStore.editorSettings.tableFontSize);
const rowNumberWidth = ref(DATA_GRID_ROW_NUM_WIDTH);
const largeValueResolutionVersion = ref(0);
@@ -1163,7 +1171,7 @@ const formatterForeignKeyManual = ref(false);
const formatterForeignKeySchemas = ref<string[]>([]);
const formatterForeignKeyTables = ref<string[]>([]);
const formatterForeignKeyColumns = ref<ColumnInfo[]>([]);
const formatterForeignKeyReferenceKeyColumns = ref<string[]>([]);
const formatterForeignKeyReferenceKeys = ref<ReferenceKeyInfo[]>([]);
const formatterForeignKeyReferenceMetadataStatus = ref<ManualReferenceMetadataStatus>("loading");
const formatterForeignKeySchemasLoading = ref(false);
const formatterForeignKeyTablesLoading = ref(false);
@@ -1183,8 +1191,18 @@ interface ForeignKeyDisplayLabelState {
labels: Map<string, string>;
}
const formatterForeignKeyReferenceColumns = computed(() => (formatterForeignKeyManual.value ? manualReferenceKeyColumns(formatterForeignKeyColumns.value, formatterForeignKeyReferenceKeyColumns.value) : formatterForeignKeyColumns.value));
const formatterForeignKeyReferenceValidation = computed(() => manualReferenceColumnValidation(formatterForeignKeyColumns.value, formatterForeignKeyReferenceKeyColumns.value, formatterForeignKeyRefColumn.value, formatterForeignKeyReferenceMetadataStatus.value));
const formatterForeignKeyReferenceFilter = computed<ForeignKeyDisplayFilterConfig | undefined>(() =>
formatterForeignKeyFilterEnabled.value
? {
column: formatterForeignKeyFilterColumn.value,
mode: formatterForeignKeyFilterMode.value,
value: formatterForeignKeyFilterValue.value,
endValue: formatterForeignKeyFilterEndValue.value,
}
: undefined,
);
const formatterForeignKeyReferenceColumns = computed(() => (formatterForeignKeyManual.value ? manualReferenceKeyColumns(formatterForeignKeyColumns.value, formatterForeignKeyReferenceKeys.value, formatterForeignKeyReferenceFilter.value) : formatterForeignKeyColumns.value));
const formatterForeignKeyReferenceValidation = computed(() => manualReferenceColumnValidation(formatterForeignKeyColumns.value, formatterForeignKeyReferenceKeys.value, formatterForeignKeyRefColumn.value, formatterForeignKeyReferenceMetadataStatus.value, formatterForeignKeyReferenceFilter.value));
const foreignKeyDisplayLabels = shallowRef(new Map<number, ForeignKeyDisplayLabelState>());
const foreignKeyDisplayRequests = createForeignKeyDisplayRequestCoordinator();
@@ -1606,7 +1624,7 @@ function loadFormatterDraft(formatter: ColumnFormatterConfig | undefined) {
formatterForeignKeySchemas.value = [];
formatterForeignKeyTables.value = [];
formatterForeignKeyColumns.value = [];
formatterForeignKeyReferenceKeyColumns.value = [];
formatterForeignKeyReferenceKeys.value = [];
formatterForeignKeyReferenceMetadataStatus.value = "loading";
formatterForeignKeyTargetError.value = "";
formatterForeignKeyColumnsError.value = "";
@@ -1668,7 +1686,7 @@ function formatterForeignKeyForColumn(columnIndex: number): ForeignKeyInfo | und
async function loadFormatterForeignKeyColumns(columnIndex: number) {
const request = ++formatterForeignKeyColumnsRequest;
formatterForeignKeyColumns.value = [];
formatterForeignKeyReferenceKeyColumns.value = [];
formatterForeignKeyReferenceKeys.value = [];
formatterForeignKeyReferenceMetadataStatus.value = "loading";
formatterForeignKeyColumnsError.value = "";
formatterForeignKeyReferenceMetadataError.value = "";
@@ -1679,7 +1697,7 @@ async function loadFormatterForeignKeyColumns(columnIndex: number) {
const manual = formatterForeignKeyManual.value;
const [columnsResult, referenceKeysResult] = await Promise.allSettled([
api.getColumns(props.connectionId, props.database || "", schema, formatterForeignKeyRefTable.value, props.tableMeta?.catalog),
manual ? api.listReferenceKeyColumns(props.connectionId, props.database || "", schema, formatterForeignKeyRefTable.value, props.tableMeta?.catalog) : Promise.resolve([] as string[]),
manual ? api.listReferenceKeys(props.connectionId, props.database || "", schema, formatterForeignKeyRefTable.value, props.tableMeta?.catalog) : Promise.resolve([] as ReferenceKeyInfo[]),
]);
if (request !== formatterForeignKeyColumnsRequest || formatterOpenColumn.value !== columnIndex) return;
if (columnsResult.status === "rejected") throw columnsResult.reason;
@@ -1689,10 +1707,10 @@ async function loadFormatterForeignKeyColumns(columnIndex: number) {
formatterForeignKeyReferenceMetadataStatus.value = "unavailable";
formatterForeignKeyReferenceMetadataError.value = String(referenceKeysResult.reason?.message || referenceKeysResult.reason);
} else {
formatterForeignKeyReferenceKeyColumns.value = referenceKeysResult.status === "fulfilled" ? referenceKeysResult.value : [];
formatterForeignKeyReferenceKeys.value = referenceKeysResult.status === "fulfilled" ? referenceKeysResult.value : [];
formatterForeignKeyReferenceMetadataStatus.value = "available";
}
const referenceColumns = manualReferenceKeyColumns(columns, formatterForeignKeyReferenceKeyColumns.value);
const referenceColumns = manualReferenceKeyColumns(columns, formatterForeignKeyReferenceKeys.value, formatterForeignKeyReferenceFilter.value);
const foreignKey = formatterForeignKeyForColumn(columnIndex);
if (manual) {
formatterForeignKeyRefColumn.value = reconcileManualReferenceColumn(formatterForeignKeyRefColumn.value, referenceColumns, formatterForeignKeyReferenceMetadataStatus.value);
@@ -1763,7 +1781,7 @@ function selectFormatterForeignKeySchema(value: string, columnIndex: number) {
formatterForeignKeyDisplayColumn.value = "";
formatterForeignKeyFilterColumn.value = "";
formatterForeignKeyColumns.value = [];
formatterForeignKeyReferenceKeyColumns.value = [];
formatterForeignKeyReferenceKeys.value = [];
formatterForeignKeyReferenceMetadataStatus.value = "loading";
formatterForeignKeyReferenceMetadataError.value = "";
void loadFormatterManualReferenceTables(columnIndex);
@@ -1775,7 +1793,7 @@ function selectFormatterForeignKeyTable(value: string, columnIndex: number) {
formatterForeignKeyRefColumn.value = "";
formatterForeignKeyDisplayColumn.value = "";
formatterForeignKeyFilterColumn.value = "";
formatterForeignKeyReferenceKeyColumns.value = [];
formatterForeignKeyReferenceKeys.value = [];
formatterForeignKeyReferenceMetadataStatus.value = "loading";
formatterForeignKeyReferenceMetadataError.value = "";
void loadFormatterForeignKeyColumns(columnIndex);
@@ -2856,6 +2874,62 @@ function unfreezeAllColumns() {
applyColumnOrderChange(unfreezeAllColumnsInLayout);
}
// --- 表头拖拽进 SQL 编辑器:目标导向模式切换的控制器 ---
// 按选择顺序(Set 插入序)取列名;被拖列未选中时仅拖该列。
function columnReferenceDragNames(draggedVisibleColIdx: number): string[] | null {
const selected = [...selection.selectedColumnIndexes.value];
const ordered = selected.includes(draggedVisibleColIdx) ? selected : [draggedVisibleColIdx];
const names = ordered.map((index) => visibleColumns.value[index]).filter((name): name is string => !!name);
return names.length > 0 ? names : null;
}
function buildColumnReferencePayload(draggedVisibleColIdx: number) {
const columnNames = columnReferenceDragNames(draggedVisibleColIdx);
if (!columnNames) return null;
return createColumnReferencePayload({
connectionId: props.connectionId,
database: props.database,
schema: props.schema,
columnNames,
databaseType: resolvedDatabaseType.value,
});
}
let referenceDragFeedback: TableReferenceDragFeedback | null = null;
// 拖拽 chip 文案:≤3 个列名直接平铺,更多时走 i18n 摘要模板。
function columnReferenceDragLabel(names: string[]): string {
if (names.length <= 3) return names.join(", ");
return t("grid.columnDragChipMany", { names: names.slice(0, 2).join(", "), count: names.length });
}
const columnHeaderReferenceDragController: ColumnHeaderReferenceDragController = {
isOverEditorTarget: (clientX, clientY) => isOverSqlEditorTarget(clientX, clientY),
onEnter(sourceVisibleIndex) {
const names = columnReferenceDragNames(sourceVisibleIndex);
if (!names) return null;
const label = columnReferenceDragLabel(names);
referenceDragFeedback?.end();
referenceDragFeedback = beginTableReferenceDragFeedback(label);
return label;
},
onMove(_sourceVisibleIndex, clientX, clientY) {
referenceDragFeedback?.update(clientX, clientY);
window.dispatchEvent(createTableReferenceHoverEvent({ clientX, clientY }));
},
onDrop(sourceVisibleIndex, clientX, clientY) {
const payload = buildColumnReferencePayload(sourceVisibleIndex);
if (!payload) return false;
window.dispatchEvent(createTableReferenceDropEvent({ payload, clientX, clientY }));
return true;
},
onCancel() {
referenceDragFeedback?.end();
referenceDragFeedback = null;
window.dispatchEvent(createTableReferenceDragEndEvent());
},
};
const {
renderedColumnOffsets,
horizontalColumnWindow,
@@ -2894,6 +2968,7 @@ const {
onRefreshMetrics: scheduleColumnLayoutRefresh,
onPersistColumnOrder: persistDraggedColumnOrder,
frozenColumnCount,
columnReferenceDrag: columnHeaderReferenceDragController,
});
function onHeaderClickCapture(event: MouseEvent) {
@@ -3488,6 +3563,13 @@ watch(
);
const canCalculateTotalRowCount = computed(() => !!props.countTotalRows || (!!props.connectionId && (!!props.tableMeta || !!props.countSql)));
const showExactTotalCountAction = computed(() => canCalculateTotalRowCount.value && (totalRowCountIsExact.value === false || typeof displayedTotalRowCount.value !== "number"));
const showRerunTotalCountAction = computed(() =>
showDataGridRerunTotalCountAction({
canCalculateTotalRowCount: canCalculateTotalRowCount.value,
displayedTotalRowCount: displayedTotalRowCount.value,
totalRowCountIsExact: totalRowCountIsExact.value,
}),
);
watch(
[
() =>
@@ -5480,6 +5562,29 @@ const {
isRowSelected,
} = selection;
// 行列十字高亮目标:以 selectionFocus(原始坐标系)为中心解析,整行/整列选中时从
// lastClickedRowIndex / selectedRowIds / selectedColumnIndexes 派生。开关关闭时恒为 null。
const crosshairTarget = computed<CrosshairTarget | null>(() => {
if (!dataGridCrosshairHighlight.value) return null;
let fallbackRowIndex = selection.lastClickedRowIndex.value;
if (fallbackRowIndex === null && selectedRowIds.value.size > 0) {
const firstSelectedId = [...selectedRowIds.value][0];
const firstSelectedIndex = displayItems.value.findIndex((item) => item.id === firstSelectedId);
fallbackRowIndex = firstSelectedIndex >= 0 ? firstSelectedIndex : null;
}
if (fallbackRowIndex === null && transposeRowIndex.value !== null) fallbackRowIndex = transposeRowIndex.value;
const fallbackColumnIndex = selectedColumnIndexes.value.size > 0 ? [...selectedColumnIndexes.value][0] : null;
return resolveCrosshairTarget({
selectionFocus: selectionFocus.value,
selectionAnchor: selectionAnchor.value,
hasRowSelection: hasRowSelection.value,
hasColumnSelection: hasColumnSelection.value,
visibleColumnIndexes: visibleColumnIndexes.value,
fallbackRowIndex,
fallbackColumnIndex,
});
});
function selectionCaptureBase(): Omit<CaptureDataGridSelectionOptions, "selectedRowIds" | "selectedColumnIndexes" | "selectedCellKeys" | "selectionAnchor" | "selectionFocus" | "selectingAll" | "lastClickedRowIndex"> {
return {
columns: props.result.columns,
@@ -7604,6 +7709,7 @@ function drawCanvasGrid() {
columnAligns: columnAligns.value,
columnTypeVisualKinds: visibleColumnTypeVisualKinds.value,
colorizeDataTypes: colorizeDataGridCellTypes.value,
crosshair: crosshairTarget.value,
rightAlignedActionCell: canvasRightAlignedActionCell.value,
columnIsBoolean: isBooleanGridColumn,
booleanDisplayMode: booleanDisplayMode.value,
@@ -10050,6 +10156,11 @@ watch(
() => props.result,
(result, previousResult) => {
const selectionSnapshot = preservedSelectionOnNextResult?.selection;
// The four preserved markers are armed together by prepareFullReload (toolbar
// refresh / rollback / row-save reload). Any of them being pending means the
// incoming result is an in-place refresh that must keep the viewport, so the
// check has to run before the markers are consumed below.
const inPlaceRefreshPending = preservedSelectionOnNextResult !== null || preservedViewportAnchorOnNextResult !== null || preservedDetailsOnNextResult !== null || preserveTransposeOnNextResult.value;
preservedSelectionOnNextResult = null;
const viewportAnchorSnapshot = preservedViewportAnchorOnNextResult?.anchor;
preservedViewportAnchorOnNextResult = null;
@@ -10076,6 +10187,12 @@ watch(
if (getResetScrollAfterResult()) {
clearResetScrollAfterResult();
resetGridVerticalScroll();
} else if (!inPlaceRefreshPending) {
// A result replaced without an internal grid action (e.g. SQL re-executed
// from the editor or a grid-embedded run button) is a brand-new dataset:
// start from the first row instead of stranding the viewport at the old
// scroll offset (#7341). In-place refreshes opt out via prepareFullReload.
resetGridVerticalScroll();
}
closeReadonlyCellTextSelection();
clearCellSelection();
@@ -10523,6 +10640,23 @@ const triggers = ref<TriggerInfo[]>([]);
const triggersLoaded = ref(false);
const triggersLoading = ref(false);
const triggersError = ref("");
const constraints = ref<ConstraintInfo[]>([]);
const constraintsLoaded = ref(false);
const constraintsLoading = ref(false);
const constraintsError = ref("");
const currentConstraintTableIdentity = computed(() =>
columnIndexTableIdentity({
connectionId: props.connectionId,
database: props.database,
catalog: props.tableMeta?.catalog,
schema: props.tableMeta?.schema,
tableName: props.tableMeta?.tableName,
}),
);
let constraintsRequestGeneration = 0;
// The Constraints tab hides foreign keys when a dedicated Foreign Keys tab is
// also shown (each constraint appears once; FK navigation stays in that tab).
const constraintsForTab = computed(() => constraintsForConstraintsTab(constraints.value, tableMetadataCapabilities.value.foreignKeys));
const searchQuery = ref("");
const cellDetailPanelLayout = computed(() => settingsStore.editorSettings.cellDetailPanelLayout);
const cellDetailPanelIsBottom = computed(() => cellDetailPanelLayout.value === "bottom");
@@ -10608,7 +10742,7 @@ function toggleCellDetailPanelLayout() {
});
}
const tableMetadataCapabilities = computed(() => getTableMetadataCapabilities(props.databaseType));
const tableMetadataCapabilities = computed(() => getTableMetadataCapabilities(resolvedDatabaseType.value));
const canOpenTableStructureEditor = computed(() => !!props.connectionId && !!props.database && !!props.tableMeta?.tableName && supportsTableStructureEditing(resolvedDatabaseType.value));
const mongoConnectionConfig = resolvedConnectionConfig;
const canManageMongoIndexes = computed(() => resolvedDatabaseType.value === "mongodb" && !!props.connectionId && !!props.database && !!props.tableMeta?.tableName && supportsMongoIndexMutations(mongoConnectionConfig.value, props.tableMeta?.tableType));
@@ -10642,6 +10776,14 @@ const tableInfoTabs = computed(() => {
count: foreignKeys.value.length,
});
}
if (tableMetadataCapabilities.value.constraints) {
tabs.push({
id: "constraints",
label: t("grid.tableInfoConstraints"),
icon: ShieldCheck,
count: constraintsForTab.value.length,
});
}
if (tableMetadataCapabilities.value.triggers) {
tabs.push({
id: "triggers",
@@ -10677,6 +10819,7 @@ async function selectTableInfoTab(tab: TableInfoTab) {
if (nextTab === "ddl") await fetchDdl();
else if (nextTab === "indexes") await fetchIndexes();
else if (nextTab === "foreignKeys") await fetchForeignKeys();
else if (nextTab === "constraints") await fetchConstraints();
else if (nextTab === "triggers") await fetchTriggers();
}
@@ -10839,6 +10982,54 @@ async function fetchTriggers() {
}
}
async function fetchConstraints() {
const requestIdentity = currentConstraintTableIdentity.value;
if (!props.connectionId || !props.tableMeta || !requestIdentity || constraintsLoaded.value || constraintsLoading.value) return;
const connectionId = props.connectionId;
const database = props.database || "";
const schema = props.tableMeta.schema || props.database || "";
const tableName = props.tableMeta.tableName;
const catalog = props.tableMeta.catalog;
const requestGeneration = ++constraintsRequestGeneration;
constraintsLoading.value = true;
constraintsError.value = "";
try {
const nextConstraints = await api.listConstraints(connectionId, database, schema, tableName, catalog);
if (
!columnIndexMetadataRequestCurrent({
requestGeneration,
currentGeneration: constraintsRequestGeneration,
requestIdentity,
currentIdentity: currentConstraintTableIdentity.value,
})
)
return;
constraints.value = nextConstraints;
constraintsLoaded.value = true;
} catch (e: any) {
if (
!columnIndexMetadataRequestCurrent({
requestGeneration,
currentGeneration: constraintsRequestGeneration,
requestIdentity,
currentIdentity: currentConstraintTableIdentity.value,
})
)
return;
constraintsError.value = String(e?.message || e);
} finally {
if (
columnIndexMetadataRequestCurrent({
requestGeneration,
currentGeneration: constraintsRequestGeneration,
requestIdentity,
currentIdentity: currentConstraintTableIdentity.value,
})
)
constraintsLoading.value = false;
}
}
watch(
() => [props.connectionId, props.database, props.tableMeta?.catalog, props.tableMeta?.schema, props.tableMeta?.tableName],
() => {
@@ -10860,6 +11051,11 @@ watch(
triggers.value = [];
triggersLoaded.value = false;
triggersError.value = "";
constraints.value = [];
constraintsLoaded.value = false;
constraintsLoading.value = false;
constraintsError.value = "";
constraintsRequestGeneration += 1;
// 表身份变更后,主动触发索引加载,确保索引指示器在切换表后立即可见
if (showIndexIndicatorsInHeader.value && canShowTableIndexes.value && currentIndexTableIdentity.value) {
void fetchIndexes();
@@ -10925,12 +11121,12 @@ async function loadForeignKeyDisplayLabels() {
const refColumnInfo = columns.find((column) => column.name === config.refColumn);
if (!refColumnInfo) return;
if (config.referenceMode === "manual") {
const referenceKeyColumns = await foreignKeyDisplayRequests.request(requestGeneration, JSON.stringify(["reference-key-columns", props.connectionId, props.database || "", props.tableMeta?.catalog || "", schema, config.refTable]), () =>
api.listReferenceKeyColumns(props.connectionId!, props.database || "", schema, config.refTable, props.tableMeta?.catalog),
const referenceKeys = await foreignKeyDisplayRequests.request(requestGeneration, JSON.stringify(["reference-keys", props.connectionId, props.database || "", props.tableMeta?.catalog || "", schema, config.refTable]), () =>
api.listReferenceKeys(props.connectionId!, props.database || "", schema, config.refTable, props.tableMeta?.catalog),
);
if (!referenceKeyColumns || !foreignKeyDisplayRequests.isCurrent(requestGeneration)) return;
if (!manualReferenceKeyColumnIsUnique(columns, referenceKeyColumns, config.refColumn)) {
appendDebugLog("warn", "[DBX][DataGrid:foreign-key-display:non-unique-manual-reference]", {
if (!referenceKeys || !foreignKeyDisplayRequests.isCurrent(requestGeneration)) return;
if (!manualReferenceKeyColumnIsUnique(columns, referenceKeys, config.refColumn, config.filter)) {
appendDebugLog("warn", "[DBX][DataGrid:foreign-key-display:unsafe-manual-reference]", {
column: props.result.columns[columnIndex],
reference: `${schema}.${config.refTable}.${config.refColumn}`,
});
@@ -11458,6 +11654,13 @@ const filteredTriggers = computed(() => {
return triggers.value.filter((t) => t.name.toLowerCase().includes(q));
});
const filteredConstraints = computed(() => {
const base = constraintsForTab.value;
if (!searchQuery.value) return base;
const q = searchQuery.value.toLowerCase();
return base.filter((c) => c.name.toLowerCase().includes(q) || c.constraint_type.toLowerCase().includes(q) || c.columns.some((col) => col.toLowerCase().includes(q)) || c.definition.toLowerCase().includes(q));
});
const filteredDdlContent = computed(() => {
if (!ddlContent.value) return "";
const html = highlight(ddlContent.value);
@@ -12214,6 +12417,7 @@ function openGridSnapshot() {
'transpose-record-header-selected text-primary font-semibold': transposeRecordUsesFramedHeader(recordIndex),
'transpose-record-header-active text-primary': transposeRecordUsesActiveHighlight(recordIndex) && !transposeRecordUsesFramedHeader(recordIndex),
'data-grid-header-cell': !transposeRecordUsesActiveHighlight(recordIndex) && !transposeRecordUsesFramedHeader(recordIndex),
'crosshair-column': !!crosshairTarget?.rowCrosshair && crosshairTarget.rowIndex === recordIndex && !transposeRecordUsesFramedHeader(recordIndex) && !transposeRecordUsesActiveHighlight(recordIndex),
}"
:style="{
width: `${getTransposeRecordWidth(recordIndex)}px`,
@@ -12293,6 +12497,8 @@ function openGridSnapshot() {
'cell-selected-dirty--sparse': transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'row-cell-selected': transposeRecordUsesSelectionVisual(cell.recordIndex) && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'row-cell-selected-dirty': transposeRecordUsesSelectionVisual(cell.recordIndex) && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex) && displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'crosshair-row':
!!crosshairTarget?.columnCrosshair && cell.valueIndex === crosshairTarget.actualColIdx && !transposeRecordUsesSelectionVisual(cell.recordIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex] && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex),
'bg-primary/15': transposeRecordUsesActiveHighlight(cell.recordIndex) && !transposeRecordUsesSelectionVisual(cell.recordIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex] && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex),
'bg-yellow-500/10 cell-dirty': displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex],
'bg-yellow-200/60 dark:bg-yellow-500/20': cellIsSearchMatch(cell.recordIndex, cell.valueIndex),
@@ -12439,6 +12645,7 @@ function openGridSnapshot() {
:name="col.name"
:actual-column-index="col.actualColIdx"
:visible-column-index="col.visibleColIdx"
:class="{ 'crosshair-column': !!crosshairTarget?.columnCrosshair && crosshairTarget.visibleColIdx === col.visibleColIdx }"
:selected="highlightedColumnIndex === col.actualColIdx || columnIsSelected(col.visibleColIdx)"
:search-match="currentSearchMatch?.kind === 'column' && currentSearchMatch.col === col.actualColIdx"
:dark="isDark"
@@ -13194,6 +13401,7 @@ function openGridSnapshot() {
'data-grid-row--draft': item.isDraft && !isRowActive(item.displayIndex),
'data-grid-row--striped': !item.isNew && !item.isDraft && !item.isDeleted && !isRowActive(item.displayIndex) && item.displayIndex % 2 === 1,
'active-row': isRowActive(item.displayIndex) && !item.isDeleted,
'crosshair-row': !!crosshairTarget?.rowCrosshair && crosshairTarget.rowIndex === item.displayIndex && !item.isDeleted,
'relative z-20 overflow-visible': editingCell?.rowId === item.id || readonlyTextCell?.rowId === item.id,
}"
:style="dataGridRowStyle(item)"
@@ -13240,6 +13448,7 @@ function openGridSnapshot() {
'cell-selected-dirty--sparse': selectionFramesData.sparse && cellIsSelected(item.displayIndex, col.visibleColIdx) && item.isDirtyCol[col.actualColIdx],
'row-cell-selected': rowCellsUseSelectionVisual(item.id) && !cellIsSelected(item.displayIndex, col.visibleColIdx) && !item.isDirtyCol[col.actualColIdx],
'row-cell-selected-dirty': rowCellsUseSelectionVisual(item.id) && !cellIsSelected(item.displayIndex, col.visibleColIdx) && item.isDirtyCol[col.actualColIdx],
'crosshair-column': !!crosshairTarget?.columnCrosshair && crosshairTarget.visibleColIdx === col.visibleColIdx && !item.isDeleted,
'cell-search-match': cellIsSearchMatch(item.displayIndex, col.actualColIdx),
'cell-current-search-match': cellIsCurrentMatch(item.displayIndex, col.actualColIdx),
'bg-yellow-200/60 dark:bg-yellow-500/20': cellIsSearchMatch(item.displayIndex, col.actualColIdx),
@@ -13633,6 +13842,34 @@ function openGridSnapshot() {
</div>
</div>
<div v-else-if="activeTableInfoTab === 'constraints'" class="flex-1 min-h-0 overflow-auto">
<div v-if="constraintsLoading" class="h-full flex items-center justify-center">
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground" />
</div>
<div v-else-if="constraintsError" class="p-3 text-xs text-destructive">
{{ constraintsError }}
</div>
<div v-else-if="searchQuery && filteredConstraints.length === 0" class="p-6 text-center text-xs text-muted-foreground">
{{ t("grid.tableInfoNoResults") }}
</div>
<div v-else-if="constraintsForTab.length === 0" class="p-6 text-center text-xs text-muted-foreground">
{{ t("grid.tableInfoEmpty") }}
</div>
<div v-else class="divide-y">
<div v-for="constraint in filteredConstraints" :key="constraint.name" class="p-3 text-xs" :class="constraint.enabled ? '' : 'opacity-60'">
<div class="flex flex-wrap items-center gap-1.5">
<span class="font-medium truncate">{{ constraint.name }}</span>
<span class="rounded border px-1 py-px text-[10px] text-muted-foreground">{{ constraint.constraint_type }}</span>
<span v-if="!constraint.enabled" class="rounded border px-1 py-px text-[10px] text-muted-foreground">{{ t("grid.tableInfoConstraintDisabled") }}</span>
<span v-else-if="!constraint.valid" class="rounded border px-1 py-px text-[10px] text-muted-foreground">{{ t("grid.tableInfoConstraintNotValidated") }}</span>
</div>
<div v-if="constraint.columns.length" class="mt-1 font-mono text-[11px] text-muted-foreground break-all">{{ constraint.columns.join(", ") }}</div>
<div v-if="constraint.ref_table" class="mt-1 font-mono text-[11px] text-muted-foreground break-all">-> {{ constraint.ref_schema ? `${constraint.ref_schema}.` : "" }}{{ constraint.ref_table }}{{ constraint.ref_columns.length ? `(${constraint.ref_columns.join(", ")})` : "" }}</div>
<div v-if="constraint.definition" class="mt-1 font-mono text-[11px] text-muted-foreground break-all whitespace-pre-wrap">{{ constraint.definition }}</div>
</div>
</div>
</div>
<pre
v-else-if="activeTableInfoTab === 'ddl' && !ddlLoading"
ref="ddlPreRef"
@@ -13850,12 +14087,25 @@ function openGridSnapshot() {
})
}}
<span v-if="typeof displayedTotalRowCount === 'number' && displayedTotalRowCount >= 0" class="text-muted-foreground/70">{{ t(totalRowCountLabelKey, { count: displayedTotalRowCount }) }}</span>
<span v-if="totalRowCountBusy" class="text-muted-foreground/70">
<span v-if="totalRowCountBusy && !(showRerunTotalCountAction && manualTotalRowCountLoading)" class="text-muted-foreground/70">
{{ t("grid.totalRowCountLoading") }}
</span>
<button v-else-if="showExactTotalCountAction" type="button" class="text-muted-foreground/70 underline underline-offset-2 hover:text-foreground disabled:pointer-events-none" :disabled="manualTotalRowCountLoading" @click="calculateTotalRowCount">
{{ t("grid.calculateTotalRowsInline") }}
</button>
<button
v-else-if="showRerunTotalCountAction"
type="button"
class="ml-1 inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-sm align-middle text-muted-foreground/70 hover:bg-gray-200 hover:text-foreground disabled:pointer-events-none disabled:opacity-50 dark:hover:bg-gray-800"
:disabled="manualTotalRowCountLoading"
:aria-busy="manualTotalRowCountLoading ? 'true' : undefined"
:title="manualTotalRowCountLoading ? t('grid.totalRowCountLoading') : t('grid.calculateTotalRows')"
:aria-label="manualTotalRowCountLoading ? t('grid.totalRowCountLoading') : t('grid.calculateTotalRows')"
@click="calculateTotalRowCount"
>
<Loader2 v-if="manualTotalRowCountLoading" aria-hidden="true" class="h-3 w-3 animate-spin" />
<RefreshCcw v-else aria-hidden="true" class="h-3 w-3" />
</button>
</span>
<span v-if="showTruncationWarning" class="shrink-0 text-amber-500 text-xs">(truncated)</span>
<span v-if="!hasData" class="shrink-0">{{ t("grid.rowsAffected", { count: result.affected_rows }) }}</span>
@@ -14083,6 +14333,8 @@ function openGridSnapshot() {
--data-grid-row-new-bg: rgb(243, 243, 243);
--data-grid-row-deleted-bg: rgb(255, 244, 244);
--data-grid-cell-active-bg: rgb(244, 248, 255);
--data-grid-cell-crosshair-row-bg: rgb(174, 195, 224);
--data-grid-cell-crosshair-col-bg: rgb(142, 170, 210);
--data-grid-cell-dirty-bg: rgb(255, 248, 230);
--data-grid-cell-selected-bg: rgb(239, 246, 255);
--data-grid-cell-selected-single-bg: rgb(191, 219, 254);
@@ -14115,6 +14367,8 @@ function openGridSnapshot() {
--data-grid-row-new-bg: rgb(51, 51, 55);
--data-grid-row-deleted-bg: rgb(55, 31, 32);
--data-grid-cell-active-bg: rgb(25, 34, 46);
--data-grid-cell-crosshair-row-bg: rgb(75, 84, 98);
--data-grid-cell-crosshair-col-bg: rgb(98, 111, 130);
--data-grid-cell-dirty-bg: rgb(94, 75, 26);
--data-grid-cell-selected-bg: rgb(20, 40, 60);
--data-grid-cell-selected-single-bg: rgb(30, 64, 96);
@@ -14148,6 +14402,8 @@ function openGridSnapshot() {
--data-grid-row-new-bg: color-mix(in oklab, var(--primary) 5%, transparent);
--data-grid-row-deleted-bg: color-mix(in oklab, var(--destructive) 5%, transparent);
--data-grid-cell-dirty-bg: color-mix(in oklab, rgb(240 177 0) 10%, transparent);
--data-grid-cell-crosshair-row-bg: color-mix(in srgb, var(--primary) 34%, var(--background));
--data-grid-cell-crosshair-col-bg: color-mix(in srgb, var(--primary) 50%, var(--background));
--data-grid-cell-selected-bg: color-mix(in oklab, rgb(59 130 246) 12%, var(--background));
--data-grid-cell-selected-single-bg: color-mix(in oklab, rgb(59 130 246) 30%, var(--background));
--data-grid-cell-selected-dirty-bg: color-mix(in oklab, rgb(234 181 50) 30%, color-mix(in oklab, rgb(59 130 246) 18%, var(--background)));
@@ -14158,6 +14414,11 @@ function openGridSnapshot() {
--data-grid-row-number-deleted-bg: color-mix(in oklab, var(--destructive) 15%, var(--background));
--data-grid-row-number-selected-bg: color-mix(in oklab, rgb(59 130 246) 30%, var(--background));
}
[data-grid-root].data-grid--dark,
:global(.dark) [data-grid-root] {
--data-grid-cell-crosshair-row-bg: color-mix(in srgb, var(--primary) 34%, var(--background));
--data-grid-cell-crosshair-col-bg: color-mix(in srgb, var(--primary) 50%, var(--background));
}
[data-grid-root].data-grid--has-save-error {
--data-grid-cell-dirty-bg: rgb(250, 212, 216) !important;
--data-grid-cell-selected-dirty-bg: rgb(240, 192, 198) !important;
@@ -14220,6 +14481,18 @@ function openGridSnapshot() {
background-color: var(--data-grid-cell-bg);
}
/* 行列十字高亮:覆盖基础单元格变量而非只涂父行,避免不透明 data-grid-cell /
* frozen cell 遮住行高亮。选中、脏数据和搜索状态仍用各自的显式背景覆盖它。 */
.crosshair-row {
--data-grid-cell-bg: var(--data-grid-cell-crosshair-row-bg) !important;
background-color: var(--data-grid-cell-crosshair-row-bg);
}
.crosshair-column {
--data-grid-cell-bg: var(--data-grid-cell-crosshair-col-bg) !important;
background-color: var(--data-grid-cell-crosshair-col-bg);
}
.data-grid-cell {
background-color: var(--data-grid-cell-bg);
}
@@ -93,7 +93,7 @@ function handlePageInputKeydown(event: KeyboardEvent) {
<span class="shrink-0">{{ t("grid.rows", { count: selectionSummary.rowCount }) }}</span>
</div>
</div>
<Loader2 v-if="loading" class="w-3 h-3 animate-spin text-muted-foreground" />
<Loader2 class="w-3 h-3 text-muted-foreground" :class="loading ? 'animate-spin' : 'invisible'" aria-hidden="true" />
<template v-if="paginationEnabled && infiniteScrollEnabled">
<span v-if="infiniteScrollAllLoaded" class="text-xs text-muted-foreground shrink-0">{{ t("grid.allLoaded") }}</span>
</template>
@@ -0,0 +1,56 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8");
function resultWatchSource(): string {
const start = dataGridSource.indexOf("watch(\n () => props.result,\n (result, previousResult) => {");
const end = dataGridSource.indexOf("// --- Context menu handlers ---");
expect(start).toBeGreaterThanOrEqual(0);
expect(end).toBeGreaterThan(start);
return dataGridSource.slice(start, end);
}
describe("DataGrid result-replacement vertical scroll", () => {
it("resets to the first row when the result is replaced without an internal grid action", () => {
const watchSource = resultWatchSource();
expect(watchSource).toContain("} else if (!inPlaceRefreshPending) {");
const externalBranch = watchSource.slice(watchSource.indexOf("} else if (!inPlaceRefreshPending) {"));
expect(externalBranch.indexOf("resetGridVerticalScroll();")).toBeGreaterThanOrEqual(0);
expect(externalBranch).toContain("#7341");
});
it("keeps the internal-action reset flag path unchanged", () => {
const watchSource = resultWatchSource();
expect(watchSource).toContain("if (getResetScrollAfterResult()) {\n clearResetScrollAfterResult();\n resetGridVerticalScroll();");
});
it("treats any pending prepareFullReload marker as an in-place refresh that keeps the viewport", () => {
const watchSource = resultWatchSource();
expect(watchSource).toContain("preservedSelectionOnNextResult !== null || preservedViewportAnchorOnNextResult !== null || preservedDetailsOnNextResult !== null || preserveTransposeOnNextResult.value");
// The pending-marker check must run before the watch consumes the markers.
const pendingCheck = watchSource.indexOf("const inPlaceRefreshPending =");
const selectionConsume = watchSource.indexOf("preservedSelectionOnNextResult = null;");
const anchorConsume = watchSource.indexOf("preservedViewportAnchorOnNextResult = null;");
const detailsConsume = watchSource.indexOf("preservedDetailsOnNextResult = null;");
const transposeConsume = watchSource.indexOf("preserveTransposeOnNextResult.value = false;");
expect(pendingCheck).toBeGreaterThanOrEqual(0);
for (const consume of [selectionConsume, anchorConsume, detailsConsume, transposeConsume]) {
expect(consume).toBeGreaterThan(pendingCheck);
}
});
it("returns before any scroll reset when the replacement completes an infinite-scroll append", () => {
const watchSource = resultWatchSource();
const appendReturn = watchSource.indexOf("if (appendCompletion) {");
const appendEarlyReturn = watchSource.indexOf("return;\n }");
const resetBranch = watchSource.indexOf("if (getResetScrollAfterResult()) {");
expect(appendReturn).toBeGreaterThanOrEqual(0);
expect(appendEarlyReturn).toBeGreaterThan(appendReturn);
expect(resetBranch).toBeGreaterThan(appendEarlyReturn);
});
});
@@ -250,6 +250,41 @@ describe("DataGridPagination", () => {
expect(summaryText).toContain("grid.rows");
});
it("keeps the loading icon mounted with a constant footprint and only animates while loading", async () => {
const mounted = mountComponent(DataGridPagination, {
selectionSummary: null,
selectionSummarySumText: "",
selectionSummaryAverageText: "",
loading: false,
infiniteScrollEnabled: false,
infiniteScrollAllLoaded: false,
pageSize: 100,
customPageSizeInput: "",
pageSizeMenuItems: [],
exportMenuItems: [],
currentPage: 1,
canGoNextPage: false,
canJumpLastPage: false,
});
// Stable within this file's mountComponent+findAll format: Loader2 is the only Icon
// combining text-muted-foreground with w-3 h-3 in the pagination bar. Filtering by
// both avoids matching a future muted icon without introducing data-testid.
const findLoader = () => findAll(mounted.root, (node) => node.props["data-stub"] === "Icon" && String(node.props.class).includes("text-muted-foreground") && String(node.props.class).includes("w-3 h-3"));
const idleLoaders = findLoader();
expect(idleLoaders).toHaveLength(1);
expect(String(idleLoaders[0].props.class)).toContain("invisible");
expect(String(idleLoaders[0].props.class)).not.toContain("animate-spin");
expect(String(idleLoaders[0].props["aria-hidden"])).toContain("true");
await mounted.setProps({ loading: true });
const busyLoaders = findLoader();
expect(busyLoaders).toHaveLength(1);
expect(String(busyLoaders[0].props.class)).toContain("animate-spin");
expect(String(busyLoaders[0].props.class)).not.toContain("invisible");
expect(String(busyLoaders[0].props["aria-hidden"])).toContain("true");
});
it("enforces first/previous/next/last disabled boundaries", async () => {
const firstPage = vi.fn();
const previousPage = vi.fn();
@@ -1,9 +1,20 @@
import { describe, expect, it } from "vitest";
import { dataGridTotalRowCountLabelKey, dataGridTruncationHintKey } from "@/lib/dataGrid/dataGridPagination";
import { dataGridTotalRowCountLabelKey, dataGridTruncationHintKey, showDataGridRerunTotalCountAction } from "@/lib/dataGrid/dataGridPagination";
import DataGrid from "../DataGrid.vue";
type VuePropDefinition = { default?: unknown };
type VueComponentWithProps = { props?: Record<string, VuePropDefinition> };
type VueComponentWithSsrRender = { ssrRender?: unknown };
const statusRenderSource = (() => {
const component = DataGrid as unknown as VueComponentWithSsrRender;
return typeof component.ssrRender === "function" ? String(component.ssrRender) : "";
})();
const rerunBranch = (() => {
const start = statusRenderSource.indexOf("} else if ($setup.showRerunTotalCountAction) {");
return start === -1 ? "" : statusRenderSource.slice(start, statusRenderSource.indexOf("</button>", start));
})();
describe("DataGrid total row count exactness", () => {
it("uses a VictoriaMetrics-specific truncation explanation", () => {
@@ -24,3 +35,47 @@ describe("DataGrid total row count exactness", () => {
expect(dataGridTotalRowCountLabelKey(false, "estimated")).toBe("grid.totalRowCountEstimated");
});
});
describe("DataGrid rerun total row count action", () => {
it("appears only for a displayed exact numeric total when counting is possible", () => {
expect(showDataGridRerunTotalCountAction({ canCalculateTotalRowCount: true, displayedTotalRowCount: 4200, totalRowCountIsExact: true })).toBe(true);
expect(showDataGridRerunTotalCountAction({ canCalculateTotalRowCount: true, displayedTotalRowCount: 0, totalRowCountIsExact: true })).toBe(true);
expect(showDataGridRerunTotalCountAction({ canCalculateTotalRowCount: true, totalRowCountIsExact: true })).toBe(false);
expect(showDataGridRerunTotalCountAction({ canCalculateTotalRowCount: true, displayedTotalRowCount: -1, totalRowCountIsExact: true })).toBe(false);
expect(showDataGridRerunTotalCountAction({ canCalculateTotalRowCount: false, displayedTotalRowCount: 4200, totalRowCountIsExact: true })).toBe(false);
expect(showDataGridRerunTotalCountAction({ canCalculateTotalRowCount: true, displayedTotalRowCount: 4200, totalRowCountIsExact: false })).toBe(false);
expect(showDataGridRerunTotalCountAction({ canCalculateTotalRowCount: true, displayedTotalRowCount: Infinity, totalRowCountIsExact: true })).toBe(false);
expect(showDataGridRerunTotalCountAction({ canCalculateTotalRowCount: true, displayedTotalRowCount: NaN, totalRowCountIsExact: true })).toBe(false);
});
it("keeps the numeric total outside the action chain and orders busy, inline link, then rerun icon", () => {
const numericTotal = statusRenderSource.indexOf('typeof $setup.displayedTotalRowCount === "number" && $setup.displayedTotalRowCount >= 0');
const busyBranch = statusRenderSource.indexOf("if ($setup.totalRowCountBusy && !($setup.showRerunTotalCountAction && $setup.manualTotalRowCountLoading)) {");
const inlineLink = statusRenderSource.indexOf("} else if ($setup.showExactTotalCountAction) {");
const rerunAction = statusRenderSource.indexOf("} else if ($setup.showRerunTotalCountAction) {");
expect(numericTotal).toBeGreaterThan(-1);
expect(busyBranch).toBeGreaterThan(numericTotal);
expect(inlineLink).toBeGreaterThan(busyBranch);
expect(rerunAction).toBeGreaterThan(inlineLink);
});
it("renders a spaced rerun icon labelled for the total-count action and disabled while counting", () => {
expect(rerunBranch).toContain('<button type="button" class="ml-1 inline-flex h-3.5 w-3.5');
expect(rerunBranch).toContain("disabled:pointer-events-none");
expect(rerunBranch).toContain("disabled:opacity-50");
// title/aria-label toggle between idle and busy copy
expect(rerunBranch).toContain('t("grid.calculateTotalRows")');
expect(rerunBranch).toContain('t("grid.totalRowCountLoading")');
expect(rerunBranch).toContain("disabled");
expect(rerunBranch).toContain("aria-busy");
});
it("swaps the rerun icon for a spinner only while the manual count is in flight and hides decorative icons", () => {
expect(rerunBranch).toContain("if ($setup.manualTotalRowCountLoading) {");
expect(rerunBranch).toContain("Loader2");
expect(rerunBranch).toContain("RefreshCcw");
expect(rerunBranch).toContain("h-3 w-3 animate-spin");
expect(rerunBranch).toContain("h-3 w-3");
expect(rerunBranch).toContain("aria-hidden");
});
});
@@ -17,6 +17,7 @@ import type { CellValue } from "@/lib/dataGrid/cellValue";
import { encodeHBaseTextInput, hbaseCellInput } from "@/lib/hbase/hbaseValues";
import { loadHBaseRowLimit, saveHBaseRowLimit } from "@/lib/hbase/hbaseBrowserPreferences";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { useQueryStore } from "@/stores/queryStore";
import type { QueryResult } from "@/types/database";
import type { HBaseCellInput, HBasePutRowInput, HBaseRow, HBaseTableSchema, HBaseValueEncoding } from "@/types/hbase";
@@ -61,7 +62,7 @@ const deleteTableLoading = ref(false);
const deleteTableError = ref("");
const hasTable = computed(() => props.table.trim().length > 0);
const readOnly = computed(() => connectionStore.getConfig(props.connectionId)?.read_only === true);
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
const qualifiedTableLabel = computed(() => {
if (!hasTable.value) return props.namespace;
return props.namespace && props.namespace !== "default" ? `${props.namespace}:${props.table}` : props.table;
@@ -8,8 +8,9 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AlertTriangle, ArrowLeft, ArrowRight, Check, CheckCircle2, FileJson, FileSpreadsheet, FileText, FileUp, Loader2, RefreshCw, Square, Upload, X } from "@lucide/vue";
import { AlertTriangle, ArrowLeft, ArrowRight, Check, CheckCircle2, FileCode, FileJson, FileSpreadsheet, FileText, FileUp, Loader2, RefreshCw, Square, Upload, X } from "@lucide/vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess";
import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import {
@@ -109,6 +110,7 @@ const formatOptions: Array<{ value: api.TableImportSourceFormat; icon: any; labe
{ value: "delimited", icon: FileText, labelKey: "tableImport.formatDelimited", descriptionKey: "tableImport.formatDelimitedDescription" },
{ value: "json", icon: FileJson, labelKey: "tableImport.formatJson", descriptionKey: "tableImport.formatJsonDescription" },
{ value: "excel", icon: FileSpreadsheet, labelKey: "tableImport.formatExcel", descriptionKey: "tableImport.formatExcelDescription" },
{ value: "sql", icon: FileCode, labelKey: "tableImport.formatSql", descriptionKey: "tableImport.formatSqlDescription" },
];
const encodingOptions: Array<{ value: api.TableImportTextEncoding; labelKey: string }> = [
@@ -275,6 +277,7 @@ function detectFormat(name: string): api.TableImportSourceFormat {
if (lower.endsWith(".txt")) return "delimited";
if (lower.endsWith(".json")) return "json";
if (lower.endsWith(".xls") || lower.endsWith(".xlsx") || lower.endsWith(".xlsm")) return "excel";
if (lower.endsWith(".sql")) return "sql";
return "csv";
}
@@ -317,12 +320,14 @@ function taskParseOptions(format: api.TableImportSourceFormat, sheetName = ""):
emptyStringAsNull: emptyStringAsNull.value,
sheetName,
jsonShape: jsonShape.value,
databaseType: structureDatabaseType.value,
});
}
function importParseOptions(format: api.TableImportSourceFormat, currentPreview: api.TableImportPreview, sheetName = ""): api.TableImportParseOptions {
const options = taskParseOptions(format, sheetName);
if (isDelimitedFormat(format) && currentPreview.totalRowsExact !== false && options.encoding === "auto" && currentPreview.effectiveEncoding) {
// SQL 脚本与分隔文本一样依赖文本编码检测,导入时沿用预览阶段确定的编码避免二次检测结果不一致
if ((isDelimitedFormat(format) || format === "sql") && currentPreview.totalRowsExact !== false && options.encoding === "auto" && currentPreview.effectiveEncoding) {
options.encoding = currentPreview.effectiveEncoding;
}
return options;
@@ -615,10 +620,11 @@ async function selectFile() {
const selected = await open({
multiple: targetMode.value === "create",
filters: [
{ name: "Data files", extensions: ["csv", "tsv", "txt", "json", "xlsx", "xlsm", "xls"] },
{ name: "Data files", extensions: ["csv", "tsv", "txt", "json", "xlsx", "xlsm", "xls", "sql"] },
{ name: "Text", extensions: ["csv", "tsv", "txt"] },
{ name: "JSON", extensions: ["json"] },
{ name: "Excel", extensions: ["xlsx", "xlsm", "xls"] },
{ name: "SQL", extensions: ["sql"] },
],
});
if (!selected) return;
@@ -708,6 +714,9 @@ async function goNext() {
async function startImport() {
saveActiveBatchTask();
if (!(await ensureReadOnlyWriteAccess({ connection: store.getConfig(props.prefillConnectionId ?? ""), source: t("readOnlyUnlock.sourceImport"), treatAsMutation: true }))) {
return;
}
if (isBatchImport.value) {
await startBatchImport();
return;
@@ -939,7 +948,8 @@ async function reloadBatchPreviewsForEncoding() {
errorMessage.value = "";
try {
for (const task of batchTasks.value) {
if (!isDelimitedFormat(task.format)) continue;
// SQL 脚本同样是文本源,编码变化时需要重新预览
if (!isDelimitedFormat(task.format) && task.format !== "sql") continue;
const reusableSource = task.preview.sourceRef ? task.preview.filePath : task.source;
const nextPreview = await api.previewTableImportFile(reusableSource, {
sourceRef: task.preview.sourceRef || null,
@@ -1018,7 +1028,7 @@ watch(rawProgressPercent, (percent) => {
<div class="min-h-0 flex-1 space-y-4 overflow-y-auto py-2 pr-1">
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2">
<input ref="fileInput" type="file" accept=".csv,.tsv,.txt,.json,.xlsx,.xlsm,.xls" :multiple="targetMode === 'create'" class="hidden" @change="handleFileInputChange" />
<input ref="fileInput" type="file" accept=".csv,.tsv,.txt,.json,.xlsx,.xlsm,.xls,.sql" :multiple="targetMode === 'create'" class="hidden" @change="handleFileInputChange" />
<div class="flex h-10 min-w-0 items-center gap-2 rounded-md border bg-muted/20 px-3">
<span class="shrink-0 text-xs text-muted-foreground">{{ t("tableImport.target") }}</span>
<span class="min-w-0 truncate text-sm font-medium">
@@ -1092,7 +1102,7 @@ watch(rawProgressPercent, (percent) => {
{{ t("tableImport.selectFile") }}
</Button>
</div>
<div class="grid grid-cols-5 gap-2">
<div class="grid grid-cols-6 gap-2">
<button v-for="format in formatOptions" :key="format.value" type="button" class="min-h-20 rounded-md border px-3 py-2 text-left" :class="sourceFormat === format.value ? 'border-primary bg-primary/5' : 'hover:bg-muted/30'" @click="sourceFormat = format.value">
<component :is="format.icon" class="mb-2 h-4 w-4 text-muted-foreground" />
<div class="text-xs font-medium">{{ t(format.labelKey) }}</div>
@@ -1194,6 +1204,25 @@ watch(rawProgressPercent, (percent) => {
</label>
</div>
<div v-else-if="sourceFormat === 'sql'" class="grid grid-cols-5 gap-3 rounded-md border p-3">
<div class="space-y-1.5">
<Label class="text-xs">{{ t("tableImport.encoding") }}</Label>
<Select :model-value="textEncoding" @update:model-value="(value: any) => (textEncoding = value)">
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in encodingOptions" :key="option.value" :value="option.value">
{{ t(option.labelKey) }}
</SelectItem>
</SelectContent>
</Select>
<div v-if="textEncoding === 'auto' && preview?.effectiveEncoding" class="truncate text-[11px] text-muted-foreground" :title="t('tableImport.encodingDetected', { encoding: encodingLabel(preview.effectiveEncoding) })">
{{ t("tableImport.encodingDetected", { encoding: encodingLabel(preview.effectiveEncoding) }) }}
</div>
</div>
</div>
<div v-else-if="sourceFormat === 'json'" class="grid grid-cols-2 gap-3 rounded-md border p-3">
<div class="space-y-1.5">
<Label class="text-xs">{{ t("tableImport.jsonShape") }}</Label>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, watch, defineAsyncComponent } from "vue";
import { computed, ref, watch, defineAsyncComponent } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@@ -24,6 +24,7 @@ const DataGenerateDialog = defineAsyncComponent(() => import("@/components/gener
import { useConnectionStore } from "@/stores/connectionStore";
import { useSqlExecutionDangerStore } from "@/stores/sqlExecutionDangerStore";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import { useReadOnlyUnlockStore, WRITE_UNLOCK_FIVE_MINUTES_SECS, WRITE_UNLOCK_ONE_MINUTE_SECS, type WriteUnlockDurationSecs } from "@/stores/readOnlyUnlockStore";
import { useDialogSources } from "@/composables/useDialogSources";
import type { ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink";
import type { DriverStoreFocus } from "@/lib/connection/agentDriverInstallHint";
@@ -92,6 +93,8 @@ const emit = defineEmits<{
const { t } = useI18n();
const connectionStore = useConnectionStore();
const productionSafetyStore = useProductionSafetyStore();
const readOnlyUnlockStore = useReadOnlyUnlockStore();
const unlockDuration = ref<WriteUnlockDurationSecs>(WRITE_UNLOCK_ONE_MINUTE_SECS);
const sqlExecutionDangerStore = useSqlExecutionDangerStore();
const dialogs = useDialogSources();
const productionConfirmationDetails = computed(() => {
@@ -103,6 +106,20 @@ const productionConfirmationDetails = computed(() => {
source: request.source || "-",
});
});
const readOnlyUnlockDetails = computed(() => {
const request = readOnlyUnlockStore.pending;
if (!request) return "";
return t("readOnlyUnlock.confirmDetails", {
connection: request.connectionName || "-",
source: request.source || "-",
});
});
watch(
() => readOnlyUnlockStore.pending,
(pending) => {
if (pending) unlockDuration.value = WRITE_UNLOCK_ONE_MINUTE_SECS;
},
);
const multiDbDangerDetails = computed(() => {
const request = sqlExecutionDangerStore.pending;
if (!request) return "";
@@ -178,6 +195,32 @@ watch(
@update:open="(open) => !open && productionSafetyStore.cancel()"
@confirm="productionSafetyStore.confirm()"
/>
<DangerConfirmDialog
v-if="readOnlyUnlockStore.pending"
:open="true"
:title="t('readOnlyUnlock.confirmTitle')"
:message="t('readOnlyUnlock.confirmMessage')"
:details-text="readOnlyUnlockDetails"
:sql="readOnlyUnlockStore.pending.sql"
:confirm-label="t('readOnlyUnlock.confirmAction')"
:close-on-confirm="false"
@update:open="(open) => !open && readOnlyUnlockStore.cancel()"
@confirm="readOnlyUnlockStore.confirm(unlockDuration)"
>
<template #options>
<fieldset class="mb-3 space-y-2 rounded-md border bg-muted/20 px-3 py-2">
<legend class="text-xs font-medium text-foreground">{{ t("readOnlyUnlock.durationLabel") }}</legend>
<label class="flex items-center gap-2 text-sm">
<input v-model="unlockDuration" type="radio" class="accent-primary" :value="WRITE_UNLOCK_ONE_MINUTE_SECS" />
{{ t("readOnlyUnlock.durationOneMinute") }}
</label>
<label class="flex items-center gap-2 text-sm">
<input v-model="unlockDuration" type="radio" class="accent-primary" :value="WRITE_UNLOCK_FIVE_MINUTES_SECS" />
{{ t("readOnlyUnlock.durationFiveMinutes") }}
</label>
</fieldset>
</template>
</DangerConfirmDialog>
<DangerConfirmDialog
v-if="sqlExecutionDangerStore.pending"
:open="true"
@@ -2,7 +2,7 @@
import { computed, ref, watch, nextTick, onUnmounted } from "vue";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { X, Pin, ChevronDown, Search, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Lock, Copy, AlertTriangle, Network, Minimize2, Maximize2, Settings, CalendarClock, Activity, Gauge, ShieldCheck, Database, GitBranch, Crosshair } from "@lucide/vue";
import { X, Pin, ChevronDown, Search, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Copy, AlertTriangle, Network, Minimize2, Maximize2, Settings, CalendarClock, Activity, Gauge, ShieldCheck, Database, GitBranch, Crosshair } from "@lucide/vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -16,7 +16,8 @@ import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useTabScroll } from "@/composables/useTabScroll";
import { useTabDrag } from "@/composables/useTabDrag";
import { connectionColor, isConnectionReadonly, tabDisplayTitle, tabTooltipLines } from "@/lib/tabs/tabPresentation";
import { connectionColor, tabDisplayTitle, tabTooltipLines } from "@/lib/tabs/tabPresentation";
import ReadOnlySessionControl from "@/components/connection/ReadOnlySessionControl.vue";
import { hexToRgba } from "@/lib/common/color";
import { copyToClipboard } from "@/lib/common/clipboard";
import { useToast } from "@/composables/useToast";
@@ -33,6 +34,7 @@ const props = defineProps<{
const emit = defineEmits<{
"activate-tab": [];
"toggle-zen-mode": [];
"locate-tab": [tab: QueryTab];
"activate-driver-store": [];
"close-driver-store": [];
@@ -576,6 +578,17 @@ function handleTabClick(tab: QueryTab) {
activateTab(tab.id);
}
function handleTabDoubleClick(tab: QueryTab, event: MouseEvent) {
event.stopPropagation();
if (tabDrag.state.suppressClick || (event.target instanceof Element && event.target.closest("button, input, [role='button']"))) return;
if (tab.mode === "data") {
if (tab.id !== queryStore.activeTabId) activateTab(tab.id);
emit("toggle-zen-mode");
return;
}
startRenameTab(tab);
}
function handleTabMouseDown(event: PointerEvent, tabId: string) {
if (event.button === 0) {
dispatchBeforeTabSwitch(tabId);
@@ -696,7 +709,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
:style="[tabColorStyle(tab), tabDropStyle(tab.id)]"
:data-active-tab="tab.id === queryStore.activeTabId && !driverStoreActive && !settingsPageActive"
@click="handleTabClick(tab)"
@dblclick.stop="startRenameTab(tab)"
@dblclick="handleTabDoubleClick(tab, $event)"
@mousedown.middle.prevent="queryStore.closeTab(tab.id)"
@pointerdown="handleTabMouseDown($event, tab.id)"
@mouseenter="handleTabDragTarget($event, tab)"
@@ -740,12 +753,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
<span v-if="isDirtyTab(tab)" aria-hidden="true" class="dirty-tab-marker">*</span>
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(tab)">{{ tabTitleText(tab) }}</span>
</span>
<Tooltip v-if="isConnectionReadonly(tab.connectionId)">
<TooltipTrigger as-child>
<Lock class="h-3 w-3 text-muted-foreground shrink-0" />
</TooltipTrigger>
<TooltipContent>{{ t("connection.readOnlyBadge") }}</TooltipContent>
</Tooltip>
<ReadOnlySessionControl :connection-id="tab.connectionId" compact />
<button class="rounded hover:bg-muted-foreground/20 p-0.5 shrink-0" @click.stop="queryStore.closeTab(tab.id)">
<X class="h-3 w-3" />
</button>
@@ -852,7 +860,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
<span v-if="isDirtyTab(tab)" aria-hidden="true" class="dirty-tab-marker">*</span>
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(tab)">{{ tabTitleText(tab) }}</span>
</span>
<Lock v-if="isConnectionReadonly(tab.connectionId)" class="h-3 w-3 shrink-0 text-muted-foreground" />
<ReadOnlySessionControl :connection-id="tab.connectionId" compact />
<Pin v-if="tab.pinned" class="h-3 w-3 shrink-0 fill-current text-primary" />
<span class="w-5 shrink-0">
<button
@@ -906,7 +914,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
:style="[tabColorStyle(tab), tabDropStyle(tab.id)]"
:data-active-tab="tab.id === queryStore.activeTabId && !driverStoreActive && !settingsPageActive"
@click="handleTabClick(tab)"
@dblclick.stop="startRenameTab(tab)"
@dblclick="handleTabDoubleClick(tab, $event)"
@mousedown.middle.prevent="queryStore.closeTab(tab.id)"
@pointerdown="handleTabMouseDown($event, tab.id)"
@mouseenter="handleTabDragTarget($event, tab)"
@@ -950,12 +958,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
<span v-if="isDirtyTab(tab)" aria-hidden="true" class="dirty-tab-marker">*</span>
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(tab)">{{ tabTitleText(tab) }}</span>
</span>
<Tooltip v-if="isConnectionReadonly(tab.connectionId)">
<TooltipTrigger as-child>
<Lock class="h-3 w-3 text-muted-foreground shrink-0" />
</TooltipTrigger>
<TooltipContent>{{ t("connection.readOnlyBadge") }}</TooltipContent>
</Tooltip>
<ReadOnlySessionControl :connection-id="tab.connectionId" compact />
<button class="rounded p-0.5 text-primary hover:bg-muted-foreground/20 shrink-0" :aria-label="t('contextMenu.unfixTab')" :title="t('contextMenu.unfixTab')" @click.stop="queryStore.togglePinnedTab(tab.id)">
<Pin class="h-3 w-3 fill-current" aria-hidden="true" />
</button>
@@ -1008,7 +1011,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
<span v-if="isDirtyTab(tab)" aria-hidden="true" class="dirty-tab-marker">*</span>
<span class="min-w-0 flex-1 truncate" :style="tabTitleStyle(tab)">{{ tabTitleText(tab) }}</span>
</span>
<Lock v-if="isConnectionReadonly(tab.connectionId)" class="h-3 w-3 shrink-0 text-muted-foreground" />
<ReadOnlySessionControl :connection-id="tab.connectionId" compact />
<Pin v-if="tab.pinned" class="h-3 w-3 shrink-0 fill-current text-primary" />
<span class="w-5 shrink-0">
<button
@@ -162,6 +162,7 @@ import type { QueryTab, ConnectionConfig, TableInfoTab, TreeNode, VectorCollecti
import type { SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
type DataGridHandle = DataGridColumnLayoutHandle & {
onToolbarRefresh: () => Promise<void> | void;
@@ -1763,7 +1764,7 @@ defineExpose({
:mongo-update-target="mongoQueryResultSaveHandler && activeTab.result.mongo_copy_documents?.length === activeTab.result.rows.length ? activeTab.mongoEditTarget : undefined"
:query-editability-reason="activeTab.queryEditabilityReason"
:allow-insert-rows="activeTab.queryAnalysis?.allowInsert ?? activeTab.queryAnalysis?.allowInsertDelete !== false"
:allow-delete-rows="activeTab.queryAnalysis?.allowInsertDelete !== false"
:allow-delete-rows="activeTab.queryAnalysis?.allowDelete ?? activeTab.queryAnalysis?.allowInsertDelete !== false"
context="results"
:auto-transpose-single-row="settingsStore.editorSettings.dataGridAutoTransposeSingleRow"
:database-type="activeEffectiveDatabaseType"
@@ -2226,7 +2227,7 @@ defineExpose({
<template v-else-if="activeTab.mode === 'nacos-access-control'">
<div class="flex-1 min-h-0">
<NacosAccessControlConsole :key="activeTab.id" :connection-id="activeTab.connectionId" :read-only="activeConnection?.read_only ?? false" />
<NacosAccessControlConsole :key="activeTab.id" :connection-id="activeTab.connectionId" :read-only="connectionIsEffectivelyReadOnly(activeConnection)" />
</div>
</template>
@@ -2297,7 +2298,7 @@ defineExpose({
<template v-else-if="activeTab.mode === 'mq'">
<div class="flex-1 min-h-0">
<MqAdminConsole :key="activeTab.id" :connection-id="activeTab.connectionId" :initial-tenant="activeTab.mqTenant" :initial-tab="activeTab.mqInitialTab" :read-only="activeConnection?.read_only ?? false" />
<MqAdminConsole :key="activeTab.id" :connection-id="activeTab.connectionId" :initial-tenant="activeTab.mqTenant" :initial-tab="activeTab.mqInitialTab" :read-only="connectionIsEffectivelyReadOnly(activeConnection)" />
</div>
</template>
@@ -2318,7 +2319,7 @@ defineExpose({
:target-group="activeTab.nacosTargetGroup"
:target-keyword="activeTab.nacosTargetKeyword"
:target-request-id="activeTab.nacosTargetRequestId"
:read-only="activeConnection?.read_only ?? false"
:read-only="connectionIsEffectivelyReadOnly(activeConnection)"
/>
</div>
</template>
@@ -90,7 +90,7 @@ watch(
<template>
<Dialog :open="open" @update:open="handleOpenChange">
<DialogContent
class="dbx-update-dialog sm:max-w-[520px]"
class="dbx-update-dialog sm:max-w-[700px]"
:show-close-button="!isCloseBlocked"
@interact-outside="
(e: Event) => {
@@ -140,29 +140,36 @@ watch(
<span>{{ t("updates.activeTasksBlockUpdate", { count: activeTaskCount }) }}</span>
</div>
</div>
<DialogFooter>
<Button v-if="!isCloseBlocked" variant="outline" @click="handleCancel">{{ t("dangerDialog.cancel") }}</Button>
<Button v-if="canIgnoreVersion" variant="ghost" :disabled="isIgnoringUpdate" @click="emit('ignore-version')">
<Loader2 v-if="isIgnoringUpdate" class="h-4 w-4 animate-spin" />
{{ t("updates.ignoreVersion") }}
</Button>
<DialogFooter class="min-w-0">
<template v-if="updateInfo?.update_available">
<Button variant="outline" @click="emit('open-latest-release')">{{ t("updates.openRelease") }}</Button>
<div class="flex min-w-0 flex-wrap items-center gap-2 sm:justify-end">
<Button v-if="!isCloseBlocked" variant="outline" class="shrink-0" @click="handleCancel">{{ t("dangerDialog.cancel") }}</Button>
<Button v-if="canIgnoreVersion" variant="ghost" class="shrink-0" :disabled="isIgnoringUpdate" @click="emit('ignore-version')">
<Loader2 v-if="isIgnoringUpdate" class="h-4 w-4 animate-spin" />
{{ t("updates.ignoreVersion") }}
</Button>
<Button variant="outline" class="shrink-0" @click="emit('open-latest-release')">{{ t("updates.openRelease") }}</Button>
</div>
<template v-if="canDownloadAndInstallUpdate(updateInfo, isDesktop)">
<Button v-if="updateReady" :disabled="activeTaskCount > 0" @click="emit('restart')">{{ t("updates.restart") }}</Button>
<Button v-else-if="isInstallingUpdate" disabled>
<Button v-if="updateReady" class="shrink-0" :disabled="activeTaskCount > 0" @click="emit('restart')">{{ t("updates.restart") }}</Button>
<Button v-else-if="isInstallingUpdate" class="shrink-0" disabled>
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("updates.installing") }}
</Button>
<Button v-else-if="isDownloadingUpdate" class="w-52 tabular-nums" disabled>
<Button v-else-if="isDownloadingUpdate" class="w-52 shrink-0 tabular-nums" disabled>
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("updates.downloading", { progress: downloadProgress }) }}
</Button>
<Button v-else-if="updateDownloaded" :disabled="activeTaskCount > 0" @click="emit('install-downloaded')">{{ t("updates.exitAndUpdate") }}</Button>
<Button v-else :disabled="activeTaskCount > 0" @click="emit('download-and-install')">{{ t("updates.downloadAndInstall") }}</Button>
<Button v-else-if="updateDownloaded" class="shrink-0" :disabled="activeTaskCount > 0" @click="emit('install-downloaded')">{{ t("updates.exitAndUpdate") }}</Button>
<Button v-else class="shrink-0" :disabled="activeTaskCount > 0" @click="emit('download-and-install')">{{ t("updates.downloadAndInstall") }}</Button>
</template>
</template>
<Button v-else-if="updateCheckMessage" @click="emit('open-latest-release')">{{ t("updates.openRelease") }}</Button>
<template v-else>
<div class="flex min-w-0 flex-wrap items-center gap-2 sm:justify-end">
<Button v-if="!isCloseBlocked" variant="outline" class="shrink-0" @click="handleCancel">{{ t("dangerDialog.cancel") }}</Button>
<Button v-if="updateCheckMessage" class="shrink-0" @click="emit('open-latest-release')">{{ t("updates.openRelease") }}</Button>
</div>
</template>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -170,7 +177,7 @@ watch(
<style>
html.dbx-legacy-webview [data-slot="dialog-content"].dbx-update-dialog[class~="max-w-sm"] {
max-width: 520px !important;
max-width: 700px !important;
}
html.dbx-legacy-webview [data-slot="dialog-content"].dbx-update-dialog [data-slot="dialog-footer"] {
@@ -66,6 +66,19 @@ describe("AppTabBar locate-in-sidebar action", () => {
});
});
describe("AppTabBar Zen mode interaction", () => {
it("switches Zen mode for data tabs while preserving query-tab renaming", () => {
const handler = sourceBetween("function handleTabDoubleClick", "function handleTabMouseDown");
expect(tabBarSource).toContain('"toggle-zen-mode": [];');
expect(handler).toContain('if (tab.mode === "data") {');
expect(handler).toContain('emit("toggle-zen-mode");');
expect(handler).toContain("startRenameTab(tab);");
expect(handler).toContain("event.target instanceof Element && event.target.closest(\"button, input, [role='button']\")");
expect(tabBarSource.match(/@dblclick="handleTabDoubleClick\(tab, \$event\)"/g)).toHaveLength(2);
});
});
describe("AppTabBar right-side close action", () => {
it("places the action after close-other and disables it when the target has no tabs to its right", () => {
expect(tabBarSource).toContain('label: t("contextMenu.closeRightTabs")');
@@ -15,3 +15,22 @@ describe("ContentArea Mongo tab reuse", () => {
expect(contentAreaSource).toContain(':key="`${activeTab.id}:${activeTab.sql}`"');
});
});
describe("ContentArea query result grid", () => {
it("passes connection and per-run count SQL to the query-tab grid so the total can be re-counted", () => {
const countSqlProp = ':count-sql="activeTab.resultCountSql"';
const countSqlIndex = contentAreaSource.indexOf(countSqlProp);
expect(countSqlIndex).toBeGreaterThan(-1);
// Robust string-matching: anchor on the per-run count SQL and walk back to
// the enclosing DataGrid so the test does not depend on DataGrid ordering
// while staying in readFileSync format.
const dataGridIndex = contentAreaSource.lastIndexOf("<DataGrid", countSqlIndex);
expect(dataGridIndex).toBeGreaterThan(-1);
const queryGridBlock = contentAreaSource.slice(dataGridIndex, countSqlIndex + 900);
expect(queryGridBlock).toContain("<DataGrid");
expect(queryGridBlock).toContain(':connection-id="activeResultConnectionId"');
expect(queryGridBlock).toContain(':count-sql="activeTab.resultCountSql"');
expect(queryGridBlock).toContain(':total-row-count="activeTab.resultTotalRowCount"');
expect(queryGridBlock).toContain(':total-row-count-loading="activeTab.resultTotalRowCountLoading"');
});
});
@@ -16,6 +16,7 @@ import * as api from "@/lib/backend/api";
import { copyToClipboard } from "@/lib/common/clipboard";
import { loadMeilisearchKeyColumns, saveMeilisearchKeyColumns, type MeilisearchKeyColumnKey } from "@/lib/meilisearch/meilisearchKeyColumns";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { useToast } from "@/composables/useToast";
import type { KeyCreateInput, KeyListItem } from "@/types/meilisearchManagement";
@@ -53,7 +54,7 @@ const props = defineProps<{ connectionId: string }>();
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
const rows = ref<KeyListItem[]>([]);
const offset = ref(0);
@@ -12,6 +12,7 @@ import ErrorBanner from "@/components/ui/ErrorBanner.vue";
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
import * as api from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { useToast } from "@/composables/useToast";
import {
formatMeilisearchTaskDateTime,
@@ -32,7 +33,7 @@ const props = defineProps<{ connectionId: string; fixedIndexUid?: string }>();
const { locale, t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const readOnly = computed(() => Boolean(connectionStore.getConfig(props.connectionId)?.read_only));
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
const rows = ref<MeilisearchTask[]>([]);
const total = ref(0);
@@ -55,6 +55,7 @@ import { useSettingsStore } from "@/stores/settingsStore";
import { useTheme } from "@/composables/useTheme";
import { executeWithProductionContextGuard } from "@/lib/database/productionExecutionGuard";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import type {
NacosBatchPreview,
NacosBatchReport,
@@ -280,7 +281,7 @@ const namespace = computed(() => props.namespace ?? connectionInfo.value?.namesp
const nacosProductionContext = computed(() => productionContextForDatabase(connectionStore.getConfig(props.connectionId), namespace.value));
const batchTargetConnections = computed<NacosConfigTransferTarget[]>(() =>
connectionStore.connections
.filter((connection) => connection.db_type === "nacos" && !connection.read_only)
.filter((connection) => connection.db_type === "nacos" && !connectionIsEffectivelyReadOnly(connection))
.map((connection) => {
const address = [connection.host, connection.port].filter(Boolean).join(":");
return { id: connection.id, label: connection.name ? `${connection.name} (${address})` : address || connection.id };
@@ -40,6 +40,7 @@ import {
Scissors,
Search,
ScrollText,
ShieldCheck,
Square,
Table2,
TableProperties,
@@ -61,12 +62,13 @@ import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDia
import CustomTypeInfoPanel from "@/components/objects/CustomTypeInfoPanel.vue";
import XlsxHeaderDialog from "@/components/export/XlsxHeaderDialog.vue";
import * as api from "@/lib/backend/api";
import type { ColumnInfo, ConnectionConfig, ForeignKeyInfo, IndexInfo, ObjectBrowserViewMode, ObjectBrowserViewport, ObjectInfo, ObjectSourceKind, ObjectStatistics, TableInfoTab, TreeNode, TriggerInfo } from "@/types/database";
import type { ColumnInfo, ConnectionConfig, ConstraintInfo, ForeignKeyInfo, IndexInfo, ObjectBrowserViewMode, ObjectBrowserViewport, ObjectInfo, ObjectSourceKind, ObjectStatistics, TableInfoTab, TreeNode, TriggerInfo } from "@/types/database";
import { sortTablesByFkDependency, type TableWithFk } from "@/lib/table/tableDependencySort";
import { isSchemaAware, supportsTableVacuum, supportsTransfer } from "@/lib/database/databaseCapabilities";
import { supportsSchemaDiagram, supportsTableImport, supportsTableStructureEditing, supportsTableTruncate } from "@/lib/database/databaseFeatureSupport";
import { codeMirrorSqlDialect, connectionObjectTreeNodeSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { getTableMetadataCapabilities, type TableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities";
import { constraintsForConstraintsTab } from "@/lib/table/constraintPresentation";
import { buildTableSelectSql } from "@/lib/table/tableSelectSql";
import {
buildDropObjectSql,
@@ -111,6 +113,7 @@ import MySqlEventEditor from "@/components/objects/MySqlEventEditor.vue";
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { buildXuguCompileSql } from "@/lib/database/xuguCompileSql";
import { formatShortcut } from "@/lib/editor/shortcutRegistry";
import { batchTableEmptyFeedback, buildBatchTableEmptyPlan, runBatchTableEmpty, type BatchTableEmptyPlanItem } from "@/lib/sidebar/batchTableEmpty";
@@ -225,6 +228,12 @@ const tableForeignKeysLoaded = ref(false);
const tableTriggers = ref<TriggerInfo[]>([]);
const tableTriggersLoading = ref(false);
const tableTriggersLoaded = ref(false);
const tableConstraints = ref<ConstraintInfo[]>([]);
const tableConstraintsLoading = ref(false);
const tableConstraintsLoaded = ref(false);
// The Constraints tab hides foreign keys when the dedicated Foreign Keys tab
// is also shown, mirroring DataGrid/TableStructureEditor.
const tableConstraintsForTab = computed(() => constraintsForConstraintsTab(tableConstraints.value, tableMetadataCapabilities.value.foreignKeys));
const tableInfoSearchQuery = ref("");
const tableInfoDdlPreRef = ref<HTMLPreElement | null>(null);
const SIDE_PANEL_MIN_WIDTH = 280;
@@ -335,7 +344,7 @@ const canOpenStructureEditor = computed(() => supportsTableStructureEditing(tabl
const canOpenDiagram = computed(() => !!props.database && supportsSchemaDiagram(effectiveDatabaseType.value));
const canOpenTableImport = computed(() => !!props.database && supportsTableImport(effectiveDatabaseType.value));
const supportsTruncateTable = computed(() => supportsTableTruncate(effectiveDatabaseType.value));
const supportsVacuumTable = computed(() => !props.connection.read_only && supportsTableVacuum(effectiveDatabaseType.value));
const supportsVacuumTable = computed(() => !connectionIsEffectivelyReadOnly(props.connection) && supportsTableVacuum(effectiveDatabaseType.value));
const vacuumRiskMessage = computed(() => (vacuumExecuting.value ? t("contextMenu.vacuumTableRunningHint") : vacuumTableFull.value ? t("contextMenu.vacuumTableFullRisk") : vacuumTableAnalyze.value ? t("contextMenu.vacuumTableAnalyzeRisk") : t("contextMenu.vacuumTableDefaultRisk")));
const sourceDialect = computed(() => codeMirrorSqlDialect(effectiveDatabaseType.value));
const sourceFormatDialect = computed<SqlFormatDialect>(() => sqlFormatDialectForDbType(effectiveDatabaseType.value));
@@ -976,6 +985,9 @@ const tableInfoTabs = computed<TableInfoTabItem[]>(() => {
if (tableMetadataCapabilities.value.foreignKeys) {
tabs.push({ id: "foreignKeys", label: t("grid.tableInfoForeignKeys"), icon: Link2, count: tableForeignKeys.value.length });
}
if (tableMetadataCapabilities.value.constraints) {
tabs.push({ id: "constraints", label: t("grid.tableInfoConstraints"), icon: ShieldCheck, count: tableConstraintsForTab.value.length });
}
if (tableMetadataCapabilities.value.triggers) {
tabs.push({ id: "triggers", label: t("grid.tableInfoTriggers"), icon: RotateCcw, count: tableTriggers.value.length });
}
@@ -1006,6 +1018,13 @@ const filteredTableTriggers = computed(() => {
return tableTriggers.value.filter((tr) => tr.name.toLowerCase().includes(q));
});
const filteredTableConstraints = computed(() => {
const base = tableConstraintsForTab.value;
if (!tableInfoSearchQuery.value) return base;
const q = tableInfoSearchQuery.value.toLowerCase();
return base.filter((c) => c.name.toLowerCase().includes(q) || c.constraint_type.toLowerCase().includes(q) || c.columns.some((col) => col.toLowerCase().includes(q)) || c.definition.toLowerCase().includes(q));
});
const filteredTableDdlContent = computed(() => {
if (!tableDdlContent.value) return "";
const html = highlight(tableDdlContent.value);
@@ -1032,11 +1051,13 @@ async function openTableInfo(row: ObjectBrowserRow, initialTab?: TableInfoTab) {
tableIndexes.value = [];
tableForeignKeys.value = [];
tableTriggers.value = [];
tableConstraints.value = [];
tableColumnsLoaded.value = false;
tableDdlLoaded.value = false;
tableIndexesLoaded.value = false;
tableForeignKeysLoaded.value = false;
tableTriggersLoaded.value = false;
tableConstraintsLoaded.value = false;
tableInfoSearchQuery.value = "";
// Determine initial tab: explicit request > previously activated
const firstTab = initialTab ?? tableInfoTab.value;
@@ -1052,6 +1073,7 @@ async function selectTableInfoTab(tab: TableInfoTab) {
else if (nextTab === "columns") await fetchTableColumns();
else if (nextTab === "indexes") await fetchTableIndexes();
else if (nextTab === "foreignKeys") await fetchTableForeignKeys();
else if (nextTab === "constraints") await fetchTableConstraints();
else if (nextTab === "triggers") await fetchTableTriggers();
}
@@ -1184,6 +1206,30 @@ async function fetchTableTriggers(force = false) {
}
}
async function fetchTableConstraints(force = false) {
const row = sidePanelRow.value;
if (!row || (tableConstraintsLoaded.value && !force)) return;
const epoch = sidePanelGuard.capture();
tableConstraintsLoading.value = true;
let loadedSuccessfully = false;
try {
const request = tableMetadataRequest(row);
const { value: constraints } = await loadObjectMetadataFacet(request, "constraints", () => api.listConstraints(request.connectionId, request.database, request.schema || request.database, request.tableName, request.catalog), { force });
if (sidePanelGuard.isStale(epoch)) return;
tableConstraints.value = constraints;
loadedSuccessfully = true;
} catch (error) {
if (sidePanelGuard.isStale(epoch)) return;
tableConstraints.value = [];
toast(translateBackendError(t, error), 5000);
} finally {
if (sidePanelGuard.isFresh(epoch)) {
tableConstraintsLoaded.value = loadedSuccessfully;
tableConstraintsLoading.value = false;
}
}
}
async function refreshActiveTableInfo() {
if (sidePanelMode.value !== "table-info" || !sidePanelRow.value) return;
sidePanelGuard.bump();
@@ -1204,6 +1250,10 @@ async function refreshActiveTableInfo() {
tableForeignKeys.value = [];
tableForeignKeysLoaded.value = false;
await fetchTableForeignKeys(true);
} else if (tableInfoTab.value === "constraints") {
tableConstraints.value = [];
tableConstraintsLoaded.value = false;
await fetchTableConstraints(true);
} else if (tableInfoTab.value === "triggers") {
tableTriggers.value = [];
tableTriggersLoaded.value = false;
@@ -2212,7 +2262,7 @@ function canTransferTableClipboard(): boolean {
if (isVictoriaMetrics.value) return false;
const entries = normalizedObjectBrowserTableClipboardEntries();
const target = pasteTableTargetContext();
if (entries.length === 0 || props.connection.read_only) return false;
if (entries.length === 0 || connectionIsEffectivelyReadOnly(props.connection)) return false;
const source = tableClipboardSourceContext(entries);
const sourceConfig = source ? connectionStore.getConfig(source.connectionId) : undefined;
return !!source && !!sourceConfig && supportsTransfer(sourceConfig.db_type) && supportsTransfer(props.connection.db_type) && !tableClipboardMatchesTarget(entries, target);
@@ -3553,6 +3603,30 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
</div>
</div>
</div>
<div v-else-if="tableInfoTab === 'constraints'" class="flex-1 min-h-0 overflow-auto">
<div v-if="tableConstraintsLoading" class="h-full flex items-center justify-center">
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground" />
</div>
<div v-else-if="tableInfoSearchQuery && filteredTableConstraints.length === 0" class="p-6 text-center text-xs text-muted-foreground">
{{ t("grid.tableInfoNoResults") }}
</div>
<div v-else-if="tableConstraintsForTab.length === 0" class="p-6 text-center text-xs text-muted-foreground">
{{ t("grid.tableInfoEmpty") }}
</div>
<div v-else class="divide-y">
<div v-for="constraint in filteredTableConstraints" :key="constraint.name" class="p-3 text-xs" :class="constraint.enabled ? '' : 'opacity-60'">
<div class="flex flex-wrap items-center gap-1.5">
<span class="font-medium truncate">{{ constraint.name }}</span>
<span class="rounded border px-1 py-px text-[10px] text-muted-foreground">{{ constraint.constraint_type }}</span>
<span v-if="!constraint.enabled" class="rounded border px-1 py-px text-[10px] text-muted-foreground">{{ t("grid.tableInfoConstraintDisabled") }}</span>
<span v-else-if="!constraint.valid" class="rounded border px-1 py-px text-[10px] text-muted-foreground">{{ t("grid.tableInfoConstraintNotValidated") }}</span>
</div>
<div v-if="constraint.columns.length" class="mt-1 font-mono text-[11px] text-muted-foreground break-all">{{ constraint.columns.join(", ") }}</div>
<div v-if="constraint.ref_table" class="mt-1 font-mono text-[11px] text-muted-foreground break-all">-> {{ constraint.ref_schema ? `${constraint.ref_schema}.` : "" }}{{ constraint.ref_table }}{{ constraint.ref_columns.length ? `(${constraint.ref_columns.join(", ")})` : "" }}</div>
<div v-if="constraint.definition" class="mt-1 font-mono text-[11px] text-muted-foreground break-all whitespace-pre-wrap">{{ constraint.definition }}</div>
</div>
</div>
</div>
<div v-else-if="tableInfoTab === 'triggers'" class="flex-1 min-h-0 overflow-auto">
<div v-if="tableTriggersLoading" class="h-full flex items-center justify-center">
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground" />
@@ -1567,6 +1567,16 @@ async function locateTabInSidebar(tab: QueryTab | undefined | null, align: Sideb
}
}
// Connection groups never register loaded tree children, so the guard above
// skips them and a collapsed group keeps the target out of the visible flat
// tree. Reopen them through the layout op so the expansion is persisted and
// survives the next layout rebuild; flipping isExpanded directly would be
// reverted by that rebuild (issue #7387).
const collapsedGroupIds = nodePath.filter((node) => node.type === "connection-group" && !node.isExpanded).map((node) => node.id);
if (collapsedGroupIds.length > 0) {
store.expandConnectionGroups(collapsedGroupIds);
}
await nextTick();
const match = target ? findSidebarNodeForTarget(target, flatNodes.value) : null;
@@ -184,6 +184,7 @@ import { isSqlServerLinkedNode } from "@/lib/database/sqlServerLinkedServers";
import { flattenTree } from "@/composables/useFlatTree";
import { createDatabaseCollationOptionsForCharset, nextCreateDatabaseCollation, normalizeCreateDatabaseCharset, parseCreateDatabaseCharsetMetadata } from "@/lib/database/createDatabaseCharsetOptions";
import { executeWithProductionContextGuard, executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { buildXuguCompileSql } from "@/lib/database/xuguCompileSql";
import type { SidebarDataOpenRequest } from "@/lib/sidebar/sidebarDataOpenCoordinator";
import { createSidebarActionTarget, findSidebarActionTarget, releaseRemovedSidebarActionTarget, type SidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget";
@@ -1153,7 +1154,7 @@ function canTransferTreeClipboardToCurrentNode(): boolean {
const source = tableClipboardSourceContext(entries);
const sourceConfig = source ? connectionStore.getConfig(source.connectionId) : undefined;
const targetConfig = connectionStore.getConfig(target.connectionId);
return !!source && !!sourceConfig && !!targetConfig && supportsTransfer(sourceConfig.db_type) && supportsTransfer(targetConfig.db_type) && !targetConfig.read_only && !tableClipboardMatchesTarget(entries, target);
return !!source && !!sourceConfig && !!targetConfig && supportsTransfer(sourceConfig.db_type) && supportsTransfer(targetConfig.db_type) && !connectionIsEffectivelyReadOnly(targetConfig) && !tableClipboardMatchesTarget(entries, target);
}
function openTransferFromTreeClipboard(): boolean {
@@ -3013,7 +3014,7 @@ async function confirmBatchEmpty() {
const canCreateTable = computed(() => {
const config = activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined;
const supportsHBaseTableCreation = config?.db_type === "hbase" && !config.read_only;
const supportsHBaseTableCreation = config?.db_type === "hbase" && !connectionIsEffectivelyReadOnly(config);
return (
(activeNode.value.type === "database" || activeNode.value.type === "schema" || activeNode.value.type === "group-tables") &&
!isSqlServerLinkedNode(activeNode.value) &&
@@ -3029,13 +3030,13 @@ const canCreateDatabase = computed(() => {
const canCreateNacosNamespace = computed(() => {
const config = activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined;
return activeNode.value.type === "connection" && config?.db_type === "nacos" && !config.read_only;
return activeNode.value.type === "connection" && config?.db_type === "nacos" && !connectionIsEffectivelyReadOnly(config);
});
const canEditNacosNamespace = computed(() => {
if (activeNode.value.type !== "nacos-namespace" || !activeNode.value.connectionId || !activeNode.value.nacosNamespace) return false;
const config = connectionStore.getConfig(activeNode.value.connectionId);
return config?.db_type === "nacos" && !config.read_only;
return config?.db_type === "nacos" && !connectionIsEffectivelyReadOnly(config);
});
const canDeleteNacosNamespace = computed(() => canEditNacosNamespace.value);
@@ -3083,7 +3084,7 @@ const canRenameDatabase = computed(() => {
const node = activeNode.value;
if (node.type !== "database" || !node.database) return false;
const config = node.connectionId ? connectionStore.getConfig(node.connectionId) : undefined;
return !!config && !config.read_only && supportsDatabaseRename(config.db_type);
return !!config && !connectionIsEffectivelyReadOnly(config) && supportsDatabaseRename(config.db_type);
});
const renameObjectDialogTitle = computed(() => {
@@ -3104,7 +3105,7 @@ const canDropSchema = computed(() => {
const canEditSchemaComment = computed(() => {
const config = activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined;
return activeNode.value.type === "schema" && !!activeNode.value.database && !config?.read_only && supportsSchemaComment(effectiveDatabaseTypeForConnection(config));
return activeNode.value.type === "schema" && !!activeNode.value.database && !connectionIsEffectivelyReadOnly(config) && supportsSchemaComment(effectiveDatabaseTypeForConnection(config));
});
const canBatchDropCascade = computed(() => {
@@ -5221,7 +5222,7 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
icon: RefreshCw,
shortcut: shortcutRefresh,
});
if (!connectionStore.getConfig(node.connectionId || "")?.read_only) {
if (!connectionIsEffectivelyReadOnly(connectionStore.getConfig(node.connectionId || ""))) {
items.push({ label: "", separator: true });
items.push({
label: t("hbase.deleteTable"),
@@ -32,7 +32,6 @@ import {
CalendarClock,
Gauge,
ShieldCheck,
Lock,
Archive,
Square,
Minus,
@@ -46,6 +45,7 @@ import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import ConnectionErrorIndicator from "@/components/connection/ConnectionErrorIndicator.vue";
import ReadOnlySessionControl from "@/components/connection/ReadOnlySessionControl.vue";
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
@@ -53,8 +53,9 @@ import { Switch } from "@/components/ui/switch";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import type { ColumnInfo, ConnectionConfig, CustomTypeTreeMemberMeta, DatabaseType, TreeNode, TriggerInfo } from "@/types/database";
import { alignedCommentLeadingWidth, canTreeNodePin, canTreeNodeShowExpander, sidebarTreeNodeComment, trailingCommentAvailableWidth, trailingCommentGapPx, treeItemPaddingLeft, treeLabelWidthClass, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout";
import { clearActiveTableReferencePayload, createTableReferencePayload, createTableReferenceDropEvent, setActiveTableReferencePayload, type QueryEditorTableReferencePayload } from "@/lib/editor/queryEditorTableDrop";
import { clearActiveTableReferencePayload, createTableReferenceDragEndEvent, createTableReferenceDropEvent, createTableReferenceHoverEvent, createTableReferencePayload, setActiveTableReferencePayload, type QueryEditorTableReferencePayload } from "@/lib/editor/queryEditorTableDrop";
import { AI_ASSISTANT_TABLE_DROP_ROOT_SELECTOR } from "@/lib/ai/aiTableReferenceDrop";
import { beginTableReferenceDragFeedback, isOverSqlEditorTarget, type TableReferenceDragFeedback } from "@/lib/editor/tableReferenceDragFeedback";
import { formatSidebarObjectStorage } from "@/lib/sidebar/sidebarDatabaseStorage";
import { dataTabOpenModeFromTreeClick } from "@/lib/sidebar/dataTabOpenPolicy";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
@@ -1126,8 +1127,6 @@ function clearTreeDragTarget() {
const TABLE_REFERENCE_DRAG_THRESHOLD = 5;
const TABLE_REFERENCE_DRAGGING_CLASS = "dbx-table-reference-dragging";
const canDragTableReference = computed(() => {
if (props.referenceDragDisabled || !activeNode.value.connectionId) return false;
if (activeNode.value.type === "database") return typeof activeNode.value.database === "string" && activeNode.value.database.trim().length > 0;
@@ -1144,8 +1143,15 @@ let pendingTableReferenceDrag: {
let draggingTableReferencePayload: QueryEditorTableReferencePayload | null = null;
let referenceDragFeedback: TableReferenceDragFeedback | null = null;
let suppressNextTableReferenceClick = false;
function tableReferenceDragLabel(payload: QueryEditorTableReferencePayload): string {
if (payload.referenceType === "column" && payload.columnName) return payload.columnName;
return payload.tableName || payload.database;
}
function tableReferenceDragPayload(): QueryEditorTableReferencePayload | null {
if (!canDragTableReference.value) return null;
if (activeNode.value.type === "database") {
@@ -1191,15 +1197,16 @@ function startTableReferenceDrag(payload: QueryEditorTableReferencePayload) {
draggingTableReferencePayload = payload;
setActiveTableReferencePayload(payload);
document.getSelection()?.removeAllRanges();
document.body.style.cursor = "copy";
referenceDragFeedback = beginTableReferenceDragFeedback(tableReferenceDragLabel(payload));
}
function finishTableReferenceDrag() {
clearActiveTableReferencePayload(draggingTableReferencePayload);
pendingTableReferenceDrag = null;
draggingTableReferencePayload = null;
document.body.classList.remove(TABLE_REFERENCE_DRAGGING_CLASS);
document.body.style.cursor = "";
referenceDragFeedback?.end();
referenceDragFeedback = null;
window.dispatchEvent(createTableReferenceDragEndEvent());
document.removeEventListener("mousemove", onTableReferenceMouseMove, true);
document.removeEventListener("mouseup", onTableReferenceMouseUp, true);
}
@@ -1215,6 +1222,11 @@ function onTableReferenceMouseMove(event: MouseEvent) {
if (draggingTableReferencePayload) {
event.preventDefault();
document.getSelection()?.removeAllRanges();
referenceDragFeedback?.update(event.clientX, event.clientY);
// 仅查询编辑器消费 hover 光标线事件;AI 面板不监听。命中判定含覆盖层拦截时的几何回退。
if (isOverSqlEditorTarget(event.clientX, event.clientY)) {
window.dispatchEvent(createTableReferenceHoverEvent({ clientX: event.clientX, clientY: event.clientY }));
}
}
}
@@ -1242,7 +1254,6 @@ function startTableReferenceMouseDrag(event: MouseEvent) {
if (!payload) return;
event.preventDefault();
document.getSelection()?.removeAllRanges();
document.body.classList.add(TABLE_REFERENCE_DRAGGING_CLASS);
pendingTableReferenceDrag = { payload, startX: event.clientX, startY: event.clientY };
document.addEventListener("mousemove", onTableReferenceMouseMove, true);
document.addEventListener("mouseup", onTableReferenceMouseUp, true);
@@ -1499,7 +1510,7 @@ function onKeydown(event: KeyboardEvent) {
</div>
<span v-if="node.type === 'connection' && node.connectionId && connectionStore.connectedIds.has(node.connectionId)" class="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" />
<span v-if="databaseOpenVisual.showsIndicator" class="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" />
<Badge v-if="isConnectionReadonly" variant="secondary" class="h-4 px-1.5 text-[10px] gap-0.5"> <Lock class="w-2.5 h-2.5" />{{ t("connection.readOnlyBadge") }} </Badge>
<ReadOnlySessionControl v-if="isConnectionReadonly && activeNode.connectionId" :connection-id="activeNode.connectionId" show-label />
<ConnectionErrorIndicator v-if="node.type === 'connection'" :connection-id="node.connectionId" trigger-class="h-4 w-4" />
<span v-if="formattedObjectStorage()" class="ml-auto shrink-0 text-right text-xs tabular-nums text-muted-foreground">{{ formattedObjectStorage() }}</span>
<button
@@ -0,0 +1,20 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const connectionTreeSource = readFileSync(new URL("../ConnectionTree.vue", import.meta.url), "utf8");
describe("ConnectionTree locate in collapsed groups", () => {
it("reopens collapsed connection groups on the node path through the persisted layout op", () => {
// Group expansion must flow through store.expandConnectionGroups so
// layout.collapsed is flipped and the next layout rebuild keeps the group
// open; flipping isExpanded directly would be reverted by that rebuild.
expect(connectionTreeSource).toMatch(/const collapsedGroupIds = nodePath\s*\.filter\(\(node\) => node\.type === "connection-group" && !node\.isExpanded\)\s*\.map\(\(node\) => node\.id\);/);
// The expansion happens before the flat-tree match that drives selection,
// scrolling and flashing, so the reopened groups are visible to it.
expect(connectionTreeSource).toMatch(/store\.expandConnectionGroups\(collapsedGroupIds\);\s*\}\s*\n\s*await nextTick\(\);\s*\n\s*const match = target \? findSidebarNodeForTarget\(target, flatNodes\.value\) : null;/);
});
it("still gates generic ancestor expansion on loaded children only (#5850)", () => {
expect(connectionTreeSource).toMatch(/if \(!ancestor\.isExpanded && store\.canUseLoadedTreeNodeToggle\(ancestor\)\) \{\s*ancestor\.isExpanded = true;\s*\}/);
});
});
@@ -16,6 +16,7 @@ import { useToast } from "@/composables/useToast";
import { useConnectionStore } from "@/stores/connectionStore";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import { connectionIsEffectivelyReadOnly, ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess";
import { fetchSqlFileTargetOptions } from "@/composables/useDatabaseOptions";
import { requiresSqlFileTargetDatabaseSelection } from "@/lib/connection/connectionLevelDatabaseBootstrap";
import { cancelSqlFileExecution, executeSqlFiles, listenSqlFileProgress, previewSqlFile, type SqlFilePreview, type SqlFileProgress, type SqlFileStatus } from "@/lib/backend/api";
@@ -357,6 +358,12 @@ async function refreshTargetAfterImport() {
async function startExecution() {
if (!canStart.value || previews.value.length === 0) return;
// Await the unlock guard only when it can actually prompt/block (effectively
// read-only); writable or already-unlocked connections stay synchronous so
// the running state flips in the same tick as the click.
if (connectionIsEffectivelyReadOnly(selectedConnection.value)) {
if (!(await ensureReadOnlyWriteAccess({ connection: selectedConnection.value, source: t("readOnlyUnlock.sourceSqlFile"), treatAsMutation: true }))) return;
}
const productionContext = productionContextForDatabase(selectedConnection.value, database.value);
if (productionContext.active) {
// File previews are truncated, so production file execution is always reviewed instead of inferring safety from a partial preview.
@@ -45,6 +45,7 @@ vi.mock("@lucide/vue", async () => {
ListChevronsUpDown: Icon,
Loader2: Icon,
Maximize2: Icon,
Pencil: Icon,
Plus: Icon,
RefreshCw: Icon,
Save: Icon,
@@ -277,7 +278,7 @@ function draft(autoIncrement = false, counter?: { value?: string; originalValue?
};
}
async function mountEditor(autoIncrement = false, counter?: { value?: string; originalValue?: string }) {
async function mountEditor(autoIncrement = false, counter?: { value?: string; originalValue?: string }, draftOverride?: ReturnType<typeof draft>) {
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.listDataTypes.mockResolvedValue([]);
mocks.buildTableStructureChangeSql.mockResolvedValue({ statements: [], warnings: [] });
@@ -289,7 +290,7 @@ async function mountEditor(autoIncrement = false, counter?: { value?: string; or
database: "test",
schema: "test",
tableName: "users",
draft: draft(autoIncrement, counter),
draft: draftOverride ?? draft(autoIncrement, counter),
});
mountedApps.push(app);
mountedEditor = app.mount(root) as unknown as { applyChanges: () => Promise<boolean> };
@@ -357,6 +358,59 @@ describe("TableStructureEditor MySQL AUTO_INCREMENT counter", () => {
const input = root.querySelector<HTMLInputElement>("[data-mysql-auto-increment-counter]");
expect(input?.value).toBe("10");
expect(input?.disabled).toBe(false);
expect(input?.classList.contains("structure-grid-control")).toBe(false);
expect(input?.classList.contains("font-mono")).toBe(true);
expect(input?.closest("td")).not.toBeNull();
expect(root.querySelector("[data-mysql-auto-increment-editor-trigger]")?.getAttribute("title")).toContain("structureEditor.editMysqlAutoIncrementValue");
});
it("loads an editable blank counter when auto-increment is checked before saving", async () => {
mocks.getMysqlTableAutoIncrement.mockResolvedValueOnce(null);
const root = await mountEditor(false);
expect(root.querySelector("[data-mysql-auto-increment-editor-trigger]")).toBeNull();
const autoIncrementLabel = Array.from(root.querySelectorAll("label")).find((label) => label.textContent?.includes("structureEditor.autoIncrement"));
const checkbox = autoIncrementLabel?.querySelector<HTMLInputElement>('input[type="checkbox"]');
expect(checkbox).toBeTruthy();
checkbox!.checked = true;
checkbox!.dispatchEvent(new Event("change", { bubbles: true }));
await vi.waitFor(() => expect(mocks.getMysqlTableAutoIncrement).toHaveBeenCalledWith("structure-charset-test", "test", "users"));
const input = root.querySelector<HTMLInputElement>("[data-mysql-auto-increment-counter]");
await vi.waitFor(() => expect(input?.disabled).toBe(false));
expect(input?.value).toBe("");
});
it("keeps a restored dirty blank-counter draft when the server has no counter", async () => {
mocks.getMysqlTableAutoIncrement.mockResolvedValueOnce(null);
const restoredDraft = draft(false, { value: "500", originalValue: "" });
restoredDraft.columns[0].extra.autoIncrement = true;
const root = await mountEditor(false, undefined, restoredDraft);
const input = root.querySelector<HTMLInputElement>("[data-mysql-auto-increment-counter]");
await vi.waitFor(() => expect(input?.value).toBe("500"));
});
it("accepts unsigned bigint digits and rejects non-decimal input without rewriting it", async () => {
const root = await mountEditor(true);
const input = root.querySelector<HTMLInputElement>("[data-mysql-auto-increment-counter]")!;
await vi.waitFor(() => expect(input.value).toBe("10"));
expect(input.getAttribute("inputmode")).toBe("numeric");
expect(input.getAttribute("pattern")).toBe("[0-9]*");
const maxUnsignedBigint = "18446744073709551615";
input.value = maxUnsignedBigint;
input.dispatchEvent(new Event("input", { bubbles: true }));
await vi.waitFor(() => expect(mocks.buildMysqlAutoIncrementSql).toHaveBeenCalledWith(expect.objectContaining({ value: maxUnsignedBigint })));
mocks.buildMysqlAutoIncrementSql.mockClear();
for (const invalidValue of ["abc", "-1", "+1", "1.5", "1e3"]) {
input.value = invalidValue;
input.dispatchEvent(new Event("input", { bubbles: true }));
expect(input.value).toBe(maxUnsignedBigint);
}
await new Promise((resolve) => setTimeout(resolve, 400));
expect(mocks.buildMysqlAutoIncrementSql).not.toHaveBeenCalled();
});
it("refreshes a restored clean counter draft from the server", async () => {
@@ -631,16 +631,25 @@ describe("TableStructureEditor data type options", () => {
});
describe("TableStructureEditor action column", () => {
it("shows the field shortcut hints in the toolbar", async () => {
it("moves delayed shortcut hints onto the add, copy, and delete controls", async () => {
const root = await mountEditor("dameng");
expect(root.textContent).toContain("settings.shortcutsTab");
expect(root.textContent).toContain("Shift+Enter");
expect(root.textContent).toContain("⌘/Ctrl+D");
expect(root.querySelector("[data-field-shortcut-hints]")?.className).toContain("flex-col");
expect(root.querySelector("[data-field-shortcut-hints]")?.className).toContain("w-56");
expect(root.querySelector("[data-field-shortcut-hints]")?.className).toContain("!bg-secondary");
expect(root.querySelector("[data-field-shortcut-hints] kbd")?.className).toContain("text-primary");
expect(root.textContent).not.toContain("settings.shortcutsTab");
expect(root.querySelector("[data-field-shortcut-hints]")).toBeNull();
const addTooltip = root.querySelector<HTMLElement>("[data-add-column-shortcut-tooltip]");
const copyTooltip = root.querySelector<HTMLElement>("[data-copy-column-shortcut-tooltip]");
const deleteTooltip = root.querySelector<HTMLElement>("[data-delete-column-shortcut-tooltip]");
expect(addTooltip?.getAttribute("delay-duration")).toBe("500");
expect(copyTooltip?.getAttribute("delay-duration")).toBe("500");
expect(deleteTooltip?.getAttribute("delay-duration")).toBe("500");
expect(root.querySelector("[data-add-column-shortcut-content]")?.textContent).toBe("Shift+Enter");
expect(root.querySelector("[data-copy-column-shortcut-content]")?.textContent).toBe("⌘/Ctrl+D");
expect(root.querySelector("[data-delete-column-shortcut-content]")?.textContent?.trim()).toBe("⌘/Ctrl+Del");
expect(root.querySelector("[data-add-column-shortcut-content]")?.textContent).not.toContain("structureEditor.addColumn");
expect(root.querySelector("[data-copy-column-shortcut-content]")?.textContent).not.toContain("structureEditor.copyColumn");
expect(copyTooltip?.querySelector("button")?.hasAttribute("title")).toBe(false);
expect(deleteTooltip?.querySelector("button")?.hasAttribute("title")).toBe(false);
});
it("adds a field below the focused input on Shift+Enter", async () => {
@@ -671,6 +680,63 @@ describe("TableStructureEditor action column", () => {
expect(document.activeElement).toBe(copiedInput);
});
it.each([
{ label: "Ctrl+Delete", event: { key: "Delete", ctrlKey: true } },
{ label: "Cmd+Delete", event: { key: "Backspace", metaKey: true } },
])("removes a focused unsaved field on $label", async ({ event }) => {
const root = await mountEditor("dameng");
buttonWithText(root, "structureEditor.addColumn").click();
await vi.waitFor(() => expect(root.querySelector('[data-column-row-index="1"]')).not.toBeNull());
const addedInput = root.querySelector<HTMLInputElement>('[data-column-row-index="1"] [data-column-name-input]');
if (!addedInput) throw new Error("Missing added column name input");
addedInput.focus();
addedInput.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, ...event }));
await vi.waitFor(() => expect(root.querySelector('[data-column-row-index="1"]')).toBeNull());
expect(root.querySelector('[data-column-row-index="0"]')).not.toBeNull();
});
it("marks a focused persisted field for deletion on Mod+Delete", async () => {
const root = await mountEditor("dameng");
const sourceInput = root.querySelector<HTMLInputElement>('[data-column-row-index="0"] [data-column-name-input]');
if (!sourceInput) throw new Error("Missing source column name input");
sourceInput.focus();
sourceInput.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Delete", ctrlKey: true }));
await vi.waitFor(() => expect(root.querySelector('button[aria-label="structureEditor.restore"]')).not.toBeNull());
});
it("keeps Ctrl+Backspace available for editing field text", async () => {
const root = await mountEditor("dameng");
buttonWithText(root, "structureEditor.addColumn").click();
await vi.waitFor(() => expect(root.querySelector('[data-column-row-index="1"]')).not.toBeNull());
const addedInput = root.querySelector<HTMLInputElement>('[data-column-row-index="1"] [data-column-name-input]');
if (!addedInput) throw new Error("Missing added column name input");
addedInput.focus();
addedInput.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Backspace", ctrlKey: true }));
await nextTick();
expect(root.querySelector('[data-column-row-index="1"]')).not.toBeNull();
});
it("keeps Cmd+Backspace available for editing non-empty field text", async () => {
const root = await mountEditor("dameng");
buttonWithText(root, "structureEditor.addColumn").click();
await vi.waitFor(() => expect(root.querySelector('[data-column-row-index="1"]')).not.toBeNull());
const addedInput = root.querySelector<HTMLInputElement>('[data-column-row-index="1"] [data-column-name-input]');
if (!addedInput) throw new Error("Missing added column name input");
addedInput.value = "name";
addedInput.focus();
addedInput.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Backspace", metaKey: true }));
await nextTick();
expect(root.querySelector('[data-column-row-index="1"]')).not.toBeNull();
});
it("widens the ordinal indicator for a two-digit primary-key row", async () => {
const root = await mountEditor("dameng");
const addColumn = buttonWithText(root, "structureEditor.addColumn");
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from "vue";
import { computed, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, shallowRef, watch } from "vue";
import { uuid } from "@/lib/common/utils";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
@@ -7,12 +7,13 @@ import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { AlertTriangle, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Copy, Database, Info, Keyboard, KeyRound, ListChevronsUpDown, Loader2, Maximize2, Plus, RefreshCw, Save, Search, Settings, SlidersHorizontal, Trash2, UserRound, X } from "@lucide/vue";
import { AlertTriangle, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Copy, Database, Info, KeyRound, ListChevronsUpDown, Loader2, Maximize2, Pencil, Plus, RefreshCw, Save, Search, Settings, SlidersHorizontal, Trash2, UserRound, X } from "@lucide/vue";
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { SearchableSelect } from "@/components/ui/searchable-select";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import EditorSearchPanel from "@/components/editor/EditorSearchPanel.vue";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useConnectionStore } from "@/stores/connectionStore";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
@@ -21,6 +22,8 @@ import { useQueryStore } from "@/stores/queryStore";
import { useHistoryStore } from "@/stores/historyStore";
import { useSettingsStore, type StructureEditorDensity } from "@/stores/settingsStore";
import { useTheme } from "@/composables/useTheme";
import { editorFontTheme, loadEditorTheme } from "@/lib/editor/editorThemes";
import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect";
import { useToast } from "@/composables/useToast";
import { type SqlHighlighter, createShikiSqlHighlighter } from "@/lib/sql/sqlHighlighter";
import { joinSqlStatementsForScript } from "@/lib/sql/sqlBatchScript";
@@ -39,12 +42,13 @@ import { getMysqlDataTypeHelp } from "@/lib/table/mysqlDataTypeHelp";
import { getPostgresDataTypeHelp, gaussdbMTypeDisplayName } from "@/lib/table/postgresDataTypeHelp";
import { getSqliteDataTypeHelp } from "@/lib/table/sqliteDataTypeHelp";
import { getTableMetadataCapabilities, firstStructureMetadataTab, isStructureMetadataTabSupported } from "@/lib/table/tableMetadataCapabilities";
import { constraintsForConstraintsTab } from "@/lib/table/constraintPresentation";
import { hasTableStructureRefreshWork, unloadedTableStructureRefreshScope, visibleTableStructureRefreshScope, type TableStructureRefreshScope } from "@/lib/table/tableStructureMetadataLoading";
import { canAddTableStructureColumn, getTableStructureCapabilities, hasLocalTableColumnOrderChange, isPhysicalTableColumnOrderChange, sanitizeStructureIndexesForCapabilities, supportsLocalTableColumnReorder } from "@/lib/table/tableStructureCapabilities";
import { getConcurrentIndexAvailability, concurrentIndexNamesInStatements, normalizeUnsupportedConcurrentIndexes, type ConcurrentIndexAvailability } from "@/lib/table/concurrentIndexAvailability";
import { orderedColumnIndexes, uniqueDataGridColumnOrderKeys } from "@/lib/dataGrid/dataGridColumnOrder";
import { loadTableDataGridColumnOrder, notifyTableDataGridColumnOrderChanged, removeTableDataGridColumnOrder, saveTableDataGridColumnOrder, tableDataGridColumnOrderScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
import { connectionObjectTreeQuerySchema, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { codeMirrorSqlDialectForConnection, connectionObjectTreeQuerySchema, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { postgresListRolesSql, usersFromPostgresRolesResult } from "@/lib/database/databaseUserAdmin";
import type { ColumnInfo, ConstraintInfo, TableInfo, TableInfoTab, TableStructureEditorDraft, TableStructureEditorTarget, TableStructureEditorViewport } from "@/types/database";
import {
@@ -93,9 +97,10 @@ import {
import { CREATE_DATABASE_CHARSET_OPTIONS, createDatabaseCollationOptionsForCharset, fallbackCreateDatabaseCharsetMetadata, normalizeCreateDatabaseCharsetKey, parseCreateDatabaseCharsetMetadata } from "@/lib/database/createDatabaseCharsetOptions";
import type { CreateDatabaseCharsetMetadata } from "@/lib/database/createDatabaseCharsetOptions";
import * as api from "@/lib/backend/api";
import type { EditorView } from "@codemirror/view";
const { t } = useI18n();
const { isDark } = useTheme();
const { isDark, themePalette } = useTheme();
const store = useConnectionStore();
const productionSafetyStore = useProductionSafetyStore();
const queryStore = useQueryStore();
@@ -161,23 +166,121 @@ const constraintsLoading = ref(false);
const triggersLoading = ref(false);
const ddlContent = ref("");
const ddlLoading = ref(false);
const ddlEditorContainer = ref<HTMLDivElement>();
const ddlSearchPanelRef = ref<InstanceType<typeof EditorSearchPanel>>();
const ddlSearchOpen = ref(false);
const ddlEditorView = shallowRef<EditorView | null>(null);
let ddlEditorInitRequestId = 0;
let ddlEditorScrollCleanup: (() => void) | null = null;
const loadedMetadataFacets = new Set<ObjectMetadataFacet>();
let structureEditorReady = false;
const ddlPreRef = ref<HTMLPreElement | null>(null);
function onDdlKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === "a") {
e.preventDefault();
const el = ddlPreRef.value;
if (!el) return;
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
}
const ddlFetched = ref(false);
function ddlEditorDocument(): string {
return ddlContent.value || t("structureEditor.emptyReadonly");
}
function destroyDdlEditor() {
ddlEditorInitRequestId += 1;
ddlEditorScrollCleanup?.();
ddlEditorScrollCleanup = null;
ddlEditorView.value?.destroy();
ddlEditorView.value = null;
}
function updateDdlEditorContent(content: string): boolean {
const view = ddlEditorView.value;
if (!view) return false;
if (view.state.doc.toString() !== content) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: content },
});
}
return true;
}
function observeDdlEditorScroll(view: EditorView) {
ddlEditorScrollCleanup?.();
const scrollDOM = view.scrollDOM;
const onScroll = (event: Event) => onStructureContentScroll("ddl", event);
scrollDOM.addEventListener("scroll", onScroll, { passive: true });
ddlEditorScrollCleanup = () => scrollDOM.removeEventListener("scroll", onScroll);
}
async function initDdlEditor(content: string) {
const container = ddlEditorContainer.value;
if (!container) return;
const existingView = ddlEditorView.value;
if (existingView?.dom.parentElement === container) {
updateDdlEditorContent(content);
existingView.focus();
return;
}
if (existingView) destroyDdlEditor();
const requestId = ++ddlEditorInitRequestId;
const [{ EditorView, keymap }, { EditorState, Prec }, langSql, { basicSetup }, { search: cmSearch }] = await Promise.all([import("@codemirror/view"), import("@codemirror/state"), import("@codemirror/lang-sql"), import("codemirror"), import("@codemirror/search")]);
if (requestId !== ddlEditorInitRequestId || activeTab.value !== "ddl" || loading.value || ddlLoading.value || ddlEditorContainer.value !== container) return;
const editorSettings = settingsStore.editorSettings;
const themeExt = await loadEditorTheme(editorSettings.theme, isDark.value ? "dark" : "light", undefined, themePalette.value);
if (requestId !== ddlEditorInitRequestId || activeTab.value !== "ddl" || loading.value || ddlLoading.value || ddlEditorContainer.value !== container) return;
const fontExt = editorFontTheme(EditorView, editorSettings.fontSize, editorSettings.fontFamily, { fixedHeight: true, scrollable: true });
const dialect = createDbxCodeMirrorSqlDialect(langSql, codeMirrorSqlDialectForConnection(connection.value), databaseType.value, connection.value?.driver_profile);
const state = EditorState.create({
doc: content,
extensions: [
cmSearch({
top: true,
createPanel: () => {
const dom = document.createElement("span");
dom.style.display = "none";
return { dom };
},
scrollToMatch: (range) => EditorView.scrollIntoView(range, { y: "center" }),
}),
basicSetup,
EditorState.allowMultipleSelections.of(true),
langSql.sql({ dialect }),
themeExt,
fontExt,
Prec.highest(keymap.of([{ key: "Mod-f", run: () => ddlSearchPanelRef.value?.openSearch() ?? false, preventDefault: true }])),
EditorView.theme({
"&.cm-focused": { outline: "none" },
".cm-content": {
cursor: "text",
padding: "0.75rem",
userSelect: "text",
WebkitUserSelect: "text",
},
".cm-line": {
userSelect: "text",
WebkitUserSelect: "text",
},
}),
EditorState.readOnly.of(true),
],
});
const editorView = new EditorView({ state, parent: container });
if (requestId !== ddlEditorInitRequestId || activeTab.value !== "ddl" || loading.value || ddlLoading.value || ddlEditorContainer.value !== container) {
editorView.destroy();
return;
}
ddlEditorView.value = editorView;
observeDdlEditorScroll(editorView);
editorView.focus();
restoreStructureScrollPosition("ddl");
}
function scheduleDdlEditorInit() {
void nextTick(() => {
if (activeTab.value !== "ddl" || loading.value || ddlLoading.value) return;
void initDdlEditor(ddlEditorDocument());
});
}
function ddlRequest() {
return {
connectionId: props.connectionId,
@@ -190,6 +293,7 @@ function ddlRequest() {
async function fetchDdl(force = false) {
if (!props.connectionId || !props.database || !props.tableName || (!force && ddlFetched.value) || !tableMetadataCapabilities.value.ddl) return;
if (force) destroyDdlEditor();
ddlLoading.value = true;
try {
const { ddl } = await loadObjectDdl(ddlRequest(), { force });
@@ -203,6 +307,7 @@ async function fetchDdl(force = false) {
}
}
const errorMessage = ref("");
const secondaryMetadataErrors = ref<Partial<Record<ObjectMetadataFacet, string>>>({});
const columns = ref<EditableStructureColumn[]>([]);
const copyColumnsDialogOpen = ref(false);
const copySourceTables = ref<TableInfo[]>([]);
@@ -244,6 +349,9 @@ const sqliteSchemaRevision = ref<string>();
const foreignKeys = ref<EditableStructureForeignKey[]>([]);
const constraints = ref<ConstraintInfo[]>([]);
const constraintsLoaded = ref(false);
// The Constraints tab hides foreign keys when the dedicated Foreign Keys tab
// is also shown, mirroring DataGrid/ObjectBrowser.
const constraintsForTab = computed(() => constraintsForConstraintsTab(constraints.value, tableMetadataCapabilities.value.foreignKeys));
const triggers = ref<EditableStructureTrigger[]>([]);
const triggersLoaded = ref(false);
const secondaryMetadataLoading = computed(() => indexesLoading.value || foreignKeysLoading.value || constraintsLoading.value || triggersLoading.value);
@@ -334,6 +442,7 @@ const structureDensityValues: StructureEditorDensity[] = ["compact", "standard",
const STRUCTURE_COLUMNS_WIDTHS_STORAGE_KEY = "dbx-structure-editor-column-widths";
const STRUCTURE_INDEX_COLUMNS_WIDTHS_STORAGE_KEY = "dbx-structure-editor-index-column-widths";
const STRUCTURE_SQL_PREVIEW_COLLAPSED_STORAGE_KEY = "dbx-structure-editor-sql-preview-collapsed";
const FIELD_SHORTCUT_TOOLTIP_DELAY_MS = 500;
const STRUCTURE_COLUMN_WIDTH_COUNT = 12;
const STRUCTURE_INDEX_COLUMN_WIDTH_COUNT = 9;
const PERSISTED_STRUCTURE_INDEX_COLUMN_WIDTHS = new Set([0, 1, 6]);
@@ -944,6 +1053,24 @@ const supportsTableOwner = computed(() => !isCreateMode.value && databaseType.va
const canEditMysqlAutoIncrement = computed(() => canEditMysqlAutoIncrementCounter(connection.value, isCreateMode.value, columns.value));
const canBuildMysqlAutoIncrement = computed(() => canEditMysqlAutoIncrement.value && !mysqlAutoIncrementLoading.value && !mysqlAutoIncrementLoadError.value && originalMysqlAutoIncrementValue.value !== undefined);
const supportsMysqlEngine = computed(() => supportsMysqlTableEngine(connection.value));
const hasPersistedMysqlAutoIncrementColumn = computed(() => columns.value.some((column) => !column.markedForDrop && (column.original?.extra ?? "").toLowerCase().includes("auto_increment")));
function isMysqlAutoIncrementCounterColumn(column: EditableStructureColumn): boolean {
return canEditMysqlAutoIncrement.value && !column.markedForDrop && column.extra.autoIncrement === true;
}
function setMysqlAutoIncrement(column: EditableStructureColumn, checked: boolean) {
column.extra.autoIncrement = checked;
if (checked && originalMysqlAutoIncrementValue.value === undefined && !mysqlAutoIncrementLoading.value) {
void loadMysqlAutoIncrementCounter(true);
}
}
function onMysqlAutoIncrementInput(event: Event) {
const input = event.target as HTMLInputElement;
if (/^\d*$/.test(input.value)) {
mysqlAutoIncrementValue.value = input.value;
return;
}
input.value = mysqlAutoIncrementValue.value ?? "";
}
const tableOwnerOptions = computed(() => {
const owner = tableOwner.value;
if (!owner || tableOwnerRoles.value.includes(owner)) return tableOwnerRoles.value;
@@ -1117,6 +1244,11 @@ function restoreStructureScrollPosition(tab = activeTab.value) {
const position = structureScrollPositions.value[tab];
if (!position) return;
nextTick(() => {
if (tab === "ddl" && ddlEditorView.value) {
ddlEditorView.value.scrollDOM.scrollTop = Math.max(0, position.scrollTop);
ddlEditorView.value.scrollDOM.scrollLeft = Math.max(0, position.scrollLeft);
return;
}
const scroller = structureScrollerForTab(tab);
if (!scroller) return;
scroller.scrollTop = Math.max(0, position.scrollTop);
@@ -1542,6 +1674,7 @@ function resetState() {
constraintsLoading.value = false;
triggersLoading.value = false;
errorMessage.value = "";
secondaryMetadataErrors.value = {};
isPartitionedParent.value = false;
partitionStatusKnown.value = true;
concurrentAvailabilityInvalidated.value = false;
@@ -1661,7 +1794,8 @@ async function loadMysqlAutoIncrementCounter(preserveDraft = false) {
await store.ensureConnected(props.connectionId);
const value = await api.getMysqlTableAutoIncrement(props.connectionId, props.database, props.tableName);
if (requestId !== mysqlAutoIncrementLoadRequestId) return;
const draft = refreshMysqlAutoIncrementCounterDraft(value, { value: mysqlAutoIncrementValue.value, originalValue: originalMysqlAutoIncrementValue.value }, preserveDraft);
const server = value === null && !hasPersistedMysqlAutoIncrementColumn.value ? "" : value;
const draft = refreshMysqlAutoIncrementCounterDraft(server, { value: mysqlAutoIncrementValue.value, originalValue: originalMysqlAutoIncrementValue.value }, preserveDraft);
originalMysqlAutoIncrementValue.value = draft.originalValue;
mysqlAutoIncrementValue.value = draft.value;
} catch (error: any) {
@@ -1779,6 +1913,7 @@ async function loadStructure(
if (!silent) loading.value = true;
setSecondaryMetadataLoading(effectiveScope, true);
errorMessage.value = "";
secondaryMetadataErrors.value = {};
let secondaryMetadataScheduled = false;
let loadedSuccessfully = false;
try {
@@ -1866,8 +2001,30 @@ async function loadStructure(
}
}
const applySecondaryMetadata = async () => {
const [nextIndexes, nextForeignKeys, nextConstraints, nextTriggers] = await Promise.all([indexesPromise, foreignKeysPromise, constraintsPromise, triggersPromise]);
const [indexesResult, foreignKeysResult, constraintsResult, triggersResult] = await Promise.allSettled([indexesPromise, foreignKeysPromise, constraintsPromise, triggersPromise]);
if (requestId !== structureLoadRequestId) return;
type SecondaryMetadataResult = { facet: ObjectMetadataFacet; result: PromiseSettledResult<unknown> };
const secondaryResults: SecondaryMetadataResult[] = [
{ facet: "indexes", result: indexesResult },
{ facet: "foreign-keys", result: foreignKeysResult },
{ facet: "constraints", result: constraintsResult },
{ facet: "triggers", result: triggersResult },
];
const failedFacets = secondaryResults.filter((entry): entry is { facet: ObjectMetadataFacet; result: PromiseRejectedResult } => entry.result.status === "rejected");
for (const { facet, result } of failedFacets) {
console.warn(`[DBX][structure-editor:${facet}-metadata-failed]`, result.reason);
}
if (showErrors && failedFacets.length > 0) {
for (const { facet, result } of failedFacets) {
secondaryMetadataErrors.value[facet] = result.reason?.message || String(result.reason);
}
}
const nextIndexes = indexesResult.status === "fulfilled" ? indexesResult.value : undefined;
const nextForeignKeys = foreignKeysResult.status === "fulfilled" ? foreignKeysResult.value : undefined;
const nextConstraints = constraintsResult.status === "fulfilled" ? constraintsResult.value : undefined;
const nextTriggers = triggersResult.status === "fulfilled" ? triggersResult.value : undefined;
if (nextIndexes) {
indexes.value = createIndexDrafts(nextIndexes);
loadedMetadataFacets.add("indexes");
@@ -1892,7 +2049,6 @@ async function loadStructure(
const secondaryMetadataPromise = applySecondaryMetadata()
.catch((error) => {
console.warn("[DBX][structure-editor:secondary-metadata-failed]", error);
if (showErrors && requestId === structureLoadRequestId) errorMessage.value = error?.message || String(error);
})
.finally(() => {
if (requestId === structureLoadRequestId) setSecondaryMetadataLoading(effectiveScope, false);
@@ -3383,7 +3539,17 @@ function isShiftEnterShortcut(event: KeyboardEvent): boolean {
return !event.isComposing && event.key === "Enter" && event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey;
}
function isPlainModDeleteShortcut(event: KeyboardEvent): boolean {
if (isPlainModShortcut(event, "delete")) return true;
if (!event.metaKey || event.ctrlKey || !isPlainModShortcut(event, "backspace")) return false;
// ⌘⌫ is macOS "delete to beginning of line" while editing text; only treat it as a
// field delete when the focused input is empty so normal text editing keeps working.
const target = event.target;
return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement ? target.value === "" : true;
}
function onStructureEditorKeydown(event: KeyboardEvent) {
if (event.defaultPrevented) return;
const focusedColumn = activeTab.value === "columns" ? focusedEditableColumn(event.target) : undefined;
if (focusedColumn && isShiftEnterShortcut(event) && canAddColumn.value) {
event.preventDefault();
@@ -3397,10 +3563,18 @@ function onStructureEditorKeydown(event: KeyboardEvent) {
void copyColumn(focusedColumn);
return;
}
if (focusedColumn && isPlainModDeleteShortcut(event) && (!focusedColumn.original || canDropColumn(focusedColumn))) {
event.preventDefault();
event.stopPropagation();
if (focusedColumn.original) toggleDropColumn(focusedColumn);
else removeNewColumn(focusedColumn);
return;
}
if (isPlainModShortcut(event, "f")) {
event.preventDefault();
event.stopPropagation();
if (activeTab.value === "columns") focusColumnSearch();
else if (activeTab.value === "ddl") ddlSearchPanelRef.value?.openSearch();
return;
}
if (isPlainModShortcut(event, "s")) {
@@ -3483,6 +3657,7 @@ onActivated(() => {
});
}
restoreStructureScrollPosition();
if (activeTab.value === "ddl") scheduleDdlEditorInit();
});
onDeactivated(() => {
unregisterStructureEditorShortcuts();
@@ -3490,6 +3665,7 @@ onDeactivated(() => {
structureHorizontalScrollbarResizeObserver?.disconnect();
structureHorizontalScrollbarResizeObserver = null;
stopStructureHorizontalScrollbarDrag();
destroyDdlEditor();
});
onBeforeUnmount(() => {
clearCopySourceTableSearchTimer();
@@ -3498,6 +3674,7 @@ onBeforeUnmount(() => {
structureHorizontalScrollbarObserverGeneration += 1;
structureHorizontalScrollbarResizeObserver?.disconnect();
unregisterStructureEditorShortcuts();
destroyDdlEditor();
clearSqlPreviewState();
if (columnHighlightTimer) window.clearTimeout(columnHighlightTimer);
if (indexHighlightTimer) window.clearTimeout(indexHighlightTimer);
@@ -3620,6 +3797,7 @@ watch(
watch(activeTab, () => {
stopStructureHorizontalScrollbarDrag();
if (activeTab.value !== "ddl") destroyDdlEditor();
clearColumnSelection();
highlightedColumnId.value = null;
highlightedIndexId.value = null;
@@ -3690,12 +3868,8 @@ async function loadActiveTableStructureMetadataIfNeeded() {
watch([activeTab, loading, secondaryMetadataLoading], () => void loadActiveTableStructureMetadataIfNeeded(), { flush: "sync" });
watch([activeTab, ddlLoading], ([tab, loading]) => {
if (tab === "ddl" && !loading) {
void nextTick(() => {
ddlPreRef.value?.focus();
});
}
watch([activeTab, loading, ddlLoading, ddlContent], ([tab, structureIsLoading, ddlIsLoading]) => {
if (tab === "ddl" && !structureIsLoading && !ddlIsLoading) scheduleDdlEditorInit();
});
</script>
@@ -3750,26 +3924,6 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
</Tooltip>
</div>
<div v-if="canEditMysqlAutoIncrement" class="flex shrink-0 items-center gap-2">
<label class="shrink-0 font-medium text-muted-foreground">AUTO_INCREMENT</label>
<Input
v-model="mysqlAutoIncrementValue"
inputmode="numeric"
autocomplete="off"
data-mysql-auto-increment-counter
:placeholder="mysqlAutoIncrementLoading ? t('common.loading') : '—'"
:title="mysqlAutoIncrementLoadError || undefined"
:class="[structureMonoControlClass, 'max-w-[220px]']"
:disabled="mysqlAutoIncrementLoading || !!mysqlAutoIncrementLoadError || originalMysqlAutoIncrementValue === undefined || saving"
/>
<Tooltip v-if="mysqlAutoIncrementLoadError">
<TooltipTrigger as-child>
<AlertTriangle :class="[structureIconClass, 'shrink-0 text-destructive']" />
</TooltipTrigger>
<TooltipContent>{{ mysqlAutoIncrementLoadError }}</TooltipContent>
</Tooltip>
</div>
<div v-if="supportsTableOwner" class="flex shrink-0 items-center gap-2">
<label class="flex shrink-0 items-center gap-1 font-medium text-muted-foreground">
<UserRound :class="structureIconClass" />
@@ -3874,31 +4028,14 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
{{ columnSearchMatchCount }}
</button>
</div>
<Button v-if="activeTab === 'columns'" size="sm" :class="structureToolbarButtonClass" :disabled="!canAddColumn" @click="addColumn">
<Plus :class="structureIconClass" />
{{ t("structureEditor.addColumn") }}
</Button>
<Tooltip v-if="activeTab === 'columns'" :delay-duration="0">
<Tooltip v-if="activeTab === 'columns'" :delay-duration="FIELD_SHORTCUT_TOOLTIP_DELAY_MS" data-add-column-shortcut-tooltip>
<TooltipTrigger as-child>
<Button type="button" size="sm" variant="ghost" :class="[structureToolbarButtonClass, 'text-muted-foreground']" :aria-label="t('settings.shortcutsTab')">
<Keyboard :class="structureIconClass" />
{{ t("settings.shortcutsTab") }}
<Button size="sm" :class="structureToolbarButtonClass" :disabled="!canAddColumn" @click="addColumn">
<Plus :class="structureIconClass" />
{{ t("structureEditor.addColumn") }}
</Button>
</TooltipTrigger>
<TooltipContent
data-field-shortcut-hints
side="bottom"
class="flex w-56 max-w-none flex-col items-stretch gap-1.5 border border-primary/30 !bg-secondary !text-secondary-foreground shadow-lg data-open:!animate-none data-[state=delayed-open]:!animate-none data-closed:!animate-none [&>svg]:!bg-secondary [&>svg]:!fill-secondary"
>
<div class="flex items-center justify-between gap-4">
<span class="whitespace-nowrap"><span class="text-primary">↓</span> {{ t("structureEditor.addColumn") }}</span>
<kbd class="min-w-5 shrink-0 whitespace-nowrap rounded border border-primary/30 bg-background px-1.5 py-0.5 text-center font-mono text-[11px] leading-none text-primary shadow-xs">Shift+Enter</kbd>
</div>
<div class="flex items-center justify-between gap-4">
<span class="whitespace-nowrap"><span class="text-primary">↓</span> {{ t("structureEditor.copyColumn") }}</span>
<kbd class="min-w-5 shrink-0 whitespace-nowrap rounded border border-primary/30 bg-background px-1.5 py-0.5 text-center font-mono text-[11px] leading-none text-primary shadow-xs">⌘/Ctrl+D</kbd>
</div>
</TooltipContent>
<TooltipContent side="bottom" class="font-mono font-medium" data-add-column-shortcut-content>Shift+Enter</TooltipContent>
</Tooltip>
<Button v-if="activeTab === 'columns'" size="sm" variant="outline" :class="structureToolbarButtonClass" :disabled="!canAddColumn" @click="openCopyColumnsDialog">
<Copy :class="structureIconClass" />
@@ -4005,25 +4142,28 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
>
<ListChevronsUpDown :class="structureIconClass" />
</Button>
<Button variant="ghost" size="icon" :class="structureActionButtonClass" :disabled="!canAddColumn || column.markedForDrop" :title="t('structureEditor.copyColumn')" :aria-label="t('structureEditor.copyColumn')" @click.stop="copyColumn(column)">
<Copy :class="structureIconClass" />
</Button>
<Button
v-if="column.original"
variant="ghost"
size="icon"
:class="structureActionButtonClass"
:disabled="!canDropColumn(column)"
:title="column.markedForDrop ? t('structureEditor.restore') : t('structureEditor.drop')"
:aria-label="column.markedForDrop ? t('structureEditor.restore') : t('structureEditor.drop')"
@click.stop="toggleDropColumn(column)"
>
<RefreshCw v-if="column.markedForDrop" :class="structureIconClass" />
<Trash2 v-else :class="structureIconClass" />
</Button>
<Button v-else variant="ghost" size="icon" :class="structureActionButtonClass" :title="t('structureEditor.remove')" :aria-label="t('structureEditor.remove')" @click.stop="removeNewColumn(column)">
<X :class="structureIconClass" />
</Button>
<Tooltip :delay-duration="FIELD_SHORTCUT_TOOLTIP_DELAY_MS" data-copy-column-shortcut-tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" :class="structureActionButtonClass" :disabled="!canAddColumn || column.markedForDrop" :aria-label="t('structureEditor.copyColumn')" @click.stop="copyColumn(column)">
<Copy :class="structureIconClass" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" class="font-mono font-medium" data-copy-column-shortcut-content>⌘/Ctrl+D</TooltipContent>
</Tooltip>
<Tooltip :delay-duration="FIELD_SHORTCUT_TOOLTIP_DELAY_MS" data-delete-column-shortcut-tooltip>
<TooltipTrigger as-child>
<Button v-if="column.original" variant="ghost" size="icon" :class="structureActionButtonClass" :disabled="!canDropColumn(column)" :aria-label="column.markedForDrop ? t('structureEditor.restore') : t('structureEditor.drop')" @click.stop="toggleDropColumn(column)">
<RefreshCw v-if="column.markedForDrop" :class="structureIconClass" />
<Trash2 v-else :class="structureIconClass" />
</Button>
<Button v-else variant="ghost" size="icon" :class="structureActionButtonClass" :aria-label="t('structureEditor.remove')" @click.stop="removeNewColumn(column)">
<X :class="structureIconClass" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" class="font-mono font-medium" data-delete-column-shortcut-content>
{{ column.markedForDrop ? t("structureEditor.restore") : "⌘/Ctrl+Del" }}
</TooltipContent>
</Tooltip>
</div>
</div>
</td>
@@ -4208,9 +4348,43 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
<!-- MySQL: AUTO_INCREMENT + ON UPDATE CURRENT_TIMESTAMP -->
<template v-else-if="structureDialect === 'mysql'">
<label :class="[structurePropertyLabelClass, 'shrink-0 pr-1']" :title="t('structureEditor.autoIncrement')">
<input v-model="column.extra.autoIncrement" type="checkbox" :class="[structureCheckboxClass, 'shrink-0']" />
<input :checked="column.extra.autoIncrement" type="checkbox" :class="[structureCheckboxClass, 'shrink-0']" @change="setMysqlAutoIncrement(column, ($event.target as HTMLInputElement).checked)" />
<span>{{ t("structureEditor.autoIncrement") }}</span>
</label>
<Popover v-if="isMysqlAutoIncrementCounterColumn(column)">
<PopoverTrigger as-child>
<Button
variant="ghost"
size="icon"
:class="[structureIconButtonClass, 'mr-1 shrink-0']"
:title="t('structureEditor.editMysqlAutoIncrementValue', { value: mysqlAutoIncrementValue || '—' })"
:aria-label="t('structureEditor.editMysqlAutoIncrementValue', { value: mysqlAutoIncrementValue || '—' })"
data-mysql-auto-increment-editor-trigger
>
<Loader2 v-if="mysqlAutoIncrementLoading" :class="[structureIconClass, 'animate-spin text-muted-foreground']" />
<AlertTriangle v-else-if="mysqlAutoIncrementLoadError" :class="[structureIconClass, 'text-destructive']" />
<Pencil v-else :class="structureIconClass" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" class="w-80 space-y-2 p-3">
<label class="block text-xs font-medium text-foreground">{{ t("structureEditor.mysqlAutoIncrementNextValue") }}</label>
<Input
:model-value="mysqlAutoIncrementValue"
inputmode="numeric"
pattern="[0-9]*"
autocomplete="off"
data-mysql-auto-increment-counter
:aria-label="t('structureEditor.mysqlAutoIncrementNextValue')"
:placeholder="mysqlAutoIncrementLoading ? t('common.loading') : '—'"
:title="mysqlAutoIncrementLoadError || undefined"
class="w-full font-mono"
:disabled="mysqlAutoIncrementLoading || !!mysqlAutoIncrementLoadError || originalMysqlAutoIncrementValue === undefined || saving"
@input.capture="onMysqlAutoIncrementInput"
/>
<p v-if="mysqlAutoIncrementLoadError" class="text-xs text-destructive">{{ mysqlAutoIncrementLoadError }}</p>
<p class="text-xs leading-5 text-muted-foreground">{{ t("contextMenu.mysqlAutoIncrementNonemptyHint") }}</p>
</PopoverContent>
</Popover>
<label :class="[structurePropertyLabelClass, 'flex-1 basis-0']" :title="t('structureEditor.onUpdateCurrentTimestamp')">
<input v-model="column.extra.onUpdateCurrentTimestamp" type="checkbox" :class="[structureCheckboxClass, 'shrink-0']" />
<span class="min-w-0 truncate">{{ t("structureEditor.onUpdateCurrentTimestamp") }}</span>
@@ -4335,6 +4509,9 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
</div>
<div v-else-if="secondaryMetadataErrors.indexes" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{{ secondaryMetadataErrors.indexes }}
</div>
<table v-else class="structure-edit-grid border-separate border-spacing-0 text-[length:var(--structure-font-size)] leading-[var(--structure-line-height)]" :style="{ minWidth: indexColWidths.reduce((a, w) => a + w, 0) + 'px' }">
<thead class="sticky top-0 z-10 bg-background">
<tr>
@@ -4450,6 +4627,9 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
</div>
<div v-else-if="secondaryMetadataErrors['foreign-keys']" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{{ secondaryMetadataErrors["foreign-keys"] }}
</div>
<div v-else-if="foreignKeys.length === 0" class="py-10 text-center text-muted-foreground">
{{ t("structureEditor.emptyReadonly") }}
</div>
@@ -4500,11 +4680,14 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
</div>
<div v-else-if="constraints.length === 0" class="py-10 text-center text-muted-foreground">
<div v-else-if="secondaryMetadataErrors.constraints" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{{ secondaryMetadataErrors.constraints }}
</div>
<div v-else-if="constraintsForTab.length === 0" class="py-10 text-center text-muted-foreground">
{{ t("structureEditor.emptyReadonly") }}
</div>
<div v-else class="space-y-1.5">
<div v-for="constraint in constraints" :key="constraint.name" class="rounded-md border px-[var(--structure-cell-px)] py-[var(--structure-header-py)] text-[length:var(--structure-font-size)]" :class="constraint.enabled ? '' : 'opacity-60'">
<div v-for="constraint in constraintsForTab" :key="constraint.name" class="rounded-md border px-[var(--structure-cell-px)] py-[var(--structure-header-py)] text-[length:var(--structure-font-size)]" :class="constraint.enabled ? '' : 'opacity-60'">
<div class="flex flex-wrap items-center gap-1.5">
<span class="font-mono font-medium">{{ constraint.name }}</span>
<Badge variant="outline" class="shrink-0">{{ constraint.constraint_type }}</Badge>
@@ -4523,6 +4706,9 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
</div>
<div v-else-if="secondaryMetadataErrors.triggers" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{{ secondaryMetadataErrors.triggers }}
</div>
<div v-else-if="triggers.length === 0" class="py-10 text-center text-muted-foreground">
{{ t("structureEditor.emptyReadonly") }}
</div>
@@ -4575,11 +4761,12 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
{{ t("common.loading") }}
</div>
<template v-else>
<Button v-if="ddlContent" variant="outline" size="sm" class="absolute right-3 top-3 z-10 h-7 gap-1 px-2" :title="t('grid.copyDdl')" @click="copyDdlContent">
<Button v-if="ddlContent && !ddlSearchOpen" variant="outline" size="sm" class="absolute right-3 top-3 z-10 h-7 gap-1 px-2" :title="t('grid.copyDdl')" @click="copyDdlContent">
<Copy class="h-3.5 w-3.5" />
{{ t("grid.copyDdl") }}
</Button>
<pre ref="ddlPreRef" tabindex="0" class="m-0 min-h-0 flex-1 whitespace-pre p-3 font-mono text-xs leading-5 select-text outline-none" v-html="ddlContent ? (sqlHighlighter?.(ddlContent) ?? ddlContent) : t('structureEditor.emptyReadonly')" @keydown="onDdlKeydown"></pre>
<div ref="ddlEditorContainer" class="structure-ddl-editor h-full min-h-full min-w-0 w-full"></div>
<EditorSearchPanel v-if="ddlEditorView" ref="ddlSearchPanelRef" :view="ddlEditorView" @open="ddlSearchOpen = true" @close="ddlSearchOpen = false" />
</template>
</TabsContent>
</Tabs>
@@ -4750,6 +4937,27 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
</template>
<style scoped>
.structure-ddl-editor :deep(.cm-editor) {
min-height: 100%;
background: transparent;
}
.structure-ddl-editor :deep(.cm-content),
.structure-ddl-editor :deep(.cm-line) {
cursor: text;
user-select: text !important;
-webkit-user-select: text !important;
}
.structure-ddl-editor :deep(.cm-selectionBackground),
.structure-ddl-editor :deep(.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground) {
background: var(--dbx-editor-selection-background, rgba(59, 130, 246, 0.35)) !important;
}
.structure-ddl-editor :deep(.cm-content ::selection) {
background: var(--dbx-editor-selection-background, rgba(59, 130, 246, 0.35)) !important;
}
.structure-table-scroller::-webkit-scrollbar {
width: 8px;
height: 0;
@@ -8,4 +8,11 @@ describe("TableStructureEditor constraints", () => {
expect(source).toContain('class="mt-1 whitespace-pre-wrap break-words font-mono text-muted-foreground">{{ constraint.definition }}');
expect(source).not.toContain('class="mt-1 truncate font-mono text-muted-foreground">{{ constraint.definition }}');
});
it("isolates secondary metadata failures to their owning tabs", () => {
expect(source).toContain("Promise.allSettled([indexesPromise, foreignKeysPromise, constraintsPromise, triggersPromise])");
expect(source).toContain("secondaryMetadataErrors.value[facet] = result.reason?.message || String(result.reason)");
expect(source).toContain('v-else-if="secondaryMetadataErrors.constraints"');
expect(source).not.toContain("errorMessage.value = reason?.message || String(reason)");
});
});
@@ -0,0 +1,146 @@
// @vitest-environment happy-dom
import { createApp, nextTick, type App } from "vue";
import { basicSetup } from "codemirror";
import { search as cmSearch } from "@codemirror/search";
import { EditorState, Prec } from "@codemirror/state";
import { EditorView, keymap } from "@codemirror/view";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { collectEditorSearchMatches, createEditorSearchQuery } from "@/lib/editor/editorSearchQuery";
import EditorSearchPanel from "@/components/editor/EditorSearchPanel.vue";
vi.mock("vue-i18n", () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({
editorSettings: {
regexMaxMatchCount: 1000,
},
}),
}));
const ddl = `CREATE TABLE users (
id BIGINT,
username VARCHAR(255),
email VARCHAR(255),
created_at DATETIME
);`;
type SearchPanelHandle = {
openSearch: () => boolean;
closeSearch: () => boolean;
};
let editorHost: HTMLDivElement;
let panelHost: HTMLDivElement;
let editorView: EditorView;
let panelApp: App;
let panel: SearchPanelHandle;
beforeEach(async () => {
editorHost = document.createElement("div");
panelHost = document.createElement("div");
document.body.append(editorHost, panelHost);
let panelHandle: SearchPanelHandle | null = null;
const state = EditorState.create({
doc: ddl,
extensions: [
cmSearch({
top: true,
createPanel: () => {
const dom = document.createElement("span");
dom.style.display = "none";
return { dom };
},
}),
basicSetup,
EditorState.allowMultipleSelections.of(true),
EditorState.readOnly.of(true),
Prec.highest(keymap.of([{ key: "Mod-f", run: () => panelHandle?.openSearch() ?? false, preventDefault: true }])),
],
});
editorView = new EditorView({ state, parent: editorHost });
panelApp = createApp(EditorSearchPanel, { view: editorView });
panel = panelApp.mount(panelHost) as unknown as SearchPanelHandle;
panelHandle = panel;
await nextTick();
});
afterEach(() => {
panelApp.unmount();
editorView.destroy();
document.body.innerHTML = "";
});
function searchInput(): HTMLInputElement {
const input = panelHost.querySelector<HTMLInputElement>(".editor-search-panel input");
if (!input) throw new Error("Missing editor search input");
return input;
}
function searchResultLabel(): HTMLElement {
const label = panelHost.querySelector<HTMLElement>('[aria-live="polite"]');
if (!label) throw new Error("Missing editor search result label");
return label;
}
async function openSearchAndFind(text: string) {
expect(panel.openSearch()).toBe(true);
await nextTick();
const input = searchInput();
input.value = text;
input.dispatchEvent(new Event("input", { bubbles: true }));
await vi.waitFor(() => expect(searchResultLabel().textContent).toContain("/2"));
return input;
}
describe("TableStructureEditor DDL search", () => {
it("routes Mod-f to the shared search panel and prevents browser find", async () => {
const event = new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
ctrlKey: true,
key: "f",
});
editorView.contentDOM.dispatchEvent(event);
await nextTick();
expect(event.defaultPrevented).toBe(true);
expect(panelHost.querySelector(".editor-search-panel")).not.toBeNull();
});
it("finds both VARCHAR matches without changing the read-only DDL", async () => {
const before = editorView.state.doc.toString();
const input = await openSearchAndFind("VARCHAR");
const query = createEditorSearchQuery({ search: "VARCHAR", caseSensitive: false, useRegex: false });
expect(collectEditorSearchMatches(query, editorView.state, 0, editorView.state.doc.length)).toHaveLength(2);
expect(searchResultLabel().textContent).toContain("/2");
expect(editorView.state.doc.toString()).toBe(before);
expect(editorView.state.facet(EditorState.readOnly)).toBe(true);
input.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Enter" }));
await nextTick();
expect(editorView.state.selection.main.from).toBe(ddl.lastIndexOf("VARCHAR"));
});
it("supports Escape and Ctrl+A in the DDL editor", async () => {
const input = await openSearchAndFind("VARCHAR");
const escape = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Escape" });
input.dispatchEvent(escape);
await nextTick();
expect(escape.defaultPrevented).toBe(true);
await vi.waitFor(() => expect(panelHost.querySelector(".editor-search-panel")).toBeNull());
const selectAll = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ctrlKey: true, key: "a" });
editorView.contentDOM.dispatchEvent(selectAll);
expect(editorView.state.selection.main.from).toBe(0);
expect(editorView.state.selection.main.to).toBe(editorView.state.doc.length);
});
});
@@ -0,0 +1,25 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const source = readFileSync(new URL("../TableStructureEditor.vue", import.meta.url), "utf8");
describe("TableStructureEditor DDL search wiring", () => {
it("uses a read-only CodeMirror viewer and the shared search panel", () => {
expect(source).toContain('import EditorSearchPanel from "@/components/editor/EditorSearchPanel.vue";');
expect(source).toContain('key: "Mod-f"');
expect(source).toContain('else if (activeTab.value === "ddl") ddlSearchPanelRef.value?.openSearch();');
expect(source).toContain("EditorState.readOnly.of(true)");
expect(source).not.toContain("ddlPreRef");
expect(source).not.toContain("onDdlKeydown");
});
it("refreshes and disposes the DDL editor across its component lifecycle", () => {
expect(source).toContain("if (force) destroyDdlEditor();");
expect(source).toContain("observeDdlEditorScroll(editorView);");
expect(source).toContain("ddlEditorView.value.scrollDOM.scrollTop");
expect(source).toContain('if (activeTab.value !== "ddl") destroyDdlEditor();');
expect(source).toContain("onDeactivated(() => {");
expect(source).toContain("onBeforeUnmount(() => {");
expect(source.match(/destroyDdlEditor\(\);/g)?.length).toBeGreaterThanOrEqual(4);
});
});
@@ -12,6 +12,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import SearchableSelect from "@/components/ui/searchable-select/SearchableSelect.vue";
import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess";
import * as api from "@/lib/backend/api";
import type { TransferContent, TransferMode, TransferObjectKind, TransferTableNameCase } from "@/lib/backend/api";
import { crossFamilyTransferableKinds, isSameTransferFamily, transferObjectKindsForDatabase } from "@/lib/database/transferObjectKinds";
@@ -735,6 +736,9 @@ function swapSourceAndTarget() {
async function startTransfer() {
if (!canStart.value || isSubmitting.value) return;
if (!(await ensureReadOnlyWriteAccess({ connection: store.getConfig(targetConnectionId.value), source: t("readOnlyUnlock.sourceTransfer"), treatAsMutation: true }))) {
return;
}
isSubmitting.value = true;
const effectiveSourceSchema = sourceSchema.value || sourceDatabaseName.value;
@@ -42,7 +42,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 relative grid max-h-[calc(var(--dbx-viewport-height)-2rem)] w-full max-w-sm gap-4 overflow-hidden rounded-lg border border-border p-4 text-sm shadow-lg duration-100 outline-none pointer-events-auto',
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 relative grid grid-cols-[minmax(0,1fr)] max-h-[calc(var(--dbx-viewport-height)-2rem)] w-full max-w-sm gap-4 overflow-hidden rounded-lg border border-border p-4 text-sm shadow-lg duration-100 outline-none pointer-events-auto',
props.class,
)
"
@@ -16,7 +16,7 @@ const props = withDefaults(
</script>
<template>
<div data-slot="dialog-footer" :class="cn('bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)">
<div data-slot="dialog-footer" :class="cn('bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 sm:flex-row sm:flex-wrap sm:justify-end', props.class)">
<slot />
<DialogClose v-if="showCloseButton" as-child>
<Button variant="outline"> Close </Button>
@@ -0,0 +1,43 @@
// Source-contract guard for the shared dialog layout primitives.
// Overview: `DialogContent` renders a single-column CSS grid (no explicit
// `grid-template-columns`). By default a grid's implicit track sizes to the
// *max-content* of its children, so any child with a large min-content (e.g. a
// footer that lays out many `whitespace-nowrap` buttons on one line) can push
// the track wider than the dialog's `max-w-*`. The dialog's `overflow-hidden`
// then clips the right edge without any scrollbar or ellipsis — see #7325
// (Update dialog clips release notes and buttons on wide locales).
//
// These two guarantees are what stop that from re-happening:
// 1. `grid-cols-[minmax(0,1fr)]` pins the track to the dialog's available
// width and lets grid items shrink (equivalent to `min-width: 0`).
// 2. `sm:flex-wrap` on the footer lets a long row of buttons wrap instead of
// forcing the track wide.
// If either is removed, the clipping regresses. Keep these assertions updated
// alongside the components whenever the class strings are touched.
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dialogContentSource = readFileSync(new URL("../DialogContent.vue", import.meta.url), "utf8");
const dialogFooterSource = readFileSync(new URL("../DialogFooter.vue", import.meta.url), "utf8");
describe("dialog layout guard", () => {
it("pins the DialogContent grid track to the dialog width so children can shrink", () => {
// The base class (before `props.class`) must lock the implicit track to the
// available width. Without this, a wide grid item re-expands the track beyond
// `max-w-*` and the dialog's `overflow-hidden` clips content (issue #7325).
expect(dialogContentSource).toContain("relative grid grid-cols-[minmax(0,1fr)]");
// Keep it on the shared base class, not a per-dialog override, so every dialog
// that reuses DialogContent is protected.
expect(dialogContentSource).toMatch(/'[^']*relative grid grid-cols-\[minmax\(0,1fr\)\]/);
});
it("lets the dialog footer wrap so a row of buttons does not widen the track", () => {
// At the sm breakpoint the footer is a no-wrap row; a long label (e.g. the
// Spanish "Descargar e instalar") plus other actions then overflows. Wrapping
// keeps the footer's min-content down to one button, which in turn keeps the
// `minmax(0,1fr)` track inside the dialog.
expect(dialogFooterSource).toMatch(/flex flex-col-reverse gap-2 sm:flex-row sm:flex-wrap sm:justify-end/);
});
});
@@ -2,7 +2,7 @@
import { effectScope, nextTick, ref } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { dataGridColumnOffsets, dataGridHorizontalColumnWindow, useDataGridColumnLayout, useDataGridColumnLayoutState } from "@/composables/useDataGridColumnLayout";
import { dataGridColumnOffsets, dataGridHorizontalColumnWindow, useDataGridColumnLayout, useDataGridColumnLayoutState, type ColumnHeaderReferenceDragController } from "@/composables/useDataGridColumnLayout";
import { columnHeaderDragAutoScrollDelta, columnHeaderDropTargetIndex } from "@/lib/dataGrid/dataGridColumnHeaderInteraction";
import { loadDataGridColumnLayout, saveDataGridColumnLayout } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
@@ -548,6 +548,280 @@ describe("useDataGridColumnLayout", () => {
scope.stop();
});
it("switches to reference mode over the editor and drops without reordering", () => {
vi.stubGlobal(
"requestAnimationFrame",
vi.fn((callback: FrameRequestCallback) => {
callback(0);
return 0;
}),
);
vi.stubGlobal("cancelAnimationFrame", vi.fn());
const header = document.createElement("div");
for (let index = 0; index < 2; index++) {
const column = document.createElement("div");
column.dataset.visibleColIndex = String(index);
column.getBoundingClientRect = () => ({ left: index * 100, width: 100, right: (index + 1) * 100, top: 0, bottom: 20, height: 20, x: index * 100, y: 0, toJSON: () => ({}) });
header.append(column);
}
const controller: ColumnHeaderReferenceDragController = {
isOverEditorTarget: (clientX) => clientX > 300,
onEnter: vi.fn(() => "id"),
onMove: vi.fn(),
onDrop: vi.fn(() => true),
onCancel: vi.fn(),
};
const persist = vi.fn();
const scope = effectScope();
const layout = scope.run(() =>
useDataGridColumnLayout({
columnNames: ref(["id", "name"]),
visibleColumnIndexes: ref([0, 1]),
renderedColumnWidths: ref([100, 100]),
scrollLeft: ref(0),
viewportWidth: ref(400),
rowNumberWidth: 40,
headerRef: ref(header),
onPersistColumnOrder: persist,
columnReferenceDrag: controller,
}),
)!;
layout.startColumnHeaderDrag(0, new PointerEvent("pointerdown", { button: 0, clientX: 50, clientY: 10 }));
// 网格内:仍是重排序预览
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 120, clientY: 10 }));
expect(controller.onEnter).not.toHaveBeenCalled();
expect(document.body.querySelector("[data-column-header-drag-preview]")).not.toBeNull();
// 进入编辑器区域:切换为引用模式,重排序预览移除
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 320, clientY: 10 }));
expect(controller.onEnter).toHaveBeenCalledWith(0);
expect(controller.onMove).toHaveBeenCalledWith(0, 320, 10);
expect(document.body.querySelector("[data-column-header-drag-preview]")).toBeNull();
expect(layout.columnHeaderPreviewOffsets.value).toEqual([0, 0]);
// 在编辑器内释放:插入但不重排列
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 320, clientY: 10 }));
expect(controller.onDrop).toHaveBeenCalledWith(0, 320, 10);
expect(controller.onCancel).toHaveBeenCalled();
expect(persist).not.toHaveBeenCalled();
scope.stop();
});
it("cancels the reference drag and restores reorder preview when leaving the editor", () => {
vi.stubGlobal(
"requestAnimationFrame",
vi.fn((callback: FrameRequestCallback) => {
callback(0);
return 0;
}),
);
vi.stubGlobal("cancelAnimationFrame", vi.fn());
const header = document.createElement("div");
for (let index = 0; index < 2; index++) {
const column = document.createElement("div");
column.dataset.visibleColIndex = String(index);
column.getBoundingClientRect = () => ({ left: index * 100, width: 100, right: (index + 1) * 100, top: 0, bottom: 20, height: 20, x: index * 100, y: 0, toJSON: () => ({}) });
header.append(column);
}
const controller: ColumnHeaderReferenceDragController = {
isOverEditorTarget: (clientX) => clientX > 300,
onEnter: vi.fn(() => "id"),
onMove: vi.fn(),
onDrop: vi.fn(() => true),
onCancel: vi.fn(),
};
const persist = vi.fn();
const scope = effectScope();
const layout = scope.run(() =>
useDataGridColumnLayout({
columnNames: ref(["id", "name"]),
visibleColumnIndexes: ref([0, 1]),
renderedColumnWidths: ref([100, 100]),
scrollLeft: ref(0),
viewportWidth: ref(400),
rowNumberWidth: 40,
headerRef: ref(header),
onPersistColumnOrder: persist,
columnReferenceDrag: controller,
}),
)!;
layout.startColumnHeaderDrag(0, new PointerEvent("pointerdown", { button: 0, clientX: 50, clientY: 10 }));
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 320, clientY: 10 }));
expect(controller.onEnter).toHaveBeenCalledTimes(1);
// 拖回网格:还原重排序预览,引用反馈被取消
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 120, clientY: 10 }));
expect(controller.onCancel).toHaveBeenCalledTimes(1);
expect(document.body.querySelector("[data-column-header-drag-preview]")).not.toBeNull();
// 拖出后释放(不在编辑器内):整体取消,不重排也不插入
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 320, clientY: 10 }));
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 120, clientY: 10 }));
expect(controller.onDrop).not.toHaveBeenCalled();
expect(persist).not.toHaveBeenCalled();
scope.stop();
});
it("keeps reorder mode when the controller refuses the reference drag", () => {
vi.stubGlobal(
"requestAnimationFrame",
vi.fn((callback: FrameRequestCallback) => {
callback(0);
return 0;
}),
);
vi.stubGlobal("cancelAnimationFrame", vi.fn());
const header = document.createElement("div");
for (let index = 0; index < 2; index++) {
const column = document.createElement("div");
column.dataset.visibleColIndex = String(index);
column.getBoundingClientRect = () => ({ left: index * 100, width: 100, right: (index + 1) * 100, top: 0, bottom: 20, height: 20, x: index * 100, y: 0, toJSON: () => ({}) });
header.append(column);
}
const controller: ColumnHeaderReferenceDragController = {
isOverEditorTarget: () => true,
onEnter: vi.fn(() => null),
onMove: vi.fn(),
onDrop: vi.fn(() => true),
onCancel: vi.fn(),
};
const persist = vi.fn();
const scope = effectScope();
const layout = scope.run(() =>
useDataGridColumnLayout({
columnNames: ref(["id", "name"]),
visibleColumnIndexes: ref([0, 1]),
renderedColumnWidths: ref([100, 100]),
scrollLeft: ref(0),
viewportWidth: ref(400),
rowNumberWidth: 40,
headerRef: ref(header),
onPersistColumnOrder: persist,
columnReferenceDrag: controller,
}),
)!;
layout.startColumnHeaderDrag(0, new PointerEvent("pointerdown", { button: 0, clientX: 50, clientY: 10 }));
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 320, clientY: 10 }));
expect(controller.onEnter).toHaveBeenCalled();
expect(controller.onMove).not.toHaveBeenCalled();
expect(document.body.querySelector("[data-column-header-drag-preview]")).not.toBeNull();
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 320, clientY: 10 }));
expect(controller.onDrop).not.toHaveBeenCalled();
expect(persist).toHaveBeenCalled();
scope.stop();
});
it("cancels a plain reorder drag released outside both the grid and the editor", () => {
vi.stubGlobal(
"requestAnimationFrame",
vi.fn((callback: FrameRequestCallback) => {
callback(0);
return 0;
}),
);
vi.stubGlobal("cancelAnimationFrame", vi.fn());
const header = document.createElement("div");
header.getBoundingClientRect = () => ({ left: 0, width: 200, right: 200, top: 0, bottom: 20, height: 20, x: 0, y: 0, toJSON: () => ({}) });
for (let index = 0; index < 2; index++) {
const column = document.createElement("div");
column.dataset.visibleColIndex = String(index);
column.getBoundingClientRect = () => ({ left: index * 100, width: 100, right: (index + 1) * 100, top: 0, bottom: 20, height: 20, x: index * 100, y: 0, toJSON: () => ({}) });
header.append(column);
}
const controller: ColumnHeaderReferenceDragController = {
isOverEditorTarget: () => false,
onEnter: vi.fn(() => null),
onMove: vi.fn(),
onDrop: vi.fn(() => true),
onCancel: vi.fn(),
};
const persist = vi.fn();
const scope = effectScope();
const layout = scope.run(() =>
useDataGridColumnLayout({
columnNames: ref(["id", "name"]),
visibleColumnIndexes: ref([0, 1]),
renderedColumnWidths: ref([100, 100]),
scrollLeft: ref(0),
viewportWidth: ref(400),
rowNumberWidth: 40,
headerRef: ref(header),
onPersistColumnOrder: persist,
columnReferenceDrag: controller,
}),
)!;
layout.startColumnHeaderDrag(0, new PointerEvent("pointerdown", { button: 0, clientX: 50, clientY: 10 }));
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 120, clientY: 10 }));
// 释放点 (320, 500) 不在表头/滚动区,也不在编辑器内:整体取消
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 320, clientY: 500 }));
expect(persist).not.toHaveBeenCalled();
// 对照:网格内释放仍提交重排序
layout.startColumnHeaderDrag(0, new PointerEvent("pointerdown", { button: 0, clientX: 50, clientY: 10 }));
window.dispatchEvent(new PointerEvent("pointermove", { clientX: 180, clientY: 10 }));
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 180, clientY: 10 }));
expect(persist).toHaveBeenCalledWith([1, 0]);
scope.stop();
});
it("blocks native selection and drag while dragging columns", () => {
vi.stubGlobal(
"requestAnimationFrame",
vi.fn((callback: FrameRequestCallback) => {
callback(0);
return 0;
}),
);
vi.stubGlobal("cancelAnimationFrame", vi.fn());
const header = document.createElement("div");
const column = document.createElement("div");
column.dataset.visibleColIndex = "0";
column.getBoundingClientRect = () => ({ left: 0, width: 100, right: 100, top: 0, bottom: 20, height: 20, x: 0, y: 0, toJSON: () => ({}) });
header.append(column);
const scope = effectScope();
const layout = scope.run(() =>
useDataGridColumnLayout({
columnNames: ref(["id", "name"]),
visibleColumnIndexes: ref([0, 1]),
renderedColumnWidths: ref([100, 100]),
scrollLeft: ref(0),
viewportWidth: ref(400),
rowNumberWidth: 40,
headerRef: ref(header),
}),
)!;
layout.startColumnHeaderDrag(0, new PointerEvent("pointerdown", { button: 0, clientX: 50, clientY: 10 }));
// 拖拽期间原生选择与拖拽启动被拦截
const selectStart = new Event("selectstart", { cancelable: true });
document.dispatchEvent(selectStart);
expect(selectStart.defaultPrevented).toBe(true);
const dragStart = new Event("dragstart", { cancelable: true });
document.dispatchEvent(dragStart);
expect(dragStart.defaultPrevented).toBe(true);
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 150, clientY: 10 }));
expect(document.body.style.userSelect).toBe("");
// 手势结束后恢复原生效行为
const laterSelect = new Event("selectstart", { cancelable: true });
document.dispatchEvent(laterSelect);
expect(laterSelect.defaultPrevented).toBe(false);
scope.stop();
});
it("uses the edge after frozen columns as the left auto-scroll trigger", () => {
const frames = new Map<number, FrameRequestCallback>();
let nextFrame = 1;
@@ -19,6 +19,9 @@ vi.mock("@/lib/backend/api", () => ({
executeConditionalUpdate: mocks.executeConditionalUpdate,
cancelConditionalUpdate: mocks.cancelConditionalUpdate,
executeInTransaction: mocks.executeInTransaction,
unlockConnectionWrites: vi.fn(),
lockConnectionWrites: vi.fn(),
connectionWriteUnlockState: vi.fn().mockResolvedValue(0),
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({ getConfig: mocks.getConfig }),
@@ -150,6 +153,104 @@ describe("useDataGridEditor result snapshots", () => {
expect(oldIdentityScroller.scrollTop).toBe(0);
expect(oldIdentityScroller.scrollLeft).toBe(0);
});
it("does not adopt a scroll-only snapshot when remounting with a same-shape result (#7341)", () => {
class TestScroller {
scrollTop = 0;
scrollLeft = 0;
scrollTo({ top, left }: ScrollToOptions) {
if (typeof top === "number") this.scrollTop = top;
if (typeof left === "number") this.scrollLeft = left;
}
}
vi.stubGlobal("HTMLElement", TestScroller);
const rows: CellValue[][] = [
["a", null, 1],
["b", null, 2],
["c", null, 3],
];
const key = "table-tab-scroll-only-snapshot";
const previous = createEditor(undefined, true, key, undefined, rows);
previous.newRows.value = [];
const previousScroller = new TestScroller();
previousScroller.scrollTop = 6_400;
previousScroller.scrollLeft = 24;
previous.scrollerRef.value = previousScroller as unknown as NonNullable<typeof previous.scrollerRef.value>;
// Unmount path: saves a pure scroll snapshot even without pending edits.
previous.savePendingSnapshot(true, true);
const remounted = createEditor(undefined, true, key, undefined, rows);
const remountedScroller = new TestScroller();
remounted.scrollerRef.value = remountedScroller as unknown as NonNullable<typeof remounted.scrollerRef.value>;
remounted.restorePendingSnapshotFocus();
expect(remountedScroller.scrollTop).toBe(0);
expect(remountedScroller.scrollLeft).toBe(0);
});
it("still adopts the cached scroll when the snapshot carries pending edits", () => {
class TestScroller {
scrollTop = 0;
scrollLeft = 0;
scrollTo({ top, left }: ScrollToOptions) {
if (typeof top === "number") this.scrollTop = top;
if (typeof left === "number") this.scrollLeft = left;
}
}
vi.stubGlobal("HTMLElement", TestScroller);
const rows: CellValue[][] = [
["a", null, 1],
["b", null, 2],
["c", null, 3],
];
const key = "table-tab-edit-snapshot";
const previous = createEditor(undefined, true, key, undefined, rows);
previous.newRows.value = [];
previous.dirtyRows.value.set(1, new Map([[0, "edited"]]));
const previousScroller = new TestScroller();
previousScroller.scrollTop = 6_400;
previousScroller.scrollLeft = 24;
previous.scrollerRef.value = previousScroller as unknown as NonNullable<typeof previous.scrollerRef.value>;
previous.savePendingSnapshot(true, true);
const remounted = createEditor(undefined, true, key, undefined, rows);
const remountedScroller = new TestScroller();
remounted.scrollerRef.value = remountedScroller as unknown as NonNullable<typeof remounted.scrollerRef.value>;
remounted.restorePendingSnapshotFocus();
expect(remounted.dirtyRows.value.get(1)?.get(0)).toBe("edited");
expect(remountedScroller.scrollTop).toBe(6_400);
expect(remountedScroller.scrollLeft).toBe(24);
});
it("keeps pure scroll restore for the KeepAlive reactivate path", () => {
class TestScroller {
scrollTop = 0;
scrollLeft = 0;
scrollTo({ top, left }: ScrollToOptions) {
if (typeof top === "number") this.scrollTop = top;
if (typeof left === "number") this.scrollLeft = left;
}
}
vi.stubGlobal("HTMLElement", TestScroller);
const rows: CellValue[][] = [
["a", null, 1],
["b", null, 2],
["c", null, 3],
];
const editor = createEditor(undefined, true, "table-tab-activate-path", undefined, rows);
editor.newRows.value = [];
const scroller = new TestScroller();
scroller.scrollTop = 6_400;
scroller.scrollLeft = 24;
editor.scrollerRef.value = scroller as unknown as NonNullable<typeof editor.scrollerRef.value>;
// Deactivate path: snapshot keeps the scroll for the same instance.
editor.savePendingSnapshot(true, true);
// Detaching the DOM resets the element's scroll offsets.
scroller.scrollTop = 0;
scroller.scrollLeft = 0;
editor.restorePendingSnapshotFocus();
expect(scroller.scrollTop).toBe(6_400);
expect(scroller.scrollLeft).toBe(24);
});
});
describe("useDataGridEditor row deletion confirmation", () => {
@@ -32,12 +32,14 @@ vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => mocks.sto
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
import { useDialogSources } from "@/composables/useDialogSources";
import { clearRememberedExportPassphrase, getRememberedExportPassphrase } from "@/lib/backend/exportPassphraseSession";
const mountedApps: App[] = [];
beforeEach(() => {
mocks.store.connections = [];
mocks.store.exportConnectionsToFile.mockReset().mockResolvedValue("saved");
clearRememberedExportPassphrase();
});
afterEach(() => {
@@ -178,4 +180,50 @@ describe("useDialogSources", () => {
await second;
expect(dialogs.configExportBusy.value).toBe(false);
});
it("keeps the passphrase dialog open and reports an error when writing the encrypted export fails", async () => {
mocks.store.connections = [conn("selected")];
mocks.store.exportConnectionsToFile.mockRejectedValue(new Error("disk full"));
const dialogs = await mountDialogs();
dialogs.onExportClick();
dialogs.onConfigConnectionSelectConfirm(["selected"]);
await dialogs.onExportConfirm("passphrase");
expect(dialogs.showConfigPassphraseDialog.value).toBe(true);
expect(dialogs.configPassphraseError.value).toBe("Failed to export connections");
expect(mocks.toast).not.toHaveBeenCalled();
expect(getRememberedExportPassphrase()).toBe("");
});
it("reports an error instead of success when writing the plaintext export fails", async () => {
mocks.store.connections = [conn("selected")];
mocks.store.exportConnectionsToFile.mockRejectedValue(new Error("disk full"));
const dialogs = await mountDialogs();
dialogs.onExportClick();
dialogs.onConfigConnectionSelectConfirm(["selected"]);
dialogs.onRequestUnencryptedExport();
await dialogs.onConfigUnencryptedExportConfirm();
expect(dialogs.showConfigUnencryptedExportConfirm.value).toBe(false);
expect(dialogs.showConfigPassphraseDialog.value).toBe(true);
expect(dialogs.configPassphraseError.value).toBe("Failed to export connections");
expect(mocks.toast).not.toHaveBeenCalled();
});
it("remembers the passphrase only after the export file is actually written", async () => {
mocks.store.connections = [conn("selected")];
mocks.store.exportConnectionsToFile.mockResolvedValueOnce("cancelled").mockResolvedValueOnce("saved");
const dialogs = await mountDialogs();
dialogs.onExportClick();
dialogs.onConfigConnectionSelectConfirm(["selected"]);
await dialogs.onExportConfirm("passphrase");
expect(getRememberedExportPassphrase()).toBe("");
await dialogs.onExportConfirm("passphrase");
expect(getRememberedExportPassphrase()).toBe("passphrase");
});
});
@@ -10,7 +10,7 @@ vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
getConfig: (id: string) => {
if (id === "missing") return undefined;
if (id === "readonly") return { name: "ro", read_only: true, is_production: false };
if (id === "readonly") return { id: "readonly", name: "ro", read_only: true, is_production: false, db_type: "mq" };
if (id === "prod") return { name: "prod-mq", read_only: false, is_production: true };
return { name: "dev-mq", read_only: false, is_production: false };
},
@@ -21,6 +21,12 @@ vi.mock("@/stores/productionSafetyStore", () => ({
useProductionSafetyStore: () => ({ requestConfirmation }),
}));
vi.mock("@/lib/backend/api", () => ({
unlockConnectionWrites: vi.fn(),
lockConnectionWrites: vi.fn(),
connectionWriteUnlockState: vi.fn().mockResolvedValue(0),
}));
vi.mock("@/composables/useToast", () => ({
useToast: () => ({ toast }),
}));
@@ -32,6 +38,7 @@ vi.mock("vue-i18n", () => ({
}));
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
import { useReadOnlyUnlockStore } from "@/stores/readOnlyUnlockStore";
describe("useMqMutationGuard", () => {
beforeEach(() => {
@@ -41,14 +48,20 @@ describe("useMqMutationGuard", () => {
toast.mockReset();
});
it("denies missing and read-only connections without prompting", async () => {
it("denies missing connections and prompts to unlock read-only ones", async () => {
const missing = useMqMutationGuard("missing");
const readonly = useMqMutationGuard("readonly");
await expect(missing.confirmMqWrite("send")).resolves.toBe(false);
await expect(readonly.confirmMqWrite("send")).resolves.toBe(false);
expect(requestConfirmation).not.toHaveBeenCalled();
expect(toast).toHaveBeenCalledWith("mqAdmin.connectionMissing");
expect(toast).toHaveBeenCalledWith("mqAdmin.writeDeniedReadOnly");
const unlockStore = useReadOnlyUnlockStore();
const readonly = useMqMutationGuard("readonly");
const pending = readonly.confirmMqWrite("send");
await vi.waitFor(() => {
expect(unlockStore.pending?.connectionId).toBe("readonly");
});
expect(requestConfirmation).not.toHaveBeenCalled();
unlockStore.cancel();
await expect(pending).resolves.toBe(false);
});
it("allows non-production writes immediately", async () => {
@@ -7,6 +7,7 @@ import { useHistoryStore } from "@/stores/historyStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import * as objectMetadataCache from "@/lib/metadata/objectMetadataCache";
import type { ConnectionConfig, QueryTab } from "@/types/database";
vi.mock("vue-i18n", () => ({
@@ -17,8 +18,16 @@ vi.mock("vue-i18n", () => ({
vi.mock("@/lib/backend/api", () => ({
saveEditorSettings: vi.fn(),
saveHistory: vi.fn(),
unlockConnectionWrites: vi.fn(),
lockConnectionWrites: vi.fn(),
connectionWriteUnlockState: vi.fn().mockResolvedValue(0),
}));
vi.mock("@/lib/metadata/objectMetadataCache", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/metadata/objectMetadataCache")>();
return { ...actual, invalidateObjectMetadataCache: vi.fn() };
});
function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
@@ -126,6 +135,7 @@ describe("useSqlExecution", () => {
beforeEach(() => {
installLocalStorage();
setActivePinia(createPinia());
vi.mocked(objectMetadataCache.invalidateObjectMetadataCache).mockClear();
});
it("invalidates object metadata after successful connection-level DDL", async () => {
@@ -155,6 +165,35 @@ describe("useSqlExecution", () => {
expect(loadDatabases).toHaveBeenCalledWith("conn-1", { force: true });
});
it("invalidates table facets after successful Kingbase constraint DDL", async () => {
const sql = "ALTER TABLE public.orders DISABLE CONSTRAINT orders_check";
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), schema: "public", sql });
const activeConnection = ref<ConnectionConfig | undefined>(connection("kingbase"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
const queryStore = useQueryStore();
const connectionStore = useConnectionStore();
useSettingsStore().editorSettings.confirmDangerousSqlExecution = false;
vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => {
if (activeTab.value) activeTab.value.result = { columns: [], rows: [], affected_rows: 0, execution_time_ms: 1 };
});
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
const invalidateObjectMetadata = vi.mocked(objectMetadataCache.invalidateObjectMetadataCache).mockResolvedValue(undefined);
const refreshObjects = vi.spyOn(connectionStore, "refreshObjectListTreeNode").mockResolvedValue(undefined);
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => sql),
activeOutputView,
requestDangerConfirmation: async () => true,
});
await execution.tryExecute();
expect(invalidateObjectMetadata).toHaveBeenCalledWith({ connectionId: "conn-1", database: "app", schema: "public" });
expect(refreshObjects).toHaveBeenCalledWith("conn-1", "app", "public");
});
it("sends every placeholder syntax and @set unchanged when substitution is disabled", async () => {
const sql = ["@set tenant = 42;", "SELECT ?, :named, ${shell}, #{mybatis}, @sqlserver, @tenant;"].join("\n");
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), sql });
@@ -236,7 +275,7 @@ describe("useSqlExecution", () => {
expect(executeCurrentSql).toHaveBeenCalledWith("SELECT * FROM patrol WHERE post_id = '224';", {});
});
it("opens the execution summary for a multi-statement batch", async () => {
it("opens the result table for a multi-statement batch by default", async () => {
const sql = "SELECT 1;\nSELECT 2;";
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), sql });
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
@@ -256,6 +295,30 @@ describe("useSqlExecution", () => {
await execution.tryExecute();
expect(activeOutputView.value).toBe("result");
});
it("opens the execution summary for a multi-statement batch when configured", async () => {
const sql = "SELECT 1;\nSELECT 2;";
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), sql });
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
const queryStore = useQueryStore();
useSettingsStore().editorSettings.multiStatementDefaultView = "summary";
vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => {
if (activeTab.value) activeTab.value.result = { columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 };
});
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => sql),
activeOutputView,
});
await execution.tryExecute();
expect(activeOutputView.value).toBe("summary");
});
@@ -429,7 +492,7 @@ GO`;
expect(activeTab.value?.result?.execution_error).toBe(true);
});
it("keeps ordinary SQL Server multi-result batches on their summary", async () => {
it("opens ordinary SQL Server multi-result batches in the result table by default", async () => {
const sql = "SELECT 1 AS first_value; SELECT 2 AS second_value;";
const activeTab = ref<QueryTab | undefined>({ ...queryTab("dbx_sqlserver_demo"), sql });
const activeConnection = ref<ConnectionConfig | undefined>(connection("sqlserver"));
@@ -455,12 +518,12 @@ GO`;
await execution.tryExecute();
expect(activeOutputView.value).toBe("summary");
expect(activeOutputView.value).toBe("result");
expect(setActiveResultIndex).not.toHaveBeenCalled();
expect(activeTab.value?.result?.rows).toEqual([[1]]);
});
it("keeps the summary for ordinary SQL Server data aliased as Message", async () => {
it("opens ordinary SQL Server data aliased as Message in the result table by default", async () => {
const sql = `DECLARE @value nvarchar(1) = N'x';
SELECT @value AS Message;`;
const activeTab = ref<QueryTab | undefined>({ ...queryTab("dbx_sqlserver_demo"), sql });
@@ -481,7 +544,7 @@ SELECT @value AS Message;`;
await execution.tryExecute();
expect(activeOutputView.value).toBe("summary");
expect(activeOutputView.value).toBe("result");
expect(activeTab.value?.result?.rows).toEqual([["x"]]);
});
@@ -1199,8 +1262,9 @@ SELECT @value AS Message;`;
});
const pendingExecution = execution.tryExecute();
await Promise.resolve();
expect(productionSafetyStore.pending?.sql).toContain("UPDATE users");
await vi.waitFor(() => {
expect(productionSafetyStore.pending?.sql).toContain("UPDATE users");
});
expect(executeCurrentSql).not.toHaveBeenCalled();
productionSafetyStore.confirm();
@@ -28,6 +28,9 @@ vi.mock("vue-i18n", () => ({
vi.mock("@/lib/backend/api", () => ({
saveEditorSettings: vi.fn(),
saveHistory: vi.fn(),
unlockConnectionWrites: vi.fn(),
lockConnectionWrites: vi.fn(),
connectionWriteUnlockState: vi.fn().mockResolvedValue(0),
}));
function installLocalStorage() {
@@ -50,8 +50,27 @@ type ColumnHeaderDragState = {
columnRects: { visibleIndex: number; left: number; width: number }[];
previewElement: HTMLElement | null;
dragging: boolean;
/** 指针进入 SQL 编辑器后切换为“插入列引用”模式,重排序预览挂起。 */
referenceMode: boolean;
/** 本次手势中 onEnter 已拒绝过该列(不可作为引用),不再重复试探。 */
referenceUnavailable: boolean;
};
/**
* 目标导向拖拽控制器:网格内保持列重排序手势;指针进入 SQL 编辑器区域时
* 由 DataGrid 提供的实现接管反馈与最终插入。
*/
export interface ColumnHeaderReferenceDragController {
isOverEditorTarget(clientX: number, clientY: number): boolean;
/** 进入编辑器目标时回调;返回 chip 文案,null 表示该列不可作为引用拖入。 */
onEnter(sourceVisibleIndex: number): string | null;
onMove(sourceVisibleIndex: number, clientX: number, clientY: number): void;
/** 在编辑器目标内释放时回调;返回 true 表示已处理插入。 */
onDrop(sourceVisibleIndex: number, clientX: number, clientY: number): boolean;
/** 引用模式结束(无论是否发生插入)时清理反馈。 */
onCancel(): void;
}
export function dataGridColumnOffsets(widths: readonly number[]): number[] {
const offsets = Array.from({ length: widths.length + 1 }, () => 0);
for (let index = 0; index < widths.length; index++) offsets[index + 1] = offsets[index] + (widths[index] ?? 0);
@@ -427,6 +446,7 @@ export function useDataGridColumnLayout(options: {
onRefreshMetrics?: () => void;
onPersistColumnOrder?: (indexes: number[]) => void;
frozenColumnCount?: MaybeRefOrGetter<number>;
columnReferenceDrag?: ColumnHeaderReferenceDragController;
}) {
const renderedColumnOffsets = computed(() => dataGridColumnOffsets(toValue(options.renderedColumnWidths)));
const frozenColumnCount = computed(() => toValue(options.frozenColumnCount ?? 0));
@@ -706,6 +726,8 @@ export function useDataGridColumnLayout(options: {
window.removeEventListener("pointerup", onColumnHeaderPointerUp, true);
window.removeEventListener("pointercancel", onColumnHeaderPointerCancel, true);
window.removeEventListener("blur", onColumnHeaderPointerCancel, true);
document.removeEventListener("selectstart", blockColumnHeaderNativeInteraction, true);
document.removeEventListener("dragstart", blockColumnHeaderNativeInteraction, true);
cancelColumnHeaderDragPreview();
removeColumnHeaderDragPreview(state);
document.body.style.userSelect = "";
@@ -723,6 +745,36 @@ export function useDataGridColumnLayout(options: {
options.onRefreshMetrics?.();
}
function enterColumnReferenceMode(state: ColumnHeaderDragState, clientX: number, clientY: number): boolean {
const controller = options.columnReferenceDrag;
if (!controller) return false;
const label = controller.onEnter(state.sourceVisibleIndex);
if (label == null) return false;
state.referenceMode = true;
cancelColumnHeaderDragPreview();
removeColumnHeaderDragPreview(state);
controller.onMove(state.sourceVisibleIndex, clientX, clientY);
return true;
}
function exitColumnReferenceMode(state: ColumnHeaderDragState) {
const controller = options.columnReferenceDrag;
state.referenceMode = false;
state.referenceUnavailable = false;
if (state.dragging) createColumnHeaderDragPreview(state);
controller?.onCancel();
}
/** 指针是否仍在本网格区域内(滚动区或表头行),用于“拖出网格释放=取消”。 */
function pointerInsideGridArea(clientX: number, clientY: number): boolean {
const rects: DOMRect[] = [];
const scroller = options.getScrollElement?.();
if (scroller) rects.push(scroller.getBoundingClientRect());
const header = toValue(options.headerRef);
if (header) rects.push(header.getBoundingClientRect());
return rects.some((rect) => clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom);
}
function onColumnHeaderPointerMove(event: PointerEvent) {
const state = columnHeaderDragState.value;
if (!state) return;
@@ -734,20 +786,59 @@ export function useDataGridColumnLayout(options: {
createColumnHeaderDragPreview(state);
}
if (!state.dragging) return;
const controller = options.columnReferenceDrag;
if (controller && !state.referenceUnavailable) {
const overEditor = controller.isOverEditorTarget(event.clientX, event.clientY);
if (overEditor && !state.referenceMode) {
// 进入编辑器:尝试切换为列引用模式;不可引用(onEnter 返回 null)时保持重排序并不再试探。
if (enterColumnReferenceMode(state, event.clientX, event.clientY)) return;
state.referenceUnavailable = true;
} else if (!overEditor && state.referenceMode) {
exitColumnReferenceMode(state);
}
}
if (state.referenceMode) {
event.preventDefault();
controller?.onMove(state.sourceVisibleIndex, event.clientX, event.clientY);
return;
}
event.preventDefault();
scheduleColumnHeaderDragPreview(event.clientX);
}
function onColumnHeaderPointerUp(event: PointerEvent) {
const state = columnHeaderDragState.value;
if (state?.referenceMode) {
const controller = options.columnReferenceDrag!;
if (controller.isOverEditorTarget(event.clientX, event.clientY)) {
// 在编辑器内释放:插入列引用(onDrop 失败也只按取消收尾)。
controller.onDrop(state.sourceVisibleIndex, event.clientX, event.clientY);
}
controller.onCancel();
stopColumnHeaderDrag(false);
return;
}
columnHeaderPendingClientX = event.clientX;
flushColumnHeaderDragPreview();
// 目标导向手势启用时,把列拖出网格与编辑器之外释放=取消,不重排列。
if (state?.dragging && options.columnReferenceDrag && !options.columnReferenceDrag.isOverEditorTarget(event.clientX, event.clientY) && !pointerInsideGridArea(event.clientX, event.clientY)) {
stopColumnHeaderDrag(false);
return;
}
stopColumnHeaderDrag(true);
}
function onColumnHeaderPointerCancel() {
const state = columnHeaderDragState.value;
if (state?.referenceMode) options.columnReferenceDrag?.onCancel();
stopColumnHeaderDrag(false);
}
/** 拖拽期间拦截原生文本选择与 HTML5 拖拽启动,防止其抢占指针事件流。 */
function blockColumnHeaderNativeInteraction(event: Event) {
event.preventDefault();
}
function startColumnHeaderDrag(visibleColIdx: number, event: PointerEvent) {
if (event.button !== 0 || options.getIsResizing?.() || columnHeaderInteractiveTarget(event.target)) return;
const scroller = options.getScrollElement?.();
@@ -755,6 +846,11 @@ export function useDataGridColumnLayout(options: {
const columnRects = columnHeaderLayoutRects();
const sourceRect = columnRects.find((rect) => rect.visibleIndex === visibleColIdx);
const dragCenterClientOffsetX = sourceRect ? sourceRect.left + sourceRect.width / 2 - event.clientX : 0;
// 阻止原生文本选择/HTML5 拖拽抢占事件流:一旦发生会派发 pointercancel 并停发 pointermove,
// 手势将被冻结(表现为拖不动)。参照侧边栏表引用路径在起点即禁用。
event.preventDefault();
document.addEventListener("selectstart", blockColumnHeaderNativeInteraction, true);
document.addEventListener("dragstart", blockColumnHeaderNativeInteraction, true);
columnHeaderDragState.value = {
sourceVisibleIndex: visibleColIdx,
targetVisibleIndex: visibleColIdx,
@@ -769,6 +865,8 @@ export function useDataGridColumnLayout(options: {
columnRects,
previewElement: null,
dragging: false,
referenceMode: false,
referenceUnavailable: false,
};
columnHeaderPendingClientX = event.clientX;
window.addEventListener("pointermove", onColumnHeaderPointerMove, true);
@@ -788,12 +886,12 @@ export function useDataGridColumnLayout(options: {
function columnHeaderDragClass(visibleColIdx: number) {
const state = columnHeaderDragState.value;
return { "opacity-0 pointer-events-none": state?.dragging && state.sourceVisibleIndex === visibleColIdx };
return { "opacity-0 pointer-events-none": state?.dragging && !state.referenceMode && state.sourceVisibleIndex === visibleColIdx };
}
function columnHeaderPreviewOffset(visibleColIdx: number): number {
const state = columnHeaderDragState.value;
if (!state) return 0;
if (!state || state.referenceMode) return 0;
const scrollCompensation = state.sourceVisibleIndex < frozenColumnCount.value ? 0 : state.currentScrollLeft - state.startScrollLeft;
return columnHeaderPreviewOffsetForColumn({
columnDragActive: state.dragging,
@@ -820,7 +918,7 @@ export function useDataGridColumnLayout(options: {
const columnHeaderPreviewOffsets = computed(() => toValue(options.renderedColumnWidths).map((_, visibleColIdx) => columnHeaderPreviewOffset(visibleColIdx)));
const columnHeaderPreviewSourceVisibleIndex = computed(() => {
const state = columnHeaderDragState.value;
return state?.dragging ? state.sourceVisibleIndex : null;
return state?.dragging && !state.referenceMode ? state.sourceVisibleIndex : null;
});
function disposeColumnHeaderInteractions() {
@@ -11,6 +11,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
import { useHistoryStore } from "@/stores/historyStore";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import { assessProductionSql, productionContextForDatabase } from "@/lib/database/productionSafety";
import { ensureReadOnlyWriteAccess, isWriteUnlockActive } from "@/lib/database/readOnlyWriteAccess";
import type { ColumnInfo, DatabaseType } from "@/types/database";
import { DBX_NEO4J_ELEMENT_ID_COLUMN, usesSyntheticRowIdKey } from "@/lib/table/tableEditing";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
@@ -314,7 +315,14 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
editValue.value = cached.editValue ?? "";
restoredEditingCell = !!cached.editingCell;
restoredTransactionActive = cached.transactionActive === true;
pendingScrollRestore = cached.scroll;
// A scroll-only snapshot (no pending edits, draft row, or active cell editor)
// must not drag a remounted grid back to the previous viewport: the fresh
// result should start at the first row (#7341). Scroll is only replayed
// alongside edit state so the user lands back on their edited rows. The
// KeepAlive activate path keeps pure scroll restore via the in-instance
// pendingScrollRestore, which this gate does not touch.
const snapshotHasEditState = cached.newRows.length > 0 || cached.dirtyRows.size > 0 || cached.deletedRows.size > 0 || !!cached.editingCell || !!cached.quickEntryDraftRow;
pendingScrollRestore = snapshotHasEditState ? cached.scroll : undefined;
pendingChangesCache.delete(key);
} else {
pendingChangesCache.delete(key);
@@ -1591,6 +1599,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
saveError.value = "";
const connection = connectionStore.getConfig(connectionId.value);
if (!(await ensureReadOnlyWriteAccess({ connection, sql: statement, source: i18n.global.t("readOnlyUnlock.sourceDataEditor") }))) return null;
const productionAssessment = assessProductionSql(statement, connection, database.value);
if (productionAssessment.active && productionAssessment.isMutation) {
const confirmed = await productionSafetyStore.requestConfirmation({
@@ -1598,7 +1607,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
connectionName: connection?.name,
database: database.value,
productionDatabases: productionAssessment.databases,
source: "Data editor",
source: i18n.global.t("readOnlyUnlock.sourceDataEditor"),
});
if (!confirmed) return null;
}
@@ -1694,6 +1703,12 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
const customHandler = customSaveHandler?.value;
const connection = connectionStore.getConfig(connectionId.value ?? "");
if (connection?.read_only) {
if (saveOptions.autoSave && !isWriteUnlockActive(connection.id)) return;
if (!(await ensureReadOnlyWriteAccess({ connection, sql: describeDataGridChanges(snapshot), source: i18n.global.t("readOnlyUnlock.sourceDataEditor"), treatAsMutation: true }))) {
return;
}
}
const customHandlerProductionContext = productionContextForDatabase(connection, database.value);
if (customHandler && customHandlerProductionContext.active) {
// Custom data sources may not expose SQL, but their row mutations still need the same production interlock.
@@ -1705,7 +1720,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
connectionName: connection?.name,
database: database.value,
productionDatabases: customHandlerProductionContext.databases,
source: "Data editor",
source: i18n.global.t("readOnlyUnlock.sourceDataEditor"),
});
if (!confirmed) return;
}
@@ -1786,7 +1801,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
connectionName: connection?.name,
database: database.value,
productionDatabases: productionAssessment.databases,
source: "Data editor",
source: i18n.global.t("readOnlyUnlock.sourceDataEditor"),
});
if (!confirmed) {
await finishInterruptedSaveChanges(snapshot);
@@ -2,6 +2,7 @@ import { ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import { rememberExportPassphrase } from "@/lib/backend/exportPassphraseSession";
import { hasSidebarLayoutEntries } from "@/lib/sidebar/sidebarLayout";
import type { ConnectionConfigBundle } from "@/lib/connection/connectionConfigTransfer";
import type { ConnectionConfig, SidebarLayout } from "@/types/database";
@@ -337,6 +338,8 @@ export function useDialogSources() {
try {
const result = await connectionStore.exportConnectionsToFile({ mode: "encrypted", passphrase }, pendingExportConnectionIds.value);
if (result === "cancelled") return;
// 仅在文件写入成功后才记住密码短语,供同一会话内下次导出对话框回显(仅内存,不落盘)
rememberExportPassphrase(passphrase);
showConfigPassphraseDialog.value = false;
clearPendingExportState();
toast(t("configExport.exportSuccess"), 2000);
@@ -2,6 +2,7 @@ import { toValue, type MaybeRefOrGetter } from "vue";
import { useI18n } from "vue-i18n";
import { useConnectionStore } from "@/stores/connectionStore";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import { ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess";
import { useToast } from "@/composables/useToast";
/**
@@ -24,8 +25,7 @@ export function useMqMutationGuard(connectionId: MaybeRefOrGetter<string>) {
toast(t("mqAdmin.connectionMissing"));
return false;
}
if (config.read_only) {
toast(t("mqAdmin.writeDeniedReadOnly"));
if (!(await ensureReadOnlyWriteAccess({ connection: config, sql: operation, source: t("production.sourceMq"), treatAsMutation: true }))) {
return false;
}
if (!config.is_production) return true;
@@ -4,6 +4,7 @@ import { useToast } from "@/composables/useToast";
import { useConnectionStore } from "@/stores/connectionStore";
import type { DatabaseType, TreeNode } from "@/types/database";
import { supportsTableTruncate, supportsTableVacuum } from "@/lib/database/databaseCapabilities";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import {
buildDropTableSql,
buildEmptyTableSql,
@@ -66,7 +67,7 @@ export function useSidebarTableMutationRuntime(options: SidebarTableMutationRunt
const supportsTruncate = computed(() => supportsTableTruncate(currentDatabaseType()));
const supportsVacuum = computed(() => {
const config = activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined;
return activeNode.value.type === "table" && !config?.read_only && supportsTableVacuum(currentDatabaseType());
return activeNode.value.type === "table" && !connectionIsEffectivelyReadOnly(config) && supportsTableVacuum(currentDatabaseType());
});
const canDropTableCascade = computed(() => activeNode.value.type === "table" && supportsDropTableCascade(currentDatabaseType()));
const canTruncateTableCascade = computed(() => activeNode.value.type === "table" && supportsTruncateTableCascade(currentDatabaseType()));
@@ -10,6 +10,7 @@ import { supportsConnectionScopedQueryExecution } from "@/lib/database/databaseF
import { supportsConnectionLevelSqlExecution } from "@/lib/connection/connectionLevelDatabaseBootstrap";
import { classifySqlActivityKind } from "@/lib/history/historyActivityKind";
import { sqlMetadataRefreshTarget } from "@/lib/sql/sqlMetadataRefresh";
import { invalidateObjectMetadataCache } from "@/lib/metadata/objectMetadataCache";
import { defaultViewForResult } from "@/lib/query/queryResultDefaultView";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import { classifyRedisCommandSafety } from "@/lib/redis/redisCommandSafety";
@@ -19,6 +20,7 @@ import { extractSqlParameterDescriptors, type SqlParameterDescriptor, type SqlPa
import { expandSqlVariables } from "@/lib/sql/sqlVariables";
import { enabledSqlParameterSyntaxes, resolveSqlVariableSyntaxToggles } from "@/lib/sql/sqlVariableSyntax";
import { assessProductionSql } from "@/lib/database/productionSafety";
import { ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import type { SqlExecutionDangerRequest } from "@/stores/sqlExecutionDangerStore";
import type { ConnectionConfig, DatabaseType, QueryTab } from "@/types/database";
@@ -176,6 +178,7 @@ export function useSqlExecution(deps: {
}
async function continueExecute(sql: string, sourceOffset?: number, options: SqlExecutionOptions = {}) {
if (!(await ensureReadOnlyWriteAccess({ connection: deps.activeConnection.value, sql, source: t("production.sourceSqlEditor") }))) return;
// Redis: block dangerous commands when toggle is on (scan entire batch for highest safety level)
if (deps.activeConnection.value?.db_type === "redis" && deps.blockDangerousRedisCommands?.value !== false) {
const commands = sql
@@ -291,7 +294,7 @@ export function useSqlExecution(deps: {
return;
}
const statementCount = splitSqlStatementRanges(sql, executionDatabaseType).length;
deps.activeOutputView.value = statementCount > 1 ? "summary" : "result";
deps.activeOutputView.value = statementCount > 1 ? settingsStore.editorSettings.multiStatementDefaultView : "result";
const connName = executionConnection?.name || "";
const start = Date.now();
const isRedis = executionDatabaseType === "redis";
@@ -331,8 +334,10 @@ export function useSqlExecution(deps: {
const refreshTarget = sqlMetadataRefreshTarget(sql, tab.schema);
if (refreshTarget.scope === "connection") {
connectionStore.invalidateMetadataCache(tab.connectionId);
await invalidateObjectMetadataCache({ connectionId: tab.connectionId });
await connectionStore.loadDatabases(tab.connectionId, { force: true });
} else if (refreshTarget.scope === "database") {
await invalidateObjectMetadataCache({ connectionId: tab.connectionId, database: tab.database, schema: refreshTarget.schema });
await connectionStore.refreshObjectListTreeNode(tab.connectionId, tab.database, refreshTarget.schema);
}
}
@@ -358,6 +363,9 @@ export function useSqlExecution(deps: {
const tabCancelRequested = (count: number) => (tab.cancelRequestCount ?? 0) !== count;
if (cancelRequested()) return finish({ status: "cancelled" });
if (!sql.trim()) return finish({ status: "failed", errorMessage: t("explain.emptySql") });
if (!(await ensureReadOnlyWriteAccess({ connection, sql, source: t("production.sourceMultiDbSql") }))) {
return finish({ status: "skipped", errorMessage: t("dangerDialog.cancel") });
}
if (requiresDatabaseSelection(executionTab, connection, sql)) {
return finish({ status: "failed", errorMessage: t("editor.selectDatabaseRequired") });
}
@@ -473,8 +481,10 @@ export function useSqlExecution(deps: {
const refreshTarget = sqlMetadataRefreshTarget(sql, executionTab.schema);
if (refreshTarget.scope === "connection") {
connectionStore.invalidateMetadataCache(executionTab.connectionId);
await invalidateObjectMetadataCache({ connectionId: executionTab.connectionId });
await connectionStore.loadDatabases(executionTab.connectionId, { force: true });
} else if (refreshTarget.scope === "database") {
await invalidateObjectMetadataCache({ connectionId: executionTab.connectionId, database: executionTab.database, schema: refreshTarget.schema });
await connectionStore.refreshObjectListTreeNode(executionTab.connectionId, executionTab.database, refreshTarget.schema);
}
}
@@ -544,6 +554,7 @@ export function useSqlExecution(deps: {
settingsStore.updateEditorSettings({ confirmDangerousSqlExecution: false });
}
suppressDangerConfirm.value = false;
if (!(await ensureReadOnlyWriteAccess({ connection: deps.activeConnection.value, sql, source: t("production.sourceSqlEditor") }))) return;
await doExecute(sql, sourceOffset, { openInNewResultTab, editorViewportRequestId });
}
+57 -3
View File
@@ -1756,8 +1756,8 @@ export default {
formatterForeignKeyReferenceColumn: "Matching column",
formatterForeignKeyReferenceColumnPlaceholder: "Select the column matching this value",
formatterForeignKeyReferenceMetadataUnavailable: "Could not verify matching-column uniqueness: {error}",
formatterForeignKeyReferenceInvalid: "This column is missing or is not a valid single-column unique key. Select another column.",
formatterForeignKeyReferenceUnavailable: "The target table has no usable single-column unique key.",
formatterForeignKeyReferenceInvalid: "This column is missing or is not guaranteed unique by a unique key and the current equality filter. Select another column.",
formatterForeignKeyReferenceUnavailable: "The target table has no usable unique key. For a composite unique key, fix the other column with an equality filter.",
formatterForeignKeyDisplayColumn: "Display column",
formatterForeignKeyDisplayColumnPlaceholder: "Select a display column",
formatterForeignKeyFilter: "Limit referenced rows",
@@ -2054,6 +2054,7 @@ export default {
setNull: "Set NULL",
restoreOriginalValue: "Restore Original",
copyColumnName: "Copy Column Name",
columnDragChipMany: "{names} ({count} columns)",
copyAlterColumnSql: "Copy as SQL ALTER",
alterSqlCopied: "SQL ALTER copied to clipboard",
alterSqlCopiedWithWarnings: "SQL ALTER copied to clipboard ({count} warning(s))",
@@ -2075,6 +2076,9 @@ export default {
columnRegularIndex: "Regular index",
tableInfoForeignKeys: "Foreign Keys",
foreignKeyNavigate: "Go to {table}",
tableInfoConstraints: "Constraints",
tableInfoConstraintDisabled: "Disabled",
tableInfoConstraintNotValidated: "Not validated",
tableInfoTriggers: "Triggers",
tableInfoNullable: "Nullable",
tableInfoEmpty: "No metadata",
@@ -2347,6 +2351,7 @@ export default {
},
connectionGroup: {
createGroup: "New Group",
group: "Group",
groupNamePlaceholder: "Group name",
selectConnection: "Select connection",
deselectConnection: "Deselect connection",
@@ -2361,7 +2366,7 @@ export default {
moveToGroup: "Move to Group",
moveToNewGroup: "Move to New Group",
ungrouped: "Ungrouped",
ungroupedLabel: "Ungrouped",
ungroupedLabel: "Default",
newGroup: "New Group...",
newGroupDefault: "New Group",
deleteGroupConfirmTitle: "Delete Group",
@@ -2437,6 +2442,7 @@ export default {
settings: "AI Settings",
noConfig: "AI is not configured. Please set up your AI provider in Settings > AI.",
provider: "Provider",
providerCustom: "Custom",
providerStatusActive: "Active",
providerStatusConfigured: "Configured",
switchProvider: "Switch Provider",
@@ -3986,6 +3992,8 @@ export default {
sqliteRebuildNotice: "SQLite retains an inert data snapshot as a backup, then rebuilds the table in one transaction and forcibly CASTs changed columns. Unrepresentable values may become 0/0.0; failures roll back.",
extendedProperties: "Extended",
autoIncrement: "Auto Increment",
mysqlAutoIncrementNextValue: "Next auto-increment value",
editMysqlAutoIncrementValue: "Edit next auto-increment value (current: {value})",
onUpdateCurrentTimestamp: "Auto Update Time",
identity: "Identity",
identityGeneration: "Generation",
@@ -5168,6 +5176,24 @@ export default {
sourceAdmin: "Database administration",
aiReviewRequired: "Production SQL was added to the editor. Review it and run it manually to confirm.",
},
readOnlyUnlock: {
confirmTitle: "Unlock writes",
confirmMessage: "This connection is read-only. Writes stay blocked unless you unlock them for a short window. The saved read-only setting will not be turned off.",
confirmDetails: "Connection: {connection}\nSource: {source}\nWrites will be allowed for the selected duration, then read-only resumes automatically.",
confirmAction: "Unlock writes",
durationLabel: "Allow writes for",
durationOneMinute: "1 minute",
durationFiveMinutes: "5 minutes",
remaining: "{time} left",
unlockedBadge: "Writes unlocked",
lockNow: "Lock writes now",
unlockAction: "Unlock writes…",
sourceImport: "Table import",
sourceTransfer: "Data transfer",
sourceSqlFile: "SQL file execution",
sourceStatus: "Read-only control",
sourceDataEditor: "Data editor",
},
sqlParameters: {
title: "SQL Parameters",
description: "Fill values for SQL template placeholders. DBX replaces them before executing the SQL.",
@@ -5179,6 +5205,7 @@ export default {
collectionValuePlaceholder: "Enter a JSON array or comma-separated values",
preview: "SQL Preview",
execute: "Execute",
clearValues: "Clear Values",
useRawForAll: "Use Raw SQL for All",
kind: {
string: "String",
@@ -5336,6 +5363,8 @@ export default {
formatJsonDescription: "Object or array rows",
formatExcel: "Excel",
formatExcelDescription: "XLS, XLSX, XLSM",
formatSql: "SQL",
formatSqlDescription: "INSERT statements in SQL scripts",
previewRows: "Preview rows",
delimiter: "Delimiter",
encoding: "Encoding",
@@ -6269,6 +6298,8 @@ export default {
tableOpenPageSizeDescription: "Rows loaded per page when opening a new table. Changing rows per page in a table updates this default for future table tabs.",
queryPageSize: "Default query rows per page",
queryPageSizeDescription: "Rows loaded per query result page. The maximum supported page size is {max}.",
multiStatementDefaultView: "Default view for multiple statements",
multiStatementDefaultViewDescription: "Choose whether batches with multiple SQL statements open in the result table or execution summary.",
queryResultMaxRows: "Maximum query result rows",
queryResultMaxRowsEnabled: "Limit query result rows",
queryResultMaxRowsDescription: "Caps rows returned by queries, table data, stored procedures, and continuous loading.",
@@ -6317,6 +6348,8 @@ export default {
dataGridAutoTransposeSingleRowDescription: "When enabled, SQL query results with exactly one row and multiple columns automatically switch to transpose view.",
dataGridCellDetailButtonVisible: "Show cell detail button",
dataGridCellDetailButtonVisibleDescription: "Show the cell detail button when hovering over a cell.",
dataGridCrosshairHighlight: "Crosshair row & column highlight",
dataGridCrosshairHighlightDescription: "When enabled, lightly highlight the entire row and column of the active cell (like Excel). The focused cell keeps its selected style.",
infiniteScroll: "Infinite scroll loading",
autoCalculateTotalRows: "Auto-calculate total row count",
autoCalculateTotalRowsDescription: "Run COUNT(*) automatically after each query to show the total matching rows. Off by default to keep large queries fast — calculate it on demand from the result footer.",
@@ -6416,6 +6449,21 @@ export default {
snippetsRestoreDefaults: "Restore Defaults",
snippetsAddTitle: "Add Snippet",
snippetsEditTitle: "Edit Snippet",
sqlShortcutsTitle: "SQL Quick Actions",
sqlShortcutsDescription: "Custom SQL templates executed after selecting a table name in the SQL editor. Select a table identifier first; shortcuts with Cmd/Ctrl are recommended.",
sqlShortcutsAdd: "Add",
sqlShortcutsAddTitle: "Add SQL Quick Action",
sqlShortcutsEditTitle: "Edit SQL Quick Action",
sqlShortcutsLabel: "Label",
sqlShortcutsLabelPlaceholder: "e.g. Count rows",
sqlShortcutsLabelRequired: "Label is required.",
sqlShortcutsSql: "SQL Template",
sqlShortcutsSqlPlaceholder: "SELECT COUNT(*) FROM ${table}",
sqlShortcutsVariableHint: "Use {token} for the selected table name.",
sqlShortcutsEmpty: "No SQL quick actions yet.",
sqlShortcutsUnbound: "Unbound",
sqlShortcutsToggle: "Enable or disable SQL shortcut",
sqlShortcutsDeleteConfirm: 'Delete SQL shortcut "{name}"?',
syncWebDavTitle: "WebDAV Sync",
syncWebDavDescription: "Upload or restore a DBX snapshot, including saved SQL library entries, from a WebDAV-compatible storage service.",
syncWebDavWebDescription: "In Web mode, the DBX server accesses WebDAV without browser CORS restrictions. Saved passwords remain in the current DBX instance.",
@@ -6485,6 +6533,11 @@ export default {
executeModeDescription: "Run SQL shortcut: {shortcut}. Controls whether it executes all SQL or the statement at the cursor by default.",
executeModeAll: "Execute all SQL",
executeModeCurrent: "Execute statement at cursor",
defaultTransactionMode: "Default transaction commit mode",
defaultTransactionModeDescription:
'The transaction mode used by default when a new SQL editor is opened. With "Manual transaction", new editors start in manual transaction mode, requiring manual commit or rollback; with "Auto commit", each statement is committed automatically. Only applies to databases that support transactions (PostgreSQL, MySQL, Oracle, JDBC).',
defaultTransactionModeAuto: "Auto commit",
defaultTransactionModeManual: "Manual transaction",
executeAllOnBlankLine: "Execute all SQL from blank lines",
executeAllOnBlankLineDescription: "In current-statement mode, execute all SQL when there is no statement at the cursor. Disabled by default.",
showExecutionTargetPicker: "Show execution target picker",
@@ -6652,6 +6705,7 @@ export default {
shortcutToggleTranspose: "Toggle transpose view",
shortcutCancelSearch: "Cancel search",
shortcutToggleSidebar: "Toggle sidebar",
shortcutToggleZenMode: "Toggle Zen mode",
shortcutCopySidebarSelection: "Copy sidebar selection",
shortcutPasteSidebarSelection: "Paste into sidebar",
shortcutEditSidebarConnection: "Edit sidebar connection",
+57 -3
View File
@@ -1636,8 +1636,8 @@ export default withEnglishFallback({
formatterForeignKeyReferenceColumn: "Columna relacionada",
formatterForeignKeyReferenceColumnPlaceholder: "Seleccionar la columna que coincide con este valor",
formatterForeignKeyReferenceMetadataUnavailable: "No se pudo verificar la unicidad de la columna relacionada: {error}",
formatterForeignKeyReferenceInvalid: "La columna no existe o no es una clave única válida de una sola columna. Seleccione otra columna.",
formatterForeignKeyReferenceUnavailable: "La tabla de destino no tiene ninguna clave única utilizable de una sola columna.",
formatterForeignKeyReferenceInvalid: "La columna no existe o una clave única y el filtro de igualdad actual no garantizan que sea única. Seleccione otra columna.",
formatterForeignKeyReferenceUnavailable: "La tabla de destino no tiene ninguna clave única utilizable. Para una clave única compuesta, fije la otra columna con un filtro de igualdad.",
formatterForeignKeyDisplayColumn: "Columna de visualización",
formatterForeignKeyDisplayColumnPlaceholder: "Seleccionar una columna",
formatterForeignKeyFilter: "Limitar filas referenciadas",
@@ -1912,6 +1912,7 @@ export default withEnglishFallback({
setNull: "Establecer NULL",
restoreOriginalValue: "Restaurar original",
copyColumnName: "Copiar nombre de columna",
columnDragChipMany: "{names} ({count} columnas)",
copySqlCondition: "Copiar condición SQL",
copyAlterColumnSql: "Copiar como SQL ALTER",
alterSqlCopied: "SQL ALTER copiado al portapapeles",
@@ -1944,6 +1945,9 @@ export default withEnglishFallback({
columnRegularIndex: "Índice normal",
tableInfoForeignKeys: "Claves foráneas",
foreignKeyNavigate: "Ir a {table}",
tableInfoConstraints: "Restricciones",
tableInfoConstraintDisabled: "Deshabilitada",
tableInfoConstraintNotValidated: "Sin validar",
tableInfoTriggers: "Disparadores",
tableInfoNullable: "Nulable",
tableInfoEmpty: "Sin metadatos",
@@ -2282,6 +2286,7 @@ export default withEnglishFallback({
},
connectionGroup: {
createGroup: "Nuevo grupo",
group: "Grupo",
groupNamePlaceholder: "Nombre del grupo",
renameGroup: "Renombrar grupo",
closeConnections: "Cerrar conexiones del grupo ({count})",
@@ -2290,7 +2295,7 @@ export default withEnglishFallback({
moveToGroup: "Mover al grupo",
moveToNewGroup: "Mover a nuevo grupo",
ungrouped: "Sin grupo",
ungroupedLabel: "Sin grupo",
ungroupedLabel: "Predeterminado",
newGroup: "Nuevo grupo...",
newGroupDefault: "Nuevo grupo",
deleteGroupConfirmTitle: "Eliminar grupo",
@@ -2373,6 +2378,7 @@ export default withEnglishFallback({
settings: "Configuración de IA",
noConfig: "La IA no está configurada. Configura tu proveedor de IA en Ajustes > IA.",
provider: "Proveedor",
providerCustom: "Personalizado",
providerStatusActive: "Activo",
providerStatusConfigured: "Configurado",
switchProvider: "Cambiar proveedor",
@@ -3801,6 +3807,8 @@ export default withEnglishFallback({
sqliteRebuildNotice: "SQLite conserva una instantánea de datos inerte como copia de seguridad, reconstruye la tabla en una sola transacción y fuerza CAST en las columnas modificadas. Los valores no representables pueden convertirse en 0/0.0; si ocurre un error, se revierte todo.",
extendedProperties: "Extendido",
autoIncrement: "Auto Incremento",
mysqlAutoIncrementNextValue: "Siguiente valor de autoincremento",
editMysqlAutoIncrementValue: "Editar el siguiente valor de autoincremento (actual: {value})",
onUpdateCurrentTimestamp: "Actualización Auto",
identity: "Identidad",
identityGeneration: "Generación",
@@ -4943,6 +4951,7 @@ export default withEnglishFallback({
collectionValuePlaceholder: "Introduce un array JSON o valores separados por comas",
preview: "Vista previa SQL",
execute: "Ejecutar",
clearValues: "Borrar valores",
useRawForAll: "Usar SQL sin procesar para todos",
kind: {
string: "Cadena",
@@ -5094,6 +5103,8 @@ export default withEnglishFallback({
formatJsonDescription: "Object or array rows",
formatExcel: "Excel",
formatExcelDescription: "XLS, XLSX, XLSM",
formatSql: "SQL",
formatSqlDescription: "Sentencias INSERT en scripts SQL",
previewRows: "Preview rows",
delimiter: "Delimiter",
encoding: "Codificación",
@@ -5989,6 +6000,8 @@ export default withEnglishFallback({
tableOpenPageSizeDescription: "Filas cargadas por página al abrir una tabla nueva. Cambiar las filas por página actualiza este valor para futuras pestañas de tablas.",
queryPageSize: "Filas predeterminadas por página de consulta",
queryPageSizeDescription: "Filas cargadas por página de resultados. Se admiten hasta {max} filas.",
multiStatementDefaultView: "Vista predeterminada para varias sentencias",
multiStatementDefaultViewDescription: "Elige si los lotes con varias sentencias SQL se abren en la tabla de resultados o en el resumen de ejecución.",
queryResultMaxRows: "Máximo de filas del resultado",
queryResultMaxRowsEnabled: "Limitar filas del resultado",
queryResultMaxRowsDescription: "Limita las filas conservadas por consultas, datos de tablas, procedimientos almacenados y carga continua.",
@@ -6037,6 +6050,8 @@ export default withEnglishFallback({
dataGridAutoTransposeSingleRowDescription: "Al activarlo, los resultados de consultas SQL con exactamente una fila y varias columnas cambian automáticamente a la vista transpuesta.",
dataGridCellDetailButtonVisible: "Mostrar botón de detalles de celda",
dataGridCellDetailButtonVisibleDescription: "Mostrar el botón de detalles de celda al pasar el cursor sobre una celda.",
dataGridCrosshairHighlight: "Resaltado de fila y columna en cruz",
dataGridCrosshairHighlightDescription: "Cuando esté activado, resalta ligeramente toda la fila y la columna de la celda activa (como Excel). La celda enfocada conserva su estilo seleccionado.",
infiniteScroll: "Carga de desplazamiento infinito",
autoCalculateTotalRows: "Calcular automáticamente el total de filas",
autoCalculateTotalRowsDescription: "Ejecuta COUNT(*) automáticamente tras cada consulta para mostrar el total de filas coincidentes. Desactivado por defecto para mantener rápidas las consultas grandes; puedes calcularlo cuando quieras desde el pie de resultados.",
@@ -6128,6 +6143,21 @@ export default withEnglishFallback({
snippetsRestoreDefaults: "Restaurar valores por defecto",
snippetsAddTitle: "Agregar fragmento",
snippetsEditTitle: "Editar fragmento",
sqlShortcutsTitle: "Acciones rápidas SQL",
sqlShortcutsDescription: "Plantillas SQL personalizadas que se ejecutan después de seleccionar un nombre de tabla en el editor SQL. Selecciona primero un identificador de tabla; se recomiendan atajos con Cmd/Ctrl.",
sqlShortcutsAdd: "Agregar",
sqlShortcutsAddTitle: "Agregar acción rápida SQL",
sqlShortcutsEditTitle: "Editar acción rápida SQL",
sqlShortcutsLabel: "Etiqueta",
sqlShortcutsLabelPlaceholder: "p. ej. Contar filas",
sqlShortcutsLabelRequired: "La etiqueta es obligatoria.",
sqlShortcutsSql: "Plantilla SQL",
sqlShortcutsSqlPlaceholder: "SELECT COUNT(*) FROM ${table}",
sqlShortcutsVariableHint: "Usa {token} para el nombre de tabla seleccionado.",
sqlShortcutsEmpty: "Aún no hay acciones rápidas SQL.",
sqlShortcutsUnbound: "Sin asignar",
sqlShortcutsToggle: "Activar o desactivar atajo SQL",
sqlShortcutsDeleteConfirm: '¿Eliminar el atajo SQL "{name}"?',
syncWebDavTitle: "Sincronización WebDAV",
syncWebDavDescription: "Sube o restaura una instantánea de DBX, incluida la biblioteca SQL guardada, desde almacenamiento compatible con WebDAV.",
syncWebDavWebDescription: "En modo Web, el servidor DBX accede a WebDAV sin las restricciones CORS del navegador. Las contraseñas guardadas permanecen en la instancia DBX actual.",
@@ -6171,6 +6201,11 @@ export default withEnglishFallback({
executeModeDescription: "Atajo para ejecutar SQL: {shortcut}. Controla si se ejecuta todo el SQL o la sentencia en el cursor de forma predeterminada.",
executeModeAll: "Ejecutar todo el SQL",
executeModeCurrent: "Ejecutar sentencia en el cursor",
defaultTransactionMode: "Modo de confirmación de transacción predeterminado",
defaultTransactionModeDescription:
'El modo de transacción que se usa de forma predeterminada al abrir un nuevo editor SQL. Con "Transacción manual", los nuevos editores inician en modo de transacción manual y requieren confirmación o reversión manual; con "Confirmación automática", cada sentencia se confirma automáticamente. Solo se aplica a bases de datos que admiten transacciones (PostgreSQL, MySQL, Oracle, JDBC).',
defaultTransactionModeAuto: "Confirmación automática",
defaultTransactionModeManual: "Transacción manual",
executeAllOnBlankLine: "Ejecutar todo el SQL desde líneas en blanco",
executeAllOnBlankLineDescription: "En el modo de sentencia actual, ejecuta todo el SQL cuando no hay una sentencia en el cursor. Desactivado de forma predeterminada.",
showExecutionTargetPicker: "Mostrar selector de objetivo",
@@ -6300,6 +6335,7 @@ export default withEnglishFallback({
shortcutOpenSettings: "Abrir configuración",
shortcutCloseTab: "Cerrar pestaña",
shortcutToggleSidebar: "Alternar barra lateral",
shortcutToggleZenMode: "Alternar modo Zen",
shortcutFocusSearch: "Enfocar búsqueda de la vista actual",
shortcutQuickOpen: "Abrir rápido (buscar todos los objetos de base de datos)",
shortcutNavigateTabHistoryBack: "Volver a la pestaña vista anteriormente",
@@ -8096,6 +8132,24 @@ export default withEnglishFallback({
aiReviewRequired: "El SQL de producción se ha colocado en el editor. Revíselo primero y luego ejecútelo manualmente para confirmar.",
sourceMultiDbSql: "Ejecución SQL multi-base de datos",
},
readOnlyUnlock: {
confirmTitle: "Desbloquear escrituras",
confirmMessage: "Esta conexión es de solo lectura. Las escrituras siguen bloqueadas a menos que las desbloquee durante un breve intervalo. El ajuste de solo lectura guardado no se desactivará.",
confirmDetails: "Conexión: {connection}\nOrigen: {source}\nLas escrituras se permitirán durante el tiempo seleccionado y luego se reanudará automáticamente el modo de solo lectura.",
confirmAction: "Desbloquear escrituras",
durationLabel: "Permitir escrituras durante",
durationOneMinute: "1 minuto",
durationFiveMinutes: "5 minutos",
remaining: "{time} restantes",
unlockedBadge: "Escrituras desbloqueadas",
lockNow: "Volver a bloquear ahora",
unlockAction: "Desbloquear escrituras…",
sourceImport: "Importación de tabla",
sourceTransfer: "Transferencia de datos",
sourceSqlFile: "Ejecución de archivo SQL",
sourceStatus: "Control de solo lectura",
sourceDataEditor: "Editor de datos",
},
processList: {
title: "Lista de procesos",
sessionCount: "{count} sesiones",
+57 -3
View File
@@ -1634,8 +1634,8 @@ export default withEnglishFallback({
formatterForeignKeyReferenceColumn: "Colonna correlata",
formatterForeignKeyReferenceColumnPlaceholder: "Seleziona la colonna corrispondente a questo valore",
formatterForeignKeyReferenceMetadataUnavailable: "Impossibile verificare l'unicità della colonna correlata: {error}",
formatterForeignKeyReferenceInvalid: "La colonna non esiste o non è una chiave univoca valida a colonna singola. Seleziona un'altra colonna.",
formatterForeignKeyReferenceUnavailable: "La tabella di destinazione non ha chiavi univoche utilizzabili a colonna singola.",
formatterForeignKeyReferenceInvalid: "La colonna non esiste o non è garantita come univoca da una chiave univoca e dal filtro di uguaglianza corrente. Seleziona un'altra colonna.",
formatterForeignKeyReferenceUnavailable: "La tabella di destinazione non ha chiavi univoche utilizzabili. Per una chiave univoca composta, fissa l'altra colonna con un filtro di uguaglianza.",
formatterForeignKeyDisplayColumn: "Colonna visualizzata",
formatterForeignKeyDisplayColumnPlaceholder: "Seleziona una colonna",
formatterForeignKeyFilter: "Limita le righe referenziate",
@@ -1910,6 +1910,7 @@ export default withEnglishFallback({
setNull: "Imposta NULL",
restoreOriginalValue: "Ripristina Originale",
copyColumnName: "Copia Nome Colonna",
columnDragChipMany: "{names} ({count} colonne)",
copyAlterColumnSql: "Copia come SQL ALTER",
alterSqlCopied: "SQL ALTER copiato negli appunti",
alterSqlCopiedWithWarnings: "SQL ALTER copiato negli appunti ({count} avviso/i)",
@@ -1931,6 +1932,9 @@ export default withEnglishFallback({
columnRegularIndex: "Indice normale",
tableInfoForeignKeys: "Chiavi Esterne",
foreignKeyNavigate: "Vai a {table}",
tableInfoConstraints: "Vincoli",
tableInfoConstraintDisabled: "Disabilitata",
tableInfoConstraintNotValidated: "Non validata",
tableInfoTriggers: "Trigger",
tableInfoNullable: "Nullabile",
tableInfoEmpty: "Nessun metadato",
@@ -2280,6 +2284,7 @@ export default withEnglishFallback({
},
connectionGroup: {
createGroup: "Nuovo Gruppo",
group: "Gruppo",
groupNamePlaceholder: "Nome gruppo",
renameGroup: "Rinomina Gruppo",
closeConnections: "Chiudi le connessioni del gruppo ({count})",
@@ -2288,7 +2293,7 @@ export default withEnglishFallback({
moveToGroup: "Sposta nel Gruppo",
moveToNewGroup: "Sposta in un Nuovo Gruppo",
ungrouped: "Non raggruppati",
ungroupedLabel: "Non raggruppati",
ungroupedLabel: "Predefinito",
newGroup: "Nuovo Gruppo...",
newGroupDefault: "Nuovo Gruppo",
deleteGroupConfirmTitle: "Elimina Gruppo",
@@ -2371,6 +2376,7 @@ export default withEnglishFallback({
settings: "Impostazioni AI",
noConfig: "L'AI non è configurata. Configura il tuo provider AI in Impostazioni > AI.",
provider: "Provider",
providerCustom: "Personalizzato",
providerStatusActive: "Attivo",
providerStatusConfigured: "Configurato",
switchProvider: "Cambia provider",
@@ -3799,6 +3805,8 @@ export default withEnglishFallback({
sqliteRebuildNotice: "SQLite conserva uno snapshot di dati inerte come backup, ricostruisce la tabella in un'unica transazione e forza CAST sulle colonne modificate. I valori non rappresentabili possono diventare 0/0.0; in caso di errore, tutte le modifiche vengono annullate.",
extendedProperties: "Esteso",
autoIncrement: "Auto Incremento",
mysqlAutoIncrementNextValue: "Valore di auto incremento successivo",
editMysqlAutoIncrementValue: "Modifica il valore di auto incremento successivo (attuale: {value})",
onUpdateCurrentTimestamp: "Aggiorna Ora Automaticamente",
identity: "Identità",
identityGeneration: "Generazione",
@@ -4941,6 +4949,7 @@ export default withEnglishFallback({
collectionValuePlaceholder: "Inserisci un array JSON o valori separati da virgole",
preview: "Anteprima SQL",
execute: "Esegui",
clearValues: "Cancella valori",
useRawForAll: "Usa SQL non elaborato per tutti",
kind: {
string: "Stringa",
@@ -5092,6 +5101,8 @@ export default withEnglishFallback({
formatJsonDescription: "Object or array rows",
formatExcel: "Excel",
formatExcelDescription: "XLS, XLSX, XLSM",
formatSql: "SQL",
formatSqlDescription: "Istruzioni INSERT negli script SQL",
previewRows: "Preview rows",
delimiter: "Delimiter",
encoding: "Codifica",
@@ -5989,6 +6000,8 @@ export default withEnglishFallback({
tableOpenPageSizeDescription: "Righe caricate per pagina quando si apre una nuova tabella. La modifica delle righe per pagina aggiorna questo valore per le future schede tabella.",
queryPageSize: "Righe predefinite per pagina di query",
queryPageSizeDescription: "Righe caricate per pagina dei risultati. Sono supportate fino a {max} righe.",
multiStatementDefaultView: "Vista predefinita per istruzioni multiple",
multiStatementDefaultViewDescription: "Scegli se i batch con più istruzioni SQL si aprono nella tabella dei risultati o nel riepilogo dell'esecuzione.",
queryResultMaxRows: "Numero massimo di righe del risultato",
queryResultMaxRowsEnabled: "Limita le righe del risultato",
queryResultMaxRowsDescription: "Limita le righe conservate da query, dati tabella, stored procedure e caricamento continuo.",
@@ -6037,6 +6050,8 @@ export default withEnglishFallback({
dataGridAutoTransposeSingleRowDescription: "Quando è attivata, i risultati delle query SQL con esattamente una riga e più colonne passano automaticamente alla vista trasposta.",
dataGridCellDetailButtonVisible: "Mostra il pulsante dei dettagli della cella",
dataGridCellDetailButtonVisibleDescription: "Mostra il pulsante dei dettagli della cella al passaggio del mouse su una cella.",
dataGridCrosshairHighlight: "Evidenzia riga e colonna a croce",
dataGridCrosshairHighlightDescription: "Se attivato, evidenzia leggermente l'intera riga e la colonna della cella attiva (come Excel). La cella focalizzata mantiene il suo stile selezionato.",
infiniteScroll: "Caricamento a scorrimento infinito",
autoCalculateTotalRows: "Calcola automaticamente il totale delle righe",
autoCalculateTotalRowsDescription: "Esegue COUNT(*) automaticamente dopo ogni query per mostrare il totale delle righe corrispondenti. Disattivato per impostazione predefinita per mantenere veloci le query grandi; puoi calcolarlo all'occorrenza dal piè di pagina dei risultati.",
@@ -6128,6 +6143,21 @@ export default withEnglishFallback({
snippetsRestoreDefaults: "Ripristina Predefiniti",
snippetsAddTitle: "Aggiungi Snippet",
snippetsEditTitle: "Modifica Snippet",
sqlShortcutsTitle: "Azioni rapide SQL",
sqlShortcutsDescription: "Modelli SQL personalizzati eseguiti dopo aver selezionato un nome di tabella nell'editor SQL. Seleziona prima un identificatore di tabella; si consigliano scorciatoie con Cmd/Ctrl.",
sqlShortcutsAdd: "Aggiungi",
sqlShortcutsAddTitle: "Aggiungi azione rapida SQL",
sqlShortcutsEditTitle: "Modifica azione rapida SQL",
sqlShortcutsLabel: "Etichetta",
sqlShortcutsLabelPlaceholder: "es. Conta righe",
sqlShortcutsLabelRequired: "L'etichetta è obbligatoria.",
sqlShortcutsSql: "Modello SQL",
sqlShortcutsSqlPlaceholder: "SELECT COUNT(*) FROM ${table}",
sqlShortcutsVariableHint: "Usa {token} per il nome della tabella selezionata.",
sqlShortcutsEmpty: "Nessuna azione rapida SQL ancora.",
sqlShortcutsUnbound: "Non assegnato",
sqlShortcutsToggle: "Attiva o disattiva scorciatoia SQL",
sqlShortcutsDeleteConfirm: 'Eliminare la scorciatoia SQL "{name}"?',
syncWebDavTitle: "Sincronizzazione WebDAV",
syncWebDavDescription: "Carica o ripristina uno snapshot di DBX, inclusa la libreria SQL salvata, da un servizio di archiviazione compatibile con WebDAV.",
syncWebDavWebDescription: "In modalità Web, il server DBX accede a WebDAV senza le limitazioni CORS del browser. Le password salvate restano nell'istanza DBX corrente.",
@@ -6171,6 +6201,11 @@ export default withEnglishFallback({
executeModeDescription: "Scorciatoia Esegui SQL: {shortcut}. Stabilisce se eseguire tutto l'SQL o l'istruzione al cursore per impostazione predefinita.",
executeModeAll: "Esegui tutto l'SQL",
executeModeCurrent: "Esegui istruzione al cursore",
defaultTransactionMode: "Modalità di commit transazione predefinita",
defaultTransactionModeDescription:
'La modalità di transazione utilizzata per impostazione predefinita quando si apre un nuovo editor SQL. Con "Transazione manuale", i nuovi editori iniziano in modalità di transazione manuale, richiedendo commit o rollback manuale; con "Commit automatico", ogni istruzione viene committata automaticamente. Si applica solo ai database che supportano le transazioni (PostgreSQL, MySQL, Oracle, JDBC).',
defaultTransactionModeAuto: "Commit automatico",
defaultTransactionModeManual: "Transazione manuale",
executeAllOnBlankLine: "Esegui tutto l'SQL dalle righe vuote",
executeAllOnBlankLineDescription: "In modalità istruzione corrente, esegue tutto l'SQL quando non è presente un'istruzione al cursore. Disattivato per impostazione predefinita.",
showExecutionTargetPicker: "Mostra selettore destinazione",
@@ -6323,6 +6358,7 @@ export default withEnglishFallback({
shortcutToggleTranspose: "Attiva/disattiva vista trasposta",
shortcutCancelSearch: "Annulla ricerca",
shortcutToggleSidebar: "Attiva/disattiva barra laterale",
shortcutToggleZenMode: "Attiva/disattiva modalità Zen",
shortcutCopySidebarSelection: "Copia selezione barra laterale",
shortcutPasteSidebarSelection: "Incolla nella barra laterale",
shortcutEditSidebarConnection: "Modifica connessione barra laterale",
@@ -8097,6 +8133,24 @@ export default withEnglishFallback({
aiReviewRequired: "La SQL di produzione è stata inserita nell'editor. Controllare prima, quindi eseguire manualmente per confermare.",
sourceMultiDbSql: "Esecuzione SQL multi-DB",
},
readOnlyUnlock: {
confirmTitle: "Sblocca scritture",
confirmMessage: "Questa connessione è in sola lettura. Le scritture restano bloccate a meno che non le sblocchi per un breve intervallo. L'impostazione di sola lettura salvata non verrà disattivata.",
confirmDetails: "Connessione: {connection}\nOrigine: {source}\nLe scritture saranno consentite per la durata selezionata, poi la sola lettura riprenderà automaticamente.",
confirmAction: "Sblocca scritture",
durationLabel: "Consenti scritture per",
durationOneMinute: "1 minuto",
durationFiveMinutes: "5 minuti",
remaining: "{time} rimanenti",
unlockedBadge: "Scritture sbloccate",
lockNow: "Blocca scritture ora",
unlockAction: "Sblocca scritture…",
sourceImport: "Importazione tabella",
sourceTransfer: "Trasferimento dati",
sourceSqlFile: "Esecuzione file SQL",
sourceStatus: "Controllo sola lettura",
sourceDataEditor: "Editor dati",
},
processList: {
title: "Elenco processi",
sessionCount: "{count} sessioni",
+57 -3
View File
@@ -1652,8 +1652,8 @@ export default withEnglishFallback({
formatterForeignKeyReferenceColumn: "対応列",
formatterForeignKeyReferenceColumnPlaceholder: "現在の値に対応する列を選択",
formatterForeignKeyReferenceMetadataUnavailable: "対応列の一意性を確認できませんでした:{error}",
formatterForeignKeyReferenceInvalid: "この列は存在しないか、有効な単一列の一意キーではありません。別の列を選択してください。",
formatterForeignKeyReferenceUnavailable: "対象テーブルに利用可能な単一列の一意キーがありません。",
formatterForeignKeyReferenceInvalid: "この列は存在しないか、一意キーと現在の等価条件によって一意性を保証できません。別の列を選択してください。",
formatterForeignKeyReferenceUnavailable: "対象テーブルに利用可能な一意キーがありません。複合一意キーでは、等価条件で別の列を固定してください。",
formatterForeignKeyDisplayColumn: "表示列",
formatterForeignKeyDisplayColumnPlaceholder: "表示列を選択",
formatterForeignKeyFilter: "参照行を絞り込む",
@@ -1928,6 +1928,7 @@ export default withEnglishFallback({
setNull: "NULLに設定",
restoreOriginalValue: "元の値に戻す",
copyColumnName: "列名をコピー",
columnDragChipMany: "{names}(全{count}列)",
copyAlterColumnSql: "SQL ALTERとしてコピー",
alterSqlCopied: "SQL ALTERをクリップボードにコピーしました",
alterSqlCopiedWithWarnings: "SQL ALTERをクリップボードにコピーしました({count}件の警告)",
@@ -1949,6 +1950,9 @@ export default withEnglishFallback({
columnRegularIndex: "通常インデックス",
tableInfoForeignKeys: "外部キー",
foreignKeyNavigate: "{table} へ移動",
tableInfoConstraints: "制約",
tableInfoConstraintDisabled: "無効",
tableInfoConstraintNotValidated: "未検証",
tableInfoTriggers: "トリガー",
tableInfoNullable: "NULL許容",
tableInfoEmpty: "メタデータなし",
@@ -2307,6 +2311,7 @@ export default withEnglishFallback({
},
connectionGroup: {
createGroup: "新しいグループ",
group: "グループ",
groupNamePlaceholder: "グループ名",
renameGroup: "グループ名を変更",
closeConnections: "グループ内の接続を閉じる({count})",
@@ -2315,7 +2320,7 @@ export default withEnglishFallback({
moveToGroup: "グループに移動",
moveToNewGroup: "新しいグループに移動",
ungrouped: "グループなし",
ungroupedLabel: "グループなし",
ungroupedLabel: "デフォルト",
newGroup: "新しいグループ...",
newGroupDefault: "新しいグループ",
deleteGroupConfirmTitle: "グループを削除",
@@ -2397,6 +2402,7 @@ export default withEnglishFallback({
settings: "AI設定",
noConfig: "AIが設定されていません。設定 > AIでAIプロバイダーを設定してください。",
provider: "プロバイダー",
providerCustom: "カスタム",
providerStatusActive: "使用中",
providerStatusConfigured: "設定済み",
switchProvider: "プロバイダーを切替",
@@ -3861,6 +3867,8 @@ export default withEnglishFallback({
sqliteRebuildNotice: "SQLite は制約が動作しないデータスナップショットをバックアップとして保持し、1 つのトランザクション内でテーブルを再構築して、変更された列を CAST で強制変換します。表現できない値は 0/0.0 になる場合があり、失敗時はすべてロールバックされます。",
extendedProperties: "拡張",
autoIncrement: "自動採番",
mysqlAutoIncrementNextValue: "次の自動採番値",
editMysqlAutoIncrementValue: "次の自動採番値を編集(現在:{value})",
onUpdateCurrentTimestamp: "自動更新時刻",
identity: "ID列",
identityGeneration: "生成",
@@ -4970,6 +4978,7 @@ export default withEnglishFallback({
collectionValuePlaceholder: "JSON 配列またはカンマ区切りの値を入力",
preview: "SQLプレビュー",
execute: "実行",
clearValues: "値をクリア",
useRawForAll: "すべてを生の SQL に設定",
kind: {
string: "文字列",
@@ -5121,6 +5130,8 @@ export default withEnglishFallback({
formatJsonDescription: "Object or array rows",
formatExcel: "Excel",
formatExcelDescription: "XLS, XLSX, XLSM",
formatSql: "SQL",
formatSqlDescription: "SQL スクリプトの INSERT ステートメント",
previewRows: "Preview rows",
delimiter: "Delimiter",
encoding: "文字コード",
@@ -6023,6 +6034,8 @@ export default withEnglishFallback({
tableOpenPageSizeDescription: "新しいテーブルを開くときに 1 ページあたり読み込む行数です。テーブルで行数を変更すると、今後のテーブルタブのデフォルトも更新されます。",
queryPageSize: "クエリ結果のデフォルト行数",
queryPageSizeDescription: "クエリ結果の 1 ページあたりの行数です。最大 {max} 行まで指定できます。",
multiStatementDefaultView: "複数ステートメントの既定ビュー",
multiStatementDefaultViewDescription: "複数の SQL ステートメントを含むバッチを、結果テーブルまたは実行サマリーのどちらで開くか選択します。",
queryResultMaxRows: "クエリ結果の最大行数",
queryResultMaxRowsEnabled: "クエリ結果の行数を制限",
queryResultMaxRowsDescription: "クエリ、テーブルデータ、ストアドプロシージャ、および連続読み込みで保持する合計行数を制限します。",
@@ -6071,6 +6084,8 @@ export default withEnglishFallback({
dataGridAutoTransposeSingleRowDescription: "有効にすると、1 行かつ複数列の SQL クエリ結果が自動的に転置表示に切り替わります。",
dataGridCellDetailButtonVisible: "セル詳細ボタンを表示",
dataGridCellDetailButtonVisibleDescription: "セルにカーソルを合わせたときにセル詳細ボタンを表示します。",
dataGridCrosshairHighlight: "行と列の十字ハイライト",
dataGridCrosshairHighlightDescription: "有効にすると、アクティブなセルの行と列全体を淡くハイライトします(Excel と同様)。フォーカスされたセルは選択スタイルを維持します。",
tableColumnTemplateFields: "新規テーブルのプリセット列",
tableColumnTemplateFieldsDescription: "データベース種別を選択し、新規テーブル作成時に使うプリセット列の型を設定します。",
tableColumnTemplateAdd: "列を追加",
@@ -6154,6 +6169,21 @@ export default withEnglishFallback({
snippetsRestoreDefaults: "デフォルトに戻す",
snippetsAddTitle: "スニペットを追加",
snippetsEditTitle: "スニペットを編集",
sqlShortcutsTitle: "SQLクイックアクション",
sqlShortcutsDescription: "SQLエディターでテーブル名を選択した後に実行するカスタムSQLテンプレート。先にテーブル識別子を選択してください。Cmd/Ctrlを含むショートカットを推奨します。",
sqlShortcutsAdd: "追加",
sqlShortcutsAddTitle: "SQLクイックアクションを追加",
sqlShortcutsEditTitle: "SQLクイックアクションを編集",
sqlShortcutsLabel: "ラベル",
sqlShortcutsLabelPlaceholder: "例: 行数をカウント",
sqlShortcutsLabelRequired: "ラベルは必須です。",
sqlShortcutsSql: "SQLテンプレート",
sqlShortcutsSqlPlaceholder: "SELECT COUNT(*) FROM ${table}",
sqlShortcutsVariableHint: "選択したテーブル名には {token} を使用します。",
sqlShortcutsEmpty: "SQLクイックアクションはまだありません。",
sqlShortcutsUnbound: "未割り当て",
sqlShortcutsToggle: "SQLショートカットを有効または無効にする",
sqlShortcutsDeleteConfirm: "SQLショートカット「{name}」を削除しますか?",
syncWebDavTitle: "WebDAV同期",
syncWebDavDescription: "保存済みSQLライブラリを含むDBXスナップショットをWebDAV互換ストレージへアップロードまたは復元します。",
syncWebDavWebDescription: "WebモードではDBXサーバーがWebDAVへ接続するため、ブラウザーのCORS制限を受けません。保存したパスワードは現在のDBXインスタンス内に保持されます。",
@@ -6197,6 +6227,11 @@ export default withEnglishFallback({
executeModeDescription: "SQL実行ショートカット: {shortcut}。既定でSQL全体またはカーソル位置のステートメントのどちらを実行するかを設定します。",
executeModeAll: "すべてのSQLを実行",
executeModeCurrent: "カーソル位置の文を実行",
defaultTransactionMode: "既定のトランザクションコミットモード",
defaultTransactionModeDescription:
"新しい SQL エディタを開いたときに既定で使用するトランザクションモード。「手動トランザクション」を選択すると、新しいエディタは手動トランザクションモードで開始され、手動でコミットまたはロールバックします。「自動コミット」を選択すると、各ステートメントが自動的にコミットされます。トランザクションをサポートするデータベース(PostgreSQL、MySQL、Oracle、JDBC)のみに適用されます。",
defaultTransactionModeAuto: "自動コミット",
defaultTransactionModeManual: "手動トランザクション",
executeAllOnBlankLine: "空行ではすべてのSQLを実行",
executeAllOnBlankLineDescription: "現在の文を実行するモードで、カーソル位置に文がない場合はすべてのSQLを実行します。既定では無効です。",
showExecutionTargetPicker: "実行対象ピッカーを表示",
@@ -6331,6 +6366,7 @@ export default withEnglishFallback({
shortcutToggleTranspose: "転置ビューを切替",
shortcutCancelSearch: "検索をキャンセル",
shortcutToggleSidebar: "サイドバーを切替",
shortcutToggleZenMode: "Zenモードを切替",
shortcutCopySidebarSelection: "サイドバーの選択をコピー",
shortcutPasteSidebarSelection: "サイドバーに貼り付け",
shortcutEditSidebarConnection: "サイドバー接続を編集",
@@ -8148,6 +8184,24 @@ export default withEnglishFallback({
aiReviewRequired: "本番SQLがエディターに配置されました。まず確認し、手動で実行して確定してください。",
sourceMultiDbSql: "複数DB SQL実行",
},
readOnlyUnlock: {
confirmTitle: "書き込みのロックを解除",
confirmMessage: "この接続は読み取り専用です。短い時間だけロックを解除しない限り、書き込みはブロックされたままです。保存済みの読み取り専用設定はオフになりません。",
confirmDetails: "接続:{connection}\nソース:{source}\n選択した時間だけ書き込みが許可され、その後自動的に読み取り専用に戻ります。",
confirmAction: "書き込みのロックを解除",
durationLabel: "書き込みを許可する時間",
durationOneMinute: "1 分",
durationFiveMinutes: "5 分",
remaining: "残り {time}",
unlockedBadge: "書き込み解除中",
lockNow: "今すぐ再ロック",
unlockAction: "書き込みのロックを解除…",
sourceImport: "テーブルのインポート",
sourceTransfer: "データ転送",
sourceSqlFile: "SQL ファイル実行",
sourceStatus: "読み取り専用コントロール",
sourceDataEditor: "データエディター",
},
processList: {
title: "プロセス一覧",
sessionCount: "{count} セッション",
+55 -1
View File
@@ -1886,6 +1886,7 @@ export default withEnglishFallback({
setNull: "NULL 설정",
restoreOriginalValue: "원래대로 복원",
copyColumnName: "컬럼 이름 복사",
columnDragChipMany: "{names} (총 {count}개)",
copyAlterColumnSql: "SQL ALTER로 복사",
alterSqlCopied: "SQL ALTER를 클립보드에 복사했습니다",
alterSqlCopiedWithWarnings: "SQL ALTER를 클립보드에 복사했습니다 (경고 {count}개)",
@@ -1907,6 +1908,9 @@ export default withEnglishFallback({
columnRegularIndex: "일반 인덱스",
tableInfoForeignKeys: "외래 키",
foreignKeyNavigate: "{table}(으)로 이동",
tableInfoConstraints: "제약 조건",
tableInfoConstraintDisabled: "비활성화됨",
tableInfoConstraintNotValidated: "검증 안 됨",
tableInfoTriggers: "트리거",
tableInfoNullable: "NULL 허용",
tableInfoEmpty: "메타데이터 없음",
@@ -2181,6 +2185,7 @@ export default withEnglishFallback({
},
connectionGroup: {
createGroup: "새 그룹",
group: "그룹",
groupNamePlaceholder: "그룹 이름",
selectConnection: "연결 선택",
deselectConnection: "연결 선택 해제",
@@ -2195,7 +2200,7 @@ export default withEnglishFallback({
moveToGroup: "그룹으로 이동",
moveToNewGroup: "새 그룹으로 이동",
ungrouped: "그룹 없음",
ungroupedLabel: "그룹 없음",
ungroupedLabel: "기본",
newGroup: "새 그룹...",
newGroupDefault: "새 그룹",
deleteGroupConfirmTitle: "그룹 삭제",
@@ -2269,6 +2274,7 @@ export default withEnglishFallback({
settings: "AI 설정",
noConfig: "AI가 구성되지 않았습니다. 설정 > AI에서 AI 공급자를 설정하세요.",
provider: "공급자",
providerCustom: "사용자 지정",
providerStatusActive: "활성",
providerStatusConfigured: "구성됨",
switchProvider: "공급자 전환",
@@ -3750,6 +3756,8 @@ export default withEnglishFallback({
sqliteRebuildNotice: "SQLite는 백업으로 비활성 데이터 스냅샷을 유지한 다음 하나의 트랜잭션에서 테이블을 재구성하고 변경된 컬럼을 강제로 CAST합니다. 표현할 수 없는 값은 0/0.0이 될 수 있으며, 실패 시 롤백됩니다.",
extendedProperties: "확장",
autoIncrement: "자동 증가",
mysqlAutoIncrementNextValue: "다음 자동 증가 값",
editMysqlAutoIncrementValue: "다음 자동 증가 값 편집(현재: {value})",
onUpdateCurrentTimestamp: "수정 시간 자동 갱신",
identity: "ID",
identityGeneration: "생성",
@@ -4597,6 +4605,24 @@ export default withEnglishFallback({
sourceAdmin: "데이터베이스 관리",
aiReviewRequired: "프로덕션 SQL이 편집기에 추가되었습니다. 확인하려면 검토 후 수동으로 실행하세요.",
},
readOnlyUnlock: {
confirmTitle: "쓰기 잠금 해제",
confirmMessage: "이 연결은 읽기 전용입니다. 짧은 시간 동안 잠금을 해제하지 않으면 쓰기가 계속 차단됩니다. 저장된 읽기 전용 설정은 꺼지지 않습니다.",
confirmDetails: "연결: {connection}\n출처: {source}\n선택한 시간 동안 쓰기가 허용된 뒤 자동으로 읽기 전용으로 돌아갑니다.",
confirmAction: "쓰기 잠금 해제",
durationLabel: "쓰기 허용 시간",
durationOneMinute: "1분",
durationFiveMinutes: "5분",
remaining: "{time} 남음",
unlockedBadge: "쓰기 잠금 해제됨",
lockNow: "지금 다시 잠금",
unlockAction: "쓰기 잠금 해제…",
sourceImport: "테이블 가져오기",
sourceTransfer: "데이터 이전",
sourceSqlFile: "SQL 파일 실행",
sourceStatus: "읽기 전용 컨트롤",
sourceDataEditor: "데이터 편집기",
},
sqlParameters: {
title: "SQL 매개변수",
description: "SQL 템플릿 자리표시자의 값을 입력하세요. DBX가 SQL을 실행하기 전에 대체합니다.",
@@ -4608,6 +4634,7 @@ export default withEnglishFallback({
collectionValuePlaceholder: "JSON 배열 또는 쉼표로 구분된 값 입력",
preview: "SQL 미리보기",
execute: "실행",
clearValues: "값 지우기",
useRawForAll: "모두 원시 SQL로 설정",
kind: {
string: "문자열",
@@ -4761,6 +4788,8 @@ export default withEnglishFallback({
formatJsonDescription: "객체 또는 배열 행",
formatExcel: "Excel",
formatExcelDescription: "XLS, XLSX, XLSM",
formatSql: "SQL",
formatSqlDescription: "SQL 스크립트의 INSERT 문",
previewRows: "미리보기 행",
delimiter: "구분자",
encoding: "인코딩",
@@ -5666,6 +5695,8 @@ export default withEnglishFallback({
tableOpenPageSizeDescription: "새 테이블을 열 때 페이지당 로드되는 행 수입니다. 테이블에서 페이지당 행을 변경하면 향후 테이블 탭의 기본값이 업데이트됩니다.",
queryPageSize: "쿼리 결과 기본 페이지당 행 수",
queryPageSizeDescription: "쿼리 결과 페이지당 로드할 행 수입니다. 최대 {max}행을 지원합니다.",
multiStatementDefaultView: "여러 구문 기본 보기",
multiStatementDefaultViewDescription: "여러 SQL 구문이 있는 배치를 결과 테이블 또는 실행 요약 중 어디에서 열지 선택합니다.",
queryResultMaxRows: "쿼리 결과 최대 행 수",
queryResultMaxRowsEnabled: "쿼리 결과 행 수 제한",
queryResultMaxRowsDescription: "쿼리, 테이블 데이터, 저장 프로시저 및 연속 로딩에서 유지할 총 행 수를 제한합니다.",
@@ -5714,6 +5745,8 @@ export default withEnglishFallback({
dataGridAutoTransposeSingleRowDescription: "활성화하면 정확히 한 행과 여러 열이 있는 SQL 쿼리 결과가 자동으로 전치 보기로 전환됩니다.",
dataGridCellDetailButtonVisible: "셀 세부 정보 버튼 표시",
dataGridCellDetailButtonVisibleDescription: "셀 위에 마우스를 올리면 셀 세부 정보 버튼을 표시합니다.",
dataGridCrosshairHighlight: "행·열 십자 강조",
dataGridCrosshairHighlightDescription: "활성화하면 활성 셀의 전체 행과 열을 연하게 강조합니다(Excel과 유사). 포커스 셀은 기존 선택 스타일을 유지합니다.",
infiniteScroll: "무한 스크롤 로딩",
autoCalculateTotalRows: "전체 행 수 자동 집계",
autoCalculateTotalRowsDescription: "각 쿼리 후 자동으로 COUNT(*)를 실행하여 일치하는 전체 행 수를 표시합니다. 대규모 쿼리를 빠르게 유지하기 위해 기본적으로 꺼져 있으며, 결과 푸터에서 요청 시 집계할 수 있습니다.",
@@ -5801,6 +5834,21 @@ export default withEnglishFallback({
snippetsRestoreDefaults: "기본값 복원",
snippetsAddTitle: "스니펫 추가",
snippetsEditTitle: "스니펫 편집",
sqlShortcutsTitle: "SQL 빠른 작업",
sqlShortcutsDescription: "SQL 편집기에서 테이블 이름을 선택한 후 실행하는 사용자 지정 SQL 템플릿입니다. 먼저 테이블 식별자를 선택하세요. Cmd/Ctrl이 포함된 단축키를 권장합니다.",
sqlShortcutsAdd: "추가",
sqlShortcutsAddTitle: "SQL 빠른 작업 추가",
sqlShortcutsEditTitle: "SQL 빠른 작업 편집",
sqlShortcutsLabel: "라벨",
sqlShortcutsLabelPlaceholder: "예: 행 수 세기",
sqlShortcutsLabelRequired: "라벨은 필수입니다.",
sqlShortcutsSql: "SQL 템플릿",
sqlShortcutsSqlPlaceholder: "SELECT COUNT(*) FROM ${table}",
sqlShortcutsVariableHint: "선택한 테이블 이름에는 {token}을 사용하세요.",
sqlShortcutsEmpty: "아직 SQL 빠른 작업이 없습니다.",
sqlShortcutsUnbound: "미할당",
sqlShortcutsToggle: "SQL 단축키 활성화 또는 비활성화",
sqlShortcutsDeleteConfirm: 'SQL 단축키 "{name}"을(를) 삭제하시겠습니까?',
syncWebDavTitle: "WebDAV 동기화",
syncWebDavDescription: "WebDAV 호환 저장 서비스에서 DBX 스냅샷을 업로드하거나 복원합니다(저장된 SQL 라이브러리 항목 포함).",
syncWebDavWebDescription: "Web 모드에서는 DBX 서버가 WebDAV에 연결하므로 브라우저 CORS 제한을 받지 않습니다. 저장된 비밀번호는 현재 DBX 인스턴스에 유지됩니다.",
@@ -5866,6 +5914,11 @@ export default withEnglishFallback({
executeModeDescription: "SQL 실행 단축키: {shortcut}. 기본적으로 전체 SQL 또는 커서가 있는 구문 중 무엇을 실행할지 설정합니다.",
executeModeAll: "모든 SQL 실행",
executeModeCurrent: "커서 위치의 구문 실행",
defaultTransactionMode: "기본 트랜잭션 커밋 모드",
defaultTransactionModeDescription:
'새 SQL 편집기를 열 때 기본적으로 사용되는 트랜잭션 모드입니다. "수동 트랜잭션"을 선택하면 새 편집기가 수동 트랜잭션 모드로 시작되어 수동으로 커밋 또는 롤백해야 합니다. "자동 커밋"을 선택하면 각 문이 자동으로 커밋됩니다. 트랜잭션을 지원하는 데이터베이스(PostgreSQL, MySQL, Oracle, JDBC)에만 적용됩니다.',
defaultTransactionModeAuto: "자동 커밋",
defaultTransactionModeManual: "수동 트랜잭션",
executeAllOnBlankLine: "빈 줄에서 모든 SQL 실행",
executeAllOnBlankLineDescription: "현재 구문 실행 모드에서 커서 위치에 구문이 없으면 모든 SQL을 실행합니다. 기본적으로 비활성화됩니다.",
showExecutionTargetPicker: "실행 대상 선택기 표시",
@@ -6028,6 +6081,7 @@ export default withEnglishFallback({
shortcutToggleTranspose: "전치 보기 전환",
shortcutCancelSearch: "검색 취소",
shortcutToggleSidebar: "사이드바 전환",
shortcutToggleZenMode: "Zen 모드 전환",
shortcutCopySidebarSelection: "사이드바 선택 복사",
shortcutPasteSidebarSelection: "사이드바에 붙여넣기",
shortcutEditSidebarConnection: "사이드바 연결 편집",
+57 -3
View File
@@ -1636,8 +1636,8 @@ export default withEnglishFallback({
formatterForeignKeyReferenceColumn: "Coluna relacionada",
formatterForeignKeyReferenceColumnPlaceholder: "Selecione a coluna correspondente a este valor",
formatterForeignKeyReferenceMetadataUnavailable: "Não foi possível verificar a exclusividade da coluna relacionada: {error}",
formatterForeignKeyReferenceInvalid: "A coluna não existe ou não é uma chave exclusiva válida de uma única coluna. Selecione outra coluna.",
formatterForeignKeyReferenceUnavailable: "A tabela de destino não tem uma chave exclusiva utilizável de uma única coluna.",
formatterForeignKeyReferenceInvalid: "A coluna não existe ou não tem unicidade garantida por uma chave exclusiva e pelo filtro de igualdade atual. Selecione outra coluna.",
formatterForeignKeyReferenceUnavailable: "A tabela de destino não tem uma chave exclusiva utilizável. Para uma chave exclusiva composta, fixe a outra coluna com um filtro de igualdade.",
formatterForeignKeyDisplayColumn: "Coluna de exibição",
formatterForeignKeyDisplayColumnPlaceholder: "Selecione uma coluna",
formatterForeignKeyFilter: "Limitar linhas referenciadas",
@@ -1912,6 +1912,7 @@ export default withEnglishFallback({
setNull: "Definir NULL",
restoreOriginalValue: "Restaurar Original",
copyColumnName: "Copiar Nome da Coluna",
columnDragChipMany: "{names} ({count} colunas)",
copyAlterColumnSql: "Copiar como SQL ALTER",
alterSqlCopied: "SQL ALTER copiado para a área de transferência",
alterSqlCopiedWithWarnings: "SQL ALTER copiado para a área de transferência ({count} aviso(s))",
@@ -1933,6 +1934,9 @@ export default withEnglishFallback({
columnRegularIndex: "Índice comum",
tableInfoForeignKeys: "Chaves Estrangeiras",
foreignKeyNavigate: "Ir para {table}",
tableInfoConstraints: "Restrições",
tableInfoConstraintDisabled: "Desabilitada",
tableInfoConstraintNotValidated: "Não validada",
tableInfoTriggers: "Gatilhos",
tableInfoNullable: "Permite Nulo",
tableInfoEmpty: "Sem metadados",
@@ -2282,6 +2286,7 @@ export default withEnglishFallback({
},
connectionGroup: {
createGroup: "Novo Grupo",
group: "Grupo",
groupNamePlaceholder: "Nome do grupo",
renameGroup: "Renomear Grupo",
closeConnections: "Fechar conexões do grupo ({count})",
@@ -2290,7 +2295,7 @@ export default withEnglishFallback({
moveToGroup: "Mover para Grupo",
moveToNewGroup: "Mover para Novo Grupo",
ungrouped: "Sem grupo",
ungroupedLabel: "Sem grupo",
ungroupedLabel: "Padrão",
newGroup: "Novo Grupo...",
newGroupDefault: "Novo Grupo",
deleteGroupConfirmTitle: "Excluir Grupo",
@@ -2373,6 +2378,7 @@ export default withEnglishFallback({
settings: "Configurações de AI",
noConfig: "A AI não está configurada. Configure seu provedor de AI em Configurações > AI.",
provider: "Provedor",
providerCustom: "Personalizado",
providerStatusActive: "Ativo",
providerStatusConfigured: "Configurado",
switchProvider: "Trocar provedor",
@@ -3801,6 +3807,8 @@ export default withEnglishFallback({
sqliteRebuildNotice: "O SQLite mantém um snapshot de dados inerte como backup, reconstrói a tabela em uma única transação e força CAST nas colunas alteradas. Valores não representáveis podem se tornar 0/0.0; em caso de falha, todas as alterações são revertidas.",
extendedProperties: "Estendidas",
autoIncrement: "Auto incremento",
mysqlAutoIncrementNextValue: "Próximo valor de incremento automático",
editMysqlAutoIncrementValue: "Editar o próximo valor de incremento automático (atual: {value})",
onUpdateCurrentTimestamp: "Atualizar hora automaticamente",
identity: "Identidade",
identityGeneration: "Geração",
@@ -4943,6 +4951,7 @@ export default withEnglishFallback({
collectionValuePlaceholder: "Insira um array JSON ou valores separados por vírgula",
preview: "Prévia SQL",
execute: "Executar",
clearValues: "Limpar valores",
useRawForAll: "Usar SQL bruto para todos",
kind: {
string: "Texto",
@@ -5094,6 +5103,8 @@ export default withEnglishFallback({
formatJsonDescription: "Object or array rows",
formatExcel: "Excel",
formatExcelDescription: "XLS, XLSX, XLSM",
formatSql: "SQL",
formatSqlDescription: "Instruções INSERT em scripts SQL",
previewRows: "Preview rows",
delimiter: "Delimiter",
encoding: "Codificação",
@@ -5991,6 +6002,8 @@ export default withEnglishFallback({
tableOpenPageSizeDescription: "Linhas carregadas por página ao abrir uma nova tabela. Alterar as linhas por página atualiza esse padrão para futuras abas de tabelas.",
queryPageSize: "Linhas padrão por página de consulta",
queryPageSizeDescription: "Linhas carregadas por página de resultados. São suportadas até {max} linhas.",
multiStatementDefaultView: "Visualização padrão para várias instruções",
multiStatementDefaultViewDescription: "Escolha se lotes com várias instruções SQL abrem na tabela de resultados ou no resumo da execução.",
queryResultMaxRows: "Máximo de linhas do resultado",
queryResultMaxRowsEnabled: "Limitar linhas do resultado",
queryResultMaxRowsDescription: "Limita as linhas mantidas por consultas, dados de tabelas, procedimentos armazenados e carregamento contínuo.",
@@ -6039,6 +6052,8 @@ export default withEnglishFallback({
dataGridAutoTransposeSingleRowDescription: "Quando ativado, os resultados de consultas SQL com exatamente uma linha e várias colunas alternam automaticamente para a visualização transposta.",
dataGridCellDetailButtonVisible: "Mostrar botão de detalhes da célula",
dataGridCellDetailButtonVisibleDescription: "Mostrar o botão de detalhes da célula ao passar o mouse sobre uma célula.",
dataGridCrosshairHighlight: "Destacar linha e coluna em cruz",
dataGridCrosshairHighlightDescription: "Quando ativado, destaca levemente toda a linha e a coluna da célula ativa (como no Excel). A célula focada mantém o estilo selecionado.",
infiniteScroll: "Carregamento por rolagem infinita",
autoCalculateTotalRows: "Calcular automaticamente o total de linhas",
autoCalculateTotalRowsDescription: "Executa COUNT(*) automaticamente após cada consulta para mostrar o total de linhas correspondentes. Desativado por padrão para manter consultas grandes rápidas; você pode calculá-lo quando quiser no rodapé dos resultados.",
@@ -6130,6 +6145,21 @@ export default withEnglishFallback({
snippetsRestoreDefaults: "Restaurar padrões",
snippetsAddTitle: "Adicionar snippet",
snippetsEditTitle: "Editar snippet",
sqlShortcutsTitle: "Ações rápidas SQL",
sqlShortcutsDescription: "Modelos SQL personalizados executados após selecionar um nome de tabela no editor SQL. Selecione primeiro um identificador de tabela; atalhos com Cmd/Ctrl são recomendados.",
sqlShortcutsAdd: "Adicionar",
sqlShortcutsAddTitle: "Adicionar ação rápida SQL",
sqlShortcutsEditTitle: "Editar ação rápida SQL",
sqlShortcutsLabel: "Rótulo",
sqlShortcutsLabelPlaceholder: "ex.: Contar linhas",
sqlShortcutsLabelRequired: "O rótulo é obrigatório.",
sqlShortcutsSql: "Modelo SQL",
sqlShortcutsSqlPlaceholder: "SELECT COUNT(*) FROM ${table}",
sqlShortcutsVariableHint: "Use {token} para o nome da tabela selecionada.",
sqlShortcutsEmpty: "Nenhuma ação rápida SQL ainda.",
sqlShortcutsUnbound: "Não atribuído",
sqlShortcutsToggle: "Ativar ou desativar atalho SQL",
sqlShortcutsDeleteConfirm: 'Excluir o atalho SQL "{name}"?',
syncWebDavTitle: "Sincronização WebDAV",
syncWebDavDescription: "Envie ou restaure um snapshot do DBX, incluindo a biblioteca SQL salva, a partir de um serviço de armazenamento compatível com WebDAV.",
syncWebDavWebDescription: "No modo Web, o servidor DBX acessa o WebDAV sem as restrições de CORS do navegador. As senhas salvas permanecem na instância DBX atual.",
@@ -6173,6 +6203,11 @@ export default withEnglishFallback({
executeModeDescription: "Atalho Executar SQL: {shortcut}. Controla se todo o SQL ou a instrução no cursor é executada por padrão.",
executeModeAll: "Executar todo o SQL",
executeModeCurrent: "Executar instrução no cursor",
defaultTransactionMode: "Modo de confirmação de transação padrão",
defaultTransactionModeDescription:
'O modo de transação usado por padrão ao abrir um novo editor SQL. Com "Transação manual", novos editores iniciam no modo de transação manual, exigindo confirmação ou reversão manual; com "Confirmação automática", cada instrução é confirmada automaticamente. Aplica-se apenas a bancos de dados que oferecem suporte a transações (PostgreSQL, MySQL, Oracle, JDBC).',
defaultTransactionModeAuto: "Confirmação automática",
defaultTransactionModeManual: "Transação manual",
executeAllOnBlankLine: "Executar todo o SQL em linhas em branco",
executeAllOnBlankLineDescription: "No modo de instrução atual, executa todo o SQL quando não há instrução no cursor. Desativado por padrão.",
showExecutionTargetPicker: "Mostrar seletor de destino",
@@ -6302,6 +6337,7 @@ export default withEnglishFallback({
shortcutOpenSettings: "Abrir configurações",
shortcutCloseTab: "Fechar aba",
shortcutToggleSidebar: "Alternar barra lateral",
shortcutToggleZenMode: "Alternar modo Zen",
shortcutFocusSearch: "Focar na pesquisa da tela atual",
shortcutQuickOpen: "Abrir rapidamente (pesquisar todos os objetos do banco de dados)",
shortcutNavigateTabHistoryBack: "Voltar para a aba visualizada anteriormente",
@@ -8098,6 +8134,24 @@ export default withEnglishFallback({
aiReviewRequired: "O SQL de produção foi colocado no editor. Verifique-o primeiro e execute manualmente para confirmar.",
sourceMultiDbSql: "Execução de SQL em múltiplos bancos de dados",
},
readOnlyUnlock: {
confirmTitle: "Desbloquear gravações",
confirmMessage: "Esta conexão é somente leitura. As gravações continuam bloqueadas a menos que você as desbloqueie por um curto intervalo. A configuração de somente leitura salva não será desativada.",
confirmDetails: "Conexão: {connection}\nOrigem: {source}\nAs gravações serão permitidas pela duração selecionada e, em seguida, o modo somente leitura será retomado automaticamente.",
confirmAction: "Desbloquear gravações",
durationLabel: "Permitir gravações por",
durationOneMinute: "1 minuto",
durationFiveMinutes: "5 minutos",
remaining: "{time} restantes",
unlockedBadge: "Gravações desbloqueadas",
lockNow: "Bloquear gravações agora",
unlockAction: "Desbloquear gravações…",
sourceImport: "Importação de tabela",
sourceTransfer: "Transferência de dados",
sourceSqlFile: "Execução de arquivo SQL",
sourceStatus: "Controle de somente leitura",
sourceDataEditor: "Editor de dados",
},
processList: {
title: "Lista de Processos",
sessionCount: "{count} sessões",
+56 -3
View File
@@ -1681,8 +1681,8 @@ export default withEnglishFallback({
formatterForeignKeyReferenceColumn: "关联字段",
formatterForeignKeyReferenceColumnPlaceholder: "选择与当前字段匹配的字段",
formatterForeignKeyReferenceMetadataUnavailable: "无法验证关联字段唯一性:{error}",
formatterForeignKeyReferenceInvalid: "该字段不存在,或不是有效的单列唯一键。请重新选择。",
formatterForeignKeyReferenceUnavailable: "目标表没有可用的单列唯一键。",
formatterForeignKeyReferenceInvalid: "该字段不存在,或无法由唯一键和当前等值条件保证唯一。请重新选择。",
formatterForeignKeyReferenceUnavailable: "目标表没有可用的唯一键。联合唯一键需使用等值条件固定另一个字段。",
formatterForeignKeyDisplayColumn: "显示字段",
formatterForeignKeyDisplayColumnPlaceholder: "选择用于显示的字段",
formatterForeignKeyFilter: "限定引用数据",
@@ -1979,6 +1979,7 @@ export default withEnglishFallback({
setNull: "设为 NULL",
restoreOriginalValue: "恢复原值",
copyColumnName: "复制列名",
columnDragChipMany: "{names} 等 {count} 列",
copyAlterColumnSql: "复制为SQL ALTER",
alterSqlCopied: "SQL ALTER 已复制到剪贴板",
alterSqlCopiedWithWarnings: "SQL ALTER 已复制到剪贴板(含 {count} 条警告)",
@@ -2000,6 +2001,9 @@ export default withEnglishFallback({
columnRegularIndex: "普通索引",
tableInfoForeignKeys: "外键",
foreignKeyNavigate: "跳转到 {table}",
tableInfoConstraints: "约束",
tableInfoConstraintDisabled: "已禁用",
tableInfoConstraintNotValidated: "未验证",
tableInfoTriggers: "触发器",
tableInfoNullable: "可空",
tableInfoEmpty: "暂无元数据",
@@ -2271,6 +2275,7 @@ export default withEnglishFallback({
},
connectionGroup: {
createGroup: "新建分组",
group: "分组",
groupNamePlaceholder: "分组名称",
selectConnection: "选择连接",
deselectConnection: "取消选择连接",
@@ -2285,7 +2290,7 @@ export default withEnglishFallback({
moveToGroup: "移至分组",
moveToNewGroup: "移至新分组",
ungrouped: "取消分组",
ungroupedLabel: "未分组",
ungroupedLabel: "默认",
newGroup: "新建分组...",
newGroupDefault: "新分组",
deleteGroupConfirmTitle: "删除分组",
@@ -2361,6 +2366,7 @@ export default withEnglishFallback({
settings: "AI 设置",
noConfig: "AI 尚未配置。请在 设置 > AI 中配置你的 AI 服务。",
provider: "提供商",
providerCustom: "自定义",
providerStatusActive: "当前",
providerStatusConfigured: "已配置",
switchProvider: "切换服务商",
@@ -3971,6 +3977,8 @@ export default withEnglishFallback({
sqliteRebuildNotice: "SQLite 会保留一张不带活动约束的数据快照作为备份表,再在同一事务内重建原表,使用 CAST 强制转换类型已修改的字段;无法表示的值可能变为 0/0.0,执行失败则整体回滚。",
extendedProperties: "扩展属性",
autoIncrement: "自增",
mysqlAutoIncrementNextValue: "下一个自增值",
editMysqlAutoIncrementValue: "编辑下一个自增值(当前:{value})",
onUpdateCurrentTimestamp: "自动更新时间",
identity: "标识列",
identityGeneration: "生成方式",
@@ -5153,6 +5161,24 @@ export default withEnglishFallback({
sourceAdmin: "数据库管理",
aiReviewRequired: "生产 SQL 已放入编辑器。请先检查,再手动执行以确认。",
},
readOnlyUnlock: {
confirmTitle: "临时解锁写入",
confirmMessage: "当前连接处于只读模式。写入会被阻止,除非你确认解锁一小段时间。已保存的只读设置不会被关闭。",
confirmDetails: "连接:{connection}\n入口:{source}\n写入仅在所选时长内允许,到期后自动恢复只读。",
confirmAction: "解锁写入",
durationLabel: "允许写入时长",
durationOneMinute: "1 分钟",
durationFiveMinutes: "5 分钟",
remaining: "剩余 {time}",
unlockedBadge: "写入已解锁",
lockNow: "立即重新锁定",
unlockAction: "解锁写入…",
sourceImport: "表导入",
sourceTransfer: "数据传输",
sourceSqlFile: "SQL 文件执行",
sourceStatus: "只读控制",
sourceDataEditor: "数据编辑器",
},
sqlParameters: {
title: "SQL 参数",
description: "为 SQL 模板占位符填写参数值,DBX 会在执行前替换为 SQL 字面量。",
@@ -5164,6 +5190,7 @@ export default withEnglishFallback({
collectionValuePlaceholder: "输入 JSON 数组或逗号分隔值",
preview: "SQL 预览",
execute: "执行",
clearValues: "清空参数值",
useRawForAll: "全部使用原始 SQL",
kind: {
string: "字符串",
@@ -5321,6 +5348,8 @@ export default withEnglishFallback({
formatJsonDescription: "对象行或数组行",
formatExcel: "Excel",
formatExcelDescription: "XLS、XLSX、XLSM",
formatSql: "SQL",
formatSqlDescription: "SQL 脚本中的 INSERT 语句",
previewRows: "预览行数",
delimiter: "分隔符",
encoding: "文件编码",
@@ -6253,6 +6282,8 @@ export default withEnglishFallback({
tableOpenPageSizeDescription: "新打开数据表时每页加载的行数。在表数据页修改每页行数后,也会更新后续新建表标签页的默认值。",
queryPageSize: "查询结果默认每页行数",
queryPageSizeDescription: "每个查询结果分页加载的行数,最大支持 {max} 行。",
multiStatementDefaultView: "多语句执行默认视图",
multiStatementDefaultViewDescription: "选择多条 SQL 语句批量执行后默认打开数据表或执行摘要。",
queryResultMaxRows: "查询结果最大行数",
queryResultMaxRowsEnabled: "限制查询结果行数",
queryResultMaxRowsDescription: "限制查询、表数据、存储过程及连续滚动累计保留的总行数。",
@@ -6301,6 +6332,8 @@ export default withEnglishFallback({
dataGridAutoTransposeSingleRowDescription: "开启后,SQL 查询结果只有一行且包含多列时,自动切换为转置视图。",
dataGridCellDetailButtonVisible: "显示单元格详情按钮",
dataGridCellDetailButtonVisibleDescription: "鼠标悬停在单元格上时显示单元格详情按钮。",
dataGridCrosshairHighlight: "行列十字高亮",
dataGridCrosshairHighlightDescription: "开启后,以当前活动单元格为中心淡色高亮整行和整列(类似 Excel)。焦点单元格仍保留原有的选中样式。",
infiniteScroll: "无限滚动加载",
autoCalculateTotalRows: "自动统计总行数",
autoCalculateTotalRowsDescription: "每次查询后自动执行 COUNT(*) 显示匹配的总行数。默认关闭以保证大查询速度 —— 可在结果栏按需手动统计。",
@@ -6399,6 +6432,21 @@ export default withEnglishFallback({
snippetsRestoreDefaults: "恢复默认",
snippetsAddTitle: "添加片段",
snippetsEditTitle: "编辑片段",
sqlShortcutsTitle: "SQL 快捷操作",
sqlShortcutsDescription: "自定义在 SQL 编辑器中选中表名后执行的 SQL 模板。需先选中表名;建议使用含 Cmd/Ctrl 的组合键。",
sqlShortcutsAdd: "添加",
sqlShortcutsAddTitle: "添加 SQL 快捷键",
sqlShortcutsEditTitle: "编辑 SQL 快捷键",
sqlShortcutsLabel: "显示名",
sqlShortcutsLabelPlaceholder: "如:统计行数",
sqlShortcutsLabelRequired: "显示名为必填项。",
sqlShortcutsSql: "SQL 模板",
sqlShortcutsSqlPlaceholder: "SELECT COUNT(*) FROM ${table}",
sqlShortcutsVariableHint: "使用 {token} 表示选中的表名。",
sqlShortcutsEmpty: "暂无 SQL 快捷操作。",
sqlShortcutsUnbound: "未绑定",
sqlShortcutsToggle: "启用或关闭 SQL 快捷键",
sqlShortcutsDeleteConfirm: "删除 SQL 快捷键「{name}」?",
syncWebDavTitle: "WebDAV 同步",
syncWebDavDescription: "将包含 SQL 库的 DBX 快照上传到兼容 WebDAV 的存储服务,或从远端恢复。",
syncWebDavWebDescription: "Web 模式下由 DBX 服务端访问 WebDAV,不受浏览器 CORS 限制;保存的密码会留在当前 DBX 实例中。",
@@ -6468,6 +6516,10 @@ export default withEnglishFallback({
executeModeDescription: "执行 SQL 快捷键:{shortcut}。用于控制默认执行全部 SQL 或光标所在语句。",
executeModeAll: "执行全部 SQL",
executeModeCurrent: "执行光标所在语句",
defaultTransactionMode: "默认事务提交方式",
defaultTransactionModeDescription: "新建 SQL 编辑器时默认采用的事务提交方式。选择“手动事务”后,新打开的编辑器将以手动事务模式开始,需手动提交或回滚;选择“自动提交”则每条语句自动提交。仅支持事务的数据库(PostgreSQL、MySQL、Oracle、JDBC)生效。",
defaultTransactionModeAuto: "自动提交",
defaultTransactionModeManual: "手动事务",
executeAllOnBlankLine: "空白位置执行全部 SQL",
executeAllOnBlankLineDescription: "在“执行光标所在语句”模式下,光标没有对应语句时执行全部 SQL。默认关闭。",
showExecutionTargetPicker: "显示执行目标选择器",
@@ -6615,6 +6667,7 @@ export default withEnglishFallback({
shortcutOpenSettings: "打开设置",
shortcutCloseTab: "关闭标签页",
shortcutToggleSidebar: "切换侧边栏",
shortcutToggleZenMode: "切换禅模式",
shortcutFocusSearch: "聚焦当前页面搜索",
shortcutQuickOpen: "快速打开 (搜索所有数据库对象)",
shortcutNavigateTabHistoryBack: "返回到上一个查看的标签页",
+56 -3
View File
@@ -1635,8 +1635,8 @@ export default withEnglishFallback({
formatterForeignKeyReferenceColumn: "關聯欄位",
formatterForeignKeyReferenceColumnPlaceholder: "選擇與目前欄位匹配的欄位",
formatterForeignKeyReferenceMetadataUnavailable: "無法驗證關聯欄位的唯一性:{error}",
formatterForeignKeyReferenceInvalid: "此欄位不存在,或不是有效的單欄唯一鍵。請重新選擇。",
formatterForeignKeyReferenceUnavailable: "目標資料表沒有可用的單欄唯一鍵。",
formatterForeignKeyReferenceInvalid: "此欄位不存在,或無法由唯一鍵和目前的等值條件保證唯一。請重新選擇。",
formatterForeignKeyReferenceUnavailable: "目標資料表沒有可用的唯一鍵。複合唯一鍵需使用等值條件固定另一個欄位。",
formatterForeignKeyDisplayColumn: "顯示欄位",
formatterForeignKeyDisplayColumnPlaceholder: "選擇用於顯示的欄位",
formatterForeignKeyFilter: "限定引用資料",
@@ -1916,6 +1916,7 @@ export default withEnglishFallback({
setNull: "設為 NULL",
restoreOriginalValue: "復原資料",
copyColumnName: "複製欄位名稱",
columnDragChipMany: "{names} 等 {count} 欄",
copyAlterColumnSql: "複製為 SQL ALTER",
alterSqlCopied: "SQL ALTER 已複製到剪貼簿",
alterSqlCopiedWithWarnings: "SQL ALTER 已複製到剪貼簿(含 {count} 條警告)",
@@ -1937,6 +1938,9 @@ export default withEnglishFallback({
columnRegularIndex: "一般索引",
tableInfoForeignKeys: "外鍵",
foreignKeyNavigate: "跳轉到 {table}",
tableInfoConstraints: "約束",
tableInfoConstraintDisabled: "已停用",
tableInfoConstraintNotValidated: "未驗證",
tableInfoTriggers: "觸發器",
tableInfoNullable: "可為空",
tableInfoEmpty: "無",
@@ -2281,6 +2285,7 @@ export default withEnglishFallback({
},
connectionGroup: {
createGroup: "建立群組",
group: "群組",
groupNamePlaceholder: "群組名稱",
renameGroup: "重新命名群組",
closeConnections: "關閉群組內連線({count})",
@@ -2289,7 +2294,7 @@ export default withEnglishFallback({
moveToGroup: "移至群組",
moveToNewGroup: "移至新群組",
ungrouped: "取消群組",
ungroupedLabel: "未分組",
ungroupedLabel: "預設",
newGroup: "新增群組……",
newGroupDefault: "新群組",
deleteGroupConfirmTitle: "刪除群組",
@@ -2371,6 +2376,7 @@ export default withEnglishFallback({
settings: "AI 設定",
noConfig: "AI 尚未設定。請在 設定 > AI 中設定你的 AI 服務。",
provider: "供應商",
providerCustom: "自訂",
providerStatusActive: "目前",
providerStatusConfigured: "已設定",
switchProvider: "切換服務商",
@@ -3541,6 +3547,8 @@ export default withEnglishFallback({
sqliteRebuildNotice: "SQLite 會保留一張不含作用中約束的資料快照作為備份表,再於同一交易內重建原表,並使用 CAST 強制轉換已修改型別的欄位;無法表示的值可能變成 0/0.0,執行失敗則整體回復。",
extendedProperties: "擴充屬性",
autoIncrement: "自動遞增",
mysqlAutoIncrementNextValue: "下一個自動遞增值",
editMysqlAutoIncrementValue: "編輯下一個自動遞增值(目前:{value})",
onUpdateCurrentTimestamp: "自動更新時間",
identity: "識別欄位",
identityGeneration: "產生方式",
@@ -4267,6 +4275,7 @@ export default withEnglishFallback({
collectionValuePlaceholder: "輸入 JSON 陣列或逗號分隔值",
preview: "SQL 預覽",
execute: "執行",
clearValues: "清空參數值",
useRawForAll: "全部使用原始 SQL",
kind: {
string: "字串",
@@ -4418,6 +4427,8 @@ export default withEnglishFallback({
formatJsonDescription: "物件列或陣列列",
formatExcel: "Excel",
formatExcelDescription: "XLS、XLSX、XLSM",
formatSql: "SQL",
formatSqlDescription: "SQL 指令檔中的 INSERT 語句",
previewRows: "預覽列數",
delimiter: "分隔符",
encoding: "檔案編碼",
@@ -5308,6 +5319,8 @@ export default withEnglishFallback({
tableOpenPageSizeDescription: "新開啟資料表時每頁載入的列數。在資料表頁面調整每頁列數後,也會更新後續新建資料表分頁的預設值。",
queryPageSize: "查詢結果預設每頁列數",
queryPageSizeDescription: "每個查詢結果分頁載入的列數,最多支援 {max} 列。",
multiStatementDefaultView: "多語句執行預設檢視",
multiStatementDefaultViewDescription: "選擇多條 SQL 語句批次執行後預設開啟資料表或執行摘要。",
queryResultMaxRows: "查詢結果最大列數",
queryResultMaxRowsEnabled: "限制查詢結果列數",
queryResultMaxRowsDescription: "限制查詢、資料表資料、預存程序及連續捲動累計保留的總列數。",
@@ -5356,6 +5369,8 @@ export default withEnglishFallback({
dataGridAutoTransposeSingleRowDescription: "開啟後,SQL 查詢結果只有一行且包含多個欄位時,會自動切換為轉置檢視。",
dataGridCellDetailButtonVisible: "顯示儲存格詳細資料按鈕",
dataGridCellDetailButtonVisibleDescription: "游標懸停在儲存格上時顯示儲存格詳細資料按鈕。",
dataGridCrosshairHighlight: "行列十字高亮",
dataGridCrosshairHighlightDescription: "開啟後,以目前活動儲存格為中心淡色高亮整列和整行(類似 Excel)。焦點儲存格仍保留原有選取樣式。",
infiniteScroll: "無限滾動載入",
autoCalculateTotalRows: "自動統計總筆數",
autoCalculateTotalRowsDescription: "每次查詢後自動執行 COUNT(*) 顯示符合的總筆數。預設關閉以確保大型查詢速度 —— 可在結果列按需手動統計。",
@@ -5446,6 +5461,21 @@ export default withEnglishFallback({
snippetsRestoreDefaults: "還原預設",
snippetsAddTitle: "新增片段",
snippetsEditTitle: "編輯片段",
sqlShortcutsTitle: "SQL 快捷操作",
sqlShortcutsDescription: "自訂在 SQL 編輯器中選取表名後執行的 SQL 範本。需先選取表名;建議使用含 Cmd/Ctrl 的組合鍵。",
sqlShortcutsAdd: "新增",
sqlShortcutsAddTitle: "新增 SQL 快捷鍵",
sqlShortcutsEditTitle: "編輯 SQL 快捷鍵",
sqlShortcutsLabel: "顯示名",
sqlShortcutsLabelPlaceholder: "如:統計行數",
sqlShortcutsLabelRequired: "顯示名為必填項。",
sqlShortcutsSql: "SQL 範本",
sqlShortcutsSqlPlaceholder: "SELECT COUNT(*) FROM ${table}",
sqlShortcutsVariableHint: "使用 {token} 表示選取的表名。",
sqlShortcutsEmpty: "暫無 SQL 快捷操作。",
sqlShortcutsUnbound: "未綁定",
sqlShortcutsToggle: "啟用或關閉 SQL 快捷鍵",
sqlShortcutsDeleteConfirm: "刪除 SQL 快捷鍵「{name}」?",
syncWebDavTitle: "WebDAV 同步",
syncWebDavDescription: "上傳包含 SQL 庫的 DBX 快照到相容 WebDAV 的儲存服務,或從該服務還原快照。",
syncWebDavWebDescription: "Web 模式由 DBX 伺服器端存取 WebDAV,不受瀏覽器 CORS 限制;儲存的密碼會保留在目前 DBX 執行個體中。",
@@ -5489,6 +5519,10 @@ export default withEnglishFallback({
executeModeDescription: "執行 SQL 快捷鍵:{shortcut}。用於控制預設執行全部 SQL 或游標所在語句。",
executeModeAll: "執行全部 SQL",
executeModeCurrent: "執行指標所在語句",
defaultTransactionMode: "預設交易提交方式",
defaultTransactionModeDescription: "新增 SQL 編輯器時預設採用的交易提交方式。選擇「手動交易」後,新開啟的編輯器將以手動交易模式開始,需手動提交或回滾;選擇「自動提交」則每條語句自動提交。僅支援交易的資料庫(PostgreSQL、MySQL、Oracle、JDBC)生效。",
defaultTransactionModeAuto: "自動提交",
defaultTransactionModeManual: "手動交易",
executeAllOnBlankLine: "空白位置執行全部 SQL",
executeAllOnBlankLineDescription: "在「執行游標所在語句」模式下,游標沒有對應語句時執行全部 SQL。預設關閉。",
showExecutionTargetPicker: "顯示執行目標選擇器",
@@ -5607,6 +5641,7 @@ export default withEnglishFallback({
shortcutOpenSettings: "開啟設定",
shortcutCloseTab: "關閉分頁",
shortcutToggleSidebar: "切換側邊欄",
shortcutToggleZenMode: "切換禪模式",
shortcutFocusSearch: "聚焦目前頁面搜尋",
shortcutQuickOpen: "快速開啟 (搜尋所有資料庫物件)",
shortcutNavigateTabHistoryBack: "返回到上一個檢視的分頁",
@@ -8088,6 +8123,24 @@ export default withEnglishFallback({
aiReviewRequired: "生產 SQL 已放入編輯器。請先檢查,再手動執行以確認。",
sourceMultiDbSql: "多庫 SQL 執行",
},
readOnlyUnlock: {
confirmTitle: "暫時解鎖寫入",
confirmMessage: "目前連線處於唯讀模式。寫入會被阻擋,除非你確認解鎖一小段時間。已儲存的唯讀設定不會被關閉。",
confirmDetails: "連線:{connection}\n入口:{source}\n寫入僅在所選時長內允許,到期後自動恢復唯讀。",
confirmAction: "解鎖寫入",
durationLabel: "允許寫入時長",
durationOneMinute: "1 分鐘",
durationFiveMinutes: "5 分鐘",
remaining: "剩餘 {time}",
unlockedBadge: "寫入已解鎖",
lockNow: "立即重新鎖定",
unlockAction: "解鎖寫入…",
sourceImport: "資料表匯入",
sourceTransfer: "資料傳輸",
sourceSqlFile: "SQL 檔案執行",
sourceStatus: "唯讀控制",
sourceDataEditor: "資料編輯器",
},
processList: {
title: "處理程序清單",
sessionCount: "{count} 個會話",
@@ -55,7 +55,20 @@ describe("right sidebar panel entry points", () => {
expect(appSource).toContain(':maximized="isAiPanelMaximized"');
expect(appSource).toContain('@toggle-maximize="toggleAiPanelMaximized"');
expect(appSource).toContain("isAiPanelMaximized ? 'min-w-0 flex-1' : 'min-w-[180px] max-w-full'");
expect(appSource).toContain('v-show="!isAiPanelMaximized"');
expect(appSource).toContain('v-show="!isAiPanelMaximized && !isZenMode"');
expect(functionSource("setRightSidebarPanelOpen", "toggleRightSidebarPanel")).toContain("isAiPanelMaximized.value = false;");
});
it("keeps Zen mode as a temporary data-tab layout", () => {
expect(appSource).toContain("const isZenMode = ref(false);");
expect(appSource).toContain('if (mode !== "data") isZenMode.value = false;');
expect(appSource).toContain("function toggleZenMode() {");
expect(appSource).toContain('if (activeTab.value?.mode !== "data") return;');
expect(appSource).toContain('isToggleZenModeShortcut(e, shortcuts) && activeTab.value?.mode === "data"');
expect(appSource).toContain('@toggle-zen-mode="toggleZenMode"');
expect(appSource).toContain('v-show="sidebarOpen && !isZenMode"');
expect(appSource).toContain('v-show="!sidebarOpen && !isZenMode"');
expect(appSource).toContain('v-show="!isAiPanelMaximized || isZenMode"');
expect(appSource).toContain('v-show="!isZenMode"');
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { databaseBackupRunsToPrune, normalizeDatabaseBackupRun, type DatabaseBackupRun } from "../../backup/scheduledDatabaseBackup";
import { databaseBackupRunsToPrune, databaseBackupTableMatchesPattern, normalizeDatabaseBackupRun, resolveScheduledDatabaseBackupTableScope, type DatabaseBackupRun } from "../../backup/scheduledDatabaseBackup";
const startedAt = "2026-08-12T00:00:00.000Z";
@@ -46,3 +46,23 @@ describe("database backup run persistence", () => {
expect(databaseBackupRunsToPrune(normalizedRuns, "schedule-1", 1).map((value) => value.id)).toEqual(["scheduled-old"]);
});
});
describe("database backup table pattern matching", () => {
it("matches a fully-qualified db.schema.table pattern when the database and schema share the same name", () => {
// Regression for #7314: PostgreSQL commonly has a schema with the same
// name as its database, and the fully-qualified candidate was previously
// suppressed whenever database === schema, silently breaking exclude
// rules written as "db.schema.table".
expect(databaseBackupTableMatchesPattern("vector_data", ["issue7314.issue7314.vector_data"], "issue7314", "issue7314")).toBe(true);
});
it("still matches a fully-qualified db.schema.table pattern when the database and schema differ", () => {
expect(databaseBackupTableMatchesPattern("vector_data", ["mydb.myschema.vector_data"], "mydb", "myschema")).toBe(true);
});
it("excludes the matched table from the backup scope when database and schema share the same name", () => {
const scope = resolveScheduledDatabaseBackupTableScope("exclude", ["issue7314.issue7314.vector_data"], ["vector_data", "keep_rows"], "issue7314", "issue7314");
expect(scope.includedTables).toEqual(["keep_rows"]);
expect(scope.excludedTables).toEqual(["vector_data"]);
});
});

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