mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-29 09:01:22 +08:00
Compare commits
29 Commits
v0.5.2
...
feature/ki
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a830c7a82 | ||
|
|
adab24085e | ||
|
|
cc201f3df6 | ||
|
|
57d6ace2f8 | ||
|
|
0f5dc2184d | ||
|
|
0b95908ae9 | ||
|
|
4c322818bc | ||
|
|
3fefa13023 | ||
|
|
75a5a322e0 | ||
|
|
1be003b0a2 | ||
|
|
0a0609c459 | ||
|
|
0682baa14d | ||
|
|
fb65b553e9 | ||
|
|
b53227cb15 | ||
|
|
6c41e15e99 | ||
|
|
4aa177ed37 | ||
|
|
ba51fa658c | ||
|
|
4f5a7bd94b | ||
|
|
18ed4ca50c | ||
|
|
37704e2b3b | ||
|
|
6b9104fae8 | ||
|
|
647768221e | ||
|
|
251e1b22d7 | ||
|
|
c99d4b1fa6 | ||
|
|
ff98ec79a4 | ||
|
|
072b4e6e78 | ||
|
|
2449184ad3 | ||
|
|
6ae49d4b84 | ||
|
|
5c23722ad8 |
26
.github/release.yaml
vendored
Normal file
26
.github/release.yaml
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
changelog:
|
||||||
|
categories:
|
||||||
|
- title: 新功能
|
||||||
|
labels:
|
||||||
|
- feature
|
||||||
|
- enhancement
|
||||||
|
- feat
|
||||||
|
- title: 问题修复
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
- fix
|
||||||
|
- title: 文档与流程
|
||||||
|
labels:
|
||||||
|
- docs
|
||||||
|
- documentation
|
||||||
|
- ci
|
||||||
|
- workflow
|
||||||
|
- chore
|
||||||
|
- title: 重构与优化
|
||||||
|
labels:
|
||||||
|
- refactor
|
||||||
|
- perf
|
||||||
|
- optimization
|
||||||
|
- title: 其他更新
|
||||||
|
labels:
|
||||||
|
- '*'
|
||||||
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -550,5 +550,6 @@ jobs:
|
|||||||
files: release-assets/*
|
files: release-assets/*
|
||||||
draft: true
|
draft: true
|
||||||
make_latest: true
|
make_latest: true
|
||||||
|
generate_release_notes: true
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
159
.github/workflows/sync-main-to-dev.yml
vendored
Normal file
159
.github/workflows/sync-main-to-dev.yml
vendored
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
name: main 回灌 dev
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: sync-main-to-dev
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync-main-to-dev:
|
||||||
|
name: 执行回灌同步
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: 检出代码
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: 检查是否需要同步
|
||||||
|
id: diff_check
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "开始检查 main 与 dev 的分支差异..."
|
||||||
|
git fetch origin main dev
|
||||||
|
ahead_count="$(git rev-list --count origin/dev..origin/main)"
|
||||||
|
echo "ahead_count=${ahead_count}" >> "$GITHUB_OUTPUT"
|
||||||
|
if [ "${ahead_count}" -eq 0 ]; then
|
||||||
|
echo "无需同步,dev 已包含 main 的最新提交。"
|
||||||
|
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "检测到 ${ahead_count} 个待同步提交,准备创建或复用同步 PR。"
|
||||||
|
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 创建或复用同步 PR
|
||||||
|
id: sync_pr
|
||||||
|
if: steps.diff_check.outputs.has_changes == 'true'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "permission_blocked=false" >> "$GITHUB_OUTPUT"
|
||||||
|
existing_number="$(gh pr list --base dev --head main --state open --json number --jq '.[0].number // empty')"
|
||||||
|
|
||||||
|
if [ -n "${existing_number}" ]; then
|
||||||
|
pr_number="${existing_number}"
|
||||||
|
pr_url="$(gh pr view "${pr_number}" --json url --jq '.url')"
|
||||||
|
echo "复用已有同步 PR:#${pr_number}"
|
||||||
|
echo "created=false" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
body_file="$(mktemp)"
|
||||||
|
error_file="$(mktemp)"
|
||||||
|
{
|
||||||
|
echo "## 自动回灌:\`main -> dev\`"
|
||||||
|
echo
|
||||||
|
echo "- 触发条件:\`main\` 分支出现新提交(含贡献者直接合并到 \`main\` 的 PR)"
|
||||||
|
echo "- 目标:让 \`dev\` 持续吸收 \`main\` 的更新,避免发布前集中冲突"
|
||||||
|
echo
|
||||||
|
echo "### 合并建议"
|
||||||
|
echo "- 无冲突:直接合并该 PR(建议 \`Merge commit\`)"
|
||||||
|
echo "- 有冲突:在该 PR 内解决冲突后再合并"
|
||||||
|
} > "${body_file}"
|
||||||
|
|
||||||
|
if pr_url="$(gh pr create \
|
||||||
|
--base dev \
|
||||||
|
--head main \
|
||||||
|
--title "🔁 chore(sync): 回灌 main 到 dev" \
|
||||||
|
--body-file "${body_file}" 2>"${error_file}")"; then
|
||||||
|
pr_number="${pr_url##*/}"
|
||||||
|
echo "已创建同步 PR:#${pr_number}"
|
||||||
|
echo "created=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
error_message="$(tr '\n' ' ' < "${error_file}")"
|
||||||
|
if printf '%s' "${error_message}" | grep -Fq "GitHub Actions is not permitted to create or approve pull requests"; then
|
||||||
|
echo "::warning::仓库未开启“Allow GitHub Actions to create and approve pull requests”,已跳过自动创建同步 PR。"
|
||||||
|
echo "permission_blocked=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "created=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "pr_number=" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "pr_url=" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "::error::创建同步 PR 失败:${error_message}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "pr_number=${pr_number}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "pr_url=${pr_url}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: 检查合并状态
|
||||||
|
id: merge_state
|
||||||
|
if: steps.diff_check.outputs.has_changes == 'true' && steps.sync_pr.outputs.permission_blocked != 'true'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
pr_number="${{ steps.sync_pr.outputs.pr_number }}"
|
||||||
|
mergeable="$(gh pr view "${pr_number}" --json mergeable --jq '.mergeable')"
|
||||||
|
merge_state_status="$(gh pr view "${pr_number}" --json mergeStateStatus --jq '.mergeStateStatus')"
|
||||||
|
echo "PR #${pr_number} 合并状态:mergeable=${mergeable}, mergeStateStatus=${merge_state_status}"
|
||||||
|
echo "mergeable=${mergeable}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "merge_state_status=${merge_state_status}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: 可合并时开启自动合并
|
||||||
|
id: auto_merge
|
||||||
|
if: steps.diff_check.outputs.has_changes == 'true' && steps.sync_pr.outputs.permission_blocked != 'true' && steps.merge_state.outputs.mergeable == 'MERGEABLE'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
pr_number="${{ steps.sync_pr.outputs.pr_number }}"
|
||||||
|
if gh pr merge "${pr_number}" --merge --auto; then
|
||||||
|
echo "已为 PR #${pr_number} 开启自动合并。"
|
||||||
|
echo "result=enabled" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "::warning::自动合并开启失败,请手动处理并合并该 PR。"
|
||||||
|
echo "result=failed" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 写入执行摘要
|
||||||
|
if: always()
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
{
|
||||||
|
echo "## main 回灌 dev 执行结果"
|
||||||
|
if [ "${{ steps.diff_check.outputs.has_changes }}" != "true" ]; then
|
||||||
|
echo "- 状态:无需同步(dev 已包含 main 最新提交)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ "${{ steps.sync_pr.outputs.permission_blocked }}" = "true" ]; then
|
||||||
|
echo "- 状态:已跳过自动创建同步 PR"
|
||||||
|
echo "- 原因:仓库未开启 GitHub Actions 创建与审批 Pull Request 权限"
|
||||||
|
echo "- 处理:前往 Settings -> Actions -> General -> Workflow permissions,开启 Allow GitHub Actions to create and approve pull requests"
|
||||||
|
echo "- 兜底:由维护者手动执行 main 到 dev 合并,或开启该设置后重新运行 workflow"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "- PR:${{ steps.sync_pr.outputs.pr_url }}"
|
||||||
|
echo "- 可合并状态:${{ steps.merge_state.outputs.mergeable }}"
|
||||||
|
echo "- 合并状态详情:${{ steps.merge_state.outputs.merge_state_status }}"
|
||||||
|
if [ "${{ steps.merge_state.outputs.mergeable }}" = "CONFLICTING" ]; then
|
||||||
|
echo "- 结论:检测到冲突,需要手动处理后合并"
|
||||||
|
elif [ "${{ steps.auto_merge.outputs.result }}" = "enabled" ]; then
|
||||||
|
echo "- 结论:已启用自动合并(满足保护规则后将自动入 dev)"
|
||||||
|
else
|
||||||
|
echo "- 结论:PR 已创建/复用,请按分支策略人工合并"
|
||||||
|
fi
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
342
.github/workflows/test-build-all-platforms.yml
vendored
Normal file
342
.github/workflows/test-build-all-platforms.yml
vendored
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
name: Test Build All Platforms (Manual)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
build_label:
|
||||||
|
description: "测试包标识(仅用于文件名)"
|
||||||
|
required: false
|
||||||
|
default: "test"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: test-build-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build ${{ matrix.platform }}
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: macos-latest
|
||||||
|
platform: darwin/amd64
|
||||||
|
os_name: MacOS
|
||||||
|
arch_name: Amd64
|
||||||
|
build_name: gonavi-test-darwin-amd64
|
||||||
|
wails_tags: ""
|
||||||
|
artifact_suffix: ""
|
||||||
|
build_optional_agents: true
|
||||||
|
linux_webkit: ""
|
||||||
|
- os: macos-latest
|
||||||
|
platform: darwin/arm64
|
||||||
|
os_name: MacOS
|
||||||
|
arch_name: Arm64
|
||||||
|
build_name: gonavi-test-darwin-arm64
|
||||||
|
wails_tags: ""
|
||||||
|
artifact_suffix: ""
|
||||||
|
build_optional_agents: true
|
||||||
|
linux_webkit: ""
|
||||||
|
- os: windows-latest
|
||||||
|
platform: windows/amd64
|
||||||
|
os_name: Windows
|
||||||
|
arch_name: Amd64
|
||||||
|
build_name: gonavi-test-windows-amd64
|
||||||
|
wails_tags: ""
|
||||||
|
artifact_suffix: ""
|
||||||
|
build_optional_agents: true
|
||||||
|
linux_webkit: ""
|
||||||
|
- os: windows-latest
|
||||||
|
platform: windows/arm64
|
||||||
|
os_name: Windows
|
||||||
|
arch_name: Arm64
|
||||||
|
build_name: gonavi-test-windows-arm64
|
||||||
|
wails_tags: ""
|
||||||
|
artifact_suffix: ""
|
||||||
|
build_optional_agents: true
|
||||||
|
linux_webkit: ""
|
||||||
|
- os: ubuntu-22.04
|
||||||
|
platform: linux/amd64
|
||||||
|
os_name: Linux
|
||||||
|
arch_name: Amd64
|
||||||
|
build_name: gonavi-test-linux-amd64
|
||||||
|
wails_tags: ""
|
||||||
|
artifact_suffix: ""
|
||||||
|
build_optional_agents: true
|
||||||
|
linux_webkit: "4.0"
|
||||||
|
- os: ubuntu-24.04
|
||||||
|
platform: linux/amd64
|
||||||
|
os_name: Linux
|
||||||
|
arch_name: Amd64
|
||||||
|
build_name: gonavi-test-linux-amd64-webkit41
|
||||||
|
wails_tags: "webkit2_41"
|
||||||
|
artifact_suffix: "-WebKit41"
|
||||||
|
build_optional_agents: false
|
||||||
|
linux_webkit: "4.1"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.24'
|
||||||
|
check-latest: true
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Install Linux Dependencies
|
||||||
|
if: contains(matrix.platform, 'linux')
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libgtk-3-dev
|
||||||
|
|
||||||
|
if [ "${{ matrix.linux_webkit }}" = "4.1" ]; then
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libsoup-3.0-dev
|
||||||
|
else
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.0-dev
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
|
||||||
|
|
||||||
|
LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
|
||||||
|
PLUGIN_URL="https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/releases/download/continuous/linuxdeploy-plugin-gtk-x86_64.AppImage"
|
||||||
|
|
||||||
|
wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 -O /tmp/linuxdeploy "$LINUXDEPLOY_URL" || {
|
||||||
|
echo "skip-appimage=true" >> "$GITHUB_ENV"
|
||||||
|
}
|
||||||
|
wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 -O /tmp/linuxdeploy-plugin-gtk "$PLUGIN_URL" || {
|
||||||
|
echo "skip-appimage=true" >> "$GITHUB_ENV"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "${skip-appimage:-false}" != "true" ]; then
|
||||||
|
chmod +x /tmp/linuxdeploy /tmp/linuxdeploy-plugin-gtk
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install Wails
|
||||||
|
run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
|
||||||
|
|
||||||
|
- name: Setup MSYS2 Toolchain For DuckDB (Windows AMD64)
|
||||||
|
id: msys2_duckdb
|
||||||
|
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
|
||||||
|
continue-on-error: true
|
||||||
|
uses: msys2/setup-msys2@v2
|
||||||
|
with:
|
||||||
|
msystem: UCRT64
|
||||||
|
update: true
|
||||||
|
install: >-
|
||||||
|
mingw-w64-ucrt-x86_64-gcc
|
||||||
|
|
||||||
|
- name: Configure DuckDB CGO Toolchain (Windows AMD64)
|
||||||
|
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
function Find-MingwBin([string[]]$candidates) {
|
||||||
|
foreach ($bin in $candidates) {
|
||||||
|
if ([string]::IsNullOrWhiteSpace($bin)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$gcc = Join-Path $bin 'gcc.exe'
|
||||||
|
$gxx = Join-Path $bin 'g++.exe'
|
||||||
|
if ((Test-Path $gcc) -and (Test-Path $gxx)) {
|
||||||
|
return $bin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$msys2Location = "${{ steps.msys2_duckdb.outputs['msys2-location'] }}"
|
||||||
|
$candidateBins = @()
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($msys2Location)) {
|
||||||
|
$candidateBins += Join-Path $msys2Location 'ucrt64\bin'
|
||||||
|
}
|
||||||
|
$candidateBins += @(
|
||||||
|
'C:\msys64\ucrt64\bin',
|
||||||
|
'D:\a\_temp\msys64\ucrt64\bin'
|
||||||
|
)
|
||||||
|
$candidateBins = @($candidateBins | Select-Object -Unique)
|
||||||
|
|
||||||
|
$mingwBin = Find-MingwBin $candidateBins
|
||||||
|
if (-not $mingwBin) {
|
||||||
|
Write-Error "❌ 未找到可用的 DuckDB UCRT64 编译器。"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$gcc = Join-Path $mingwBin 'gcc.exe'
|
||||||
|
$gxx = Join-Path $mingwBin 'g++.exe'
|
||||||
|
"$mingwBin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||||
|
"CC=$gcc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
|
"CXX=$gxx" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
|
|
||||||
|
- name: Build App
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
BUILD_LABEL="${{ inputs.build_label }}"
|
||||||
|
if [ -z "$BUILD_LABEL" ]; then
|
||||||
|
BUILD_LABEL="test"
|
||||||
|
fi
|
||||||
|
APP_VERSION="${BUILD_LABEL}-${GITHUB_RUN_NUMBER}"
|
||||||
|
if [ -n "${{ matrix.wails_tags }}" ]; then
|
||||||
|
wails build -platform "${{ matrix.platform }}" -clean -o "${{ matrix.build_name }}" -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}"
|
||||||
|
else
|
||||||
|
wails build -platform "${{ matrix.platform }}" -clean -o "${{ matrix.build_name }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build Optional Driver Agents
|
||||||
|
if: ${{ matrix.build_optional_agents }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TARGET_PLATFORM="${{ matrix.platform }}"
|
||||||
|
GOOS="${TARGET_PLATFORM%%/*}"
|
||||||
|
GOARCH="${TARGET_PLATFORM##*/}"
|
||||||
|
DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
|
||||||
|
OUTDIR="drivers/${{ matrix.os_name }}"
|
||||||
|
mkdir -p "$OUTDIR"
|
||||||
|
|
||||||
|
for DRIVER in "${DRIVERS[@]}"; do
|
||||||
|
BUILD_DRIVER="$DRIVER"
|
||||||
|
if [ "$DRIVER" = "doris" ]; then
|
||||||
|
BUILD_DRIVER="diros"
|
||||||
|
fi
|
||||||
|
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then
|
||||||
|
echo "跳过 DuckDB driver: ${GOOS}/${GOARCH}"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
TAG="gonavi_${BUILD_DRIVER}_driver"
|
||||||
|
OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}"
|
||||||
|
if [ "$GOOS" = "windows" ]; then
|
||||||
|
OUTPUT="${OUTPUT}.exe"
|
||||||
|
fi
|
||||||
|
OUTPUT_PATH="${OUTDIR}/${OUTPUT}"
|
||||||
|
if [ "$DRIVER" = "duckdb" ]; then
|
||||||
|
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build -tags "$TAG" -trimpath -ldflags "-s -w" -o "$OUTPUT_PATH" ./cmd/optional-driver-agent
|
||||||
|
else
|
||||||
|
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build -tags "$TAG" -trimpath -ldflags "-s -w" -o "$OUTPUT_PATH" ./cmd/optional-driver-agent
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Package macOS
|
||||||
|
if: contains(matrix.platform, 'darwin')
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
brew install create-dmg
|
||||||
|
LABEL="${{ inputs.build_label }}"
|
||||||
|
if [ -z "$LABEL" ]; then
|
||||||
|
LABEL="test"
|
||||||
|
fi
|
||||||
|
cd build/bin
|
||||||
|
APP_PATH=$(find . -maxdepth 1 -name "*.app" | head -n 1)
|
||||||
|
if [ -z "$APP_PATH" ]; then
|
||||||
|
echo "未找到 .app 应用包"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
APP_NAME=$(basename "$APP_PATH")
|
||||||
|
codesign --force --deep --sign - "$APP_NAME"
|
||||||
|
ZIP_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}-run${GITHUB_RUN_NUMBER}.zip"
|
||||||
|
DMG_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}-run${GITHUB_RUN_NUMBER}.dmg"
|
||||||
|
mkdir -p ../../artifacts
|
||||||
|
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "../../artifacts/$ZIP_NAME"
|
||||||
|
create-dmg \
|
||||||
|
--volname "GoNavi Test Installer" \
|
||||||
|
--window-pos 200 120 \
|
||||||
|
--window-size 800 400 \
|
||||||
|
--icon-size 100 \
|
||||||
|
--icon "$APP_NAME" 200 190 \
|
||||||
|
--hide-extension "$APP_NAME" \
|
||||||
|
--app-drop-link 600 185 \
|
||||||
|
"$DMG_NAME" \
|
||||||
|
"$APP_NAME"
|
||||||
|
mv "$DMG_NAME" "../../artifacts/$DMG_NAME"
|
||||||
|
shasum -a 256 "../../artifacts/$ZIP_NAME" > "../../artifacts/$ZIP_NAME.sha256"
|
||||||
|
shasum -a 256 "../../artifacts/$DMG_NAME" > "../../artifacts/$DMG_NAME.sha256"
|
||||||
|
|
||||||
|
- name: Package Windows
|
||||||
|
if: contains(matrix.platform, 'windows')
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$label = "${{ inputs.build_label }}"
|
||||||
|
if ([string]::IsNullOrWhiteSpace($label)) { $label = 'test' }
|
||||||
|
Set-Location build/bin
|
||||||
|
$target = "${{ matrix.build_name }}"
|
||||||
|
$finalExeName = "GoNavi-$label-${{ matrix.os_name }}-${{ matrix.arch_name }}-run$env:GITHUB_RUN_NUMBER.exe"
|
||||||
|
$finalZipName = "GoNavi-$label-${{ matrix.os_name }}-${{ matrix.arch_name }}-run$env:GITHUB_RUN_NUMBER.zip"
|
||||||
|
if (Test-Path "$target.exe") {
|
||||||
|
$finalExe = "$target.exe"
|
||||||
|
} elseif (Test-Path "$target") {
|
||||||
|
Rename-Item -Path "$target" -NewName "$target.exe"
|
||||||
|
$finalExe = "$target.exe"
|
||||||
|
} else {
|
||||||
|
Write-Error "未找到构建产物 '$target'"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Force -Path ..\..\artifacts | Out-Null
|
||||||
|
Copy-Item -LiteralPath $finalExe -Destination "..\..\artifacts\$finalExeName" -Force
|
||||||
|
Compress-Archive -LiteralPath $finalExe -DestinationPath "..\..\artifacts\$finalZipName" -Force
|
||||||
|
Get-FileHash "..\..\artifacts\$finalExeName" -Algorithm SHA256 | ForEach-Object { "{0} *{1}" -f $_.Hash.ToLower(), (Split-Path $_.Path -Leaf) } | Out-File "..\..\artifacts\$finalExeName.sha256" -Encoding ascii
|
||||||
|
Get-FileHash "..\..\artifacts\$finalZipName" -Algorithm SHA256 | ForEach-Object { "{0} *{1}" -f $_.Hash.ToLower(), (Split-Path $_.Path -Leaf) } | Out-File "..\..\artifacts\$finalZipName.sha256" -Encoding ascii
|
||||||
|
|
||||||
|
- name: Package Linux
|
||||||
|
if: contains(matrix.platform, 'linux')
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
LABEL="${{ inputs.build_label }}"
|
||||||
|
if [ -z "$LABEL" ]; then
|
||||||
|
LABEL="test"
|
||||||
|
fi
|
||||||
|
cd build/bin
|
||||||
|
TARGET="${{ matrix.build_name }}"
|
||||||
|
TAR_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}-run${GITHUB_RUN_NUMBER}.tar.gz"
|
||||||
|
APPIMAGE_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}-run${GITHUB_RUN_NUMBER}.AppImage"
|
||||||
|
mkdir -p ../../artifacts
|
||||||
|
|
||||||
|
if [ ! -f "$TARGET" ]; then
|
||||||
|
echo "未找到构建产物 '$TARGET'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
chmod +x "$TARGET"
|
||||||
|
tar -czvf "../../artifacts/$TAR_NAME" "$TARGET"
|
||||||
|
sha256sum "../../artifacts/$TAR_NAME" > "../../artifacts/$TAR_NAME.sha256"
|
||||||
|
|
||||||
|
if [ "${skip-appimage:-false}" = "true" ]; then
|
||||||
|
echo "跳过 AppImage 打包"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p AppDir/usr/bin AppDir/usr/share/applications AppDir/usr/share/icons/hicolor/256x256/apps
|
||||||
|
cp "$TARGET" AppDir/usr/bin/gonavi
|
||||||
|
printf '%s\n' '[Desktop Entry]' 'Name=GoNavi' 'Exec=gonavi' 'Icon=gonavi' 'Type=Application' 'Categories=Development;Database;' 'Comment=Database Management Tool' > AppDir/usr/share/applications/gonavi.desktop
|
||||||
|
cp AppDir/usr/share/applications/gonavi.desktop AppDir/gonavi.desktop
|
||||||
|
if [ -f "../../build/appicon.png" ]; then
|
||||||
|
cp "../../build/appicon.png" AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png
|
||||||
|
cp "../../build/appicon.png" AppDir/gonavi.png
|
||||||
|
else
|
||||||
|
touch AppDir/gonavi.png
|
||||||
|
cp AppDir/gonavi.png AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png
|
||||||
|
fi
|
||||||
|
export DEPLOY_GTK_VERSION=3
|
||||||
|
/tmp/linuxdeploy --appdir AppDir --plugin gtk --output appimage || exit 0
|
||||||
|
mv GoNavi*.AppImage "$APPIMAGE_NAME" 2>/dev/null || exit 0
|
||||||
|
mv "$APPIMAGE_NAME" "../../artifacts/$APPIMAGE_NAME"
|
||||||
|
sha256sum "../../artifacts/$APPIMAGE_NAME" > "../../artifacts/$APPIMAGE_NAME.sha256"
|
||||||
|
|
||||||
|
- name: Upload Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: test-build-${{ matrix.build_name }}-run${{ github.run_number }}
|
||||||
|
path: |
|
||||||
|
artifacts/*
|
||||||
|
drivers/**
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
@@ -79,7 +79,14 @@ Because external pull requests are merged directly into `main`, maintainers must
|
|||||||
|
|
||||||
### 1. Sync `main` -> `dev` (required)
|
### 1. Sync `main` -> `dev` (required)
|
||||||
|
|
||||||
Every change merged into `main` must be synced into `dev`:
|
This repository provides automatic sync via GitHub Actions workflow:
|
||||||
|
|
||||||
|
- `.github/workflows/sync-main-to-dev.yml`
|
||||||
|
- Trigger: every push to `main`
|
||||||
|
- Behavior: create/reuse a PR from `main` to `dev`; if mergeable, it tries to enable auto-merge
|
||||||
|
- Prerequisite: in `Settings -> Actions -> General -> Workflow permissions`, enable `Allow GitHub Actions to create and approve pull requests`; otherwise the workflow will skip PR creation and only emit a warning summary
|
||||||
|
|
||||||
|
Manual fallback (when conflicts or automation is unavailable):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git checkout dev
|
git checkout dev
|
||||||
@@ -114,7 +121,7 @@ git push origin v0.6.0
|
|||||||
|
|
||||||
### 4. Sync `main` back to `dev` after release
|
### 4. Sync `main` back to `dev` after release
|
||||||
|
|
||||||
After the release, sync `main` back into `dev` again:
|
After the release, the same automation still applies. If needed, you can run the workflow manually (`workflow_dispatch`) or execute the fallback commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git checkout dev
|
git checkout dev
|
||||||
|
|||||||
@@ -79,7 +79,14 @@ feature/* / fix/* -> dev -> release/* -> main -> tag(vX.Y.Z)
|
|||||||
|
|
||||||
### 1. main → dev 同步(必做)
|
### 1. main → dev 同步(必做)
|
||||||
|
|
||||||
任何合入 `main` 的变更,都必须同步到 `dev`:
|
仓库已提供 GitHub Actions 自动同步机制:
|
||||||
|
|
||||||
|
- `.github/workflows/sync-main-to-dev.yml`
|
||||||
|
- 触发时机:每次 `main` 分支有新的 push
|
||||||
|
- 行为:自动创建或复用 `main` 到 `dev` 的同步 PR;若可合并,则尝试开启自动合并
|
||||||
|
- 前置条件:需在 `Settings -> Actions -> General -> Workflow permissions` 中开启 `Allow GitHub Actions to create and approve pull requests`,否则 workflow 只会输出告警摘要并跳过建 PR
|
||||||
|
|
||||||
|
当出现冲突,或自动化暂不可用时,使用以下手动兜底方式:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git checkout dev
|
git checkout dev
|
||||||
@@ -114,7 +121,7 @@ git push origin v0.6.0
|
|||||||
|
|
||||||
### 4. main 回流到 dev(发版后必做)
|
### 4. main 回流到 dev(发版后必做)
|
||||||
|
|
||||||
发布完成后,再次将 `main` 回流到 `dev`,确保开发线与发布线一致:
|
发布完成后,仍沿用同一套自动化流程;如有需要,也可以手动触发 `workflow_dispatch`,或执行以下兜底命令,确保开发线与发布线一致:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git checkout dev
|
git checkout dev
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ Artifacts are generated in `build/bin`.
|
|||||||
|
|
||||||
The repository includes a release workflow.
|
The repository includes a release workflow.
|
||||||
Push a `v*` tag to trigger automated build and release.
|
Push a `v*` tag to trigger automated build and release.
|
||||||
|
Release notes are generated automatically from merged pull requests and categorized by `.github/release.yaml`.
|
||||||
|
|
||||||
Target artifacts include:
|
Target artifacts include:
|
||||||
- macOS (AMD64 / ARM64)
|
- macOS (AMD64 / ARM64)
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ wails build -clean
|
|||||||
### 跨平台发布(GitHub Actions)
|
### 跨平台发布(GitHub Actions)
|
||||||
|
|
||||||
仓库内置发布流水线,推送 `v*` Tag 可自动构建并发布 Release。
|
仓库内置发布流水线,推送 `v*` Tag 可自动构建并发布 Release。
|
||||||
|
Release 更新说明会基于已合并 Pull Request 自动生成,并按 `.github/release.yaml` 分类。
|
||||||
|
|
||||||
支持目标:
|
支持目标:
|
||||||
- macOS (AMD64 / ARM64)
|
- macOS (AMD64 / ARM64)
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
5b8157374dae5f9340e31b2d0bd2c00e
|
d0f9366af59a6367ad3c7e2d4185ead4
|
||||||
@@ -283,6 +283,7 @@ function App() {
|
|||||||
let inFlight = false;
|
let inFlight = false;
|
||||||
let lastRatio = Number(window.devicePixelRatio) || 1;
|
let lastRatio = Number(window.devicePixelRatio) || 1;
|
||||||
let lastFixAt = 0;
|
let lastFixAt = 0;
|
||||||
|
let activationTimer: number | null = null;
|
||||||
|
|
||||||
const wait = (ms: number) => new Promise<void>((resolve) => window.setTimeout(resolve, ms));
|
const wait = (ms: number) => new Promise<void>((resolve) => window.setTimeout(resolve, ms));
|
||||||
|
|
||||||
@@ -334,17 +335,55 @@ function App() {
|
|||||||
void fixWindowScaleIfNeeded();
|
void fixWindowScaleIfNeeded();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const scheduleActivationFix = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (activationTimer !== null) {
|
||||||
|
window.clearTimeout(activationTimer);
|
||||||
|
}
|
||||||
|
activationTimer = window.setTimeout(() => {
|
||||||
|
activationTimer = null;
|
||||||
|
if (cancelled) return;
|
||||||
|
void fixWindowScaleIfNeeded();
|
||||||
|
}, 80);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWindowFocus = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
checkDevicePixelRatio();
|
||||||
|
scheduleActivationFix();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (document.visibilityState !== 'visible') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
checkDevicePixelRatio();
|
||||||
|
scheduleActivationFix();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageShow = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
checkDevicePixelRatio();
|
||||||
|
scheduleActivationFix();
|
||||||
|
};
|
||||||
|
|
||||||
const pollTimer = window.setInterval(checkDevicePixelRatio, 900);
|
const pollTimer = window.setInterval(checkDevicePixelRatio, 900);
|
||||||
window.addEventListener('resize', checkDevicePixelRatio);
|
window.addEventListener('resize', checkDevicePixelRatio);
|
||||||
window.addEventListener('focus', checkDevicePixelRatio);
|
window.addEventListener('focus', handleWindowFocus);
|
||||||
document.addEventListener('visibilitychange', checkDevicePixelRatio);
|
window.addEventListener('pageshow', handlePageShow);
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
if (activationTimer !== null) {
|
||||||
|
window.clearTimeout(activationTimer);
|
||||||
|
}
|
||||||
window.clearInterval(pollTimer);
|
window.clearInterval(pollTimer);
|
||||||
window.removeEventListener('resize', checkDevicePixelRatio);
|
window.removeEventListener('resize', checkDevicePixelRatio);
|
||||||
window.removeEventListener('focus', checkDevicePixelRatio);
|
window.removeEventListener('focus', handleWindowFocus);
|
||||||
document.removeEventListener('visibilitychange', checkDevicePixelRatio);
|
window.removeEventListener('pageshow', handlePageShow);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ const ConnectionModal: React.FC<{
|
|||||||
const [useSSL, setUseSSL] = useState(false);
|
const [useSSL, setUseSSL] = useState(false);
|
||||||
const [useSSH, setUseSSH] = useState(false);
|
const [useSSH, setUseSSH] = useState(false);
|
||||||
const [useProxy, setUseProxy] = useState(false);
|
const [useProxy, setUseProxy] = useState(false);
|
||||||
|
const [useHttpTunnel, setUseHttpTunnel] = useState(false);
|
||||||
const [dbType, setDbType] = useState('mysql');
|
const [dbType, setDbType] = useState('mysql');
|
||||||
const [step, setStep] = useState(1); // 1: Select Type, 2: Configure
|
const [step, setStep] = useState(1); // 1: Select Type, 2: Configure
|
||||||
const [activeGroup, setActiveGroup] = useState(0); // Active category index in step 1
|
const [activeGroup, setActiveGroup] = useState(0); // Active category index in step 1
|
||||||
@@ -1026,6 +1027,8 @@ const ConnectionModal: React.FC<{
|
|||||||
const mysqlIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mysqlReplicaHosts.length > 0;
|
const mysqlIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mysqlReplicaHosts.length > 0;
|
||||||
const mongoIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mongoHosts.length > 0 || !!config.replicaSet;
|
const mongoIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mongoHosts.length > 0 || !!config.replicaSet;
|
||||||
const redisIsCluster = String(config.topology || '').toLowerCase() === 'cluster' || redisHosts.length > 0;
|
const redisIsCluster = String(config.topology || '').toLowerCase() === 'cluster' || redisHosts.length > 0;
|
||||||
|
const hasHttpTunnel = !!config.useHttpTunnel;
|
||||||
|
const hasProxy = !hasHttpTunnel && !!config.useProxy;
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
type: configType,
|
type: configType,
|
||||||
name: initialValues.name,
|
name: initialValues.name,
|
||||||
@@ -1047,12 +1050,17 @@ const ConnectionModal: React.FC<{
|
|||||||
sshUser: config.ssh?.user,
|
sshUser: config.ssh?.user,
|
||||||
sshPassword: config.ssh?.password,
|
sshPassword: config.ssh?.password,
|
||||||
sshKeyPath: config.ssh?.keyPath,
|
sshKeyPath: config.ssh?.keyPath,
|
||||||
useProxy: config.useProxy,
|
useProxy: hasProxy,
|
||||||
proxyType: config.proxy?.type || 'socks5',
|
proxyType: config.proxy?.type || 'socks5',
|
||||||
proxyHost: config.proxy?.host,
|
proxyHost: config.proxy?.host,
|
||||||
proxyPort: config.proxy?.port,
|
proxyPort: config.proxy?.port,
|
||||||
proxyUser: config.proxy?.user,
|
proxyUser: config.proxy?.user,
|
||||||
proxyPassword: config.proxy?.password,
|
proxyPassword: config.proxy?.password,
|
||||||
|
useHttpTunnel: hasHttpTunnel,
|
||||||
|
httpTunnelHost: config.httpTunnel?.host,
|
||||||
|
httpTunnelPort: config.httpTunnel?.port || 8080,
|
||||||
|
httpTunnelUser: config.httpTunnel?.user,
|
||||||
|
httpTunnelPassword: config.httpTunnel?.password,
|
||||||
driver: config.driver,
|
driver: config.driver,
|
||||||
dsn: config.dsn,
|
dsn: config.dsn,
|
||||||
timeout: config.timeout || 30,
|
timeout: config.timeout || 30,
|
||||||
@@ -1076,7 +1084,8 @@ const ConnectionModal: React.FC<{
|
|||||||
});
|
});
|
||||||
setUseSSL(!!config.useSSL);
|
setUseSSL(!!config.useSSL);
|
||||||
setUseSSH(config.useSSH || false);
|
setUseSSH(config.useSSH || false);
|
||||||
setUseProxy(config.useProxy || false);
|
setUseProxy(hasProxy);
|
||||||
|
setUseHttpTunnel(hasHttpTunnel);
|
||||||
setDbType(configType);
|
setDbType(configType);
|
||||||
// 如果是 Redis 编辑模式,设置已保存的 Redis 数据库列表
|
// 如果是 Redis 编辑模式,设置已保存的 Redis 数据库列表
|
||||||
if (configType === 'redis') {
|
if (configType === 'redis') {
|
||||||
@@ -1089,6 +1098,7 @@ const ConnectionModal: React.FC<{
|
|||||||
setUseSSL(false);
|
setUseSSL(false);
|
||||||
setUseSSH(false);
|
setUseSSH(false);
|
||||||
setUseProxy(false);
|
setUseProxy(false);
|
||||||
|
setUseHttpTunnel(false);
|
||||||
setDbType('mysql');
|
setDbType('mysql');
|
||||||
setActiveGroup(0);
|
setActiveGroup(0);
|
||||||
}
|
}
|
||||||
@@ -1140,6 +1150,7 @@ const ConnectionModal: React.FC<{
|
|||||||
setUseSSL(false);
|
setUseSSL(false);
|
||||||
setUseSSH(false);
|
setUseSSH(false);
|
||||||
setUseProxy(false);
|
setUseProxy(false);
|
||||||
|
setUseHttpTunnel(false);
|
||||||
setDbType('mysql');
|
setDbType('mysql');
|
||||||
setStep(1);
|
setStep(1);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -1185,19 +1196,24 @@ const ConnectionModal: React.FC<{
|
|||||||
? await RedisConnect(config as any)
|
? await RedisConnect(config as any)
|
||||||
: await TestConnection(config as any);
|
: await TestConnection(config as any);
|
||||||
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
setTestResult({ type: 'success', message: res.message });
|
setTestResult({ type: 'success', message: res.message });
|
||||||
if (isRedisType) {
|
if (isRedisType) {
|
||||||
setRedisDbList(Array.from({ length: 16 }, (_, i) => i));
|
setRedisDbList(Array.from({ length: 16 }, (_, i) => i));
|
||||||
} else {
|
} else {
|
||||||
// Other databases: fetch database list
|
// Other databases: fetch database list
|
||||||
const dbRes = await DBGetDatabases(config as any);
|
const dbRes = await DBGetDatabases(config as any);
|
||||||
if (dbRes.success) {
|
if (dbRes.success) {
|
||||||
const dbs = (dbRes.data as any[]).map((row: any) => row.Database || row.database);
|
const dbRows = Array.isArray(dbRes.data) ? dbRes.data : [];
|
||||||
setDbList(dbs);
|
const dbs = dbRows
|
||||||
}
|
.map((row: any) => row?.Database || row?.database)
|
||||||
}
|
.filter((name: any) => typeof name === 'string' && name.trim() !== '');
|
||||||
} else {
|
setDbList(dbs);
|
||||||
|
} else {
|
||||||
|
setDbList([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
const failMessage = buildTestFailureMessage(
|
const failMessage = buildTestFailureMessage(
|
||||||
res?.message,
|
res?.message,
|
||||||
'连接被拒绝或参数无效,请检查后重试'
|
'连接被拒绝或参数无效,请检查后重试'
|
||||||
@@ -1388,7 +1404,8 @@ const ConnectionModal: React.FC<{
|
|||||||
password: mergedValues.sshPassword || "",
|
password: mergedValues.sshPassword || "",
|
||||||
keyPath: mergedValues.sshKeyPath || ""
|
keyPath: mergedValues.sshKeyPath || ""
|
||||||
} : { host: "", port: 22, user: "", password: "", keyPath: "" };
|
} : { host: "", port: 22, user: "", password: "", keyPath: "" };
|
||||||
const effectiveUseProxy = !isFileDbType && !!mergedValues.useProxy;
|
const effectiveUseHttpTunnel = !isFileDbType && !!mergedValues.useHttpTunnel;
|
||||||
|
const effectiveUseProxy = !isFileDbType && !!mergedValues.useProxy && !effectiveUseHttpTunnel;
|
||||||
const proxyTypeRaw = String(mergedValues.proxyType || 'socks5').toLowerCase();
|
const proxyTypeRaw = String(mergedValues.proxyType || 'socks5').toLowerCase();
|
||||||
const proxyType: 'socks5' | 'http' = proxyTypeRaw === 'http' ? 'http' : 'socks5';
|
const proxyType: 'socks5' | 'http' = proxyTypeRaw === 'http' ? 'http' : 'socks5';
|
||||||
const proxyConfig: NonNullable<ConnectionConfig['proxy']> = effectiveUseProxy ? {
|
const proxyConfig: NonNullable<ConnectionConfig['proxy']> = effectiveUseProxy ? {
|
||||||
@@ -1404,6 +1421,25 @@ const ConnectionModal: React.FC<{
|
|||||||
user: '',
|
user: '',
|
||||||
password: '',
|
password: '',
|
||||||
};
|
};
|
||||||
|
const httpTunnelConfig: NonNullable<ConnectionConfig['httpTunnel']> = effectiveUseHttpTunnel ? {
|
||||||
|
host: String(mergedValues.httpTunnelHost || '').trim(),
|
||||||
|
port: Number(mergedValues.httpTunnelPort || 8080),
|
||||||
|
user: String(mergedValues.httpTunnelUser || '').trim(),
|
||||||
|
password: mergedValues.httpTunnelPassword || "",
|
||||||
|
} : {
|
||||||
|
host: '',
|
||||||
|
port: 8080,
|
||||||
|
user: '',
|
||||||
|
password: '',
|
||||||
|
};
|
||||||
|
if (effectiveUseHttpTunnel) {
|
||||||
|
if (!httpTunnelConfig.host) {
|
||||||
|
throw new Error('HTTP 隧道主机不能为空');
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(httpTunnelConfig.port) || httpTunnelConfig.port <= 0 || httpTunnelConfig.port > 65535) {
|
||||||
|
throw new Error('HTTP 隧道端口必须在 1-65535 之间');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const keepPassword = !forPersist || savePassword;
|
const keepPassword = !forPersist || savePassword;
|
||||||
|
|
||||||
@@ -1423,6 +1459,8 @@ const ConnectionModal: React.FC<{
|
|||||||
ssh: sshConfig,
|
ssh: sshConfig,
|
||||||
useProxy: effectiveUseProxy,
|
useProxy: effectiveUseProxy,
|
||||||
proxy: proxyConfig,
|
proxy: proxyConfig,
|
||||||
|
useHttpTunnel: effectiveUseHttpTunnel,
|
||||||
|
httpTunnel: httpTunnelConfig,
|
||||||
driver: mergedValues.driver,
|
driver: mergedValues.driver,
|
||||||
dsn: mergedValues.dsn,
|
dsn: mergedValues.dsn,
|
||||||
timeout: Number(mergedValues.timeout || 30),
|
timeout: Number(mergedValues.timeout || 30),
|
||||||
@@ -1461,6 +1499,7 @@ const ConnectionModal: React.FC<{
|
|||||||
setUseSSL(false);
|
setUseSSL(false);
|
||||||
setUseSSH(false);
|
setUseSSH(false);
|
||||||
setUseProxy(false);
|
setUseProxy(false);
|
||||||
|
setUseHttpTunnel(false);
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
host: '',
|
host: '',
|
||||||
port: 0,
|
port: 0,
|
||||||
@@ -1483,6 +1522,11 @@ const ConnectionModal: React.FC<{
|
|||||||
proxyPort: 1080,
|
proxyPort: 1080,
|
||||||
proxyUser: '',
|
proxyUser: '',
|
||||||
proxyPassword: '',
|
proxyPassword: '',
|
||||||
|
useHttpTunnel: false,
|
||||||
|
httpTunnelHost: '',
|
||||||
|
httpTunnelPort: 8080,
|
||||||
|
httpTunnelUser: '',
|
||||||
|
httpTunnelPassword: '',
|
||||||
mysqlTopology: 'single',
|
mysqlTopology: 'single',
|
||||||
redisTopology: 'single',
|
redisTopology: 'single',
|
||||||
mongoTopology: 'single',
|
mongoTopology: 'single',
|
||||||
@@ -1505,6 +1549,7 @@ const ConnectionModal: React.FC<{
|
|||||||
const defaultUser = type === 'clickhouse' ? 'default' : 'root';
|
const defaultUser = type === 'clickhouse' ? 'default' : 'root';
|
||||||
const sslCapableType = supportsSSLForType(type);
|
const sslCapableType = supportsSSLForType(type);
|
||||||
setUseSSL(false);
|
setUseSSL(false);
|
||||||
|
setUseHttpTunnel(false);
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
user: defaultUser,
|
user: defaultUser,
|
||||||
database: '',
|
database: '',
|
||||||
@@ -1513,6 +1558,11 @@ const ConnectionModal: React.FC<{
|
|||||||
sslMode: sslCapableType ? 'preferred' : undefined,
|
sslMode: sslCapableType ? 'preferred' : undefined,
|
||||||
sslCertPath: sslCapableType ? '' : undefined,
|
sslCertPath: sslCapableType ? '' : undefined,
|
||||||
sslKeyPath: sslCapableType ? '' : undefined,
|
sslKeyPath: sslCapableType ? '' : undefined,
|
||||||
|
useHttpTunnel: false,
|
||||||
|
httpTunnelHost: '',
|
||||||
|
httpTunnelPort: 8080,
|
||||||
|
httpTunnelUser: '',
|
||||||
|
httpTunnelPassword: '',
|
||||||
mysqlTopology: 'single',
|
mysqlTopology: 'single',
|
||||||
redisTopology: 'single',
|
redisTopology: 'single',
|
||||||
mongoTopology: 'single',
|
mongoTopology: 'single',
|
||||||
@@ -1665,6 +1715,8 @@ const ConnectionModal: React.FC<{
|
|||||||
useProxy: false,
|
useProxy: false,
|
||||||
proxyType: 'socks5',
|
proxyType: 'socks5',
|
||||||
proxyPort: 1080,
|
proxyPort: 1080,
|
||||||
|
useHttpTunnel: false,
|
||||||
|
httpTunnelPort: 8080,
|
||||||
timeout: 30,
|
timeout: 30,
|
||||||
uri: '',
|
uri: '',
|
||||||
mysqlTopology: 'single',
|
mysqlTopology: 'single',
|
||||||
@@ -1693,7 +1745,14 @@ const ConnectionModal: React.FC<{
|
|||||||
}
|
}
|
||||||
if (changed.useSSL !== undefined) setUseSSL(changed.useSSL);
|
if (changed.useSSL !== undefined) setUseSSL(changed.useSSL);
|
||||||
if (changed.useSSH !== undefined) setUseSSH(changed.useSSH);
|
if (changed.useSSH !== undefined) setUseSSH(changed.useSSH);
|
||||||
if (changed.useProxy !== undefined) setUseProxy(changed.useProxy);
|
if (changed.useProxy !== undefined) {
|
||||||
|
const enabledProxy = !!changed.useProxy;
|
||||||
|
setUseProxy(enabledProxy);
|
||||||
|
if (enabledProxy && form.getFieldValue('useHttpTunnel')) {
|
||||||
|
form.setFieldValue('useHttpTunnel', false);
|
||||||
|
setUseHttpTunnel(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (changed.proxyType !== undefined) {
|
if (changed.proxyType !== undefined) {
|
||||||
const nextType = String(changed.proxyType || 'socks5').toLowerCase();
|
const nextType = String(changed.proxyType || 'socks5').toLowerCase();
|
||||||
if (nextType === 'http') {
|
if (nextType === 'http') {
|
||||||
@@ -1708,6 +1767,20 @@ const ConnectionModal: React.FC<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (changed.useHttpTunnel !== undefined) {
|
||||||
|
const enabledHttpTunnel = !!changed.useHttpTunnel;
|
||||||
|
setUseHttpTunnel(enabledHttpTunnel);
|
||||||
|
if (enabledHttpTunnel && form.getFieldValue('useProxy')) {
|
||||||
|
form.setFieldValue('useProxy', false);
|
||||||
|
setUseProxy(false);
|
||||||
|
}
|
||||||
|
if (enabledHttpTunnel) {
|
||||||
|
const currentPort = Number(form.getFieldValue('httpTunnelPort') || 0);
|
||||||
|
if (!currentPort || currentPort <= 0) {
|
||||||
|
form.setFieldValue('httpTunnelPort', 8080);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Type change handled by step 1, but keep sync if select changes (hidden now)
|
// Type change handled by step 1, but keep sync if select changes (hidden now)
|
||||||
if (changed.type !== undefined) setDbType(changed.type);
|
if (changed.type !== undefined) setDbType(changed.type);
|
||||||
if (changed.redisTopology !== undefined) {
|
if (changed.redisTopology !== undefined) {
|
||||||
@@ -2194,6 +2267,35 @@ const ConnectionModal: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Divider style={{ margin: '12px 0' }} />
|
||||||
|
<Form.Item name="useHttpTunnel" valuePropName="checked" style={{ marginBottom: 0 }}>
|
||||||
|
<Checkbox>使用 HTTP 隧道(独立代理)</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{useHttpTunnel && (
|
||||||
|
<div style={tunnelSectionStyle}>
|
||||||
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
|
<Form.Item name="httpTunnelHost" label="隧道主机" rules={[{ required: useHttpTunnel, message: '请输入隧道主机' }]} style={{ flex: 1 }}>
|
||||||
|
<Input placeholder="例如: tunnel.company.com 或 127.0.0.1" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="httpTunnelPort" label="端口" rules={[{ required: useHttpTunnel, message: '请输入隧道端口' }]} style={{ width: 120 }}>
|
||||||
|
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
|
<Form.Item name="httpTunnelUser" label="隧道用户名(可选)" style={{ flex: 1 }}>
|
||||||
|
<Input placeholder="留空表示无认证" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="httpTunnelPassword" label="隧道密码(可选)" style={{ flex: 1 }}>
|
||||||
|
<Input.Password placeholder="留空表示无认证" />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
与“使用代理”互斥,启用后将通过 HTTP CONNECT 建立独立隧道。
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Divider style={{ margin: '12px 0' }} />
|
<Divider style={{ margin: '12px 0' }} />
|
||||||
|
|
||||||
<Collapse
|
<Collapse
|
||||||
|
|||||||
@@ -142,10 +142,19 @@ const formatCellValue = (val: any) => {
|
|||||||
try {
|
try {
|
||||||
if (val === null) return <span style={{ color: '#ccc' }}>NULL</span>;
|
if (val === null) return <span style={{ color: '#ccc' }}>NULL</span>;
|
||||||
if (typeof val === 'object') {
|
if (typeof val === 'object') {
|
||||||
|
if (!Array.isArray(val) && !isPlainObject(val)) {
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
const cached = objectCellPreviewCache.get(val);
|
const cached = objectCellPreviewCache.get(val);
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
const topLevelSize = Array.isArray(val) ? val.length : Object.keys(val || {}).length;
|
||||||
|
if (topLevelSize > 80) {
|
||||||
|
const summary = Array.isArray(val) ? `[Array(${topLevelSize})]` : `{Object(${topLevelSize})}`;
|
||||||
|
objectCellPreviewCache.set(val, summary);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const nextText = JSON.stringify(val);
|
const nextText = JSON.stringify(val);
|
||||||
const previewText = nextText.length > TABLE_CELL_PREVIEW_MAX_CHARS ? `${nextText.slice(0, TABLE_CELL_PREVIEW_MAX_CHARS)}…` : nextText;
|
const previewText = nextText.length > TABLE_CELL_PREVIEW_MAX_CHARS ? `${nextText.slice(0, TABLE_CELL_PREVIEW_MAX_CHARS)}…` : nextText;
|
||||||
@@ -191,6 +200,26 @@ const isCellValueEqualForDiff = (left: any, right: any): boolean => {
|
|||||||
return toFormText(left) === toFormText(right);
|
return toFormText(left) === toFormText(right);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 渲染阶段轻量比较:避免对象值在 shouldCellUpdate 中反复深度序列化导致卡顿。
|
||||||
|
const isCellValueEqualForRender = (left: any, right: any): boolean => {
|
||||||
|
if (left === right) return true;
|
||||||
|
const leftNullish = left === null || left === undefined;
|
||||||
|
const rightNullish = right === null || right === undefined;
|
||||||
|
if (leftNullish || rightNullish) return leftNullish && rightNullish;
|
||||||
|
|
||||||
|
const leftType = typeof left;
|
||||||
|
const rightType = typeof right;
|
||||||
|
if (leftType === 'object' || rightType === 'object') {
|
||||||
|
// 对象仅按引用比较;真正的值差异在提交保存时再做严格比对。
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leftType === 'string' || rightType === 'string') {
|
||||||
|
return normalizeDateTimeString(String(left)) === normalizeDateTimeString(String(right));
|
||||||
|
}
|
||||||
|
return left === right;
|
||||||
|
};
|
||||||
|
|
||||||
const INLINE_EDIT_MAX_CHARS = 2000;
|
const INLINE_EDIT_MAX_CHARS = 2000;
|
||||||
|
|
||||||
const shouldOpenModalEditor = (val: any): boolean => {
|
const shouldOpenModalEditor = (val: any): boolean => {
|
||||||
@@ -581,6 +610,8 @@ interface DataGridProps {
|
|||||||
exportSqlWithFilter?: string;
|
exportSqlWithFilter?: string;
|
||||||
onApplyFilter?: (conditions: GridFilterCondition[]) => void;
|
onApplyFilter?: (conditions: GridFilterCondition[]) => void;
|
||||||
appliedFilterConditions?: FilterCondition[];
|
appliedFilterConditions?: FilterCondition[];
|
||||||
|
scrollSnapshot?: { top: number; left: number };
|
||||||
|
onScrollSnapshotChange?: (snapshot: { top: number; left: number }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type GridFilterCondition = FilterCondition & {
|
type GridFilterCondition = FilterCondition & {
|
||||||
@@ -600,7 +631,8 @@ type ColumnMeta = {
|
|||||||
|
|
||||||
const DataGrid: React.FC<DataGridProps> = ({
|
const DataGrid: React.FC<DataGridProps> = ({
|
||||||
data, columnNames, loading, tableName, exportScope = 'table', resultSql, dbName, connectionId, pkColumns = [], readOnly = false,
|
data, columnNames, loading, tableName, exportScope = 'table', resultSql, dbName, connectionId, pkColumns = [], readOnly = false,
|
||||||
onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions
|
onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions,
|
||||||
|
scrollSnapshot, onScrollSnapshotChange
|
||||||
}) => {
|
}) => {
|
||||||
const connections = useStore(state => state.connections);
|
const connections = useStore(state => state.connections);
|
||||||
const addSqlLog = useStore(state => state.addSqlLog);
|
const addSqlLog = useStore(state => state.addSqlLog);
|
||||||
@@ -721,6 +753,8 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
const lastTableScrollLeftRef = useRef(0);
|
const lastTableScrollLeftRef = useRef(0);
|
||||||
const lastExternalScrollLeftRef = useRef(0);
|
const lastExternalScrollLeftRef = useRef(0);
|
||||||
const pendingScrollToBottomRef = useRef(false);
|
const pendingScrollToBottomRef = useRef(false);
|
||||||
|
const lastReportedScrollRef = useRef<{ top: number; left: number }>({ top: 0, left: 0 });
|
||||||
|
const didRestoreScrollRef = useRef(false);
|
||||||
|
|
||||||
// 批量编辑模式状态
|
// 批量编辑模式状态
|
||||||
const [cellEditMode, setCellEditMode] = useState(false);
|
const [cellEditMode, setCellEditMode] = useState(false);
|
||||||
@@ -2045,9 +2079,14 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
|
|
||||||
const estimatedVisibleCellCount = mergedDisplayData.length * Math.max(columnNames.length, 1);
|
const estimatedVisibleCellCount = mergedDisplayData.length * Math.max(columnNames.length, 1);
|
||||||
const enableLargeResultOptimizedEditing =
|
const enableLargeResultOptimizedEditing =
|
||||||
viewMode === 'table' && (mergedDisplayData.length >= 60 || estimatedVisibleCellCount >= 4000);
|
viewMode === 'table' && (
|
||||||
|
mergedDisplayData.length >= 60 ||
|
||||||
|
estimatedVisibleCellCount >= 1600 ||
|
||||||
|
columnNames.length >= 36 ||
|
||||||
|
(isMacLike && columnNames.length >= 24)
|
||||||
|
);
|
||||||
const enableVirtual = enableLargeResultOptimizedEditing;
|
const enableVirtual = enableLargeResultOptimizedEditing;
|
||||||
const enableInlineEditableCell = canModifyData;
|
const enableInlineEditableCell = canModifyData && !enableLargeResultOptimizedEditing;
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
return columnNames.map(key => ({
|
return columnNames.map(key => ({
|
||||||
@@ -2067,7 +2106,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
shouldCellUpdate: (record: Item, prevRecord: Item) => {
|
shouldCellUpdate: (record: Item, prevRecord: Item) => {
|
||||||
const rowKeyChanged = record?.[GONAVI_ROW_KEY] !== prevRecord?.[GONAVI_ROW_KEY];
|
const rowKeyChanged = record?.[GONAVI_ROW_KEY] !== prevRecord?.[GONAVI_ROW_KEY];
|
||||||
if (rowKeyChanged) return true;
|
if (rowKeyChanged) return true;
|
||||||
return !isCellValueEqualForDiff(record?.[key], prevRecord?.[key]);
|
return !isCellValueEqualForRender(record?.[key], prevRecord?.[key]);
|
||||||
},
|
},
|
||||||
onHeaderCell: (column: any) => ({
|
onHeaderCell: (column: any) => ({
|
||||||
width: column.width,
|
width: column.width,
|
||||||
@@ -2733,6 +2772,13 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
return active ? [active] : [];
|
return active ? [active] : [];
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const pickVerticalScrollTarget = useCallback((tableContainer: HTMLElement): HTMLElement | null => {
|
||||||
|
const virtualHolder = tableContainer.querySelector('.ant-table-tbody-virtual-holder') as HTMLElement | null;
|
||||||
|
const rcVirtualHolder = tableContainer.querySelector('.rc-virtual-list-holder') as HTMLElement | null;
|
||||||
|
const body = tableContainer.querySelector('.ant-table-body') as HTMLElement | null;
|
||||||
|
return virtualHolder || rcVirtualHolder || body;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const syncExternalScrollFromTargets = useCallback((targets?: HTMLElement[], source?: HTMLElement | null) => {
|
const syncExternalScrollFromTargets = useCallback((targets?: HTMLElement[], source?: HTMLElement | null) => {
|
||||||
const externalScroll = externalHScrollRef.current;
|
const externalScroll = externalHScrollRef.current;
|
||||||
if (!(externalScroll instanceof HTMLDivElement) || horizontalSyncSourceRef.current === 'external') {
|
if (!(externalScroll instanceof HTMLDivElement) || horizontalSyncSourceRef.current === 'external') {
|
||||||
@@ -2815,12 +2861,162 @@ const DataGrid: React.FC<DataGridProps> = ({
|
|||||||
};
|
};
|
||||||
}, [horizontalScrollVisible]);
|
}, [horizontalScrollVisible]);
|
||||||
|
|
||||||
|
// 非虚拟模式:支持在数据区直接使用触摸板/Shift+滚轮进行横向滚动。
|
||||||
|
// 某些平台在表格内容未铺满一页时,不会把水平手势正确路由到表格 body,导致只能在表头/底部滚动条区域滚动。
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewMode !== 'table' || enableVirtual) return;
|
||||||
|
const container = tableContainerRef.current;
|
||||||
|
if (!(container instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const resolveHorizontalDelta = (event: WheelEvent) => {
|
||||||
|
if (Math.abs(event.deltaX) > 0.5) {
|
||||||
|
return event.deltaX;
|
||||||
|
}
|
||||||
|
if (event.shiftKey && Math.abs(event.deltaY) > 0.5) {
|
||||||
|
return event.deltaY;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTableDataAreaTarget = (target: EventTarget | null) => {
|
||||||
|
const element = target instanceof HTMLElement ? target : null;
|
||||||
|
if (!element) return false;
|
||||||
|
if (element.closest('.data-grid-external-hscroll')) return false;
|
||||||
|
return !!element.closest('.ant-table-body, .ant-table-content, .ant-table-cell, .ant-table-row, .ant-table-tbody');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContainerHorizontalWheel = (event: WheelEvent) => {
|
||||||
|
const horizontalDelta = resolveHorizontalDelta(event);
|
||||||
|
if (!Number.isFinite(horizontalDelta) || Math.abs(horizontalDelta) < 0.5) return;
|
||||||
|
if (!isTableDataAreaTarget(event.target)) return;
|
||||||
|
|
||||||
|
const targets = pickHorizontalScrollTargets(container);
|
||||||
|
const activeTarget = targets.find((target) => target.scrollWidth > target.clientWidth + 1) || targets[0];
|
||||||
|
if (!(activeTarget instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const maxScrollLeft = Math.max(0, activeTarget.scrollWidth - activeTarget.clientWidth);
|
||||||
|
if (maxScrollLeft <= 0) return;
|
||||||
|
|
||||||
|
const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, activeTarget.scrollLeft + horizontalDelta));
|
||||||
|
if (Math.abs(nextScrollLeft - activeTarget.scrollLeft) < 1) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
horizontalSyncSourceRef.current = 'table';
|
||||||
|
activeTarget.scrollLeft = nextScrollLeft;
|
||||||
|
lastTableScrollLeftRef.current = nextScrollLeft;
|
||||||
|
|
||||||
|
const externalScroll = externalHScrollRef.current;
|
||||||
|
if (externalScroll && Math.abs(externalScroll.scrollLeft - nextScrollLeft) > 1) {
|
||||||
|
externalScroll.scrollLeft = nextScrollLeft;
|
||||||
|
lastExternalScrollLeftRef.current = nextScrollLeft;
|
||||||
|
}
|
||||||
|
horizontalSyncSourceRef.current = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener('wheel', handleContainerHorizontalWheel, { passive: false, capture: true });
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener('wheel', handleContainerHorizontalWheel, { capture: true } as EventListenerOptions);
|
||||||
|
};
|
||||||
|
}, [viewMode, enableVirtual, pickHorizontalScrollTargets]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (viewMode !== 'table') return;
|
if (viewMode !== 'table') return;
|
||||||
const rafId = requestAnimationFrame(() => recalculateTableMetrics(containerRef.current));
|
const rafId = requestAnimationFrame(() => recalculateTableMetrics(containerRef.current));
|
||||||
return () => cancelAnimationFrame(rafId);
|
return () => cancelAnimationFrame(rafId);
|
||||||
}, [viewMode, totalWidth, mergedDisplayData.length, recalculateTableMetrics]);
|
}, [viewMode, totalWidth, mergedDisplayData.length, recalculateTableMetrics]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewMode !== 'table' || !onScrollSnapshotChange) return;
|
||||||
|
const tableContainer = tableContainerRef.current;
|
||||||
|
if (!(tableContainer instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
let rafId: number | null = null;
|
||||||
|
let boundVerticalTarget: HTMLElement | null = null;
|
||||||
|
let boundHorizontalTargets: HTMLElement[] = [];
|
||||||
|
const externalScroll = externalHScrollRef.current;
|
||||||
|
const hasStoredScroll = !!scrollSnapshot && (Math.abs(scrollSnapshot.top) > 0.5 || Math.abs(scrollSnapshot.left) > 0.5);
|
||||||
|
|
||||||
|
const emitSnapshot = () => {
|
||||||
|
if (!didRestoreScrollRef.current && hasStoredScroll) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const verticalTarget = boundVerticalTarget || pickVerticalScrollTarget(tableContainer);
|
||||||
|
const horizontalTargets = boundHorizontalTargets.length > 0 ? boundHorizontalTargets : pickHorizontalScrollTargets(tableContainer);
|
||||||
|
const top = verticalTarget ? verticalTarget.scrollTop : 0;
|
||||||
|
const left = horizontalTargets[0]?.scrollLeft ?? externalScroll?.scrollLeft ?? 0;
|
||||||
|
if (Math.abs(lastReportedScrollRef.current.top - top) < 1 && Math.abs(lastReportedScrollRef.current.left - left) < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastReportedScrollRef.current = { top, left };
|
||||||
|
onScrollSnapshotChange({ top, left });
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindTargets = () => {
|
||||||
|
if (boundVerticalTarget) {
|
||||||
|
boundVerticalTarget.removeEventListener('scroll', emitSnapshot);
|
||||||
|
}
|
||||||
|
boundHorizontalTargets.forEach(target => target.removeEventListener('scroll', emitSnapshot));
|
||||||
|
externalScroll?.removeEventListener('scroll', emitSnapshot);
|
||||||
|
|
||||||
|
boundVerticalTarget = pickVerticalScrollTarget(tableContainer);
|
||||||
|
boundHorizontalTargets = pickHorizontalScrollTargets(tableContainer);
|
||||||
|
|
||||||
|
boundVerticalTarget?.addEventListener('scroll', emitSnapshot, { passive: true });
|
||||||
|
boundHorizontalTargets.forEach(target => target.addEventListener('scroll', emitSnapshot, { passive: true }));
|
||||||
|
externalScroll?.addEventListener('scroll', emitSnapshot, { passive: true });
|
||||||
|
emitSnapshot();
|
||||||
|
};
|
||||||
|
|
||||||
|
rafId = requestAnimationFrame(bindTargets);
|
||||||
|
return () => {
|
||||||
|
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||||
|
if (boundVerticalTarget) {
|
||||||
|
boundVerticalTarget.removeEventListener('scroll', emitSnapshot);
|
||||||
|
}
|
||||||
|
boundHorizontalTargets.forEach(target => target.removeEventListener('scroll', emitSnapshot));
|
||||||
|
externalScroll?.removeEventListener('scroll', emitSnapshot);
|
||||||
|
};
|
||||||
|
}, [viewMode, mergedDisplayData.length, onScrollSnapshotChange, pickHorizontalScrollTargets, pickVerticalScrollTarget, scrollSnapshot]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewMode !== 'table') return;
|
||||||
|
if (!scrollSnapshot) return;
|
||||||
|
if (didRestoreScrollRef.current) return;
|
||||||
|
const tableContainer = tableContainerRef.current;
|
||||||
|
if (!(tableContainer instanceof HTMLElement)) return;
|
||||||
|
if (mergedDisplayData.length === 0) return;
|
||||||
|
|
||||||
|
let rafId = requestAnimationFrame(() => {
|
||||||
|
const verticalTarget = pickVerticalScrollTarget(tableContainer);
|
||||||
|
const horizontalTargets = pickHorizontalScrollTargets(tableContainer);
|
||||||
|
const nextTop = Math.max(0, scrollSnapshot.top);
|
||||||
|
const nextLeft = Math.max(0, scrollSnapshot.left);
|
||||||
|
if (verticalTarget && Math.abs(verticalTarget.scrollTop - scrollSnapshot.top) > 1) {
|
||||||
|
verticalTarget.scrollTop = nextTop;
|
||||||
|
}
|
||||||
|
if (Math.abs(nextLeft) > 0.5) {
|
||||||
|
horizontalTargets.forEach(target => {
|
||||||
|
if (Math.abs(target.scrollLeft - nextLeft) > 1) {
|
||||||
|
target.scrollLeft = nextLeft;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const externalScroll = externalHScrollRef.current;
|
||||||
|
if (externalScroll && Math.abs(externalScroll.scrollLeft - nextLeft) > 1) {
|
||||||
|
externalScroll.scrollLeft = nextLeft;
|
||||||
|
}
|
||||||
|
lastTableScrollLeftRef.current = nextLeft;
|
||||||
|
lastExternalScrollLeftRef.current = nextLeft;
|
||||||
|
}
|
||||||
|
lastReportedScrollRef.current = { top: nextTop, left: nextLeft };
|
||||||
|
didRestoreScrollRef.current = true;
|
||||||
|
onScrollSnapshotChange?.({ top: nextTop, left: nextLeft });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => cancelAnimationFrame(rafId);
|
||||||
|
}, [viewMode, mergedDisplayData.length, scrollSnapshot, pickHorizontalScrollTargets, pickVerticalScrollTarget, onScrollSnapshotChange]);
|
||||||
|
|
||||||
// 虚拟模式下,在容器级别监听 wheel 事件,当鼠标在底部水平滚动条区域时拦截并转为水平滚动
|
// 虚拟模式下,在容器级别监听 wheel 事件,当鼠标在底部水平滚动条区域时拦截并转为水平滚动
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (viewMode !== 'table' || !enableVirtual) return;
|
if (viewMode !== 'table' || !enableVirtual) return;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { Modal, Form, Select, Button, message, Steps, Transfer, Card, Alert, Divider, Typography, Progress, Checkbox, Table, Drawer, Tabs } from 'antd';
|
import { Modal, Form, Select, Button, message, Steps, Transfer, Card, Alert, Divider, Typography, Progress, Checkbox, Table, Drawer, Tabs } from 'antd';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
import { DBGetDatabases, DBGetTables, DataSync, DataSyncAnalyze, DataSyncPreview } from '../../wailsjs/go/app/App';
|
import { DBGetDatabases, DBGetTables, DataSync, DataSyncAnalyze, DataSyncPreview } from '../../wailsjs/go/app/App';
|
||||||
@@ -31,6 +31,118 @@ type TableOps = {
|
|||||||
selectedDeletePks?: string[];
|
selectedDeletePks?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const quoteSqlIdent = (dbType: string, ident: string): string => {
|
||||||
|
const raw = String(ident || '').trim();
|
||||||
|
if (!raw) return raw;
|
||||||
|
const t = String(dbType || '').toLowerCase();
|
||||||
|
if (t === 'mysql' || t === 'mariadb' || t === 'diros' || t === 'sphinx' || t === 'clickhouse' || t === 'tdengine') {
|
||||||
|
return `\`${raw.replace(/`/g, '``')}\``;
|
||||||
|
}
|
||||||
|
if (t === 'sqlserver') {
|
||||||
|
return `[${raw.replace(/]/g, ']]')}]`;
|
||||||
|
}
|
||||||
|
return `"${raw.replace(/"/g, '""')}"`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const quoteSqlTable = (dbType: string, tableName: string): string => {
|
||||||
|
const raw = String(tableName || '').trim();
|
||||||
|
if (!raw) return raw;
|
||||||
|
if (!raw.includes('.')) return quoteSqlIdent(dbType, raw);
|
||||||
|
return raw
|
||||||
|
.split('.')
|
||||||
|
.map((part) => quoteSqlIdent(dbType, part))
|
||||||
|
.join('.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toSqlLiteral = (value: any, dbType: string): string => {
|
||||||
|
if (value === null || value === undefined) return 'NULL';
|
||||||
|
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'NULL';
|
||||||
|
if (typeof value === 'bigint') return value.toString();
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
const t = String(dbType || '').toLowerCase();
|
||||||
|
if (t === 'sqlserver') return value ? '1' : '0';
|
||||||
|
return value ? 'TRUE' : 'FALSE';
|
||||||
|
}
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return `'${value.toISOString().replace(/'/g, "''")}'`;
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
try {
|
||||||
|
return `'${JSON.stringify(value).replace(/'/g, "''")}'`;
|
||||||
|
} catch {
|
||||||
|
return `'${String(value).replace(/'/g, "''")}'`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `'${String(value).replace(/'/g, "''")}'`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSqlPreview = (
|
||||||
|
previewData: any,
|
||||||
|
tableName: string,
|
||||||
|
dbType: string,
|
||||||
|
ops?: TableOps,
|
||||||
|
): { sqlText: string; statementCount: number } => {
|
||||||
|
if (!previewData || !tableName) return { sqlText: '', statementCount: 0 };
|
||||||
|
const tableExpr = quoteSqlTable(dbType, tableName);
|
||||||
|
const pkCol = String(previewData.pkColumn || 'id');
|
||||||
|
const statements: string[] = [];
|
||||||
|
|
||||||
|
const insertRows = Array.isArray(previewData.inserts) ? previewData.inserts : [];
|
||||||
|
const updateRows = Array.isArray(previewData.updates) ? previewData.updates : [];
|
||||||
|
const deleteRows = Array.isArray(previewData.deletes) ? previewData.deletes : [];
|
||||||
|
|
||||||
|
const selectedInsert = new Set((ops?.selectedInsertPks || []).map((v) => String(v)));
|
||||||
|
const selectedUpdate = new Set((ops?.selectedUpdatePks || []).map((v) => String(v)));
|
||||||
|
const selectedDelete = new Set((ops?.selectedDeletePks || []).map((v) => String(v)));
|
||||||
|
|
||||||
|
if (ops?.insert !== false) {
|
||||||
|
insertRows.forEach((rowWrap: any) => {
|
||||||
|
const pk = String(rowWrap?.pk ?? '');
|
||||||
|
if (selectedInsert.size > 0 && !selectedInsert.has(pk)) return;
|
||||||
|
const row = rowWrap?.row || {};
|
||||||
|
const columns = Object.keys(row);
|
||||||
|
if (columns.length === 0) return;
|
||||||
|
const colExpr = columns.map((c) => quoteSqlIdent(dbType, c)).join(', ');
|
||||||
|
const valExpr = columns.map((c) => toSqlLiteral(row[c], dbType)).join(', ');
|
||||||
|
statements.push(`INSERT INTO ${tableExpr} (${colExpr}) VALUES (${valExpr});`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ops?.update !== false) {
|
||||||
|
updateRows.forEach((rowWrap: any) => {
|
||||||
|
const pk = String(rowWrap?.pk ?? '');
|
||||||
|
if (selectedUpdate.size > 0 && !selectedUpdate.has(pk)) return;
|
||||||
|
const source = rowWrap?.source || {};
|
||||||
|
const changedColumns = Array.isArray(rowWrap?.changedColumns)
|
||||||
|
? rowWrap.changedColumns
|
||||||
|
: Object.keys(source).filter((k) => k !== pkCol);
|
||||||
|
const setCols = changedColumns.filter((c: string) => String(c) !== pkCol);
|
||||||
|
if (setCols.length === 0) return;
|
||||||
|
const setExpr = setCols
|
||||||
|
.map((c: string) => `${quoteSqlIdent(dbType, c)} = ${toSqlLiteral(source[c], dbType)}`)
|
||||||
|
.join(', ');
|
||||||
|
statements.push(
|
||||||
|
`UPDATE ${tableExpr} SET ${setExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toSqlLiteral(pk, dbType)};`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ops?.delete) {
|
||||||
|
deleteRows.forEach((rowWrap: any) => {
|
||||||
|
const pk = String(rowWrap?.pk ?? '');
|
||||||
|
if (selectedDelete.size > 0 && !selectedDelete.has(pk)) return;
|
||||||
|
statements.push(
|
||||||
|
`DELETE FROM ${tableExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toSqlLiteral(pk, dbType)};`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sqlText: statements.join('\n'),
|
||||||
|
statementCount: statements.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
|
const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
|
||||||
const connections = useStore((state) => state.connections);
|
const connections = useStore((state) => state.connections);
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
@@ -152,32 +264,38 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
|||||||
setSourceConnId(connId);
|
setSourceConnId(connId);
|
||||||
setSourceDb('');
|
setSourceDb('');
|
||||||
const conn = connections.find(c => c.id === connId);
|
const conn = connections.find(c => c.id === connId);
|
||||||
if (conn) {
|
if (conn) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
|
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
setSourceDbs((res.data as any[]).map((r: any) => r.Database || r.database || r.username));
|
const dbRows = Array.isArray(res.data) ? res.data : [];
|
||||||
}
|
setSourceDbs(dbRows
|
||||||
} catch(e) { message.error("Failed to fetch source databases"); }
|
.map((r: any) => r?.Database || r?.database || r?.username)
|
||||||
setLoading(false);
|
.filter((name: any) => typeof name === 'string' && name.trim() !== ''));
|
||||||
}
|
}
|
||||||
|
} catch(e) { message.error("Failed to fetch source databases"); }
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTargetConnChange = async (connId: string) => {
|
const handleTargetConnChange = async (connId: string) => {
|
||||||
setTargetConnId(connId);
|
setTargetConnId(connId);
|
||||||
setTargetDb('');
|
setTargetDb('');
|
||||||
const conn = connections.find(c => c.id === connId);
|
const conn = connections.find(c => c.id === connId);
|
||||||
if (conn) {
|
if (conn) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
|
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
setTargetDbs((res.data as any[]).map((r: any) => r.Database || r.database || r.username));
|
const dbRows = Array.isArray(res.data) ? res.data : [];
|
||||||
}
|
setTargetDbs(dbRows
|
||||||
} catch(e) { message.error("Failed to fetch target databases"); }
|
.map((r: any) => r?.Database || r?.database || r?.username)
|
||||||
setLoading(false);
|
.filter((name: any) => typeof name === 'string' && name.trim() !== ''));
|
||||||
}
|
}
|
||||||
|
} catch(e) { message.error("Failed to fetch target databases"); }
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextToTables = async () => {
|
const nextToTables = async () => {
|
||||||
@@ -189,14 +307,17 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
|||||||
try {
|
try {
|
||||||
const conn = connections.find(c => c.id === sourceConnId);
|
const conn = connections.find(c => c.id === sourceConnId);
|
||||||
if (conn) {
|
if (conn) {
|
||||||
const config = normalizeConnConfig(conn, sourceDb);
|
const config = normalizeConnConfig(conn, sourceDb);
|
||||||
const res = await DBGetTables(config as any, sourceDb);
|
const res = await DBGetTables(config as any, sourceDb);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
// DBGetTables returns [{Table: "name"}, ...]
|
// DBGetTables returns [{Table: "name"}, ...]
|
||||||
const tables = (res.data as any[]).map((row: any) => row.Table || row.table || row.TABLE_NAME || Object.values(row)[0]);
|
const tableRows = Array.isArray(res.data) ? res.data : [];
|
||||||
setAllTables(tables as string[]);
|
const tables = tableRows
|
||||||
setCurrentStep(1);
|
.map((row: any) => row?.Table || row?.table || row?.TABLE_NAME || Object.values(row || {})[0])
|
||||||
} else {
|
.filter((name: any) => typeof name === 'string' && name.trim() !== '');
|
||||||
|
setAllTables(tables as string[]);
|
||||||
|
setCurrentStep(1);
|
||||||
|
} else {
|
||||||
message.error(res.message);
|
message.error(res.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -402,6 +523,13 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const previewSql = useMemo(() => {
|
||||||
|
if (!previewData || !previewTable) return { sqlText: '', statementCount: 0 };
|
||||||
|
const targetType = String(connections.find(c => c.id === targetConnId)?.config?.type || '');
|
||||||
|
const ops = tableOptions[previewTable] || { insert: true, update: true, delete: false };
|
||||||
|
return buildSqlPreview(previewData, previewTable, targetType, ops);
|
||||||
|
}, [previewData, previewTable, targetConnId, connections, tableOptions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal
|
<Modal
|
||||||
@@ -794,6 +922,51 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sql',
|
||||||
|
label: `SQL(${previewSql.statementCount})`,
|
||||||
|
children: (
|
||||||
|
<div>
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message="SQL 预览会按当前勾选的插入/更新/删除与行选择范围生成,用于审核确认。"
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 8, marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Text type="secondary">共 {previewSql.statementCount} 条语句(预览数据最多 200 条/类型)</Text>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
disabled={!previewSql.sqlText}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(previewSql.sqlText || '');
|
||||||
|
message.success('SQL 已复制');
|
||||||
|
} catch {
|
||||||
|
message.error('复制失败,请手动复制');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
复制 SQL
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: 10,
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: '#fafafa',
|
||||||
|
maxHeight: 420,
|
||||||
|
overflow: 'auto',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{previewSql.sqlText || '-- 当前勾选范围下无 SQL 可预览'}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -155,6 +155,16 @@ const reverseOrderBySQL = (orderBySQL: string): string => {
|
|||||||
type ViewerFilterSnapshot = {
|
type ViewerFilterSnapshot = {
|
||||||
showFilter: boolean;
|
showFilter: boolean;
|
||||||
conditions: FilterCondition[];
|
conditions: FilterCondition[];
|
||||||
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
sortInfo: { columnKey: string, order: string } | null;
|
||||||
|
scrollTop: number;
|
||||||
|
scrollLeft: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ViewerScrollSnapshot = {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const viewerFilterSnapshotsByTab = new Map<string, ViewerFilterSnapshot>();
|
const viewerFilterSnapshotsByTab = new Map<string, ViewerFilterSnapshot>();
|
||||||
@@ -175,15 +185,23 @@ const normalizeViewerFilterConditions = (conditions: FilterCondition[] | undefin
|
|||||||
const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => {
|
const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => {
|
||||||
const cached = viewerFilterSnapshotsByTab.get(String(tabId || '').trim());
|
const cached = viewerFilterSnapshotsByTab.get(String(tabId || '').trim());
|
||||||
if (!cached) {
|
if (!cached) {
|
||||||
return { showFilter: false, conditions: [] };
|
return { showFilter: false, conditions: [], currentPage: 1, pageSize: 100, sortInfo: null, scrollTop: 0, scrollLeft: 0 };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
showFilter: cached.showFilter === true,
|
showFilter: cached.showFilter === true,
|
||||||
conditions: normalizeViewerFilterConditions(cached.conditions),
|
conditions: normalizeViewerFilterConditions(cached.conditions),
|
||||||
|
currentPage: Number.isFinite(Number(cached.currentPage)) && Number(cached.currentPage) > 0 ? Number(cached.currentPage) : 1,
|
||||||
|
pageSize: Number.isFinite(Number(cached.pageSize)) && Number(cached.pageSize) > 0 ? Number(cached.pageSize) : 100,
|
||||||
|
sortInfo: cached.sortInfo && cached.sortInfo.columnKey && (cached.sortInfo.order === 'ascend' || cached.sortInfo.order === 'descend')
|
||||||
|
? { columnKey: String(cached.sortInfo.columnKey), order: cached.sortInfo.order }
|
||||||
|
: null,
|
||||||
|
scrollTop: Number.isFinite(Number(cached.scrollTop)) ? Number(cached.scrollTop) : 0,
|
||||||
|
scrollLeft: Number.isFinite(Number(cached.scrollLeft)) ? Number(cached.scrollLeft) : 0,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||||
|
const initialViewerSnapshot = useMemo(() => getViewerFilterSnapshot(tab.id), [tab.id]);
|
||||||
const [data, setData] = useState<any[]>([]);
|
const [data, setData] = useState<any[]>([]);
|
||||||
const [columnNames, setColumnNames] = useState<string[]>([]);
|
const [columnNames, setColumnNames] = useState<string[]>([]);
|
||||||
const [pkColumns, setPkColumns] = useState<string[]>([]);
|
const [pkColumns, setPkColumns] = useState<string[]>([]);
|
||||||
@@ -204,10 +222,15 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
const latestDbNameRef = useRef<string>('');
|
const latestDbNameRef = useRef<string>('');
|
||||||
const latestCountSqlRef = useRef<string>('');
|
const latestCountSqlRef = useRef<string>('');
|
||||||
const latestCountKeyRef = useRef<string>('');
|
const latestCountKeyRef = useRef<string>('');
|
||||||
|
const scrollSnapshotRef = useRef<ViewerScrollSnapshot>({
|
||||||
|
top: initialViewerSnapshot.scrollTop,
|
||||||
|
left: initialViewerSnapshot.scrollLeft,
|
||||||
|
});
|
||||||
|
const initialLoadRef = useRef(false);
|
||||||
|
|
||||||
const [pagination, setPagination] = useState<ViewerPaginationState>({
|
const [pagination, setPagination] = useState<ViewerPaginationState>({
|
||||||
current: 1,
|
current: initialViewerSnapshot.currentPage,
|
||||||
pageSize: 100,
|
pageSize: initialViewerSnapshot.pageSize,
|
||||||
total: 0,
|
total: 0,
|
||||||
totalKnown: false,
|
totalKnown: false,
|
||||||
totalApprox: false,
|
totalApprox: false,
|
||||||
@@ -215,10 +238,10 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
totalCountCancelled: false,
|
totalCountCancelled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null);
|
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(initialViewerSnapshot.sortInfo);
|
||||||
|
|
||||||
const [showFilter, setShowFilter] = useState<boolean>(() => getViewerFilterSnapshot(tab.id).showFilter);
|
const [showFilter, setShowFilter] = useState<boolean>(initialViewerSnapshot.showFilter);
|
||||||
const [filterConditions, setFilterConditions] = useState<FilterCondition[]>(() => getViewerFilterSnapshot(tab.id).conditions);
|
const [filterConditions, setFilterConditions] = useState<FilterCondition[]>(initialViewerSnapshot.conditions);
|
||||||
const duckdbSafeSelectCacheRef = useRef<Record<string, string>>({});
|
const duckdbSafeSelectCacheRef = useRef<Record<string, string>>({});
|
||||||
const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config;
|
const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config;
|
||||||
const currentConnCaps = getDataSourceCapabilities(currentConnConfig);
|
const currentConnCaps = getDataSourceCapabilities(currentConnConfig);
|
||||||
@@ -229,16 +252,25 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
const snapshot = getViewerFilterSnapshot(tab.id);
|
const snapshot = getViewerFilterSnapshot(tab.id);
|
||||||
setShowFilter(snapshot.showFilter);
|
setShowFilter(snapshot.showFilter);
|
||||||
setFilterConditions(snapshot.conditions);
|
setFilterConditions(snapshot.conditions);
|
||||||
|
setSortInfo(snapshot.sortInfo);
|
||||||
|
scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft };
|
||||||
|
initialLoadRef.current = false;
|
||||||
}, [tab.id]);
|
}, [tab.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
viewerFilterSnapshotsByTab.set(tab.id, {
|
viewerFilterSnapshotsByTab.set(tab.id, {
|
||||||
showFilter,
|
showFilter,
|
||||||
conditions: normalizeViewerFilterConditions(filterConditions),
|
conditions: normalizeViewerFilterConditions(filterConditions),
|
||||||
|
currentPage: pagination.current,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
sortInfo,
|
||||||
|
scrollTop: scrollSnapshotRef.current.top,
|
||||||
|
scrollLeft: scrollSnapshotRef.current.left,
|
||||||
});
|
});
|
||||||
}, [tab.id, showFilter, filterConditions]);
|
}, [tab.id, showFilter, filterConditions, pagination.current, pagination.pageSize, sortInfo]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const snapshot = getViewerFilterSnapshot(tab.id);
|
||||||
setPkColumns([]);
|
setPkColumns([]);
|
||||||
pkKeyRef.current = '';
|
pkKeyRef.current = '';
|
||||||
countKeyRef.current = '';
|
countKeyRef.current = '';
|
||||||
@@ -250,16 +282,29 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
latestDbNameRef.current = '';
|
latestDbNameRef.current = '';
|
||||||
latestCountSqlRef.current = '';
|
latestCountSqlRef.current = '';
|
||||||
latestCountKeyRef.current = '';
|
latestCountKeyRef.current = '';
|
||||||
|
scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft };
|
||||||
|
initialLoadRef.current = false;
|
||||||
setPagination(prev => ({
|
setPagination(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
current: 1,
|
current: snapshot.currentPage,
|
||||||
|
pageSize: snapshot.pageSize,
|
||||||
total: 0,
|
total: 0,
|
||||||
totalKnown: false,
|
totalKnown: false,
|
||||||
totalApprox: false,
|
totalApprox: false,
|
||||||
totalCountLoading: false,
|
totalCountLoading: false,
|
||||||
totalCountCancelled: false,
|
totalCountCancelled: false,
|
||||||
}));
|
}));
|
||||||
}, [tab.connectionId, tab.dbName, tab.tableName]);
|
}, [tab.id, tab.connectionId, tab.dbName, tab.tableName]);
|
||||||
|
|
||||||
|
const handleTableScrollSnapshotChange = useCallback((snapshot: ViewerScrollSnapshot) => {
|
||||||
|
scrollSnapshotRef.current = snapshot;
|
||||||
|
const cached = getViewerFilterSnapshot(tab.id);
|
||||||
|
viewerFilterSnapshotsByTab.set(tab.id, {
|
||||||
|
...cached,
|
||||||
|
scrollTop: snapshot.top,
|
||||||
|
scrollLeft: snapshot.left,
|
||||||
|
});
|
||||||
|
}, [tab.id]);
|
||||||
|
|
||||||
const handleDuckDBManualCount = useCallback(async () => {
|
const handleDuckDBManualCount = useCallback(async () => {
|
||||||
if (latestDbTypeRef.current !== 'duckdb') {
|
if (latestDbTypeRef.current !== 'duckdb') {
|
||||||
@@ -765,8 +810,13 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
}, [tab.tableName, currentConnConfig?.type, filterConditions, sortInfo, pkColumns]);
|
}, [tab.tableName, currentConnConfig?.type, filterConditions, sortInfo, pkColumns]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData(1, pagination.pageSize);
|
if (!initialLoadRef.current) {
|
||||||
}, [tab, sortInfo, filterConditions]); // Initial load and re-load on sort/filter
|
initialLoadRef.current = true;
|
||||||
|
fetchData(pagination.current, pagination.pageSize);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchData(1, pagination.pageSize);
|
||||||
|
}, [tab.id, tab.connectionId, tab.dbName, tab.tableName, sortInfo, filterConditions]); // Initial load and re-load on sort/filter
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ flex: '1 1 auto', minHeight: 0, minWidth: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
<div style={{ flex: '1 1 auto', minHeight: 0, minWidth: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
@@ -792,6 +842,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
readOnly={forceReadOnly}
|
readOnly={forceReadOnly}
|
||||||
sortInfoExternal={sortInfo}
|
sortInfoExternal={sortInfo}
|
||||||
exportSqlWithFilter={exportSqlWithFilter || undefined}
|
exportSqlWithFilter={exportSqlWithFilter || undefined}
|
||||||
|
scrollSnapshot={scrollSnapshotRef.current}
|
||||||
|
onScrollSnapshotChange={handleTableScrollSnapshotChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
const [editorHeight, setEditorHeight] = useState(300);
|
const [editorHeight, setEditorHeight] = useState(300);
|
||||||
const editorRef = useRef<any>(null);
|
const editorRef = useRef<any>(null);
|
||||||
const monacoRef = useRef<any>(null);
|
const monacoRef = useRef<any>(null);
|
||||||
|
const lastExternalQueryRef = useRef<string>(tab.query || '');
|
||||||
const dragRef = useRef<{ startY: number, startHeight: number } | null>(null);
|
const dragRef = useRef<{ startY: number, startHeight: number } | null>(null);
|
||||||
const tablesRef = useRef<{dbName: string, tableName: string}[]>([]); // Store tables for autocomplete (cross-db)
|
const tablesRef = useRef<{dbName: string, tableName: string}[]>([]); // Store tables for autocomplete (cross-db)
|
||||||
const allColumnsRef = useRef<{dbName: string, tableName: string, name: string, type: string}[]>([]); // Store all columns (cross-db)
|
const allColumnsRef = useRef<{dbName: string, tableName: string, name: string, type: string}[]>([]); // Store all columns (cross-db)
|
||||||
@@ -95,10 +96,30 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
connectionsRef.current = connections;
|
connectionsRef.current = connections;
|
||||||
}, [connections]);
|
}, [connections]);
|
||||||
|
|
||||||
|
const getCurrentQuery = () => {
|
||||||
|
const val = editorRef.current?.getValue?.();
|
||||||
|
if (typeof val === 'string') return val;
|
||||||
|
return query || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncQueryToEditor = (sql: string) => {
|
||||||
|
const next = sql || '';
|
||||||
|
setQuery(next);
|
||||||
|
const editor = editorRef.current;
|
||||||
|
if (editor && editor.getValue?.() !== next) {
|
||||||
|
editor.setValue(next);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// If opening a saved query, load its SQL
|
// If opening a saved query, load its SQL
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tab.query) setQuery(tab.query);
|
const incoming = tab.query || '';
|
||||||
}, [tab.query]);
|
if (incoming === lastExternalQueryRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastExternalQueryRef.current = incoming;
|
||||||
|
syncQueryToEditor(incoming || 'SELECT * FROM ');
|
||||||
|
}, [tab.id, tab.query]);
|
||||||
|
|
||||||
// Fetch Database List
|
// Fetch Database List
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -557,8 +578,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
|
|
||||||
const handleFormat = () => {
|
const handleFormat = () => {
|
||||||
try {
|
try {
|
||||||
const formatted = format(query, { language: 'mysql', keywordCase: sqlFormatOptions.keywordCase });
|
const formatted = format(getCurrentQuery(), { language: 'mysql', keywordCase: sqlFormatOptions.keywordCase });
|
||||||
setQuery(formatted);
|
syncQueryToEditor(formatted);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error("格式化失败: SQL 语法可能有误");
|
message.error("格式化失败: SQL 语法可能有误");
|
||||||
}
|
}
|
||||||
@@ -1045,7 +1066,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRun = async () => {
|
const handleRun = async () => {
|
||||||
if (!query.trim()) return;
|
const currentQuery = getCurrentQuery();
|
||||||
|
if (!currentQuery.trim()) return;
|
||||||
if (!currentDb) {
|
if (!currentDb) {
|
||||||
message.error("请先选择数据库");
|
message.error("请先选择数据库");
|
||||||
return;
|
return;
|
||||||
@@ -1086,7 +1108,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rawSQL = getSelectedSQL() || query;
|
const rawSQL = getSelectedSQL() || currentQuery;
|
||||||
const dbType = String((config as any).type || 'mysql');
|
const dbType = String((config as any).type || 'mysql');
|
||||||
const normalizedDbType = dbType.trim().toLowerCase();
|
const normalizedDbType = dbType.trim().toLowerCase();
|
||||||
const normalizedRawSQL = String(rawSQL || '').replace(/;/g, ';');
|
const normalizedRawSQL = String(rawSQL || '').replace(/;/g, ';');
|
||||||
@@ -1367,7 +1389,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
saveQuery({
|
saveQuery({
|
||||||
id: tab.id.startsWith('saved-') ? tab.id : `saved-${Date.now()}`,
|
id: tab.id.startsWith('saved-') ? tab.id : `saved-${Date.now()}`,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
sql: query,
|
sql: getCurrentQuery(),
|
||||||
connectionId: currentConnectionId,
|
connectionId: currentConnectionId,
|
||||||
dbName: currentDb || tab.dbName || '',
|
dbName: currentDb || tab.dbName || '',
|
||||||
createdAt: Date.now()
|
createdAt: Date.now()
|
||||||
@@ -1512,7 +1534,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
height="100%"
|
height="100%"
|
||||||
defaultLanguage="sql"
|
defaultLanguage="sql"
|
||||||
theme={darkMode ? "transparent-dark" : "transparent-light"}
|
theme={darkMode ? "transparent-dark" : "transparent-light"}
|
||||||
value={query}
|
defaultValue={query}
|
||||||
onChange={(val) => setQuery(val || '')}
|
onChange={(val) => setQuery(val || '')}
|
||||||
onMount={handleEditorDidMount}
|
onMount={handleEditorDidMount}
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
@@ -382,6 +382,16 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
password: readString(rawProxy.password, rawProxy.Password, cloned.proxyPassword, cloned.ProxyPassword),
|
password: readString(rawProxy.password, rawProxy.Password, cloned.proxyPassword, cloned.ProxyPassword),
|
||||||
};
|
};
|
||||||
const hasProxyDetail = Boolean(normalizedProxy.host || normalizedProxy.user || normalizedProxy.password);
|
const hasProxyDetail = Boolean(normalizedProxy.host || normalizedProxy.user || normalizedProxy.password);
|
||||||
|
const rawHttpTunnel = (cloned.httpTunnel ?? cloned.HTTPTunnel ?? {}) as Record<string, unknown>;
|
||||||
|
const normalizedHttpTunnel = {
|
||||||
|
host: readString(rawHttpTunnel.host, rawHttpTunnel.Host, cloned.httpTunnelHost, cloned.HttpTunnelHost),
|
||||||
|
port: readNumber(8080, rawHttpTunnel.port, rawHttpTunnel.Port, cloned.httpTunnelPort, cloned.HttpTunnelPort),
|
||||||
|
user: readString(rawHttpTunnel.user, rawHttpTunnel.User, cloned.httpTunnelUser, cloned.HttpTunnelUser),
|
||||||
|
password: readString(rawHttpTunnel.password, rawHttpTunnel.Password, cloned.httpTunnelPassword, cloned.HttpTunnelPassword),
|
||||||
|
};
|
||||||
|
const hasHttpTunnelDetail = Boolean(normalizedHttpTunnel.host || normalizedHttpTunnel.user || normalizedHttpTunnel.password);
|
||||||
|
const normalizedUseHttpTunnel = readBool(hasHttpTunnelDetail, cloned.useHttpTunnel, cloned.UseHTTPTunnel);
|
||||||
|
const normalizedUseProxy = !normalizedUseHttpTunnel && readBool(hasProxyDetail, cloned.useProxy, cloned.UseProxy);
|
||||||
|
|
||||||
const rawHosts = Array.isArray(cloned.hosts)
|
const rawHosts = Array.isArray(cloned.hosts)
|
||||||
? cloned.hosts
|
? cloned.hosts
|
||||||
@@ -394,8 +404,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
...(cloned as SavedConnection['config']),
|
...(cloned as SavedConnection['config']),
|
||||||
useSSH: readBool(hasSSHDetail, cloned.useSSH, cloned.UseSSH),
|
useSSH: readBool(hasSSHDetail, cloned.useSSH, cloned.UseSSH),
|
||||||
ssh: normalizedSSH,
|
ssh: normalizedSSH,
|
||||||
useProxy: readBool(hasProxyDetail, cloned.useProxy, cloned.UseProxy),
|
useProxy: normalizedUseProxy,
|
||||||
proxy: normalizedProxy,
|
proxy: normalizedProxy,
|
||||||
|
useHttpTunnel: normalizedUseHttpTunnel,
|
||||||
|
httpTunnel: normalizedHttpTunnel,
|
||||||
hosts: normalizedHosts,
|
hosts: normalizedHosts,
|
||||||
timeout: readNumber(30, cloned.timeout, cloned.Timeout),
|
timeout: readNumber(30, cloned.timeout, cloned.Timeout),
|
||||||
};
|
};
|
||||||
@@ -645,10 +657,15 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
}
|
}
|
||||||
case 'oracle':
|
case 'oracle':
|
||||||
case 'dm':
|
case 'dm':
|
||||||
if (!safeDbName) {
|
return normalizeMetadataQuerySpecs([
|
||||||
return [{ sql: `SELECT VIEW_NAME AS view_name FROM USER_VIEWS ORDER BY VIEW_NAME` }];
|
{ sql: `SELECT VIEW_NAME AS view_name FROM USER_VIEWS ORDER BY VIEW_NAME` },
|
||||||
}
|
{ sql: `SELECT OWNER AS schema_name, VIEW_NAME AS view_name FROM ALL_VIEWS WHERE OWNER = USER ORDER BY VIEW_NAME` },
|
||||||
return [{ sql: `SELECT OWNER AS schema_name, VIEW_NAME AS view_name FROM ALL_VIEWS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY VIEW_NAME` }];
|
{
|
||||||
|
sql: safeDbName
|
||||||
|
? `SELECT OWNER AS schema_name, VIEW_NAME AS view_name FROM ALL_VIEWS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY VIEW_NAME`
|
||||||
|
: '',
|
||||||
|
},
|
||||||
|
]);
|
||||||
case 'sqlite':
|
case 'sqlite':
|
||||||
return [{ sql: `SELECT name AS view_name FROM sqlite_master WHERE type = 'view' ORDER BY name` }];
|
return [{ sql: `SELECT name AS view_name FROM sqlite_master WHERE type = 'view' ORDER BY name` }];
|
||||||
case 'duckdb':
|
case 'duckdb':
|
||||||
@@ -731,10 +748,15 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
|||||||
}
|
}
|
||||||
case 'oracle':
|
case 'oracle':
|
||||||
case 'dm':
|
case 'dm':
|
||||||
if (!safeDbName) {
|
return normalizeMetadataQuerySpecs([
|
||||||
return [{ sql: `SELECT OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM USER_OBJECTS WHERE OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` }];
|
{ sql: `SELECT OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM USER_OBJECTS WHERE OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` },
|
||||||
}
|
{ sql: `SELECT OWNER AS schema_name, OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM ALL_OBJECTS WHERE OWNER = USER AND OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` },
|
||||||
return [{ sql: `SELECT OWNER AS schema_name, OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM ALL_OBJECTS WHERE OWNER = '${safeDbName.toUpperCase()}' AND OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` }];
|
{
|
||||||
|
sql: safeDbName
|
||||||
|
? `SELECT OWNER AS schema_name, OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM ALL_OBJECTS WHERE OWNER = '${safeDbName.toUpperCase()}' AND OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME`
|
||||||
|
: '',
|
||||||
|
},
|
||||||
|
]);
|
||||||
case 'duckdb':
|
case 'duckdb':
|
||||||
return [{
|
return [{
|
||||||
sql: `SELECT schema_name, function_name AS routine_name, 'FUNCTION' AS routine_type FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND COALESCE(macro_definition, '') <> '' ORDER BY schema_name, function_name`,
|
sql: `SELECT schema_name, function_name AS routine_name, 'FUNCTION' AS routine_type FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND COALESCE(macro_definition, '') <> '' ORDER BY schema_name, function_name`,
|
||||||
|
|||||||
@@ -231,6 +231,18 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
|
|||||||
user: toTrimmedString(proxyRaw.user),
|
user: toTrimmedString(proxyRaw.user),
|
||||||
password: toTrimmedString(proxyRaw.password),
|
password: toTrimmedString(proxyRaw.password),
|
||||||
};
|
};
|
||||||
|
const httpTunnelRaw = (raw.httpTunnel && typeof raw.httpTunnel === 'object')
|
||||||
|
? raw.httpTunnel as Record<string, unknown>
|
||||||
|
: ((raw.HTTPTunnel && typeof raw.HTTPTunnel === 'object') ? raw.HTTPTunnel as Record<string, unknown> : {});
|
||||||
|
const httpTunnel = {
|
||||||
|
host: toTrimmedString(httpTunnelRaw.host ?? raw.httpTunnelHost),
|
||||||
|
port: normalizePort(httpTunnelRaw.port ?? raw.httpTunnelPort, 8080),
|
||||||
|
user: toTrimmedString(httpTunnelRaw.user ?? raw.httpTunnelUser),
|
||||||
|
password: toTrimmedString(httpTunnelRaw.password ?? raw.httpTunnelPassword),
|
||||||
|
};
|
||||||
|
const supportsNetworkTunnel = type !== 'sqlite' && type !== 'duckdb';
|
||||||
|
const useHttpTunnel = supportsNetworkTunnel && (raw.useHttpTunnel === true || raw.UseHTTPTunnel === true);
|
||||||
|
const useProxy = supportsNetworkTunnel && !!raw.useProxy && !useHttpTunnel;
|
||||||
|
|
||||||
const safeConfig: ConnectionConfig & Record<string, unknown> = {
|
const safeConfig: ConnectionConfig & Record<string, unknown> = {
|
||||||
...raw,
|
...raw,
|
||||||
@@ -247,8 +259,10 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
|
|||||||
sslKeyPath: sslCapable ? toTrimmedString(raw.sslKeyPath) : '',
|
sslKeyPath: sslCapable ? toTrimmedString(raw.sslKeyPath) : '',
|
||||||
useSSH: !!raw.useSSH,
|
useSSH: !!raw.useSSH,
|
||||||
ssh,
|
ssh,
|
||||||
useProxy: !!raw.useProxy,
|
useProxy,
|
||||||
proxy,
|
proxy,
|
||||||
|
useHttpTunnel,
|
||||||
|
httpTunnel,
|
||||||
uri: toTrimmedString(raw.uri).slice(0, MAX_URI_LENGTH),
|
uri: toTrimmedString(raw.uri).slice(0, MAX_URI_LENGTH),
|
||||||
hosts: sanitizeAddressList(raw.hosts),
|
hosts: sanitizeAddressList(raw.hosts),
|
||||||
topology: raw.topology === 'replica' ? 'replica' : (raw.topology === 'cluster' ? 'cluster' : 'single'),
|
topology: raw.topology === 'replica' ? 'replica' : (raw.topology === 'cluster' ? 'cluster' : 'single'),
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ export interface ProxyConfig {
|
|||||||
password?: string;
|
password?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HTTPTunnelConfig {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
user?: string;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConnectionConfig {
|
export interface ConnectionConfig {
|
||||||
type: string;
|
type: string;
|
||||||
host: string;
|
host: string;
|
||||||
@@ -30,6 +37,8 @@ export interface ConnectionConfig {
|
|||||||
ssh?: SSHConfig;
|
ssh?: SSHConfig;
|
||||||
useProxy?: boolean;
|
useProxy?: boolean;
|
||||||
proxy?: ProxyConfig;
|
proxy?: ProxyConfig;
|
||||||
|
useHttpTunnel?: boolean;
|
||||||
|
httpTunnel?: HTTPTunnelConfig;
|
||||||
driver?: string;
|
driver?: string;
|
||||||
dsn?: string;
|
dsn?: string;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
|
|||||||
@@ -48,6 +48,24 @@ export namespace connection {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class HTTPTunnelConfig {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
user?: string;
|
||||||
|
password?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new HTTPTunnelConfig(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.host = source["host"];
|
||||||
|
this.port = source["port"];
|
||||||
|
this.user = source["user"];
|
||||||
|
this.password = source["password"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class ProxyConfig {
|
export class ProxyConfig {
|
||||||
type: string;
|
type: string;
|
||||||
host: string;
|
host: string;
|
||||||
@@ -104,6 +122,8 @@ export namespace connection {
|
|||||||
ssh: SSHConfig;
|
ssh: SSHConfig;
|
||||||
useProxy?: boolean;
|
useProxy?: boolean;
|
||||||
proxy?: ProxyConfig;
|
proxy?: ProxyConfig;
|
||||||
|
useHttpTunnel?: boolean;
|
||||||
|
httpTunnel?: HTTPTunnelConfig;
|
||||||
driver?: string;
|
driver?: string;
|
||||||
dsn?: string;
|
dsn?: string;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
@@ -142,6 +162,8 @@ export namespace connection {
|
|||||||
this.ssh = this.convertValues(source["ssh"], SSHConfig);
|
this.ssh = this.convertValues(source["ssh"], SSHConfig);
|
||||||
this.useProxy = source["useProxy"];
|
this.useProxy = source["useProxy"];
|
||||||
this.proxy = this.convertValues(source["proxy"], ProxyConfig);
|
this.proxy = this.convertValues(source["proxy"], ProxyConfig);
|
||||||
|
this.useHttpTunnel = source["useHttpTunnel"];
|
||||||
|
this.httpTunnel = this.convertValues(source["httpTunnel"], HTTPTunnelConfig);
|
||||||
this.driver = source["driver"];
|
this.driver = source["driver"];
|
||||||
this.dsn = source["dsn"];
|
this.dsn = source["dsn"];
|
||||||
this.timeout = source["timeout"];
|
this.timeout = source["timeout"];
|
||||||
@@ -179,6 +201,7 @@ export namespace connection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export class QueryResult {
|
export class QueryResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.Conn
|
|||||||
if !normalized.UseProxy {
|
if !normalized.UseProxy {
|
||||||
normalized.Proxy = connection.ProxyConfig{}
|
normalized.Proxy = connection.ProxyConfig{}
|
||||||
}
|
}
|
||||||
|
if !normalized.UseHTTPTunnel {
|
||||||
|
normalized.HTTPTunnel = connection.HTTPTunnelConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
if isFileDatabaseType(normalized.Type) {
|
if isFileDatabaseType(normalized.Type) {
|
||||||
dsn := strings.TrimSpace(normalized.Host)
|
dsn := strings.TrimSpace(normalized.Host)
|
||||||
@@ -124,6 +127,8 @@ func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.Conn
|
|||||||
normalized.MongoAuthMechanism = ""
|
normalized.MongoAuthMechanism = ""
|
||||||
normalized.MongoReplicaUser = ""
|
normalized.MongoReplicaUser = ""
|
||||||
normalized.MongoReplicaPassword = ""
|
normalized.MongoReplicaPassword = ""
|
||||||
|
normalized.UseHTTPTunnel = false
|
||||||
|
normalized.HTTPTunnel = connection.HTTPTunnelConfig{}
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized
|
return normalized
|
||||||
@@ -303,6 +308,12 @@ func formatConnSummary(config connection.ConnectionConfig) string {
|
|||||||
b.WriteString(" 代理认证=已配置")
|
b.WriteString(" 代理认证=已配置")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if config.UseHTTPTunnel {
|
||||||
|
b.WriteString(fmt.Sprintf(" HTTP隧道=%s:%d", strings.TrimSpace(config.HTTPTunnel.Host), config.HTTPTunnel.Port))
|
||||||
|
if strings.TrimSpace(config.HTTPTunnel.User) != "" {
|
||||||
|
b.WriteString(" HTTP隧道认证=已配置")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if config.Type == "custom" {
|
if config.Type == "custom" {
|
||||||
driver := strings.TrimSpace(config.Driver)
|
driver := strings.TrimSpace(config.Driver)
|
||||||
|
|||||||
@@ -12,8 +12,35 @@ import (
|
|||||||
|
|
||||||
func resolveDialConfigWithProxy(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
|
func resolveDialConfigWithProxy(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
|
||||||
config := raw
|
config := raw
|
||||||
|
if config.UseHTTPTunnel {
|
||||||
|
if config.UseProxy {
|
||||||
|
return connection.ConnectionConfig{}, fmt.Errorf("HTTP 隧道与普通代理不能同时启用")
|
||||||
|
}
|
||||||
|
tunnelHost := strings.TrimSpace(config.HTTPTunnel.Host)
|
||||||
|
if tunnelHost == "" {
|
||||||
|
return connection.ConnectionConfig{}, fmt.Errorf("HTTP 隧道主机不能为空")
|
||||||
|
}
|
||||||
|
tunnelPort := config.HTTPTunnel.Port
|
||||||
|
if tunnelPort <= 0 {
|
||||||
|
tunnelPort = 8080
|
||||||
|
}
|
||||||
|
if tunnelPort > 65535 {
|
||||||
|
return connection.ConnectionConfig{}, fmt.Errorf("HTTP 隧道端口无效:%d", config.HTTPTunnel.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
config.UseProxy = true
|
||||||
|
config.Proxy = connection.ProxyConfig{
|
||||||
|
Type: "http",
|
||||||
|
Host: tunnelHost,
|
||||||
|
Port: tunnelPort,
|
||||||
|
User: strings.TrimSpace(config.HTTPTunnel.User),
|
||||||
|
Password: config.HTTPTunnel.Password,
|
||||||
|
}
|
||||||
|
}
|
||||||
if !config.UseProxy {
|
if !config.UseProxy {
|
||||||
config.Proxy = connection.ProxyConfig{}
|
config.Proxy = connection.ProxyConfig{}
|
||||||
|
config.UseHTTPTunnel = false
|
||||||
|
config.HTTPTunnel = connection.HTTPTunnelConfig{}
|
||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,6 +49,8 @@ func resolveDialConfigWithProxy(raw connection.ConnectionConfig) (connection.Con
|
|||||||
return connection.ConnectionConfig{}, err
|
return connection.ConnectionConfig{}, err
|
||||||
}
|
}
|
||||||
config.Proxy = normalizedProxy
|
config.Proxy = normalizedProxy
|
||||||
|
config.UseHTTPTunnel = false
|
||||||
|
config.HTTPTunnel = connection.HTTPTunnelConfig{}
|
||||||
|
|
||||||
if config.UseSSH {
|
if config.UseSSH {
|
||||||
sshPort := config.SSH.Port
|
sshPort := config.SSH.Port
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ func (a *App) GetGlobalProxyConfig() connection.QueryResult {
|
|||||||
|
|
||||||
func applyGlobalProxyToConnection(config connection.ConnectionConfig) connection.ConnectionConfig {
|
func applyGlobalProxyToConnection(config connection.ConnectionConfig) connection.ConnectionConfig {
|
||||||
effective := config
|
effective := config
|
||||||
if effective.UseProxy {
|
if effective.UseProxy || effective.UseHTTPTunnel {
|
||||||
return effective
|
return effective
|
||||||
}
|
}
|
||||||
if isFileDatabaseType(effective.Type) {
|
if isFileDatabaseType(effective.Type) {
|
||||||
|
|||||||
@@ -416,12 +416,7 @@ func (a *App) DBQueryWithCancel(config connection.ConnectionConfig, dbName strin
|
|||||||
a.queryMu.Unlock()
|
a.queryMu.Unlock()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
lowerQuery := strings.TrimSpace(strings.ToLower(query))
|
isReadQuery := isReadOnlySQLQuery(runConfig.Type, query)
|
||||||
isReadQuery := strings.HasPrefix(lowerQuery, "select") || strings.HasPrefix(lowerQuery, "show") || strings.HasPrefix(lowerQuery, "describe") || strings.HasPrefix(lowerQuery, "explain")
|
|
||||||
// MongoDB JSON 命令中的 find/count/aggregate 也属于读查询
|
|
||||||
if !isReadQuery && strings.ToLower(strings.TrimSpace(runConfig.Type)) == "mongodb" && strings.HasPrefix(strings.TrimSpace(query), "{") {
|
|
||||||
isReadQuery = true
|
|
||||||
}
|
|
||||||
|
|
||||||
runReadQuery := func(inst db.Database) ([]map[string]interface{}, []string, error) {
|
runReadQuery := func(inst db.Database) ([]map[string]interface{}, []string, error) {
|
||||||
if q, ok := inst.(interface {
|
if q, ok := inst.(interface {
|
||||||
@@ -500,11 +495,7 @@ func (a *App) DBQueryIsolated(config connection.ConnectionConfig, dbName string,
|
|||||||
ctx, cancel := utils.ContextWithTimeout(time.Duration(timeoutSeconds) * time.Second)
|
ctx, cancel := utils.ContextWithTimeout(time.Duration(timeoutSeconds) * time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
lowerQuery := strings.TrimSpace(strings.ToLower(query))
|
isReadQuery := isReadOnlySQLQuery(runConfig.Type, query)
|
||||||
isReadQuery := strings.HasPrefix(lowerQuery, "select") || strings.HasPrefix(lowerQuery, "show") || strings.HasPrefix(lowerQuery, "describe") || strings.HasPrefix(lowerQuery, "explain")
|
|
||||||
if !isReadQuery && strings.ToLower(strings.TrimSpace(runConfig.Type)) == "mongodb" && strings.HasPrefix(strings.TrimSpace(query), "{") {
|
|
||||||
isReadQuery = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if isReadQuery {
|
if isReadQuery {
|
||||||
var data []map[string]interface{}
|
var data []map[string]interface{}
|
||||||
@@ -547,6 +538,13 @@ func sqlSnippet(query string) string {
|
|||||||
return q[:max] + "..."
|
return q[:max] + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureNonNilSlice[T any](items []T) []T {
|
||||||
|
if items == nil {
|
||||||
|
return make([]T, 0)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) DBGetDatabases(config connection.ConnectionConfig) connection.QueryResult {
|
func (a *App) DBGetDatabases(config connection.ConnectionConfig) connection.QueryResult {
|
||||||
runConfig := normalizeRunConfig(config, "")
|
runConfig := normalizeRunConfig(config, "")
|
||||||
dbInst, err := a.getDatabase(runConfig)
|
dbInst, err := a.getDatabase(runConfig)
|
||||||
@@ -571,7 +569,7 @@ func (a *App) DBGetDatabases(config connection.ConnectionConfig) connection.Quer
|
|||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
}
|
}
|
||||||
|
|
||||||
var resData []map[string]string
|
resData := make([]map[string]string, 0, len(dbs))
|
||||||
for _, name := range dbs {
|
for _, name := range dbs {
|
||||||
resData = append(resData, map[string]string{"Database": name})
|
resData = append(resData, map[string]string{"Database": name})
|
||||||
}
|
}
|
||||||
@@ -604,7 +602,7 @@ func (a *App) DBGetTables(config connection.ConnectionConfig, dbName string) con
|
|||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
}
|
}
|
||||||
|
|
||||||
var resData []map[string]string
|
resData := make([]map[string]string, 0, len(tables))
|
||||||
for _, name := range tables {
|
for _, name := range tables {
|
||||||
resData = append(resData, map[string]string{"Table": name})
|
resData = append(resData, map[string]string{"Table": name})
|
||||||
}
|
}
|
||||||
@@ -786,7 +784,7 @@ func (a *App) DBGetColumns(config connection.ConnectionConfig, dbName string, ta
|
|||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
}
|
}
|
||||||
|
|
||||||
return connection.QueryResult{Success: true, Data: columns}
|
return connection.QueryResult{Success: true, Data: ensureNonNilSlice(columns)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) DBGetIndexes(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
|
func (a *App) DBGetIndexes(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
|
||||||
@@ -803,7 +801,7 @@ func (a *App) DBGetIndexes(config connection.ConnectionConfig, dbName string, ta
|
|||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
}
|
}
|
||||||
|
|
||||||
return connection.QueryResult{Success: true, Data: indexes}
|
return connection.QueryResult{Success: true, Data: ensureNonNilSlice(indexes)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) DBGetForeignKeys(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
|
func (a *App) DBGetForeignKeys(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
|
||||||
@@ -820,7 +818,7 @@ func (a *App) DBGetForeignKeys(config connection.ConnectionConfig, dbName string
|
|||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
}
|
}
|
||||||
|
|
||||||
return connection.QueryResult{Success: true, Data: fks}
|
return connection.QueryResult{Success: true, Data: ensureNonNilSlice(fks)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) DBGetTriggers(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
|
func (a *App) DBGetTriggers(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
|
||||||
@@ -837,7 +835,7 @@ func (a *App) DBGetTriggers(config connection.ConnectionConfig, dbName string, t
|
|||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
}
|
}
|
||||||
|
|
||||||
return connection.QueryResult{Success: true, Data: triggers}
|
return connection.QueryResult{Success: true, Data: ensureNonNilSlice(triggers)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) DropView(config connection.ConnectionConfig, dbName string, viewName string) connection.QueryResult {
|
func (a *App) DropView(config connection.ConnectionConfig, dbName string, viewName string) connection.QueryResult {
|
||||||
@@ -975,5 +973,5 @@ func (a *App) DBGetAllColumns(config connection.ConnectionConfig, dbName string)
|
|||||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
}
|
}
|
||||||
|
|
||||||
return connection.QueryResult{Success: true, Data: cols}
|
return connection.QueryResult{Success: true, Data: ensureNonNilSlice(cols)}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2536,6 +2536,9 @@ func installOptionalDriverAgentFromLocalPath(definition driverDefinition, filePa
|
|||||||
return installedDriverPackage{}, fmt.Errorf("导入本地驱动代理失败:%w", copyErr)
|
return installedDriverPackage{}, fmt.Errorf("导入本地驱动代理失败:%w", copyErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if validateErr := db.ValidateOptionalDriverAgentExecutable(driverType, executablePath); validateErr != nil {
|
||||||
|
return installedDriverPackage{}, validateErr
|
||||||
|
}
|
||||||
|
|
||||||
hash, hashErr := hashFileSHA256(executablePath)
|
hash, hashErr := hashFileSHA256(executablePath)
|
||||||
if hashErr != nil {
|
if hashErr != nil {
|
||||||
@@ -2793,11 +2796,15 @@ func ensureOptionalDriverAgentBinary(a *App, definition driverDefinition, execut
|
|||||||
|
|
||||||
info, err := os.Stat(executablePath)
|
info, err := os.Stat(executablePath)
|
||||||
if err == nil && !info.IsDir() {
|
if err == nil && !info.IsDir() {
|
||||||
hash, hashErr := hashFileSHA256(executablePath)
|
if validateErr := db.ValidateOptionalDriverAgentExecutable(driverType, executablePath); validateErr != nil {
|
||||||
if hashErr != nil {
|
_ = os.Remove(executablePath)
|
||||||
return "", "", fmt.Errorf("读取已安装 %s 驱动代理摘要失败:%w", displayName, hashErr)
|
} else {
|
||||||
|
hash, hashErr := hashFileSHA256(executablePath)
|
||||||
|
if hashErr != nil {
|
||||||
|
return "", "", fmt.Errorf("读取已安装 %s 驱动代理摘要失败:%w", displayName, hashErr)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("local://existing/%s-driver-agent", driverType), hash, nil
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("local://existing/%s-driver-agent", driverType), hash, nil
|
|
||||||
}
|
}
|
||||||
if err == nil && info.IsDir() {
|
if err == nil && info.IsDir() {
|
||||||
return "", "", fmt.Errorf("%s 驱动代理路径被目录占用:%s", displayName, executablePath)
|
return "", "", fmt.Errorf("%s 驱动代理路径被目录占用:%s", displayName, executablePath)
|
||||||
@@ -2814,6 +2821,10 @@ func ensureOptionalDriverAgentBinary(a *App, definition driverDefinition, execut
|
|||||||
if copyErr := copyAgentBinary(sourcePath, executablePath); copyErr != nil {
|
if copyErr := copyAgentBinary(sourcePath, executablePath); copyErr != nil {
|
||||||
return "", "", fmt.Errorf("复制预置 %s 驱动代理失败:%w", displayName, copyErr)
|
return "", "", fmt.Errorf("复制预置 %s 驱动代理失败:%w", displayName, copyErr)
|
||||||
}
|
}
|
||||||
|
if validateErr := db.ValidateOptionalDriverAgentExecutable(driverType, executablePath); validateErr != nil {
|
||||||
|
_ = os.Remove(executablePath)
|
||||||
|
return "", "", validateErr
|
||||||
|
}
|
||||||
hash, hashErr := hashFileSHA256(executablePath)
|
hash, hashErr := hashFileSHA256(executablePath)
|
||||||
if hashErr != nil {
|
if hashErr != nil {
|
||||||
return "", "", fmt.Errorf("计算预置 %s 驱动代理摘要失败:%w", displayName, hashErr)
|
return "", "", fmt.Errorf("计算预置 %s 驱动代理摘要失败:%w", displayName, hashErr)
|
||||||
@@ -2901,6 +2912,10 @@ func downloadOptionalDriverAgentBinary(a *App, definition driverDefinition, urlT
|
|||||||
if chmodErr := os.Chmod(executablePath, 0o755); chmodErr != nil && stdRuntime.GOOS != "windows" {
|
if chmodErr := os.Chmod(executablePath, 0o755); chmodErr != nil && stdRuntime.GOOS != "windows" {
|
||||||
return "", fmt.Errorf("设置代理权限失败:%w", chmodErr)
|
return "", fmt.Errorf("设置代理权限失败:%w", chmodErr)
|
||||||
}
|
}
|
||||||
|
if validateErr := db.ValidateOptionalDriverAgentExecutable(driverType, executablePath); validateErr != nil {
|
||||||
|
_ = os.Remove(executablePath)
|
||||||
|
return "", validateErr
|
||||||
|
}
|
||||||
return hash, nil
|
return hash, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3009,6 +3024,10 @@ func downloadOptionalDriverAgentFromBundle(a *App, definition driverDefinition,
|
|||||||
if chmodErr := os.Chmod(executablePath, 0o755); chmodErr != nil && stdRuntime.GOOS != "windows" {
|
if chmodErr := os.Chmod(executablePath, 0o755); chmodErr != nil && stdRuntime.GOOS != "windows" {
|
||||||
return "", "", fmt.Errorf("设置驱动代理权限失败:%w", chmodErr)
|
return "", "", fmt.Errorf("设置驱动代理权限失败:%w", chmodErr)
|
||||||
}
|
}
|
||||||
|
if validateErr := db.ValidateOptionalDriverAgentExecutable(driverType, executablePath); validateErr != nil {
|
||||||
|
_ = os.Remove(executablePath)
|
||||||
|
return "", "", validateErr
|
||||||
|
}
|
||||||
hash, err := hashFileSHA256(executablePath)
|
hash, err := hashFileSHA256(executablePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("计算驱动代理摘要失败:%w", err)
|
return "", "", fmt.Errorf("计算驱动代理摘要失败:%w", err)
|
||||||
@@ -3334,6 +3353,7 @@ func resolveOptionalDriverAgentDownloadURLs(definition driverDefinition, rawURL
|
|||||||
}
|
}
|
||||||
|
|
||||||
func findExistingOptionalDriverAgentCandidate(definition driverDefinition, targetPath string) (string, bool) {
|
func findExistingOptionalDriverAgentCandidate(definition driverDefinition, targetPath string) (string, bool) {
|
||||||
|
driverType := normalizeDriverType(definition.Type)
|
||||||
targetAbs, _ := filepath.Abs(targetPath)
|
targetAbs, _ := filepath.Abs(targetPath)
|
||||||
candidates := resolveOptionalDriverAgentCandidatePaths(definition)
|
candidates := resolveOptionalDriverAgentCandidatePaths(definition)
|
||||||
for _, candidate := range candidates {
|
for _, candidate := range candidates {
|
||||||
@@ -3349,9 +3369,13 @@ func findExistingOptionalDriverAgentCandidate(definition driverDefinition, targe
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
info, statErr := os.Stat(absPath)
|
info, statErr := os.Stat(absPath)
|
||||||
if statErr == nil && !info.IsDir() {
|
if statErr != nil || info.IsDir() {
|
||||||
return absPath, true
|
continue
|
||||||
}
|
}
|
||||||
|
if validateErr := db.ValidateOptionalDriverAgentExecutable(driverType, absPath); validateErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return absPath, true
|
||||||
}
|
}
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,12 +23,20 @@ var (
|
|||||||
|
|
||||||
// getRedisClient gets or creates a Redis client from cache
|
// getRedisClient gets or creates a Redis client from cache
|
||||||
func (a *App) getRedisClient(config connection.ConnectionConfig) (redis.RedisClient, error) {
|
func (a *App) getRedisClient(config connection.ConnectionConfig) (redis.RedisClient, error) {
|
||||||
key := getRedisClientCacheKey(config)
|
effectiveConfig := applyGlobalProxyToConnection(config)
|
||||||
|
connectConfig, proxyErr := resolveDialConfigWithProxy(effectiveConfig)
|
||||||
|
if proxyErr != nil {
|
||||||
|
wrapped := wrapConnectError(effectiveConfig, proxyErr)
|
||||||
|
logger.Error(wrapped, "Redis 代理准备失败:%s", formatRedisConnSummary(effectiveConfig))
|
||||||
|
return nil, wrapped
|
||||||
|
}
|
||||||
|
|
||||||
|
key := getRedisClientCacheKey(connectConfig)
|
||||||
shortKey := key
|
shortKey := key
|
||||||
if len(shortKey) > 12 {
|
if len(shortKey) > 12 {
|
||||||
shortKey = shortKey[:12]
|
shortKey = shortKey[:12]
|
||||||
}
|
}
|
||||||
logger.Infof("获取 Redis 连接:%s 缓存Key=%s", formatRedisConnSummary(config), shortKey)
|
logger.Infof("获取 Redis 连接:%s 缓存Key=%s", formatRedisConnSummary(effectiveConfig), shortKey)
|
||||||
|
|
||||||
redisCacheMu.Lock()
|
redisCacheMu.Lock()
|
||||||
defer redisCacheMu.Unlock()
|
defer redisCacheMu.Unlock()
|
||||||
@@ -47,21 +55,20 @@ func (a *App) getRedisClient(config connection.ConnectionConfig) (redis.RedisCli
|
|||||||
|
|
||||||
logger.Infof("创建 Redis 客户端实例:缓存Key=%s", shortKey)
|
logger.Infof("创建 Redis 客户端实例:缓存Key=%s", shortKey)
|
||||||
client := redis.NewRedisClient()
|
client := redis.NewRedisClient()
|
||||||
if err := client.Connect(config); err != nil {
|
if err := client.Connect(connectConfig); err != nil {
|
||||||
logger.Error(err, "Redis 连接失败:%s 缓存Key=%s", formatRedisConnSummary(config), shortKey)
|
wrapped := wrapConnectError(effectiveConfig, err)
|
||||||
return nil, err
|
logger.Error(wrapped, "Redis 连接失败:%s 缓存Key=%s", formatRedisConnSummary(effectiveConfig), shortKey)
|
||||||
|
return nil, wrapped
|
||||||
}
|
}
|
||||||
|
|
||||||
redisCache[key] = client
|
redisCache[key] = client
|
||||||
logger.Infof("Redis 连接成功并写入缓存:%s 缓存Key=%s", formatRedisConnSummary(config), shortKey)
|
logger.Infof("Redis 连接成功并写入缓存:%s 缓存Key=%s", formatRedisConnSummary(effectiveConfig), shortKey)
|
||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getRedisClientCacheKey(config connection.ConnectionConfig) string {
|
func getRedisClientCacheKey(config connection.ConnectionConfig) string {
|
||||||
if !config.UseSSH {
|
normalized := normalizeCacheKeyConfig(config)
|
||||||
config.SSH = connection.SSHConfig{}
|
b, _ := json.Marshal(normalized)
|
||||||
}
|
|
||||||
b, _ := json.Marshal(config)
|
|
||||||
sum := sha256.Sum256(b)
|
sum := sha256.Sum256(b)
|
||||||
return hex.EncodeToString(sum[:])
|
return hex.EncodeToString(sum[:])
|
||||||
}
|
}
|
||||||
@@ -91,6 +98,26 @@ func formatRedisConnSummary(config connection.ConnectionConfig) string {
|
|||||||
b.WriteString(" 用户=")
|
b.WriteString(" 用户=")
|
||||||
b.WriteString(config.SSH.User)
|
b.WriteString(config.SSH.User)
|
||||||
}
|
}
|
||||||
|
if config.UseProxy {
|
||||||
|
b.WriteString(" 代理=")
|
||||||
|
b.WriteString(strings.ToLower(strings.TrimSpace(config.Proxy.Type)))
|
||||||
|
b.WriteString("://")
|
||||||
|
b.WriteString(config.Proxy.Host)
|
||||||
|
b.WriteString(":")
|
||||||
|
b.WriteString(strconv.Itoa(config.Proxy.Port))
|
||||||
|
if strings.TrimSpace(config.Proxy.User) != "" {
|
||||||
|
b.WriteString(" 代理认证=已配置")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.UseHTTPTunnel {
|
||||||
|
b.WriteString(" HTTP隧道=")
|
||||||
|
b.WriteString(strings.TrimSpace(config.HTTPTunnel.Host))
|
||||||
|
b.WriteString(":")
|
||||||
|
b.WriteString(strconv.Itoa(config.HTTPTunnel.Port))
|
||||||
|
if strings.TrimSpace(config.HTTPTunnel.User) != "" {
|
||||||
|
b.WriteString(" HTTP隧道认证=已配置")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,66 @@ import (
|
|||||||
"unicode"
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func leadingSQLKeyword(query string) string {
|
||||||
|
text := strings.TrimSpace(query)
|
||||||
|
for len(text) > 0 {
|
||||||
|
trimmed := strings.TrimLeft(text, " \t\r\n")
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
text = trimmed
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(text, "--"):
|
||||||
|
if idx := strings.IndexByte(text, '\n'); idx >= 0 {
|
||||||
|
text = text[idx+1:]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
case strings.HasPrefix(text, "#"):
|
||||||
|
if idx := strings.IndexByte(text, '\n'); idx >= 0 {
|
||||||
|
text = text[idx+1:]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
case strings.HasPrefix(text, "/*"):
|
||||||
|
if idx := strings.Index(text, "*/"); idx >= 0 {
|
||||||
|
text = text[idx+2:]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if text == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for i, r := range text {
|
||||||
|
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.ToLower(text[:i])
|
||||||
|
}
|
||||||
|
return strings.ToLower(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isReadOnlySQLQuery(dbType string, query string) bool {
|
||||||
|
if strings.ToLower(strings.TrimSpace(dbType)) == "mongodb" && strings.HasPrefix(strings.TrimSpace(query), "{") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
switch leadingSQLKeyword(query) {
|
||||||
|
case "select", "with", "show", "describe", "desc", "explain", "pragma", "values":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func sanitizeSQLForPgLike(dbType string, query string) string {
|
func sanitizeSQLForPgLike(dbType string, query string) string {
|
||||||
switch strings.ToLower(strings.TrimSpace(dbType)) {
|
switch strings.ToLower(strings.TrimSpace(dbType)) {
|
||||||
case "postgres", "kingbase", "highgo", "vastbase":
|
case "postgres", "kingbase", "highgo", "vastbase":
|
||||||
|
|||||||
@@ -18,39 +18,49 @@ type ProxyConfig struct {
|
|||||||
Password string `json:"password,omitempty"`
|
Password string `json:"password,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HTTPTunnelConfig holds independent HTTP CONNECT tunnel details
|
||||||
|
type HTTPTunnelConfig struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
User string `json:"user,omitempty"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// ConnectionConfig holds database connection details including SSH
|
// ConnectionConfig holds database connection details including SSH
|
||||||
type ConnectionConfig struct {
|
type ConnectionConfig struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection
|
SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection
|
||||||
Database string `json:"database"`
|
Database string `json:"database"`
|
||||||
UseSSL bool `json:"useSSL,omitempty"` // MySQL-like SSL/TLS switch
|
UseSSL bool `json:"useSSL,omitempty"` // MySQL-like SSL/TLS switch
|
||||||
SSLMode string `json:"sslMode,omitempty"` // preferred | required | skip-verify | disable
|
SSLMode string `json:"sslMode,omitempty"` // preferred | required | skip-verify | disable
|
||||||
SSLCertPath string `json:"sslCertPath,omitempty"` // TLS client certificate path (e.g., Dameng)
|
SSLCertPath string `json:"sslCertPath,omitempty"` // TLS client certificate path (e.g., Dameng)
|
||||||
SSLKeyPath string `json:"sslKeyPath,omitempty"` // TLS client private key path (e.g., Dameng)
|
SSLKeyPath string `json:"sslKeyPath,omitempty"` // TLS client private key path (e.g., Dameng)
|
||||||
UseSSH bool `json:"useSSH"`
|
UseSSH bool `json:"useSSH"`
|
||||||
SSH SSHConfig `json:"ssh"`
|
SSH SSHConfig `json:"ssh"`
|
||||||
UseProxy bool `json:"useProxy,omitempty"`
|
UseProxy bool `json:"useProxy,omitempty"`
|
||||||
Proxy ProxyConfig `json:"proxy,omitempty"`
|
Proxy ProxyConfig `json:"proxy,omitempty"`
|
||||||
Driver string `json:"driver,omitempty"` // For custom connection
|
UseHTTPTunnel bool `json:"useHttpTunnel,omitempty"`
|
||||||
DSN string `json:"dsn,omitempty"` // For custom connection
|
HTTPTunnel HTTPTunnelConfig `json:"httpTunnel,omitempty"`
|
||||||
Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30)
|
Driver string `json:"driver,omitempty"` // For custom connection
|
||||||
RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15)
|
DSN string `json:"dsn,omitempty"` // For custom connection
|
||||||
URI string `json:"uri,omitempty"` // Connection URI for copy/paste
|
Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30)
|
||||||
Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port
|
RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15)
|
||||||
Topology string `json:"topology,omitempty"` // single | replica | cluster
|
URI string `json:"uri,omitempty"` // Connection URI for copy/paste
|
||||||
MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user
|
Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port
|
||||||
MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` // MySQL replica auth password
|
Topology string `json:"topology,omitempty"` // single | replica | cluster
|
||||||
ReplicaSet string `json:"replicaSet,omitempty"` // MongoDB replica set name
|
MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user
|
||||||
AuthSource string `json:"authSource,omitempty"` // MongoDB authSource
|
MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` // MySQL replica auth password
|
||||||
ReadPreference string `json:"readPreference,omitempty"` // MongoDB readPreference
|
ReplicaSet string `json:"replicaSet,omitempty"` // MongoDB replica set name
|
||||||
MongoSRV bool `json:"mongoSrv,omitempty"` // MongoDB use mongodb+srv URI scheme
|
AuthSource string `json:"authSource,omitempty"` // MongoDB authSource
|
||||||
MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"` // MongoDB authMechanism
|
ReadPreference string `json:"readPreference,omitempty"` // MongoDB readPreference
|
||||||
MongoReplicaUser string `json:"mongoReplicaUser,omitempty"` // MongoDB replica auth user
|
MongoSRV bool `json:"mongoSrv,omitempty"` // MongoDB use mongodb+srv URI scheme
|
||||||
MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password
|
MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"` // MongoDB authMechanism
|
||||||
|
MongoReplicaUser string `json:"mongoReplicaUser,omitempty"` // MongoDB replica auth user
|
||||||
|
MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueryResult is the standard response format for Wails methods
|
// QueryResult is the standard response format for Wails methods
|
||||||
|
|||||||
@@ -107,7 +107,9 @@ func (c *ClickHouseDB) buildClickHouseOptions(config connection.ConnectionConfig
|
|||||||
if readTimeout < minClickHouseReadTimeout {
|
if readTimeout < minClickHouseReadTimeout {
|
||||||
readTimeout = minClickHouseReadTimeout
|
readTimeout = minClickHouseReadTimeout
|
||||||
}
|
}
|
||||||
|
protocol := detectClickHouseProtocol(config)
|
||||||
opts := &clickhouse.Options{
|
opts := &clickhouse.Options{
|
||||||
|
Protocol: protocol,
|
||||||
Addr: []string{
|
Addr: []string{
|
||||||
net.JoinHostPort(config.Host, strconv.Itoa(config.Port)),
|
net.JoinHostPort(config.Host, strconv.Itoa(config.Port)),
|
||||||
},
|
},
|
||||||
@@ -125,6 +127,46 @@ func (c *ClickHouseDB) buildClickHouseOptions(config connection.ConnectionConfig
|
|||||||
return opts
|
return opts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func detectClickHouseProtocol(config connection.ConnectionConfig) clickhouse.Protocol {
|
||||||
|
uriText := strings.ToLower(strings.TrimSpace(config.URI))
|
||||||
|
if strings.HasPrefix(uriText, "http://") || strings.HasPrefix(uriText, "https://") {
|
||||||
|
return clickhouse.HTTP
|
||||||
|
}
|
||||||
|
if config.Port == 8123 || config.Port == 8443 {
|
||||||
|
return clickhouse.HTTP
|
||||||
|
}
|
||||||
|
return clickhouse.Native
|
||||||
|
}
|
||||||
|
|
||||||
|
func isClickHouseProtocolMismatch(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
text := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||||
|
if text == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(text, "unexpected packet [72]") ||
|
||||||
|
(strings.Contains(text, "unexpected packet") && strings.Contains(text, "handshake")) ||
|
||||||
|
strings.Contains(text, "http response to https client") ||
|
||||||
|
strings.Contains(text, "malformed http response")
|
||||||
|
}
|
||||||
|
|
||||||
|
func withClickHouseProtocol(config connection.ConnectionConfig, protocol clickhouse.Protocol) connection.ConnectionConfig {
|
||||||
|
next := config
|
||||||
|
switch protocol {
|
||||||
|
case clickhouse.HTTP:
|
||||||
|
if next.Port == 0 {
|
||||||
|
next.Port = 8123
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if next.Port == 0 {
|
||||||
|
next.Port = defaultClickHousePort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
func (c *ClickHouseDB) Connect(config connection.ConnectionConfig) error {
|
func (c *ClickHouseDB) Connect(config connection.ConnectionConfig) error {
|
||||||
if supported, reason := DriverRuntimeSupportStatus("clickhouse"); !supported {
|
if supported, reason := DriverRuntimeSupportStatus("clickhouse"); !supported {
|
||||||
if strings.TrimSpace(reason) == "" {
|
if strings.TrimSpace(reason) == "" {
|
||||||
@@ -176,23 +218,41 @@ func (c *ClickHouseDB) Connect(config connection.ConnectionConfig) error {
|
|||||||
|
|
||||||
var failures []string
|
var failures []string
|
||||||
for idx, attempt := range attempts {
|
for idx, attempt := range attempts {
|
||||||
c.conn = clickhouse.OpenDB(c.buildClickHouseOptions(attempt))
|
primaryProtocol := detectClickHouseProtocol(attempt)
|
||||||
if err := c.Ping(); err != nil {
|
protocols := []clickhouse.Protocol{primaryProtocol}
|
||||||
failures = append(failures, fmt.Sprintf("第%d次连接验证失败: %v", idx+1, err))
|
if primaryProtocol == clickhouse.Native {
|
||||||
if c.conn != nil {
|
protocols = append(protocols, clickhouse.HTTP)
|
||||||
_ = c.conn.Close()
|
} else {
|
||||||
c.conn = nil
|
protocols = append(protocols, clickhouse.Native)
|
||||||
|
}
|
||||||
|
|
||||||
|
for pIdx, protocol := range protocols {
|
||||||
|
protocolConfig := withClickHouseProtocol(attempt, protocol)
|
||||||
|
c.conn = clickhouse.OpenDB(c.buildClickHouseOptions(protocolConfig))
|
||||||
|
if err := c.Ping(); err != nil {
|
||||||
|
failures = append(failures, fmt.Sprintf("第%d次连接验证失败(protocol=%s): %v", idx+1, protocol.String(), err))
|
||||||
|
if c.conn != nil {
|
||||||
|
_ = c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
|
if pIdx == 0 && !isClickHouseProtocolMismatch(err) {
|
||||||
|
// 首次连接不是协议误配特征,避免无谓重试次协议。
|
||||||
|
break
|
||||||
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
continue
|
if idx > 0 {
|
||||||
|
logger.Warnf("ClickHouse SSL 优先连接失败,已回退至明文连接")
|
||||||
|
}
|
||||||
|
if pIdx > 0 {
|
||||||
|
logger.Warnf("ClickHouse 已自动切换连接协议为 %s(常见于 8123/8443 HTTP 端口)", protocol.String())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
if idx > 0 {
|
|
||||||
logger.Warnf("ClickHouse SSL 优先连接失败,已回退至明文连接")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = c.Close()
|
_ = c.Close()
|
||||||
return fmt.Errorf("连接建立后验证失败:%s", strings.Join(failures, ";"))
|
return fmt.Errorf("连接建立后验证失败(可检查 ClickHouse 端口与协议是否匹配:Native=9000/9440,HTTP=8123/8443):%s", strings.Join(failures, ";"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ClickHouseDB) Close() error {
|
func (c *ClickHouseDB) Close() error {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -204,24 +205,82 @@ func (d *DamengDB) Exec(query string) (int64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *DamengDB) GetDatabases() ([]string, error) {
|
func (d *DamengDB) GetDatabases() ([]string, error) {
|
||||||
// DM: List Users/Schemas
|
// 达梦将「用户/模式」作为数据库列表来源,不同权限下可见口径不同。
|
||||||
data, _, err := d.Query("SELECT username FROM dba_users")
|
// 这里采用多查询口径聚合,避免仅依赖单一视图导致“少库”。
|
||||||
if err != nil {
|
queries := []string{
|
||||||
// Fallback if dba_users not accessible
|
"SELECT USERNAME AS DATABASE_NAME FROM SYS.DBA_USERS ORDER BY USERNAME",
|
||||||
data, _, err = d.Query("SELECT username FROM all_users")
|
"SELECT USERNAME AS DATABASE_NAME FROM DBA_USERS ORDER BY USERNAME",
|
||||||
|
"SELECT USERNAME AS DATABASE_NAME FROM ALL_USERS ORDER BY USERNAME",
|
||||||
|
"SELECT USERNAME AS DATABASE_NAME FROM USER_USERS",
|
||||||
|
"SELECT DISTINCT OWNER AS DATABASE_NAME FROM ALL_TABLES ORDER BY OWNER",
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
dbs := make([]string, 0, 64)
|
||||||
|
var lastErr error
|
||||||
|
success := false
|
||||||
|
|
||||||
|
for _, q := range queries {
|
||||||
|
data, _, err := d.Query(q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
success = true
|
||||||
|
for _, row := range data {
|
||||||
|
name := getDamengRowString(row, "DATABASE_NAME", "USERNAME", "OWNER", "SCHEMA_NAME")
|
||||||
|
if name == "" {
|
||||||
|
// 回退到第一列,兼容驱动返回列名差异。
|
||||||
|
for _, v := range row {
|
||||||
|
text := strings.TrimSpace(fmt.Sprintf("%v", v))
|
||||||
|
if text == "" || strings.EqualFold(text, "<nil>") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name = text
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToUpper(name)
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
dbs = append(dbs, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var dbs []string
|
|
||||||
for _, row := range data {
|
if !success && lastErr != nil {
|
||||||
if val, ok := row["USERNAME"]; ok {
|
return nil, lastErr
|
||||||
dbs = append(dbs, fmt.Sprintf("%v", val))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sort.Slice(dbs, func(i, j int) bool {
|
||||||
|
return strings.ToUpper(dbs[i]) < strings.ToUpper(dbs[j])
|
||||||
|
})
|
||||||
return dbs, nil
|
return dbs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getDamengRowString(row map[string]interface{}, keys ...string) string {
|
||||||
|
if len(row) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, key := range keys {
|
||||||
|
for k, v := range row {
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(k), strings.TrimSpace(key)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text := strings.TrimSpace(fmt.Sprintf("%v", v))
|
||||||
|
if text == "" || strings.EqualFold(text, "<nil>") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (d *DamengDB) GetTables(dbName string) ([]string, error) {
|
func (d *DamengDB) GetTables(dbName string) ([]string, error) {
|
||||||
query := fmt.Sprintf("SELECT owner, table_name FROM all_tables WHERE owner = '%s' ORDER BY table_name", strings.ToUpper(dbName))
|
query := fmt.Sprintf("SELECT owner, table_name FROM all_tables WHERE owner = '%s' ORDER BY table_name", strings.ToUpper(dbName))
|
||||||
if dbName == "" {
|
if dbName == "" {
|
||||||
|
|||||||
74
internal/db/driver_agent_binary_check.go
Normal file
74
internal/db/driver_agent_binary_check.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"debug/pe"
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
peMachineI386 uint16 = 0x014c
|
||||||
|
peMachineAmd64 uint16 = 0x8664
|
||||||
|
peMachineArm64 uint16 = 0xaa64
|
||||||
|
)
|
||||||
|
|
||||||
|
func windowsMachineLabel(machine uint16) string {
|
||||||
|
switch machine {
|
||||||
|
case peMachineI386:
|
||||||
|
return "windows-386"
|
||||||
|
case peMachineAmd64:
|
||||||
|
return "windows-amd64"
|
||||||
|
case peMachineArm64:
|
||||||
|
return "windows-arm64"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("windows-unknown(0x%04x)", machine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectedWindowsMachineForGoArch(goarch string) (uint16, string, bool) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(goarch)) {
|
||||||
|
case "386":
|
||||||
|
return peMachineI386, "windows-386", true
|
||||||
|
case "amd64":
|
||||||
|
return peMachineAmd64, "windows-amd64", true
|
||||||
|
case "arm64":
|
||||||
|
return peMachineArm64, "windows-arm64", true
|
||||||
|
default:
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateWindowsExecutableMachine(pathText string) error {
|
||||||
|
file, err := pe.Open(pathText)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("无法识别为有效的 Windows 可执行文件:%w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
expectedMachine, expectedLabel, ok := expectedWindowsMachineForGoArch(runtime.GOARCH)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
actualMachine := file.FileHeader.Machine
|
||||||
|
if actualMachine != expectedMachine {
|
||||||
|
return fmt.Errorf("可执行文件架构不兼容(文件=%s,当前进程=%s)", windowsMachineLabel(actualMachine), expectedLabel)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOptionalDriverAgentExecutable 校验可选驱动代理二进制是否可在当前进程中执行。
|
||||||
|
// 当前主要用于 Windows 下的 PE 架构兼容性校验,避免升级后复用到错误架构的旧代理。
|
||||||
|
func ValidateOptionalDriverAgentExecutable(driverType string, executablePath string) error {
|
||||||
|
pathText := strings.TrimSpace(executablePath)
|
||||||
|
if pathText == "" {
|
||||||
|
return fmt.Errorf("%s 驱动代理路径为空", driverDisplayName(driverType))
|
||||||
|
}
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := validateWindowsExecutableMachine(pathText); err != nil {
|
||||||
|
return fmt.Errorf("%s 驱动代理不可用:%w", driverDisplayName(driverType), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -194,6 +194,9 @@ func optionalGoDriverRuntimeReady(driverType string) (bool, string) {
|
|||||||
if statErr != nil || info.IsDir() {
|
if statErr != nil || info.IsDir() {
|
||||||
return false, fmt.Sprintf("%s 驱动代理缺失,请在驱动管理中重新安装启用", driverDisplayName(normalized))
|
return false, fmt.Sprintf("%s 驱动代理缺失,请在驱动管理中重新安装启用", driverDisplayName(normalized))
|
||||||
}
|
}
|
||||||
|
if validateErr := ValidateOptionalDriverAgentExecutable(normalized, executablePath); validateErr != nil {
|
||||||
|
return false, fmt.Sprintf("%s;请在驱动管理中重新安装启用", validateErr.Error())
|
||||||
|
}
|
||||||
return true, ""
|
return true, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,11 +65,22 @@ func TestManagedDriverRequiresInstallMarker(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("解析 mariadb 代理路径失败: %v", err)
|
t.Fatalf("解析 mariadb 代理路径失败: %v", err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(executablePath, []byte("placeholder"), 0o755); err != nil {
|
|
||||||
t.Fatalf("写入 mariadb 代理占位文件失败: %v", err)
|
|
||||||
}
|
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
_ = os.Chmod(executablePath, 0o644)
|
selfPath, selfErr := os.Executable()
|
||||||
|
if selfErr != nil {
|
||||||
|
t.Fatalf("获取测试进程路径失败: %v", selfErr)
|
||||||
|
}
|
||||||
|
content, readErr := os.ReadFile(selfPath)
|
||||||
|
if readErr != nil {
|
||||||
|
t.Fatalf("读取测试进程失败: %v", readErr)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(executablePath, content, 0o755); err != nil {
|
||||||
|
t.Fatalf("写入 mariadb 代理占位可执行文件失败: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := os.WriteFile(executablePath, []byte("placeholder"), 0o755); err != nil {
|
||||||
|
t.Fatalf("写入 mariadb 代理占位文件失败: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
supported, reason := DriverRuntimeSupportStatus("mariadb")
|
supported, reason := DriverRuntimeSupportStatus("mariadb")
|
||||||
|
|||||||
@@ -305,10 +305,30 @@ func (k *KingbaseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDe
|
|||||||
return strings.ReplaceAll(s, "'", "''")
|
return strings.ReplaceAll(s, "'", "''")
|
||||||
}
|
}
|
||||||
|
|
||||||
query := fmt.Sprintf(`SELECT column_name, data_type, is_nullable, column_default
|
query := fmt.Sprintf(`
|
||||||
FROM information_schema.columns
|
SELECT
|
||||||
WHERE table_schema = '%s' AND table_name = '%s'
|
a.attname AS column_name,
|
||||||
ORDER BY ordinal_position`, esc(schema), esc(table))
|
pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type,
|
||||||
|
CASE WHEN a.attnotnull THEN 'NO' ELSE 'YES' END AS is_nullable,
|
||||||
|
pg_get_expr(ad.adbin, ad.adrelid) AS column_default,
|
||||||
|
col_description(a.attrelid, a.attnum) AS comment,
|
||||||
|
CASE WHEN pk.attname IS NOT NULL THEN 'PRI' ELSE '' END AS column_key
|
||||||
|
FROM pg_class c
|
||||||
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
JOIN pg_attribute a ON a.attrelid = c.oid
|
||||||
|
LEFT JOIN pg_attrdef ad ON ad.adrelid = c.oid AND ad.adnum = a.attnum
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT i.indrelid, a3.attname
|
||||||
|
FROM pg_index i
|
||||||
|
JOIN pg_attribute a3 ON a3.attrelid = i.indrelid AND a3.attnum = ANY(i.indkey)
|
||||||
|
WHERE i.indisprimary
|
||||||
|
) pk ON pk.indrelid = c.oid AND pk.attname = a.attname
|
||||||
|
WHERE c.relkind IN ('r', 'p')
|
||||||
|
AND n.nspname = '%s'
|
||||||
|
AND c.relname = '%s'
|
||||||
|
AND a.attnum > 0
|
||||||
|
AND NOT a.attisdropped
|
||||||
|
ORDER BY a.attnum`, esc(schema), esc(table))
|
||||||
|
|
||||||
data, _, err := k.Query(query)
|
data, _, err := k.Query(query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -321,11 +341,21 @@ func (k *KingbaseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDe
|
|||||||
Name: fmt.Sprintf("%v", row["column_name"]),
|
Name: fmt.Sprintf("%v", row["column_name"]),
|
||||||
Type: fmt.Sprintf("%v", row["data_type"]),
|
Type: fmt.Sprintf("%v", row["data_type"]),
|
||||||
Nullable: fmt.Sprintf("%v", row["is_nullable"]),
|
Nullable: fmt.Sprintf("%v", row["is_nullable"]),
|
||||||
|
Key: fmt.Sprintf("%v", row["column_key"]),
|
||||||
|
Extra: "",
|
||||||
|
Comment: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
if row["column_default"] != nil {
|
if row["column_default"] != nil {
|
||||||
def := fmt.Sprintf("%v", row["column_default"])
|
def := fmt.Sprintf("%v", row["column_default"])
|
||||||
col.Default = &def
|
col.Default = &def
|
||||||
|
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(def)), "nextval(") {
|
||||||
|
col.Extra = "auto_increment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := row["comment"]; ok && v != nil {
|
||||||
|
col.Comment = fmt.Sprintf("%v", v)
|
||||||
}
|
}
|
||||||
|
|
||||||
columns = append(columns, col)
|
columns = append(columns, col)
|
||||||
@@ -347,10 +377,30 @@ func (k *KingbaseDB) getColumnsWithCurrentSchema(tableName string) ([]connection
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 使用 current_schema() 获取当前schema
|
// 使用 current_schema() 获取当前schema
|
||||||
query := fmt.Sprintf(`SELECT column_name, data_type, is_nullable, column_default
|
query := fmt.Sprintf(`
|
||||||
FROM information_schema.columns
|
SELECT
|
||||||
WHERE table_schema = current_schema() AND table_name = '%s'
|
a.attname AS column_name,
|
||||||
ORDER BY ordinal_position`, esc(table))
|
pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type,
|
||||||
|
CASE WHEN a.attnotnull THEN 'NO' ELSE 'YES' END AS is_nullable,
|
||||||
|
pg_get_expr(ad.adbin, ad.adrelid) AS column_default,
|
||||||
|
col_description(a.attrelid, a.attnum) AS comment,
|
||||||
|
CASE WHEN pk.attname IS NOT NULL THEN 'PRI' ELSE '' END AS column_key
|
||||||
|
FROM pg_class c
|
||||||
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
JOIN pg_attribute a ON a.attrelid = c.oid
|
||||||
|
LEFT JOIN pg_attrdef ad ON ad.adrelid = c.oid AND ad.adnum = a.attnum
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT i.indrelid, a3.attname
|
||||||
|
FROM pg_index i
|
||||||
|
JOIN pg_attribute a3 ON a3.attrelid = i.indrelid AND a3.attnum = ANY(i.indkey)
|
||||||
|
WHERE i.indisprimary
|
||||||
|
) pk ON pk.indrelid = c.oid AND pk.attname = a.attname
|
||||||
|
WHERE c.relkind IN ('r', 'p')
|
||||||
|
AND n.nspname = current_schema()
|
||||||
|
AND c.relname = '%s'
|
||||||
|
AND a.attnum > 0
|
||||||
|
AND NOT a.attisdropped
|
||||||
|
ORDER BY a.attnum`, esc(table))
|
||||||
|
|
||||||
data, _, err := k.Query(query)
|
data, _, err := k.Query(query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -363,11 +413,21 @@ func (k *KingbaseDB) getColumnsWithCurrentSchema(tableName string) ([]connection
|
|||||||
Name: fmt.Sprintf("%v", row["column_name"]),
|
Name: fmt.Sprintf("%v", row["column_name"]),
|
||||||
Type: fmt.Sprintf("%v", row["data_type"]),
|
Type: fmt.Sprintf("%v", row["data_type"]),
|
||||||
Nullable: fmt.Sprintf("%v", row["is_nullable"]),
|
Nullable: fmt.Sprintf("%v", row["is_nullable"]),
|
||||||
|
Key: fmt.Sprintf("%v", row["column_key"]),
|
||||||
|
Extra: "",
|
||||||
|
Comment: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
if row["column_default"] != nil {
|
if row["column_default"] != nil {
|
||||||
def := fmt.Sprintf("%v", row["column_default"])
|
def := fmt.Sprintf("%v", row["column_default"])
|
||||||
col.Default = &def
|
col.Default = &def
|
||||||
|
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(def)), "nextval(") {
|
||||||
|
col.Extra = "auto_increment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := row["comment"]; ok && v != nil {
|
||||||
|
col.Comment = fmt.Sprintf("%v", v)
|
||||||
}
|
}
|
||||||
|
|
||||||
columns = append(columns, col)
|
columns = append(columns, col)
|
||||||
@@ -623,28 +683,16 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
|
|||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
quoteIdent := func(name string) string {
|
schema, table := splitKingbaseQualifiedTable(tableName)
|
||||||
n := strings.TrimSpace(name)
|
if table == "" {
|
||||||
n = strings.Trim(n, "\"")
|
return fmt.Errorf("table name required")
|
||||||
n = strings.ReplaceAll(n, "\"", "\"\"")
|
|
||||||
if n == "" {
|
|
||||||
return "\"\""
|
|
||||||
}
|
|
||||||
return `"` + n + `"`
|
|
||||||
}
|
|
||||||
|
|
||||||
schema := ""
|
|
||||||
table := strings.TrimSpace(tableName)
|
|
||||||
if parts := strings.SplitN(table, ".", 2); len(parts) == 2 {
|
|
||||||
schema = strings.TrimSpace(parts[0])
|
|
||||||
table = strings.TrimSpace(parts[1])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
qualifiedTable := ""
|
qualifiedTable := ""
|
||||||
if schema != "" {
|
if schema != "" {
|
||||||
qualifiedTable = fmt.Sprintf("%s.%s", quoteIdent(schema), quoteIdent(table))
|
qualifiedTable = fmt.Sprintf("%s.%s", quoteKingbaseIdent(schema), quoteKingbaseIdent(table))
|
||||||
} else {
|
} else {
|
||||||
qualifiedTable = quoteIdent(table)
|
qualifiedTable = quoteKingbaseIdent(table)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Deletes
|
// 1. Deletes
|
||||||
@@ -654,7 +702,7 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
|
|||||||
idx := 0
|
idx := 0
|
||||||
for k, v := range pk {
|
for k, v := range pk {
|
||||||
idx++
|
idx++
|
||||||
wheres = append(wheres, fmt.Sprintf("%s = $%d", quoteIdent(k), idx))
|
wheres = append(wheres, fmt.Sprintf("%s = $%d", quoteKingbaseIdent(k), idx))
|
||||||
args = append(args, v)
|
args = append(args, v)
|
||||||
}
|
}
|
||||||
if len(wheres) == 0 {
|
if len(wheres) == 0 {
|
||||||
@@ -662,7 +710,7 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
|
|||||||
}
|
}
|
||||||
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
|
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
|
||||||
if _, err := tx.Exec(query, args...); err != nil {
|
if _, err := tx.Exec(query, args...); err != nil {
|
||||||
return fmt.Errorf("delete error: %v", err)
|
return fmt.Errorf("delete error: %v; sql=%s", err, query)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -674,7 +722,7 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
|
|||||||
|
|
||||||
for k, v := range update.Values {
|
for k, v := range update.Values {
|
||||||
idx++
|
idx++
|
||||||
sets = append(sets, fmt.Sprintf("%s = $%d", quoteIdent(k), idx))
|
sets = append(sets, fmt.Sprintf("%s = $%d", quoteKingbaseIdent(k), idx))
|
||||||
args = append(args, v)
|
args = append(args, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,7 +733,7 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
|
|||||||
var wheres []string
|
var wheres []string
|
||||||
for k, v := range update.Keys {
|
for k, v := range update.Keys {
|
||||||
idx++
|
idx++
|
||||||
wheres = append(wheres, fmt.Sprintf("%s = $%d", quoteIdent(k), idx))
|
wheres = append(wheres, fmt.Sprintf("%s = $%d", quoteKingbaseIdent(k), idx))
|
||||||
args = append(args, v)
|
args = append(args, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,7 +743,7 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
|
|||||||
|
|
||||||
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
|
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
|
||||||
if _, err := tx.Exec(query, args...); err != nil {
|
if _, err := tx.Exec(query, args...); err != nil {
|
||||||
return fmt.Errorf("update error: %v", err)
|
return fmt.Errorf("update error: %v; sql=%s", err, query)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,7 +756,7 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
|
|||||||
|
|
||||||
for k, v := range row {
|
for k, v := range row {
|
||||||
idx++
|
idx++
|
||||||
cols = append(cols, quoteIdent(k))
|
cols = append(cols, quoteKingbaseIdent(k))
|
||||||
placeholders = append(placeholders, fmt.Sprintf("$%d", idx))
|
placeholders = append(placeholders, fmt.Sprintf("$%d", idx))
|
||||||
args = append(args, v)
|
args = append(args, v)
|
||||||
}
|
}
|
||||||
@@ -719,13 +767,74 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
|
|||||||
|
|
||||||
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
||||||
if _, err := tx.Exec(query, args...); err != nil {
|
if _, err := tx.Exec(query, args...); err != nil {
|
||||||
return fmt.Errorf("insert error: %v", err)
|
return fmt.Errorf("insert error: %v; sql=%s", err, query)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeKingbaseIdentifier(raw string) string {
|
||||||
|
value := strings.TrimSpace(raw)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容 JSON/字符串转义后传入的标识符:\"schema\" -> "schema"
|
||||||
|
value = strings.ReplaceAll(value, `\"`, `"`)
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
|
||||||
|
// 兼容异常多重包裹引号(例如 ""schema""、""""schema"""")。
|
||||||
|
// strings.Trim 会移除两端连续引号,迭代后可收敛到纯标识符。
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
next := strings.TrimSpace(strings.Trim(value, `"`))
|
||||||
|
if next == value {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容其他方言可能残留的引用形式
|
||||||
|
if len(value) >= 2 && strings.HasPrefix(value, "`") && strings.HasSuffix(value, "`") {
|
||||||
|
value = strings.TrimSpace(strings.Trim(value, "`"))
|
||||||
|
}
|
||||||
|
if len(value) >= 2 && strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") {
|
||||||
|
value = strings.TrimSpace(value[1 : len(value)-1])
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func quoteKingbaseIdent(name string) string {
|
||||||
|
n := normalizeKingbaseIdentifier(name)
|
||||||
|
n = strings.ReplaceAll(n, `"`, `""`)
|
||||||
|
if n == "" {
|
||||||
|
return "\"\""
|
||||||
|
}
|
||||||
|
return `"` + n + `"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitKingbaseQualifiedTable(tableName string) (schema string, table string) {
|
||||||
|
raw := strings.TrimSpace(tableName)
|
||||||
|
if raw == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if parts := strings.SplitN(raw, ".", 2); len(parts) == 2 {
|
||||||
|
schema = normalizeKingbaseIdentifier(parts[0])
|
||||||
|
table = normalizeKingbaseIdentifier(parts[1])
|
||||||
|
if table == "" {
|
||||||
|
return "", normalizeKingbaseIdentifier(raw)
|
||||||
|
}
|
||||||
|
if schema == "" {
|
||||||
|
return "", table
|
||||||
|
}
|
||||||
|
return schema, table
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", normalizeKingbaseIdentifier(raw)
|
||||||
|
}
|
||||||
|
|
||||||
func (k *KingbaseDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
func (k *KingbaseDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||||
// dbName 在本项目语义里是“数据库”,schema 由 table_schema 决定;这里返回全部用户 schema 的列用于查询提示。
|
// dbName 在本项目语义里是“数据库”,schema 由 table_schema 决定;这里返回全部用户 schema 的列用于查询提示。
|
||||||
query := `
|
query := `
|
||||||
|
|||||||
74
internal/db/kingbase_impl_test.go
Normal file
74
internal/db/kingbase_impl_test.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
//go:build gonavi_full_drivers || gonavi_kingbase_driver
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNormalizeKingbaseIdentifier(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "plain", in: "ldf_server", want: "ldf_server"},
|
||||||
|
{name: "quoted", in: `"ldf_server"`, want: "ldf_server"},
|
||||||
|
{name: "double quoted", in: `""ldf_server""`, want: "ldf_server"},
|
||||||
|
{name: "quad quoted", in: `""""ldf_server""""`, want: "ldf_server"},
|
||||||
|
{name: "escaped quoted", in: `\"ldf_server\"`, want: "ldf_server"},
|
||||||
|
{name: "backtick quoted", in: "`ldf_server`", want: "ldf_server"},
|
||||||
|
{name: "bracket quoted", in: "[ldf_server]", want: "ldf_server"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := normalizeKingbaseIdentifier(tt.in); got != tt.want {
|
||||||
|
t.Fatalf("normalizeKingbaseIdentifier(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuoteKingbaseIdent(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "plain", in: "ldf_server", want: `"ldf_server"`},
|
||||||
|
{name: "double quoted", in: `""ldf_server""`, want: `"ldf_server"`},
|
||||||
|
{name: "escaped quoted", in: `\"ldf_server\"`, want: `"ldf_server"`},
|
||||||
|
{name: "with embedded quote", in: `ab"cd`, want: `"ab""cd"`},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := quoteKingbaseIdent(tt.in); got != tt.want {
|
||||||
|
t.Fatalf("quoteKingbaseIdent(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitKingbaseQualifiedTable(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
wantSchema string
|
||||||
|
wantTable string
|
||||||
|
}{
|
||||||
|
{name: "plain qualified", in: "ldf_server.t_user", wantSchema: "ldf_server", wantTable: "t_user"},
|
||||||
|
{name: "double quoted qualified", in: `""ldf_server"".""t_user""`, wantSchema: "ldf_server", wantTable: "t_user"},
|
||||||
|
{name: "escaped qualified", in: `\"ldf_server\".\"t_user\"`, wantSchema: "ldf_server", wantTable: "t_user"},
|
||||||
|
{name: "bracket qualified", in: "[ldf_server].[t_user]", wantSchema: "ldf_server", wantTable: "t_user"},
|
||||||
|
{name: "table only", in: `""t_user""`, wantSchema: "", wantTable: "t_user"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gotSchema, gotTable := splitKingbaseQualifiedTable(tt.in)
|
||||||
|
if gotSchema != tt.wantSchema || gotTable != tt.wantTable {
|
||||||
|
t.Fatalf("splitKingbaseQualifiedTable(%q) = (%q, %q), want (%q, %q)", tt.in, gotSchema, gotTable, tt.wantSchema, tt.wantTable)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,10 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"GoNavi-Wails/internal/connection"
|
"GoNavi-Wails/internal/connection"
|
||||||
@@ -94,6 +96,9 @@ func newOptionalDriverAgentClient(driverType string, executablePath string) (*op
|
|||||||
return nil, fmt.Errorf("创建 %s 驱动代理 stderr 失败:%w", driverDisplayName(driverType), err)
|
return nil, fmt.Errorf("创建 %s 驱动代理 stderr 失败:%w", driverDisplayName(driverType), err)
|
||||||
}
|
}
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
|
if isWindowsExecutableMachineMismatch(err) {
|
||||||
|
return nil, fmt.Errorf("启动 %s 驱动代理失败:%w(检测到驱动代理与当前系统架构不兼容,请在驱动管理中重新安装启用)", driverDisplayName(driverType), err)
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("启动 %s 驱动代理失败:%w", driverDisplayName(driverType), err)
|
return nil, fmt.Errorf("启动 %s 驱动代理失败:%w", driverDisplayName(driverType), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +112,30 @@ func newOptionalDriverAgentClient(driverType string, executablePath string) (*op
|
|||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isWindowsExecutableMachineMismatch(err error) bool {
|
||||||
|
if err == nil || runtime.GOOS != "windows" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var errno syscall.Errno
|
||||||
|
if errors.As(err, &errno) && errno == syscall.Errno(216) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
text := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||||
|
if text == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "not compatible with the version of windows") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "win32") && strings.Contains(text, "compatible") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "不是有效的win32应用程序") || strings.Contains(text, "无法在win32模式下运行") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (c *optionalDriverAgentClient) captureStderr(stderr io.Reader) {
|
func (c *optionalDriverAgentClient) captureStderr(stderr io.Reader) {
|
||||||
scanner := bufio.NewScanner(stderr)
|
scanner := bufio.NewScanner(stderr)
|
||||||
buffer := make([]byte, 0, 8<<10)
|
buffer := make([]byte, 0, 8<<10)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
@@ -30,12 +31,44 @@ func normalizeQueryValue(v interface{}) interface{} {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func normalizeQueryValueWithDBType(v interface{}, databaseTypeName string) interface{} {
|
func normalizeQueryValueWithDBType(v interface{}, databaseTypeName string) interface{} {
|
||||||
|
if tm, ok := v.(time.Time); ok {
|
||||||
|
return normalizeTemporalValueForDisplay(tm, databaseTypeName)
|
||||||
|
}
|
||||||
if b, ok := v.([]byte); ok {
|
if b, ok := v.([]byte); ok {
|
||||||
return bytesToDisplayValue(b, databaseTypeName)
|
return bytesToDisplayValue(b, databaseTypeName)
|
||||||
}
|
}
|
||||||
return normalizeCompositeQueryValue(v)
|
return normalizeCompositeQueryValue(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeTemporalValueForDisplay(value time.Time, databaseTypeName string) interface{} {
|
||||||
|
if value.IsZero() {
|
||||||
|
if zeroValue, ok := zeroTemporalDisplayValue(databaseTypeName); ok {
|
||||||
|
return zeroValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value.Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
|
||||||
|
func zeroTemporalDisplayValue(databaseTypeName string) (string, bool) {
|
||||||
|
typeName := strings.ToUpper(strings.TrimSpace(databaseTypeName))
|
||||||
|
if typeName == "" {
|
||||||
|
return "0000-00-00 00:00:00", true
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.Contains(typeName, "TIMESTAMP") || strings.Contains(typeName, "DATETIME"):
|
||||||
|
return "0000-00-00 00:00:00", true
|
||||||
|
case typeName == "DATE" || typeName == "NEWDATE":
|
||||||
|
return "0000-00-00", true
|
||||||
|
case strings.Contains(typeName, "TIME"):
|
||||||
|
return "00:00:00", true
|
||||||
|
case strings.Contains(typeName, "YEAR"):
|
||||||
|
return "0000", true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeCompositeQueryValue(v interface{}) interface{} {
|
func normalizeCompositeQueryValue(v interface{}) interface{} {
|
||||||
if v == nil {
|
if v == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -86,6 +119,16 @@ func normalizeCompositeQueryValue(v interface{}) interface{} {
|
|||||||
items[i] = normalizeQueryValue(rv.Index(i).Interface())
|
items[i] = normalizeQueryValue(rv.Index(i).Interface())
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
|
case reflect.Struct:
|
||||||
|
// 部分驱动(如 Kingbase)会返回复杂结构体值,直接透传会导致前端渲染和比较开销激增。
|
||||||
|
// 统一降级为可读字符串,避免对象深层序列化触发 UI 卡顿。
|
||||||
|
if tm, ok := v.(time.Time); ok {
|
||||||
|
return normalizeTemporalValueForDisplay(tm, "")
|
||||||
|
}
|
||||||
|
if stringer, ok := v.(fmt.Stringer); ok {
|
||||||
|
return stringer.String()
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%v", v)
|
||||||
default:
|
default:
|
||||||
return normalizeUnsafeIntegerForJS(rv, v)
|
return normalizeUnsafeIntegerForJS(rv, v)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type duckMapLike map[any]any
|
type duckMapLike map[any]any
|
||||||
@@ -165,3 +167,61 @@ func TestNormalizeQueryValueWithDBType_JSONNumber(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type customStructValue struct {
|
||||||
|
Name string
|
||||||
|
Age int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v customStructValue) String() string {
|
||||||
|
return fmt.Sprintf("%s-%d", v.Name, v.Age)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeQueryValueWithDBType_StructToString(t *testing.T) {
|
||||||
|
got := normalizeQueryValueWithDBType(customStructValue{Name: "alice", Age: 18}, "")
|
||||||
|
if got != "alice-18" {
|
||||||
|
t.Fatalf("结构体应降级为可读字符串,实际=%v(%T)", got, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeQueryValueWithDBType_TimeStructToRFC3339(t *testing.T) {
|
||||||
|
input := time.Date(2026, 3, 5, 18, 30, 15, 123456789, time.UTC)
|
||||||
|
got := normalizeQueryValueWithDBType(input, "")
|
||||||
|
text, ok := got.(string)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("time.Time 应转为字符串,实际=%v(%T)", got, got)
|
||||||
|
}
|
||||||
|
if text != "2026-03-05T18:30:15.123456789Z" {
|
||||||
|
t.Fatalf("time.Time 规整值异常,实际=%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeQueryValueWithDBType_ZeroTemporalValues(t *testing.T) {
|
||||||
|
zero := time.Time{}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
dbType string
|
||||||
|
wantText string
|
||||||
|
}{
|
||||||
|
{name: "date", dbType: "DATE", wantText: "0000-00-00"},
|
||||||
|
{name: "newdate", dbType: "NEWDATE", wantText: "0000-00-00"},
|
||||||
|
{name: "datetime", dbType: "DATETIME", wantText: "0000-00-00 00:00:00"},
|
||||||
|
{name: "timestamp", dbType: "TIMESTAMP", wantText: "0000-00-00 00:00:00"},
|
||||||
|
{name: "time", dbType: "TIME", wantText: "00:00:00"},
|
||||||
|
{name: "year", dbType: "YEAR", wantText: "0000"},
|
||||||
|
{name: "unknown", dbType: "", wantText: "0000-00-00 00:00:00"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := normalizeQueryValueWithDBType(zero, tc.dbType)
|
||||||
|
text, ok := got.(string)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("期望 string,实际=%v(%T)", got, got)
|
||||||
|
}
|
||||||
|
if text != tc.wantText {
|
||||||
|
t.Fatalf("dbType=%s 期望=%s,实际=%s", tc.dbType, tc.wantText, text)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user