mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f54385b97 | ||
|
|
cb9f87031f | ||
|
|
5c738f5bf2 | ||
|
|
038ff4e773 | ||
|
|
9c56de2e9f | ||
|
|
ef87e45cbc | ||
|
|
f83f707fbc | ||
|
|
7a4edb6c19 | ||
|
|
b4787a63af | ||
|
|
fd17037a2a | ||
|
|
41db375ee6 | ||
|
|
1498b27314 | ||
|
|
6d8e667bed | ||
|
|
df88d7baa7 | ||
|
|
659fb7f330 | ||
|
|
c237ecbfca | ||
|
|
bcc64274e8 | ||
|
|
802019daa3 | ||
|
|
60bfb41f13 | ||
|
|
17e2573fbe | ||
|
|
2467d212d5 | ||
|
|
80224ab1d9 | ||
|
|
89086cc22b | ||
|
|
fdfad88590 | ||
|
|
d3a97164aa | ||
|
|
d4245f0d58 | ||
|
|
2c5f926bd8 | ||
|
|
20a2994fa1 | ||
|
|
7ad05b178f | ||
|
|
be307ba2d7 | ||
|
|
10bce698ae | ||
|
|
c0abc26fe8 | ||
|
|
425ca055ea | ||
|
|
99a9961712 | ||
|
|
d8ec9bb8d0 | ||
|
|
4636788f49 | ||
|
|
22305b7d0f | ||
|
|
71d07348ad | ||
|
|
b198dd1327 | ||
|
|
9290391985 | ||
|
|
1016ccc352 | ||
|
|
ec0b6768be | ||
|
|
b09c6f4f21 | ||
|
|
74ffab29ca | ||
|
|
e996f8aefb | ||
|
|
4fed29fba2 | ||
|
|
214bf86a6f | ||
|
|
955558c81f | ||
|
|
7933b7656f | ||
|
|
e5572ead7a | ||
|
|
bd6c780aba | ||
|
|
dfd4d3b7ff | ||
|
|
ab4e747bf9 | ||
|
|
9e4fe8ce56 | ||
|
|
6a6e5fbad9 | ||
|
|
e8d6370b1e | ||
|
|
4eeeafa858 | ||
|
|
e4dc5bace8 | ||
|
|
920ab62cf1 | ||
|
|
36a7b7bd3a | ||
|
|
565feb8745 | ||
|
|
40549c9573 | ||
|
|
eb0994e980 | ||
|
|
cf03de122a | ||
|
|
d9beaaaa1c |
+67
@@ -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"/}"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, "'", "''") + "'"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
+55
-1
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+56
@@ -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
|
||||
|
||||
+20
@@ -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.
|
||||
|
||||
+56
-2
@@ -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 () => {
|
||||
|
||||
+74
-8
@@ -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;
|
||||
|
||||
+7
@@ -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)");
|
||||
});
|
||||
});
|
||||
|
||||
+146
@@ -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);
|
||||
});
|
||||
});
|
||||
Vendored
+25
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} セッション",
|
||||
|
||||
@@ -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: "사이드바 연결 편집",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "返回到上一个查看的标签页",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user