mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-13 05:40:00 +08:00
Compare commits
51 Commits
v0.5.2
...
release/0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c49a4c3458 | ||
|
|
ca49b37dc7 | ||
|
|
c8c0c5f20a | ||
|
|
d61d7ec39b | ||
|
|
e964c8ecf8 | ||
|
|
7644462180 | ||
|
|
3bd02e2e09 | ||
|
|
22bd1c4c28 | ||
|
|
0daf702d25 | ||
|
|
058c74e49a | ||
|
|
b85c7529ec | ||
|
|
e521d2125f | ||
|
|
450fdfa59e | ||
|
|
c87b15b22a | ||
|
|
89c81823bc | ||
|
|
797ba27d20 | ||
|
|
ed1f40e04a | ||
|
|
2b190e564f | ||
|
|
1c050aefd0 | ||
|
|
75a5a322e0 | ||
|
|
61d6197fe3 | ||
|
|
6157161293 | ||
|
|
0f843a7dcf | ||
|
|
fb65b553e9 | ||
|
|
1a5bf79dd3 | ||
|
|
dea096d4c2 | ||
|
|
04f8b266d3 | ||
|
|
b53227cb15 | ||
|
|
0246d7fae5 | ||
|
|
4aa177ed37 | ||
|
|
4f5a7bd94b | ||
|
|
6a4b397ecc | ||
|
|
3973038aea | ||
|
|
786835c9bc | ||
|
|
462ca57907 | ||
|
|
6918b56ed9 | ||
|
|
1afb8850ad | ||
|
|
3284eeba17 | ||
|
|
6156884455 | ||
|
|
f477feab2f | ||
|
|
e76e174bfe | ||
|
|
b904c0b107 | ||
|
|
c02e7c12e8 | ||
|
|
a87c801e66 | ||
|
|
7f00139847 | ||
|
|
78c5351399 | ||
|
|
e3b142053f | ||
|
|
3ca898a950 | ||
|
|
84688e995a | ||
|
|
4d0940636d | ||
|
|
26b79adc5f |
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/*
|
||||
draft: true
|
||||
make_latest: true
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
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
|
||||
91
.github/workflows/test-macos-build.yml
vendored
Normal file
91
.github/workflows/test-macos-build.yml
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
name: Test Build macOS (Manual)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_label:
|
||||
description: "测试包标识(仅用于文件名)"
|
||||
required: false
|
||||
default: "test"
|
||||
push:
|
||||
branches:
|
||||
- feature/kingbase_opt
|
||||
paths:
|
||||
- ".github/workflows/test-macos-build.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-macos:
|
||||
name: Build macOS ${{ matrix.arch }}
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: darwin/amd64
|
||||
arch: amd64
|
||||
- platform: darwin/arm64
|
||||
arch: arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24.3"
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install Wails
|
||||
run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
|
||||
|
||||
- name: Build App
|
||||
run: |
|
||||
set -euo pipefail
|
||||
OUTPUT_NAME="gonavi-test-${{ matrix.arch }}"
|
||||
BUILD_LABEL="${{ inputs.build_label }}"
|
||||
if [ -z "$BUILD_LABEL" ]; then
|
||||
BUILD_LABEL="test"
|
||||
fi
|
||||
APP_VERSION="${BUILD_LABEL}-${GITHUB_RUN_NUMBER}"
|
||||
wails build \
|
||||
-platform "${{ matrix.platform }}" \
|
||||
-clean \
|
||||
-o "$OUTPUT_NAME" \
|
||||
-ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}"
|
||||
|
||||
- name: Package Zip
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APP_PATH="build/bin/gonavi-test-${{ matrix.arch }}.app"
|
||||
if [ ! -d "$APP_PATH" ]; then
|
||||
APP_PATH=$(find build/bin -maxdepth 1 -name "*.app" | head -n 1 || true)
|
||||
fi
|
||||
if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then
|
||||
echo "未找到 .app 产物"
|
||||
ls -la build/bin || true
|
||||
exit 1
|
||||
fi
|
||||
LABEL="${{ inputs.build_label }}"
|
||||
if [ -z "$LABEL" ]; then
|
||||
LABEL="test"
|
||||
fi
|
||||
ZIP_NAME="GoNavi-${LABEL}-macos-${{ matrix.arch }}-run${GITHUB_RUN_NUMBER}.zip"
|
||||
mkdir -p artifacts
|
||||
ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "artifacts/$ZIP_NAME"
|
||||
shasum -a 256 "artifacts/$ZIP_NAME" > "artifacts/$ZIP_NAME.sha256"
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gonavi-macos-${{ matrix.arch }}-run${{ github.run_number }}
|
||||
path: artifacts/*
|
||||
if-no-files-found: error
|
||||
@@ -79,7 +79,8 @@ Because external pull requests are merged directly into `main`, maintainers must
|
||||
|
||||
### 1. Sync `main` -> `dev` (required)
|
||||
|
||||
Every change merged into `main` must be synced into `dev`:
|
||||
The automatic GitHub Actions sync workflow has been removed.
|
||||
Maintainers should sync `main` back to `dev` manually when needed:
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
@@ -114,7 +115,7 @@ git push origin v0.6.0
|
||||
|
||||
### 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
|
||||
git checkout dev
|
||||
|
||||
@@ -79,7 +79,8 @@ feature/* / fix/* -> dev -> release/* -> main -> tag(vX.Y.Z)
|
||||
|
||||
### 1. main → dev 同步(必做)
|
||||
|
||||
任何合入 `main` 的变更,都必须同步到 `dev`:
|
||||
仓库已移除 GitHub Actions 自动回灌 workflow。
|
||||
当前统一采用手动方式将 `main` 同步回 `dev`:
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
@@ -114,7 +115,7 @@ git push origin v0.6.0
|
||||
|
||||
### 4. main 回流到 dev(发版后必做)
|
||||
|
||||
发布完成后,再次将 `main` 回流到 `dev`,确保开发线与发布线一致:
|
||||
发布完成后,仍沿用同一套自动化流程;如有需要,也可以手动触发 `workflow_dispatch`,或执行以下兜底命令,确保开发线与发布线一致:
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
|
||||
@@ -154,6 +154,7 @@ Artifacts are generated in `build/bin`.
|
||||
|
||||
The repository includes a release workflow.
|
||||
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:
|
||||
- macOS (AMD64 / ARM64)
|
||||
|
||||
@@ -147,6 +147,7 @@ wails build -clean
|
||||
### 跨平台发布(GitHub Actions)
|
||||
|
||||
仓库内置发布流水线,推送 `v*` Tag 可自动构建并发布 Release。
|
||||
Release 更新说明会基于已合并 Pull Request 自动生成,并按 `.github/release.yaml` 分类。
|
||||
|
||||
支持目标:
|
||||
- macOS (AMD64 / ARM64)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined } from '@ant-design/icons';
|
||||
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined } from '@ant-design/icons';
|
||||
import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowMaximise, WindowMinimise, WindowSetSize, WindowToggleMaximise } from '../wailsjs/runtime';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import TabManager from './components/TabManager';
|
||||
@@ -11,7 +11,7 @@ import DriverManagerModal from './components/DriverManagerModal';
|
||||
import LogPanel from './components/LogPanel';
|
||||
import { useStore } from './store';
|
||||
import { SavedConnection } from './types';
|
||||
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform } from './utils/appearance';
|
||||
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance';
|
||||
import {
|
||||
SHORTCUT_ACTION_META,
|
||||
SHORTCUT_ACTION_ORDER,
|
||||
@@ -78,11 +78,11 @@ function App() {
|
||||
const tokenControlHeightLG = Math.max(30, Math.round(40 * effectiveUiScale));
|
||||
const appComponentSize: 'small' | 'middle' | 'large' = effectiveUiScale <= 0.92 ? 'small' : (effectiveUiScale >= 1.12 ? 'large' : 'middle');
|
||||
const titleBarHeight = Math.max(28, Math.round(32 * effectiveUiScale));
|
||||
const toolbarHeight = Math.max(32, Math.round(36 * effectiveUiScale));
|
||||
const titleBarButtonWidth = Math.max(40, Math.round(46 * effectiveUiScale));
|
||||
const floatingLogButtonHeight = Math.max(30, Math.round(34 * effectiveUiScale));
|
||||
const effectiveOpacity = normalizeOpacityForPlatform(appearance.opacity);
|
||||
const effectiveBlur = normalizeBlurForPlatform(appearance.blur);
|
||||
const resolvedAppearance = resolveAppearanceValues(appearance);
|
||||
const effectiveOpacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
|
||||
const effectiveBlur = normalizeBlurForPlatform(resolvedAppearance.blur);
|
||||
const blurFilter = blurToFilter(effectiveBlur);
|
||||
const windowCornerRadius = 14;
|
||||
const [runtimePlatform, setRuntimePlatform] = useState('');
|
||||
@@ -93,8 +93,8 @@ function App() {
|
||||
// 同步 macOS 窗口透明度:opacity=1.0 且 blur=0 时关闭 NSVisualEffectView,
|
||||
// 避免 GPU 持续计算窗口背后的模糊合成
|
||||
useEffect(() => {
|
||||
void SetWindowTranslucency(appearance.opacity, appearance.blur).catch(() => undefined);
|
||||
}, [appearance.opacity, appearance.blur]);
|
||||
void SetWindowTranslucency(resolvedAppearance.opacity, resolvedAppearance.blur).catch(() => undefined);
|
||||
}, [resolvedAppearance.blur, resolvedAppearance.opacity]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -283,6 +283,7 @@ function App() {
|
||||
let inFlight = false;
|
||||
let lastRatio = Number(window.devicePixelRatio) || 1;
|
||||
let lastFixAt = 0;
|
||||
let activationTimer: number | null = null;
|
||||
|
||||
const wait = (ms: number) => new Promise<void>((resolve) => window.setTimeout(resolve, ms));
|
||||
|
||||
@@ -334,17 +335,55 @@ function App() {
|
||||
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);
|
||||
window.addEventListener('resize', checkDevicePixelRatio);
|
||||
window.addEventListener('focus', checkDevicePixelRatio);
|
||||
document.addEventListener('visibilitychange', checkDevicePixelRatio);
|
||||
window.addEventListener('focus', handleWindowFocus);
|
||||
window.addEventListener('pageshow', handlePageShow);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (activationTimer !== null) {
|
||||
window.clearTimeout(activationTimer);
|
||||
}
|
||||
window.clearInterval(pollTimer);
|
||||
window.removeEventListener('resize', checkDevicePixelRatio);
|
||||
window.removeEventListener('focus', checkDevicePixelRatio);
|
||||
document.removeEventListener('visibilitychange', checkDevicePixelRatio);
|
||||
window.removeEventListener('focus', handleWindowFocus);
|
||||
window.removeEventListener('pageshow', handlePageShow);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -370,6 +409,141 @@ function App() {
|
||||
const floatingLogButtonShadow = darkMode
|
||||
? '0 8px 22px rgba(0,0,0,0.38)'
|
||||
: '0 8px 20px rgba(0,0,0,0.16)';
|
||||
|
||||
const isOpaqueUtilityMode = resolvedAppearance.opacity >= 0.999 && resolvedAppearance.blur <= 0;
|
||||
const utilityButtonBgAlpha = darkMode
|
||||
? Math.max(0.28, Math.min(0.76, effectiveOpacity * 0.72))
|
||||
: Math.max(0.52, Math.min(0.92, effectiveOpacity * 0.9));
|
||||
const utilityButtonBgColor = isOpaqueUtilityMode
|
||||
? 'transparent'
|
||||
: (darkMode
|
||||
? `rgba(20, 26, 38, ${utilityButtonBgAlpha})`
|
||||
: `rgba(255, 255, 255, ${utilityButtonBgAlpha})`);
|
||||
const utilityButtonBorderColor = isOpaqueUtilityMode
|
||||
? (darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(16,24,40,0.10)')
|
||||
: (darkMode
|
||||
? `rgba(255,255,255,${Math.max(0.08, Math.min(0.18, effectiveOpacity * 0.16))})`
|
||||
: `rgba(16,24,40,${Math.max(0.06, Math.min(0.14, effectiveOpacity * 0.12))})`);
|
||||
const utilityButtonShadow = isOpaqueUtilityMode
|
||||
? 'none'
|
||||
: (darkMode
|
||||
? `0 8px 18px rgba(0,0,0,${Math.max(0.10, Math.min(0.22, effectiveOpacity * 0.24))})`
|
||||
: `0 8px 18px rgba(15,23,42,${Math.max(0.04, Math.min(0.12, effectiveOpacity * 0.12))})`);
|
||||
const utilityButtonStyle = useMemo(() => ({
|
||||
height: Math.max(30, Math.round(32 * effectiveUiScale)),
|
||||
width: '100%',
|
||||
paddingInline: Math.max(10, Math.round(12 * effectiveUiScale)),
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${utilityButtonBorderColor}`,
|
||||
background: utilityButtonBgColor,
|
||||
color: darkMode ? 'rgba(255,255,255,0.94)' : '#162033',
|
||||
boxShadow: utilityButtonShadow,
|
||||
backdropFilter: isOpaqueUtilityMode ? 'none' : blurFilter,
|
||||
WebkitBackdropFilter: isOpaqueUtilityMode ? 'none' : blurFilter,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
}), [blurFilter, darkMode, effectiveUiScale, isOpaqueUtilityMode, utilityButtonBgColor, utilityButtonBorderColor, utilityButtonShadow]);
|
||||
const utilityDropdownShellStyle = useMemo(() => ({
|
||||
borderRadius: 14,
|
||||
padding: 6,
|
||||
background: darkMode ? 'linear-gradient(180deg, rgba(20,26,38,0.96) 0%, rgba(13,17,26,0.98) 100%)' : 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
|
||||
boxShadow: darkMode ? '0 20px 48px rgba(0,0,0,0.32)' : '0 16px 36px rgba(15,23,42,0.12)',
|
||||
backdropFilter: darkMode ? 'blur(16px)' : 'none',
|
||||
overflow: 'hidden',
|
||||
}), [darkMode]);
|
||||
|
||||
const sidebarQuickActionBaseStyle = useMemo(() => ({
|
||||
height: Math.max(34, Math.round(36 * effectiveUiScale)),
|
||||
borderRadius: 12,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
paddingInline: Math.max(12, Math.round(14 * effectiveUiScale)),
|
||||
fontWeight: 700,
|
||||
boxShadow: darkMode ? '0 8px 18px rgba(0,0,0,0.16)' : '0 8px 16px rgba(15,23,42,0.08)',
|
||||
backdropFilter: blurFilter,
|
||||
WebkitBackdropFilter: blurFilter,
|
||||
minWidth: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
}), [blurFilter, darkMode, effectiveUiScale]);
|
||||
const sidebarQueryActionStyle = useMemo(() => ({
|
||||
...sidebarQuickActionBaseStyle,
|
||||
flex: '1 1 0',
|
||||
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(16,24,40,0.10)'}`,
|
||||
background: darkMode ? `rgba(255,255,255,0.05)` : 'rgba(255,255,255,0.88)',
|
||||
color: darkMode ? 'rgba(255,255,255,0.92)' : '#162033',
|
||||
}), [darkMode, sidebarQuickActionBaseStyle]);
|
||||
const sidebarCreateConnectionActionStyle = useMemo(() => ({
|
||||
...sidebarQuickActionBaseStyle,
|
||||
flex: '1 1 0',
|
||||
border: 'none',
|
||||
background: 'linear-gradient(135deg, rgba(255,214,102,0.96) 0%, rgba(240,183,39,0.92) 100%)',
|
||||
color: '#2a1f00',
|
||||
}), [sidebarQuickActionBaseStyle]);
|
||||
|
||||
const utilityMenuTheme = useMemo(() => ({
|
||||
components: {
|
||||
Menu: {
|
||||
popupBg: 'transparent',
|
||||
darkPopupBg: 'transparent',
|
||||
itemBg: 'transparent',
|
||||
darkItemBg: 'transparent',
|
||||
subMenuItemBg: 'transparent',
|
||||
itemColor: darkMode ? 'rgba(255,255,255,0.88)' : '#162033',
|
||||
itemHoverColor: darkMode ? '#fff7d6' : '#0f172a',
|
||||
itemHoverBg: darkMode ? 'rgba(255,214,102,0.10)' : 'rgba(24,144,255,0.08)',
|
||||
itemSelectedColor: darkMode ? '#ffd666' : '#1677ff',
|
||||
itemSelectedBg: darkMode ? 'rgba(255,214,102,0.14)' : 'rgba(24,144,255,0.12)',
|
||||
itemBorderRadius: 10,
|
||||
itemMarginBlock: 4,
|
||||
itemMarginInline: 0,
|
||||
itemPaddingInline: 12,
|
||||
itemHeight: 40,
|
||||
groupTitleColor: darkMode ? 'rgba(255,255,255,0.48)' : 'rgba(16,24,40,0.48)',
|
||||
},
|
||||
},
|
||||
}), [darkMode]);
|
||||
const renderUtilityDropdown = (menu: React.ReactNode) => (
|
||||
<ConfigProvider theme={utilityMenuTheme}>
|
||||
<div style={{ ...utilityDropdownShellStyle, minWidth: 220 }}>
|
||||
{menu}
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
);
|
||||
const utilityModalShellStyle = useMemo(() => ({
|
||||
background: darkMode ? 'linear-gradient(180deg, rgba(20,26,38,0.96) 0%, rgba(13,17,26,0.98) 100%)' : 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
|
||||
boxShadow: darkMode ? '0 24px 56px rgba(0,0,0,0.32)' : '0 18px 42px rgba(15,23,42,0.12)',
|
||||
backdropFilter: darkMode ? 'blur(18px)' : 'none',
|
||||
}), [darkMode]);
|
||||
const utilityPanelStyle = useMemo(() => ({
|
||||
padding: 16,
|
||||
borderRadius: 14,
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
|
||||
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.84)',
|
||||
}), [darkMode]);
|
||||
const utilityMutedTextStyle = useMemo(() => ({
|
||||
color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
}), [darkMode]);
|
||||
const renderUtilityModalTitle = (icon: React.ReactNode, title: string, description: string) => (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 12, display: 'grid', placeItems: 'center', background: darkMode ? 'rgba(255,214,102,0.12)' : 'rgba(24,144,255,0.1)', color: darkMode ? '#ffd666' : '#1677ff', flexShrink: 0 }}>
|
||||
{icon}
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: darkMode ? '#f5f7ff' : '#162033' }}>{title}</div>
|
||||
<div style={{ marginTop: 4, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', fontSize: 12, lineHeight: 1.6 }}>{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const sidebarHorizontalPadding = 10;
|
||||
|
||||
const addTab = useStore(state => state.addTab);
|
||||
const activeContext = useStore(state => state.activeContext);
|
||||
@@ -786,37 +960,18 @@ function App() {
|
||||
label: '驱动管理',
|
||||
icon: <SettingOutlined />,
|
||||
onClick: () => setIsDriverModalOpen(true)
|
||||
}
|
||||
];
|
||||
|
||||
const themeMenu: MenuProps['items'] = [
|
||||
{
|
||||
key: 'light',
|
||||
label: '亮色主题',
|
||||
icon: themeMode === 'light' ? <CheckOutlined /> : undefined,
|
||||
onClick: () => setTheme('light')
|
||||
},
|
||||
{
|
||||
key: 'dark',
|
||||
label: '暗色主题',
|
||||
icon: themeMode === 'dark' ? <CheckOutlined /> : undefined,
|
||||
onClick: () => setTheme('dark')
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'settings',
|
||||
label: '外观设置...',
|
||||
icon: <SettingOutlined />,
|
||||
onClick: () => setIsAppearanceModalOpen(true)
|
||||
},
|
||||
{
|
||||
key: 'shortcut-settings',
|
||||
label: '快捷键管理...',
|
||||
label: '快捷键管理',
|
||||
icon: <LinkOutlined />,
|
||||
onClick: () => setIsShortcutModalOpen(true)
|
||||
}
|
||||
];
|
||||
|
||||
const [isThemeModalOpen, setIsThemeModalOpen] = useState(false);
|
||||
const [themeModalSection, setThemeModalSection] = useState<'theme' | 'appearance'>('theme');
|
||||
const [isAppearanceModalOpen, setIsAppearanceModalOpen] = useState(false);
|
||||
const [isShortcutModalOpen, setIsShortcutModalOpen] = useState(false);
|
||||
const [capturingShortcutAction, setCapturingShortcutAction] = useState<ShortcutAction | null>(null);
|
||||
@@ -1190,8 +1345,6 @@ function App() {
|
||||
},
|
||||
components: {
|
||||
Layout: {
|
||||
colorBgBody: 'transparent',
|
||||
colorBgHeader: 'transparent',
|
||||
bodyBg: 'transparent',
|
||||
headerBg: 'transparent',
|
||||
siderBg: 'transparent',
|
||||
@@ -1272,28 +1425,6 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
height: toolbarHeight,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
gap: Math.max(2, Math.round(4 * effectiveUiScale)),
|
||||
padding: `0 ${Math.max(6, Math.round(8 * effectiveUiScale))}px`,
|
||||
borderBottom: 'none',
|
||||
background: bgMain,
|
||||
}}
|
||||
>
|
||||
<Dropdown menu={{ items: toolsMenu }} placement="bottomLeft">
|
||||
<Button type="text" icon={<ToolOutlined />} title="工具">工具</Button>
|
||||
</Dropdown>
|
||||
<Button type="text" icon={<GlobalOutlined />} title="代理" onClick={() => setIsProxyModalOpen(true)}>代理</Button>
|
||||
<Dropdown menu={{ items: themeMenu }} placement="bottomLeft">
|
||||
<Button type="text" icon={<SkinOutlined />} title="主题">主题</Button>
|
||||
</Dropdown>
|
||||
<Button type="text" icon={<InfoCircleOutlined />} title="关于" onClick={() => setIsAboutOpen(true)}>关于</Button>
|
||||
</div>
|
||||
<Layout style={{ flex: 1, minHeight: 0, minWidth: 0 }}>
|
||||
<Sider
|
||||
width={sidebarWidth}
|
||||
@@ -1304,13 +1435,26 @@ function App() {
|
||||
}}
|
||||
>
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ padding: '10px', borderBottom: 'none', display: 'flex', justifyContent: 'flex-end', alignItems: 'center', flexShrink: 0 }}>
|
||||
|
||||
<div>
|
||||
<Button type="text" icon={<ConsoleSqlOutlined />} onClick={handleNewQuery} title="新建查询" />
|
||||
<Button type="text" icon={<PlusOutlined />} onClick={() => setIsModalOpen(true)} title="新建连接" />
|
||||
<div style={{ padding: `12px ${sidebarHorizontalPadding}px 8px`, borderBottom: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 8, width: '100%' }}>
|
||||
<Dropdown menu={{ items: toolsMenu }} placement="bottomLeft" dropdownRender={renderUtilityDropdown}>
|
||||
<Button type="text" icon={<ToolOutlined />} title="工具" style={utilityButtonStyle}>工具</Button>
|
||||
</Dropdown>
|
||||
<Button type="text" icon={<GlobalOutlined />} title="代理" style={utilityButtonStyle} onClick={() => setIsProxyModalOpen(true)}>代理</Button>
|
||||
<Button type="text" icon={<SkinOutlined />} title="主题" style={utilityButtonStyle} onClick={() => setIsThemeModalOpen(true)}>主题</Button>
|
||||
<Button type="text" icon={<InfoCircleOutlined />} title="关于" style={utilityButtonStyle} onClick={() => setIsAboutOpen(true)}>关于</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: `0 ${sidebarHorizontalPadding}px 10px`, borderBottom: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: 8, width: '100%' }}>
|
||||
<Button icon={<ConsoleSqlOutlined />} onClick={handleNewQuery} title="新建查询" style={sidebarQueryActionStyle}>
|
||||
新建查询
|
||||
</Button>
|
||||
<Button icon={<PlusOutlined />} onClick={() => setIsModalOpen(true)} title="新建连接" style={sidebarCreateConnectionActionStyle}>
|
||||
新建连接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'hidden', paddingBottom: 58 }}>
|
||||
<Sidebar onEditConnection={handleEditConnection} />
|
||||
@@ -1370,8 +1514,8 @@ function App() {
|
||||
title="拖动调整宽度"
|
||||
/>
|
||||
</Sider>
|
||||
<Content style={{ background: 'transparent', overflow: 'hidden', display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
<div style={{ flex: 1, minHeight: 0, minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', background: bgContent }}>
|
||||
<Content style={{ background: isLogPanelOpen ? bgContent : 'transparent', overflow: 'hidden', display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
<div style={{ flex: 1, minHeight: 0, minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', background: bgContent, marginBottom: isLogPanelOpen ? 8 : 0, borderRadius: isLogPanelOpen ? windowCornerRadius : 0, clipPath: isLogPanelOpen ? `inset(0 round ${windowCornerRadius}px)` : 'none' }}>
|
||||
<TabManager />
|
||||
</div>
|
||||
{isLogPanelOpen && (
|
||||
@@ -1399,9 +1543,10 @@ function App() {
|
||||
onOpenGlobalProxySettings={() => setIsProxyModalOpen(true)}
|
||||
/>
|
||||
<Modal
|
||||
title="关于 GoNavi"
|
||||
title={renderUtilityModalTitle(<InfoCircleOutlined />, '关于 GoNavi', '查看版本信息、仓库地址、更新状态与下载入口。')}
|
||||
open={isAboutOpen}
|
||||
onCancel={() => setIsAboutOpen(false)}
|
||||
styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }}
|
||||
footer={[
|
||||
canShowProgressEntry ? (
|
||||
<Button key="progress" icon={<DownloadOutlined />} onClick={showUpdateDownloadProgress}>下载进度</Button>
|
||||
@@ -1421,150 +1566,274 @@ function App() {
|
||||
<Spin />
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div>版本:{aboutInfo?.version || '未知'}</div>
|
||||
<div>作者:{aboutInfo?.author || '未知'}</div>
|
||||
{aboutInfo?.communityUrl ? (
|
||||
<div>技术圈:<a onClick={(e) => { e.preventDefault(); if (aboutInfo?.communityUrl) BrowserOpenURL(aboutInfo.communityUrl); }} href={aboutInfo.communityUrl}>AI全书</a></div>
|
||||
) : null}
|
||||
<div>更新状态:{aboutUpdateStatus || '未检查'}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<GithubOutlined />
|
||||
{aboutInfo?.repoUrl ? (
|
||||
<a onClick={(e) => { e.preventDefault(); if (aboutInfo?.repoUrl) BrowserOpenURL(aboutInfo.repoUrl); }} href={aboutInfo.repoUrl}>
|
||||
{aboutInfo.repoUrl}
|
||||
</a>
|
||||
) : '未知'}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 12 }}>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontWeight: 600 }}>版本</div>
|
||||
<div style={utilityMutedTextStyle}>{aboutInfo?.version || '未知'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontWeight: 600 }}>作者</div>
|
||||
<div style={utilityMutedTextStyle}>{aboutInfo?.author || '未知'}</div>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<div style={{ marginBottom: 6, fontWeight: 600 }}>更新状态</div>
|
||||
<div style={utilityMutedTextStyle}>{aboutUpdateStatus || '未检查'}</div>
|
||||
</div>
|
||||
{aboutInfo?.communityUrl ? (
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<div style={{ marginBottom: 6, fontWeight: 600 }}>技术圈</div>
|
||||
<a onClick={(e) => { e.preventDefault(); if (aboutInfo?.communityUrl) BrowserOpenURL(aboutInfo.communityUrl); }} href={aboutInfo.communityUrl}>AI全书</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ marginBottom: 10, fontWeight: 600 }}>项目入口</div>
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<GithubOutlined />
|
||||
{aboutInfo?.repoUrl ? (
|
||||
<a onClick={(e) => { e.preventDefault(); if (aboutInfo?.repoUrl) BrowserOpenURL(aboutInfo.repoUrl); }} href={aboutInfo.repoUrl}>{aboutInfo.repoUrl}</a>
|
||||
) : '未知'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<BugOutlined />
|
||||
{aboutInfo?.issueUrl ? (
|
||||
<a onClick={(e) => { e.preventDefault(); if (aboutInfo?.issueUrl) BrowserOpenURL(aboutInfo.issueUrl); }} href={aboutInfo.issueUrl}>{aboutInfo.issueUrl}</a>
|
||||
) : '未知'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<CloudDownloadOutlined />
|
||||
{aboutInfo?.releaseUrl ? (
|
||||
<a onClick={(e) => { e.preventDefault(); if (aboutInfo?.releaseUrl) BrowserOpenURL(aboutInfo.releaseUrl); }} href={aboutInfo.releaseUrl}>{aboutInfo.releaseUrl}</a>
|
||||
) : '未知'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<BugOutlined />
|
||||
{aboutInfo?.issueUrl ? (
|
||||
<a onClick={(e) => { e.preventDefault(); if (aboutInfo?.issueUrl) BrowserOpenURL(aboutInfo.issueUrl); }} href={aboutInfo.issueUrl}>
|
||||
{aboutInfo.issueUrl}
|
||||
</a>
|
||||
) : '未知'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<CloudDownloadOutlined />
|
||||
{aboutInfo?.releaseUrl ? (
|
||||
<a onClick={(e) => { e.preventDefault(); if (aboutInfo?.releaseUrl) BrowserOpenURL(aboutInfo.releaseUrl); }} href={aboutInfo.releaseUrl}>
|
||||
{aboutInfo.releaseUrl}
|
||||
</a>
|
||||
) : '未知'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="外观设置"
|
||||
open={isAppearanceModalOpen}
|
||||
onCancel={() => setIsAppearanceModalOpen(false)}
|
||||
title={renderUtilityModalTitle(
|
||||
themeModalSection === 'theme' ? <SkinOutlined /> : <BgColorsOutlined />,
|
||||
themeModalSection === 'theme' ? '主题设置' : '外观设置',
|
||||
themeModalSection === 'theme'
|
||||
? '切换亮暗主题,保持整体视觉风格统一。'
|
||||
: '统一调整缩放、字体、透明度与模糊效果。'
|
||||
)}
|
||||
open={isThemeModalOpen}
|
||||
onCancel={() => { setIsThemeModalOpen(false); setThemeModalSection('theme'); }}
|
||||
footer={null}
|
||||
width={460}
|
||||
width={820}
|
||||
styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8, height: 620, overflow: 'hidden' }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24, padding: '12px 0' }}>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>界面缩放 (UI Scale)</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Slider
|
||||
min={MIN_UI_SCALE}
|
||||
max={MAX_UI_SCALE}
|
||||
step={0.05}
|
||||
value={effectiveUiScale}
|
||||
onChange={(v) => setUiScale(Number(v))}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: 56 }}>{Math.round(effectiveUiScale * 100)}%</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
||||
* 建议小屏设备设置为 85%-95%
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '180px minmax(0, 1fr)', gap: 16, padding: '12px 0', height: '100%', minHeight: 0, overflow: 'hidden', alignItems: 'stretch' }}>
|
||||
<div style={{ ...utilityPanelStyle, padding: 12, height: 'fit-content' }}>
|
||||
<div style={{ marginBottom: 12, fontWeight: 600 }}>设置导航</div>
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
{[
|
||||
{ key: 'theme', title: '主题模式', description: '亮色与暗色切换', icon: <SkinOutlined /> },
|
||||
{ key: 'appearance', title: '外观参数', description: '缩放、字体与透明度', icon: <BgColorsOutlined /> },
|
||||
].map((item) => {
|
||||
const active = themeModalSection === item.key;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => setThemeModalSection(item.key as 'theme' | 'appearance')}
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: '12px 12px',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${active
|
||||
? (darkMode ? 'rgba(255,214,102,0.3)' : 'rgba(24,144,255,0.24)')
|
||||
: (darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(16,24,40,0.08)')}`,
|
||||
background: active
|
||||
? (darkMode ? 'linear-gradient(180deg, rgba(255,214,102,0.12) 0%, rgba(255,214,102,0.06) 100%)' : 'linear-gradient(180deg, rgba(24,144,255,0.10) 0%, rgba(24,144,255,0.05) 100%)')
|
||||
: (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.72)'),
|
||||
color: active ? (darkMode ? '#f5f7ff' : '#162033') : (darkMode ? 'rgba(255,255,255,0.82)' : '#3f4b5e'),
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span>{item.icon}</span>
|
||||
<span style={{ fontWeight: 700 }}>{item.title}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 12, lineHeight: 1.6, color: active ? (darkMode ? 'rgba(255,255,255,0.68)' : 'rgba(22,32,51,0.68)') : utilityMutedTextStyle.color }}>
|
||||
{item.description}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>基础字体大小 (Font Size)</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Slider
|
||||
min={MIN_FONT_SIZE}
|
||||
max={MAX_FONT_SIZE}
|
||||
step={1}
|
||||
value={effectiveFontSize}
|
||||
onChange={(v) => setFontSize(Number(v))}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: 56 }}>{effectiveFontSize}px</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>背景不透明度 (Opacity)</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Slider
|
||||
min={0.1}
|
||||
max={1.0}
|
||||
step={0.05}
|
||||
value={appearance.opacity ?? 1.0}
|
||||
onChange={(v) => setAppearance({ opacity: v })}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: 40 }}>{Math.round((appearance.opacity ?? 1.0) * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>高斯模糊 (Blur)</div>
|
||||
{isWindowsPlatform() ? (
|
||||
<div style={{ fontSize: 12, color: '#888' }}>
|
||||
Windows 使用系统 Acrylic 效果,模糊程度由系统控制
|
||||
<div style={{ minWidth: 0, minHeight: 0, height: '100%', overflowY: 'auto', overflowX: 'hidden', paddingRight: 8, paddingBottom: 28 }}>
|
||||
{themeModalSection === 'theme' ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ marginBottom: 10, fontWeight: 600 }}>主题模式</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 12 }}>
|
||||
{[
|
||||
{ key: 'light', label: '亮色主题', description: '适合明亮环境,层次更轻。' },
|
||||
{ key: 'dark', label: '暗色主题', description: '适合低光环境,视觉更沉稳。' },
|
||||
].map((item) => {
|
||||
const active = themeMode === item.key;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => setTheme(item.key as 'light' | 'dark')}
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: '14px 14px',
|
||||
borderRadius: 14,
|
||||
border: `1px solid ${active
|
||||
? (darkMode ? 'rgba(255,214,102,0.3)' : 'rgba(24,144,255,0.24)')
|
||||
: (darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(16,24,40,0.08)')}`,
|
||||
background: active
|
||||
? (darkMode ? 'linear-gradient(180deg, rgba(255,214,102,0.12) 0%, rgba(255,214,102,0.06) 100%)' : 'linear-gradient(180deg, rgba(24,144,255,0.10) 0%, rgba(24,144,255,0.05) 100%)')
|
||||
: (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.72)'),
|
||||
color: active ? (darkMode ? '#f5f7ff' : '#162033') : (darkMode ? 'rgba(255,255,255,0.82)' : '#3f4b5e'),
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 700 }}>{item.label}</span>
|
||||
{active ? <CheckOutlined style={{ color: darkMode ? '#ffd666' : '#1677ff' }} /> : null}
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 12, lineHeight: 1.6, color: active ? (darkMode ? 'rgba(255,255,255,0.68)' : 'rgba(22,32,51,0.68)') : utilityMutedTextStyle.color }}>
|
||||
{item.description}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Slider
|
||||
min={0}
|
||||
max={20}
|
||||
value={appearance.blur ?? 0}
|
||||
onChange={(v) => setAppearance({ blur: v })}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: 40 }}>{appearance.blur}px</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>界面缩放 (UI Scale)</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Slider
|
||||
min={MIN_UI_SCALE}
|
||||
max={MAX_UI_SCALE}
|
||||
step={0.05}
|
||||
value={effectiveUiScale}
|
||||
onChange={(v) => setUiScale(Number(v))}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: 56 }}>{Math.round(effectiveUiScale * 100)}%</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', marginTop: 4 }}>
|
||||
* 建议小屏设备设置为 85%-95%
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
||||
* 仅控制应用内覆盖层的模糊效果
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>基础字体大小 (Font Size)</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Slider
|
||||
min={MIN_FONT_SIZE}
|
||||
max={MAX_FONT_SIZE}
|
||||
step={1}
|
||||
value={effectiveFontSize}
|
||||
onChange={(v) => setFontSize(Number(v))}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: 56 }}>{effectiveFontSize}px</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ marginBottom: 10, fontWeight: 500 }}>透明与模糊效果</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>启用透明与模糊</div>
|
||||
<div style={{ ...utilityMutedTextStyle, marginTop: 4 }}>关闭后保留当前阈值,重新开启时直接恢复之前的设置。</div>
|
||||
</div>
|
||||
<Switch checked={appearance.enabled !== false} onChange={(checked) => setAppearance({ enabled: checked })} />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 14, opacity: appearance.enabled !== false ? 1 : 0.6 }}>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>背景不透明度 (Opacity)</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Slider
|
||||
min={0.1}
|
||||
max={1.0}
|
||||
step={0.05}
|
||||
disabled={appearance.enabled === false}
|
||||
value={appearance.opacity ?? 1.0}
|
||||
onChange={(v) => setAppearance({ opacity: v })}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: 40 }}>{Math.round((appearance.opacity ?? 1.0) * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>高斯模糊 (Blur)</div>
|
||||
{isWindowsPlatform() ? (
|
||||
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>
|
||||
Windows 使用系统 Acrylic 效果,模糊程度由系统控制
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Slider
|
||||
min={0}
|
||||
max={20}
|
||||
disabled={appearance.enabled === false}
|
||||
value={appearance.blur ?? 0}
|
||||
onChange={(v) => setAppearance({ blur: v })}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: 40 }}>{appearance.blur}px</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', marginTop: 4 }}>
|
||||
* 仅控制应用内覆盖层的模糊效果
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>启动窗口</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<span>启动时全屏</span>
|
||||
<Switch checked={startupFullscreen} onChange={(checked) => setStartupFullscreen(checked)} />
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', marginTop: 4 }}>
|
||||
* 修改后下次启动生效
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 12, paddingTop: 8, paddingBottom: 12 }}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setUiScale(DEFAULT_UI_SCALE);
|
||||
setFontSize(DEFAULT_FONT_SIZE);
|
||||
setAppearance({ enabled: true, opacity: 1.0, blur: 0 });
|
||||
}}
|
||||
>
|
||||
恢复默认
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>启动窗口</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<span>启动时全屏</span>
|
||||
<Switch checked={startupFullscreen} onChange={(checked) => setStartupFullscreen(checked)} />
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
||||
* 修改后下次启动生效
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setUiScale(DEFAULT_UI_SCALE);
|
||||
setFontSize(DEFAULT_FONT_SIZE);
|
||||
setAppearance({ opacity: 1.0, blur: 0 });
|
||||
}}
|
||||
>
|
||||
恢复默认
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="快捷键管理"
|
||||
title={renderUtilityModalTitle(<LinkOutlined />, '快捷键管理', '统一查看、录制与启停常用快捷键,保持操作习惯一致。')}
|
||||
open={isShortcutModalOpen}
|
||||
onCancel={() => {
|
||||
setIsShortcutModalOpen(false);
|
||||
setCapturingShortcutAction(null);
|
||||
}}
|
||||
width={720}
|
||||
width={760}
|
||||
styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }}
|
||||
footer={[
|
||||
<Button
|
||||
key="reset"
|
||||
@@ -1588,9 +1857,11 @@ function App() {
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, paddingTop: 8 }}>
|
||||
<div style={{ fontSize: 12, color: '#8c8c8c' }}>
|
||||
点击“录制”后按下快捷键。按 Esc 可取消录制。建议至少包含一个修饰键(Ctrl/Alt/Shift/Meta)。
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, paddingTop: 8 }}>
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>
|
||||
点击“录制”后按下快捷键。按 Esc 可取消录制。建议至少包含一个修饰键(Ctrl/Alt/Shift/Meta)。
|
||||
</div>
|
||||
</div>
|
||||
{SHORTCUT_ACTION_ORDER.map((action) => {
|
||||
const meta = SHORTCUT_ACTION_META[action];
|
||||
@@ -1600,18 +1871,17 @@ function App() {
|
||||
<div
|
||||
key={action}
|
||||
style={{
|
||||
...utilityPanelStyle,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
padding: '10px 12px',
|
||||
border: '1px solid rgba(128, 128, 128, 0.2)',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{meta.label}</div>
|
||||
<div style={{ fontSize: 12, color: '#8c8c8c' }}>{meta.description}</div>
|
||||
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>{meta.description}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Input
|
||||
@@ -1636,14 +1906,15 @@ function App() {
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
title="全局代理设置"
|
||||
title={renderUtilityModalTitle(<GlobalOutlined />, '全局代理设置', '统一配置更新检查、驱动管理与未单独指定代理的连接网络出口。')}
|
||||
open={isProxyModalOpen}
|
||||
onCancel={() => setIsProxyModalOpen(false)}
|
||||
footer={null}
|
||||
width={460}
|
||||
width={520}
|
||||
styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24, padding: '12px 0' }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, padding: '12px 0' }}>
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>全局代理</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<span>启用全局代理</span>
|
||||
@@ -1651,7 +1922,7 @@ function App() {
|
||||
</div>
|
||||
<div style={{ marginTop: 12, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, opacity: globalProxy.enabled ? 1 : 0.7 }}>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontSize: 12, color: '#8c8c8c' }}>代理类型</div>
|
||||
<div style={{ marginBottom: 6, fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>代理类型</div>
|
||||
<Select
|
||||
value={globalProxy.type}
|
||||
disabled={!globalProxy.enabled}
|
||||
@@ -1663,7 +1934,7 @@ function App() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontSize: 12, color: '#8c8c8c' }}>端口</div>
|
||||
<div style={{ marginBottom: 6, fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>端口</div>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={65535}
|
||||
@@ -1676,7 +1947,7 @@ function App() {
|
||||
/>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / span 2' }}>
|
||||
<div style={{ marginBottom: 6, fontSize: 12, color: '#8c8c8c' }}>代理地址</div>
|
||||
<div style={{ marginBottom: 6, fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>代理地址</div>
|
||||
<Input
|
||||
placeholder="例如:127.0.0.1"
|
||||
value={globalProxy.host}
|
||||
@@ -1685,7 +1956,7 @@ function App() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontSize: 12, color: '#8c8c8c' }}>用户名(可选)</div>
|
||||
<div style={{ marginBottom: 6, fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>用户名(可选)</div>
|
||||
<Input
|
||||
placeholder="proxy-user"
|
||||
value={globalProxy.user}
|
||||
@@ -1694,7 +1965,7 @@ function App() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontSize: 12, color: '#8c8c8c' }}>密码(可选)</div>
|
||||
<div style={{ marginBottom: 6, fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>密码(可选)</div>
|
||||
<Input.Password
|
||||
placeholder="proxy-password"
|
||||
value={globalProxy.password}
|
||||
@@ -1703,7 +1974,7 @@ function App() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#888', marginTop: 6 }}>
|
||||
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', marginTop: 6 }}>
|
||||
* 作用于更新检查、驱动管理网络请求,以及未单独配置代理的数据库连接
|
||||
</div>
|
||||
</div>
|
||||
@@ -1738,7 +2009,7 @@ function App() {
|
||||
percent={Math.round(updateDownloadProgress.percent)}
|
||||
status={updateDownloadProgress.status === 'error' ? 'exception' : (updateDownloadProgress.status === 'done' ? 'success' : 'active')}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: '#8c8c8c' }}>
|
||||
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>
|
||||
{`${formatBytes(updateDownloadProgress.downloaded)} / ${formatBytes(updateDownloadProgress.total)}`}
|
||||
</div>
|
||||
{updateDownloadProgress.message ? (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef, useContext, useMemo, useCallback }
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover } from 'antd';
|
||||
import type { SortOrder } from 'antd/es/table/interface';
|
||||
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons';
|
||||
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges, DBGetColumns } from '../../wailsjs/go/app/App';
|
||||
import ImportPreviewModal from './ImportPreviewModal';
|
||||
@@ -10,8 +10,8 @@ import { useStore } from '../store';
|
||||
import type { ColumnDefinition } from '../types';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import 'react-resizable/css/styles.css';
|
||||
import { buildOrderBySQL, buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
||||
import { isMacLikePlatform, normalizeOpacityForPlatform } from '../utils/appearance';
|
||||
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
||||
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||
|
||||
// --- Error Boundary ---
|
||||
@@ -142,10 +142,19 @@ const formatCellValue = (val: any) => {
|
||||
try {
|
||||
if (val === null) return <span style={{ color: '#ccc' }}>NULL</span>;
|
||||
if (typeof val === 'object') {
|
||||
if (!Array.isArray(val) && !isPlainObject(val)) {
|
||||
return String(val);
|
||||
}
|
||||
const cached = objectCellPreviewCache.get(val);
|
||||
if (cached !== undefined) {
|
||||
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 {
|
||||
const nextText = JSON.stringify(val);
|
||||
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);
|
||||
};
|
||||
|
||||
// 渲染阶段轻量比较:避免对象值在 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 shouldOpenModalEditor = (val: any): boolean => {
|
||||
@@ -581,6 +610,8 @@ interface DataGridProps {
|
||||
exportSqlWithFilter?: string;
|
||||
onApplyFilter?: (conditions: GridFilterCondition[]) => void;
|
||||
appliedFilterConditions?: FilterCondition[];
|
||||
scrollSnapshot?: { top: number; left: number };
|
||||
onScrollSnapshotChange?: (snapshot: { top: number; left: number }) => void;
|
||||
}
|
||||
|
||||
type GridFilterCondition = FilterCondition & {
|
||||
@@ -600,7 +631,8 @@ type ColumnMeta = {
|
||||
|
||||
const DataGrid: React.FC<DataGridProps> = ({
|
||||
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 addSqlLog = useStore(state => state.addSqlLog);
|
||||
@@ -610,7 +642,8 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const setQueryOptions = useStore(state => state.setQueryOptions);
|
||||
const isMacLike = useMemo(() => isMacLikePlatform(), []);
|
||||
const darkMode = theme === 'dark';
|
||||
const opacity = normalizeOpacityForPlatform(appearance.opacity);
|
||||
const resolvedAppearance = resolveAppearanceValues(appearance);
|
||||
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
|
||||
const canModifyData = !readOnly && !!tableName;
|
||||
const showColumnComment = queryOptions?.showColumnComment !== false;
|
||||
const showColumnType = queryOptions?.showColumnType !== false;
|
||||
@@ -677,6 +710,33 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const toolbarDividerColor = darkMode ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.10)';
|
||||
const columnMetaHintColor = darkMode ? darkHighlightTextColor : lightMetaHintColor;
|
||||
const columnMetaTooltipColor = darkMode ? darkHighlightTextColor : lightMetaTooltipColor;
|
||||
const paginationPageSizeOptions = ['100', '200', '500', '1000'];
|
||||
const paginationGlassMode = opacity < 0.999 || resolvedAppearance.blur > 0;
|
||||
const paginationShellBg = darkMode
|
||||
? `linear-gradient(135deg, rgba(17,22,34,${paginationGlassMode ? Math.max(0.22, opacity * 0.38) : 0.82}) 0%, rgba(10,14,24,${paginationGlassMode ? Math.max(0.28, opacity * 0.46) : 0.9}) 100%)`
|
||||
: `linear-gradient(135deg, rgba(255,255,255,${paginationGlassMode ? Math.max(0.24, opacity * 0.36) : 0.96}) 0%, rgba(246,248,252,${paginationGlassMode ? Math.max(0.32, opacity * 0.44) : 0.99}) 100%)`;
|
||||
const paginationShellBorderColor = darkMode
|
||||
? `rgba(255,255,255,${paginationGlassMode ? 0.10 : 0.08})`
|
||||
: `rgba(16,24,40,${paginationGlassMode ? 0.08 : 0.08})`;
|
||||
const paginationShellShadow = darkMode
|
||||
? `0 16px 34px rgba(0,0,0,${paginationGlassMode ? 0.10 : 0.22})`
|
||||
: `0 14px 30px rgba(15,23,42,${paginationGlassMode ? 0.03 : 0.08})`;
|
||||
const paginationChipBg = darkMode
|
||||
? `rgba(255,255,255,${paginationGlassMode ? Math.max(0.02, opacity * 0.035) : 0.04})`
|
||||
: `rgba(255,255,255,${paginationGlassMode ? Math.max(0.18, opacity * 0.26) : 0.86})`;
|
||||
const paginationChipBorderColor = darkMode
|
||||
? `rgba(255,255,255,${paginationGlassMode ? 0.10 : 0.08})`
|
||||
: `rgba(16,24,40,${paginationGlassMode ? 0.10 : 0.08})`;
|
||||
const paginationHoverBg = darkMode
|
||||
? `rgba(255,255,255,${paginationGlassMode ? Math.max(0.04, opacity * 0.06) : 0.07})`
|
||||
: `rgba(255,255,255,${paginationGlassMode ? Math.max(0.24, opacity * 0.34) : 0.96})`;
|
||||
const paginationPrimaryTextColor = darkMode ? '#f5f7ff' : '#162033';
|
||||
const paginationSecondaryTextColor = darkMode ? 'rgba(255,255,255,0.54)' : 'rgba(16,24,40,0.56)';
|
||||
const paginationAccentBg = darkMode ? 'rgba(255,214,102,0.14)' : 'rgba(24,144,255,0.10)';
|
||||
const paginationAccentBorderColor = darkMode ? 'rgba(255,214,102,0.38)' : 'rgba(24,144,255,0.22)';
|
||||
const paginationActiveItemBg = darkMode ? 'rgba(255,214,102,0.18)' : 'rgba(24,144,255,0.12)';
|
||||
const paginationActiveItemBorderColor = darkMode ? 'rgba(255,214,102,0.46)' : 'rgba(24,144,255,0.28)';
|
||||
const paginationActiveItemTextColor = darkMode ? '#fff7d6' : '#0958d9';
|
||||
|
||||
const [form] = Form.useForm();
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
@@ -721,6 +781,8 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const lastTableScrollLeftRef = useRef(0);
|
||||
const lastExternalScrollLeftRef = useRef(0);
|
||||
const pendingScrollToBottomRef = useRef(false);
|
||||
const lastReportedScrollRef = useRef<{ top: number; left: number }>({ top: 0, left: 0 });
|
||||
const didRestoreScrollRef = useRef(false);
|
||||
|
||||
// 批量编辑模式状态
|
||||
const [cellEditMode, setCellEditMode] = useState(false);
|
||||
@@ -1140,11 +1202,11 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
// 直接操作 DOM 更新选中效果,避免 React 重渲染
|
||||
const updateCellSelection = useCallback((newSelection: Set<string>) => {
|
||||
const tableBody = containerRef.current?.querySelector('.ant-table-body');
|
||||
if (!tableBody) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// 只同步可见单元格(兼容 virtual 渲染 + 极大选区)
|
||||
const visibleCells = tableBody.querySelectorAll('td[data-row-key][data-col-name]');
|
||||
// 只同步可见单元格,严格限定 `.ant-table-cell`,避免虚拟列表中内嵌的 EditableCell 被重复获取并打上 selected 样式从而产生白边。
|
||||
const visibleCells = container.querySelectorAll('.ant-table-cell[data-row-key][data-col-name]');
|
||||
visibleCells.forEach((cell) => {
|
||||
const el = cell as HTMLElement;
|
||||
const rowKey = el.getAttribute('data-row-key');
|
||||
@@ -1272,10 +1334,10 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
const getCellInfo = (target: HTMLElement | null): { rowKey: string; colName: string } | null => {
|
||||
if (!target) return null;
|
||||
const td = target.closest('td[data-row-key][data-col-name]') as HTMLElement;
|
||||
if (!td) return null;
|
||||
const rowKey = td.getAttribute('data-row-key');
|
||||
const colName = td.getAttribute('data-col-name');
|
||||
const cell = target.closest('[data-row-key][data-col-name]') as HTMLElement;
|
||||
if (!cell) return null;
|
||||
const rowKey = cell.getAttribute('data-row-key');
|
||||
const colName = cell.getAttribute('data-col-name');
|
||||
if (!rowKey || !colName) return null;
|
||||
return { rowKey, colName };
|
||||
};
|
||||
@@ -2043,10 +2105,8 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
closeRowEditor();
|
||||
}, [rowEditorRowKey, rowEditorForm, addedRows, columnNames, rowKeyStr, closeRowEditor]);
|
||||
|
||||
const estimatedVisibleCellCount = mergedDisplayData.length * Math.max(columnNames.length, 1);
|
||||
const enableLargeResultOptimizedEditing =
|
||||
viewMode === 'table' && (mergedDisplayData.length >= 60 || estimatedVisibleCellCount >= 4000);
|
||||
const enableVirtual = enableLargeResultOptimizedEditing;
|
||||
|
||||
const enableVirtual = viewMode === 'table';
|
||||
const enableInlineEditableCell = canModifyData;
|
||||
|
||||
const columns = useMemo(() => {
|
||||
@@ -2067,7 +2127,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
shouldCellUpdate: (record: Item, prevRecord: Item) => {
|
||||
const rowKeyChanged = record?.[GONAVI_ROW_KEY] !== prevRecord?.[GONAVI_ROW_KEY];
|
||||
if (rowKeyChanged) return true;
|
||||
return !isCellValueEqualForDiff(record?.[key], prevRecord?.[key]);
|
||||
return !isCellValueEqualForRender(record?.[key], prevRecord?.[key]);
|
||||
},
|
||||
onHeaderCell: (column: any) => ({
|
||||
width: column.width,
|
||||
@@ -2102,22 +2162,28 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
return {
|
||||
...col,
|
||||
onCell: (record: Item) => {
|
||||
if (!enableInlineEditableCell) {
|
||||
const rowKey = record?.[GONAVI_ROW_KEY];
|
||||
return {
|
||||
'data-row-key': rowKey === undefined || rowKey === null ? undefined : String(rowKey),
|
||||
'data-col-name': dataIndex,
|
||||
onDoubleClick: () => handleVirtualCellActivate(record, dataIndex, dataIndex),
|
||||
};
|
||||
}
|
||||
return {
|
||||
record,
|
||||
editable: col.editable,
|
||||
dataIndex: col.dataIndex,
|
||||
title: dataIndex,
|
||||
handleSave: handleCellSave,
|
||||
focusCell: openCellEditor,
|
||||
const rowKey = record?.[GONAVI_ROW_KEY];
|
||||
const cellProps: any = {
|
||||
'data-row-key': rowKey === undefined || rowKey === null ? undefined : String(rowKey),
|
||||
'data-col-name': dataIndex,
|
||||
};
|
||||
|
||||
if (!enableInlineEditableCell) {
|
||||
cellProps.onDoubleClick = () => handleVirtualCellActivate(record, dataIndex, dataIndex);
|
||||
cellProps.onContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
showCellContextMenu(e, record, dataIndex, dataIndex);
|
||||
};
|
||||
} else {
|
||||
cellProps.record = record;
|
||||
cellProps.editable = col.editable;
|
||||
cellProps.dataIndex = col.dataIndex;
|
||||
cellProps.title = dataIndex;
|
||||
cellProps.handleSave = handleCellSave;
|
||||
cellProps.focusCell = openCellEditor;
|
||||
}
|
||||
return cellProps;
|
||||
},
|
||||
render: (text: any, record: Item, index: number) => {
|
||||
const originalRenderContent = col.render ? (col.render as any)(text, record, index) : text;
|
||||
@@ -2137,10 +2203,24 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
</EditableCell>
|
||||
);
|
||||
}
|
||||
if (enableVirtual) {
|
||||
return (
|
||||
<div
|
||||
style={{ margin: -8, padding: '8px 8px 8px 8px' }}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
showCellContextMenu(e, record, dataIndex, dataIndex);
|
||||
}}
|
||||
>
|
||||
{originalRenderContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return originalRenderContent;
|
||||
}
|
||||
};
|
||||
}), [columns, enableInlineEditableCell, enableVirtual, handleCellSave, openCellEditor, handleVirtualCellActivate]);
|
||||
}), [columns, enableInlineEditableCell, enableVirtual, handleCellSave, openCellEditor, handleVirtualCellActivate, showCellContextMenu]);
|
||||
|
||||
const handleAddRow = () => {
|
||||
const newKey = `new-${Date.now()}`;
|
||||
@@ -2380,18 +2460,18 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
return clauses.join(' OR ');
|
||||
}, [pkColumns, tableName]);
|
||||
|
||||
const buildCurrentPageSql = useCallback((dbType: string) => {
|
||||
const buildCurrentPageSql = useCallback((dbType: string) => {
|
||||
if (!tableName || !pagination) return '';
|
||||
const whereSQL = buildWhereSQL(dbType, filterConditions);
|
||||
let sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
||||
sql += buildOrderBySQL(dbType, sortInfo, pkColumns);
|
||||
const baseSql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
||||
const orderBySQL = buildOrderBySQL(dbType, sortInfo, pkColumns);
|
||||
const normalizedType = String(dbType || '').trim().toLowerCase();
|
||||
const hasExplicitSort = !!sortInfo?.columnKey && (sortInfo?.order === 'ascend' || sortInfo?.order === 'descend');
|
||||
const offset = (pagination.current - 1) * pagination.pageSize;
|
||||
let sql = buildPaginatedSelectSQL(dbType, baseSql, orderBySQL, pagination.pageSize, offset);
|
||||
if (hasExplicitSort && (normalizedType === 'mysql' || normalizedType === 'mariadb')) {
|
||||
sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024);
|
||||
}
|
||||
const offset = (pagination.current - 1) * pagination.pageSize;
|
||||
sql += ` LIMIT ${pagination.pageSize} OFFSET ${offset}`;
|
||||
return sql;
|
||||
}, [tableName, pagination, filterConditions, sortInfo, pkColumns]);
|
||||
|
||||
@@ -2679,7 +2759,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
handleExportSelected,
|
||||
copyToClipboard,
|
||||
tableName,
|
||||
enableRowContextMenu: !canModifyData,
|
||||
enableRowContextMenu: false,
|
||||
supportsCopyInsert,
|
||||
}), [handleCopyCsv, handleCopyInsert, handleCopyJson, handleExportSelected, copyToClipboard, tableName, canModifyData, supportsCopyInsert]);
|
||||
|
||||
@@ -2697,7 +2777,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const rowPropsFactory = useCallback((record: any) => ({ record } as any), []);
|
||||
|
||||
const totalWidth = columns.reduce((sum, col) => sum + (Number(col.width) || 200), 0) + selectionColumnWidth;
|
||||
const useContextMenuRow = !canModifyData;
|
||||
const useContextMenuRow = false;
|
||||
const tableScrollX = useMemo(() => {
|
||||
const baseWidth = Math.max(totalWidth, 1000);
|
||||
if (!isMacLike || tableViewportWidth <= 0) return baseWidth;
|
||||
@@ -2733,6 +2813,13 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
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 externalScroll = externalHScrollRef.current;
|
||||
if (!(externalScroll instanceof HTMLDivElement) || horizontalSyncSourceRef.current === 'external') {
|
||||
@@ -2815,12 +2902,162 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
};
|
||||
}, [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(() => {
|
||||
if (viewMode !== 'table') return;
|
||||
const rafId = requestAnimationFrame(() => recalculateTableMetrics(containerRef.current));
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [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 事件,当鼠标在底部水平滚动条区域时拦截并转为水平滚动
|
||||
useEffect(() => {
|
||||
if (viewMode !== 'table' || !enableVirtual) return;
|
||||
@@ -2936,6 +3173,49 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
};
|
||||
}, [viewMode, tableScrollX, mergedDisplayData.length, syncExternalScrollFromTargets, pickHorizontalScrollTargets]);
|
||||
|
||||
const paginationSummaryText = useMemo(() => {
|
||||
if (!pagination) return '';
|
||||
const total = Number.isFinite(pagination.total) ? pagination.total : 0;
|
||||
const rangeStart = Math.max(0, (pagination.current - 1) * pagination.pageSize + (total > 0 ? 1 : 0));
|
||||
const hasValidRange = total > 0 && rangeStart > 0;
|
||||
const rangeEnd = hasValidRange ? Math.min(total, rangeStart + pagination.pageSize - 1) : 0;
|
||||
const currentCount = hasValidRange ? Math.max(0, rangeEnd - rangeStart + 1) : 0;
|
||||
|
||||
if (pagination.totalKnown === false) {
|
||||
if (isDuckDBConnection) {
|
||||
if (pagination.totalCountLoading) return `当前 ${currentCount} 条 / 正在统计精确总数…`;
|
||||
if (pagination.totalApprox && Number.isFinite(total) && total > 0) return `当前 ${currentCount} 条 / 约 ${total} 条`;
|
||||
if (pagination.totalCountCancelled) return `当前 ${currentCount} 条 / 已取消统计`;
|
||||
return `当前 ${currentCount} 条 / 总数未统计`;
|
||||
}
|
||||
return `当前 ${currentCount} 条 / 正在统计总数…`;
|
||||
}
|
||||
|
||||
if (isDuckDBConnection && (!Number.isFinite(total) || total <= 0)) {
|
||||
return '当前 0 条 / 共 0 条';
|
||||
}
|
||||
|
||||
return `当前 ${currentCount} 条 / 共 ${total} 条`;
|
||||
}, [pagination, isDuckDBConnection]);
|
||||
|
||||
const paginationPageText = useMemo(() => {
|
||||
if (!pagination) return '';
|
||||
const total = Number.isFinite(pagination.total) ? pagination.total : 0;
|
||||
const canShowTotalPages = pagination.totalKnown !== false || (isDuckDBConnection && pagination.totalApprox && total > 0);
|
||||
if (!canShowTotalPages || total <= 0) return `第 ${pagination.current} 页`;
|
||||
const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pagination.pageSize)));
|
||||
return `第 ${pagination.current} / ${totalPages} 页`;
|
||||
}, [pagination, isDuckDBConnection]);
|
||||
|
||||
const handlePageSizeChange = useCallback((value: string) => {
|
||||
if (!pagination || !onPageChange) return;
|
||||
const nextSize = Number(value);
|
||||
if (!Number.isFinite(nextSize) || nextSize <= 0) return;
|
||||
const firstRowIndex = Math.max(0, (pagination.current - 1) * pagination.pageSize);
|
||||
const nextPage = Math.floor(firstRowIndex / nextSize) + 1;
|
||||
onPageChange(nextPage, nextSize);
|
||||
}, [pagination, onPageChange]);
|
||||
|
||||
return (
|
||||
<div className={`${gridId}${cellEditMode ? ' cell-edit-mode' : ''} data-grid-root`} style={{ flex: '1 1 auto', height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column', minHeight: 0, minWidth: 0, background: 'transparent' }}>
|
||||
{/* Toolbar + Filter Panel */}
|
||||
@@ -3117,22 +3397,17 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
<Checkbox
|
||||
checked={cond.enabled !== false}
|
||||
onChange={e => updateFilter(cond.id, 'enabled', e.target.checked)}
|
||||
style={{ marginTop: 6 }}
|
||||
style={{ marginTop: 6, flex: '0 0 auto', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
启用
|
||||
</Checkbox>
|
||||
{condIndex === 0 ? (
|
||||
<div style={{ width: 96, marginTop: 7, textAlign: 'center', fontSize: 12, color: '#8c8c8c' }}>
|
||||
首条
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
style={{ width: 96 }}
|
||||
value={cond.logic === 'OR' ? 'OR' : 'AND'}
|
||||
onChange={v => updateFilter(cond.id, 'logic', v)}
|
||||
options={filterLogicOptions as any}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
style={{ width: 96, minWidth: 96, maxWidth: 96, flex: '0 0 96px' }}
|
||||
value={condIndex === 0 ? '__FIRST__' : (cond.logic === 'OR' ? 'OR' : 'AND')}
|
||||
onChange={v => updateFilter(cond.id, 'logic', v)}
|
||||
options={condIndex === 0 ? [{ value: '__FIRST__', label: '首条' }] : (filterLogicOptions as any)}
|
||||
disabled={condIndex === 0}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 180 }}
|
||||
value={cond.column}
|
||||
@@ -3494,6 +3769,8 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{canModifyData && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
@@ -3527,6 +3804,8 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
填充到选中行 ({selectedRowKeys.length})
|
||||
</div>
|
||||
<div style={{ height: 1, background: darkMode ? '#303030' : '#f0f0f0', margin: '4px 0' }} />
|
||||
</>
|
||||
)}
|
||||
{supportsCopyInsert && (
|
||||
<div
|
||||
style={{
|
||||
@@ -3663,33 +3942,41 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<div style={{ padding: '8px', borderTop: 'none', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Pagination
|
||||
current={pagination.current}
|
||||
pageSize={pagination.pageSize}
|
||||
total={pagination.total}
|
||||
showTotal={(total, range) => {
|
||||
const hasValidRange = Array.isArray(range) && range[0] > 0 && range[1] >= range[0];
|
||||
const currentCount = hasValidRange ? Math.max(0, range[1] - range[0] + 1) : 0;
|
||||
if (pagination.totalKnown === false) {
|
||||
if (isDuckDBConnection) {
|
||||
if (pagination.totalCountLoading) return `当前 ${currentCount} 条 / 正在统计精确总数...`;
|
||||
if (pagination.totalApprox && Number.isFinite(total) && total > 0) return `当前 ${currentCount} 条 / 约 ${total} 条`;
|
||||
if (pagination.totalCountCancelled) return `当前 ${currentCount} 条 / 已取消统计`;
|
||||
return `当前 ${currentCount} 条 / 总数未统计`;
|
||||
<div style={{ padding: '12px 0 0', borderTop: 'none', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<div className="data-grid-pagination-shell">
|
||||
<div className="data-grid-pagination-summary" aria-live="polite">
|
||||
<span className="data-grid-pagination-kicker">结果集</span>
|
||||
<span className="data-grid-pagination-summary-value">{paginationSummaryText}</span>
|
||||
</div>
|
||||
<div className="data-grid-pagination-page-chip">{paginationPageText}</div>
|
||||
<Pagination
|
||||
current={pagination.current}
|
||||
pageSize={pagination.pageSize}
|
||||
total={pagination.total}
|
||||
showSizeChanger={false}
|
||||
onChange={onPageChange}
|
||||
showTitle={false}
|
||||
size="small"
|
||||
itemRender={(_page, type, originalElement) => {
|
||||
if (type === 'prev') {
|
||||
return <span className="data-grid-pagination-nav-icon" aria-hidden="true"><LeftOutlined /></span>;
|
||||
}
|
||||
return `当前 ${currentCount} 条 / 正在统计总数...`;
|
||||
}
|
||||
if (isDuckDBConnection && (!Number.isFinite(total) || total <= 0)) {
|
||||
return '当前 0 条 / 共 0 条';
|
||||
}
|
||||
return `当前 ${currentCount} 条 / 共 ${total} 条`;
|
||||
}}
|
||||
showSizeChanger
|
||||
pageSizeOptions={['100', '200', '500', '1000']}
|
||||
onChange={onPageChange}
|
||||
size="small"
|
||||
/>
|
||||
if (type === 'next') {
|
||||
return <span className="data-grid-pagination-nav-icon" aria-hidden="true"><RightOutlined /></span>;
|
||||
}
|
||||
return originalElement;
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
size="small"
|
||||
popupMatchSelectWidth={false}
|
||||
value={String(pagination.pageSize)}
|
||||
onChange={handlePageSizeChange}
|
||||
options={paginationPageSizeOptions.map((value) => ({ value, label: `${value} 条 / 页` }))}
|
||||
className="data-grid-pagination-size-select"
|
||||
aria-label="每页条数"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3751,12 +4038,13 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
.${gridId} .ant-table-tbody .ant-table-row.row-added:hover > .ant-table-cell { background-color: ${rowAddedHover} !important; }
|
||||
.${gridId} .ant-table-tbody > tr.row-modified:hover > td,
|
||||
.${gridId} .ant-table-tbody .ant-table-row.row-modified:hover > .ant-table-cell { background-color: ${rowModHover} !important; }
|
||||
.${gridId}.cell-edit-mode .ant-table-tbody > tr > td[data-col-name],
|
||||
.${gridId}.cell-edit-mode .ant-table-tbody .ant-table-row > .ant-table-cell[data-col-name] { user-select: none; -webkit-user-select: none; cursor: crosshair; }
|
||||
.${gridId}.cell-edit-mode .ant-table-tbody > tr > td[data-cell-selected="true"],
|
||||
.${gridId}.cell-edit-mode .ant-table-tbody .ant-table-row > .ant-table-cell[data-cell-selected="true"] {
|
||||
box-shadow: inset 0 0 0 2px ${selectionAccentHex};
|
||||
background-image: linear-gradient(${darkMode ? `rgba(${selectionAccentRgb}, 0.20)` : `rgba(${selectionAccentRgb}, 0.08)`}, ${darkMode ? `rgba(${selectionAccentRgb}, 0.20)` : `rgba(${selectionAccentRgb}, 0.08)`});
|
||||
.${gridId} .ant-table-tbody > tr > td[data-col-name],
|
||||
.${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell[data-col-name] { user-select: none; -webkit-user-select: none; cursor: crosshair; }
|
||||
.${gridId} .ant-table-tbody > tr > td[data-cell-selected="true"],
|
||||
.${gridId} .ant-table-tbody .ant-table-row > .ant-table-cell[data-cell-selected="true"],
|
||||
.${gridId} [data-cell-selected="true"] {
|
||||
box-shadow: inset 0 0 0 2px ${selectionAccentHex} !important;
|
||||
background-image: linear-gradient(${darkMode ? `rgba(${selectionAccentRgb}, 0.20)` : `rgba(${selectionAccentRgb}, 0.08)`}, ${darkMode ? `rgba(${selectionAccentRgb}, 0.20)` : `rgba(${selectionAccentRgb}, 0.08)`}) !important;
|
||||
}
|
||||
.${gridId} .ant-table-content,
|
||||
.${gridId} .ant-table-body {
|
||||
@@ -3865,6 +4153,266 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
.${gridId} .data-grid-external-hscroll-inner {
|
||||
height: 1px;
|
||||
}
|
||||
.${gridId} .data-grid-pagination-shell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
max-width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid ${paginationShellBorderColor};
|
||||
background: ${paginationShellBg};
|
||||
box-shadow: ${paginationShellShadow};
|
||||
backdrop-filter: ${opacity < 0.999 ? 'blur(14px)' : 'none'};
|
||||
-webkit-backdrop-filter: ${opacity < 0.999 ? 'blur(14px)' : 'none'};
|
||||
}
|
||||
.${gridId} .data-grid-pagination-summary,
|
||||
.${gridId} .data-grid-pagination-page-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid ${paginationChipBorderColor};
|
||||
background: ${paginationChipBg};
|
||||
color: ${paginationPrimaryTextColor};
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.${gridId} .data-grid-pagination-kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
background: ${paginationAccentBg};
|
||||
border: 1px solid ${paginationAccentBorderColor};
|
||||
color: ${paginationActiveItemTextColor};
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.${gridId} .data-grid-pagination-summary-value {
|
||||
color: ${paginationPrimaryTextColor};
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.${gridId} .data-grid-pagination-page-chip {
|
||||
color: ${paginationSecondaryTextColor};
|
||||
font-weight: 600;
|
||||
}
|
||||
.${gridId} .ant-pagination {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
color: ${paginationPrimaryTextColor};
|
||||
}
|
||||
.${gridId} .ant-pagination .ant-pagination-item,
|
||||
.${gridId} .ant-pagination .ant-pagination-prev,
|
||||
.${gridId} .ant-pagination .ant-pagination-next,
|
||||
.${gridId} .ant-pagination .ant-pagination-jump-prev,
|
||||
.${gridId} .ant-pagination .ant-pagination-jump-next {
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
margin-inline-end: 0;
|
||||
border-radius: 12px;
|
||||
border: 1px solid ${paginationChipBorderColor};
|
||||
background: ${paginationChipBg};
|
||||
box-shadow: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
transition: border-color 160ms ease, background-color 160ms ease, transform 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
.${gridId} .ant-pagination .ant-pagination-item a,
|
||||
.${gridId} .ant-pagination .ant-pagination-prev .ant-pagination-item-link,
|
||||
.${gridId} .ant-pagination .ant-pagination-next .ant-pagination-item-link,
|
||||
.${gridId} .ant-pagination .ant-pagination-prev > *,
|
||||
.${gridId} .ant-pagination .ant-pagination-next > * {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: ${paginationPrimaryTextColor};
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: inherit;
|
||||
line-height: 1;
|
||||
}
|
||||
.${gridId} .ant-pagination .ant-pagination-item:hover,
|
||||
.${gridId} .ant-pagination .ant-pagination-prev:hover,
|
||||
.${gridId} .ant-pagination .ant-pagination-next:hover {
|
||||
background: ${paginationHoverBg};
|
||||
border-color: ${paginationActiveItemBorderColor};
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.${gridId} .ant-pagination .ant-pagination-item-active {
|
||||
border-color: ${paginationActiveItemBorderColor};
|
||||
background: ${paginationActiveItemBg};
|
||||
box-shadow: inset 0 0 0 1px ${paginationAccentBorderColor};
|
||||
}
|
||||
.${gridId} .ant-pagination .ant-pagination-item-active a {
|
||||
color: ${paginationActiveItemTextColor};
|
||||
}
|
||||
.${gridId} .ant-pagination .ant-pagination-disabled,
|
||||
.${gridId} .ant-pagination .ant-pagination-disabled:hover {
|
||||
background: transparent;
|
||||
border-color: ${paginationChipBorderColor};
|
||||
transform: none;
|
||||
opacity: 0.42;
|
||||
}
|
||||
.${gridId} .ant-pagination .ant-pagination-jump-prev,
|
||||
.${gridId} .ant-pagination .ant-pagination-jump-next {
|
||||
padding: 0;
|
||||
}
|
||||
.${gridId} .ant-pagination .ant-pagination-jump-prev .ant-pagination-item-link,
|
||||
.${gridId} .ant-pagination .ant-pagination-jump-next .ant-pagination-item-link {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.${gridId} .ant-pagination .ant-pagination-jump-prev .ant-pagination-item-container,
|
||||
.${gridId} .ant-pagination .ant-pagination-jump-next .ant-pagination-item-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
line-height: 1;
|
||||
}
|
||||
.${gridId} .ant-pagination .ant-pagination-jump-prev .ant-pagination-item-ellipsis,
|
||||
.${gridId} .ant-pagination .ant-pagination-jump-next .ant-pagination-item-ellipsis,
|
||||
.${gridId} .ant-pagination .ant-pagination-jump-prev .ant-pagination-item-link-icon,
|
||||
.${gridId} .ant-pagination .ant-pagination-jump-next .ant-pagination-item-link-icon {
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: 0 !important;
|
||||
left: 0 !important;
|
||||
inset: 0 !important;
|
||||
width: fit-content !important;
|
||||
height: fit-content !important;
|
||||
min-width: 0 !important;
|
||||
min-height: 0 !important;
|
||||
margin: auto !important;
|
||||
padding: 0 !important;
|
||||
transform: none !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
line-height: 1 !important;
|
||||
color: ${paginationSecondaryTextColor};
|
||||
}
|
||||
.${gridId} .ant-pagination .ant-pagination-jump-prev .ant-pagination-item-ellipsis,
|
||||
.${gridId} .ant-pagination .ant-pagination-jump-next .ant-pagination-item-ellipsis {
|
||||
letter-spacing: 0.18em;
|
||||
text-indent: 0.18em;
|
||||
text-align: center;
|
||||
}
|
||||
.${gridId} .ant-pagination .ant-pagination-jump-prev .ant-pagination-item-link-icon .anticon,
|
||||
.${gridId} .ant-pagination .ant-pagination-jump-next .ant-pagination-item-link-icon .anticon,
|
||||
.${gridId} .ant-pagination .ant-pagination-jump-prev .ant-pagination-item-link-icon svg,
|
||||
.${gridId} .ant-pagination .ant-pagination-jump-next .ant-pagination-item-link-icon svg {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
line-height: 1;
|
||||
}
|
||||
.${gridId} .data-grid-pagination-nav-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
.${gridId} .data-grid-pagination-nav-icon .anticon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.${gridId} .data-grid-pagination-size-select {
|
||||
min-width: 112px;
|
||||
height: 34px;
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
.${gridId} .data-grid-pagination-size-select.ant-select-single,
|
||||
.${gridId} .data-grid-pagination-size-select.ant-select-single.ant-select-sm {
|
||||
height: 34px;
|
||||
}
|
||||
.${gridId} .data-grid-pagination-size-select .ant-select-selector {
|
||||
height: 34px !important;
|
||||
border-radius: 12px !important;
|
||||
border: 1px solid ${paginationChipBorderColor} !important;
|
||||
background: ${paginationChipBg} !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 12px !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
.${gridId} .data-grid-pagination-size-select .ant-select-selection-wrap {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
height: 100%;
|
||||
}
|
||||
.${gridId} .data-grid-pagination-size-select .ant-select-selection-search,
|
||||
.${gridId} .data-grid-pagination-size-select .ant-select-selection-search-input {
|
||||
height: 100% !important;
|
||||
}
|
||||
.${gridId} .data-grid-pagination-size-select .ant-select-selection-item,
|
||||
.${gridId} .data-grid-pagination-size-select .ant-select-selection-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
line-height: 34px !important;
|
||||
color: ${paginationPrimaryTextColor};
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.${gridId} .data-grid-pagination-size-select .ant-select-selection-search {
|
||||
inset-inline-start: 12px !important;
|
||||
inset-inline-end: 32px !important;
|
||||
}
|
||||
.${gridId} .data-grid-pagination-size-select .ant-select-arrow {
|
||||
color: ${paginationSecondaryTextColor};
|
||||
inset-inline-end: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
margin-top: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
.${gridId} .data-grid-pagination-size-select .ant-select-arrow .anticon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Ghost Resize Line for Columns */}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Modal, Form, Select, Button, message, Steps, Transfer, Card, Alert, Divider, Typography, Progress, Checkbox, Table, Drawer, Tabs } from 'antd';
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { Modal, Form, Select, Input, Button, message, Steps, Transfer, Card, Alert, Divider, Typography, Progress, Checkbox, Table, Drawer, Tabs, theme as antdTheme } from 'antd';
|
||||
import { DatabaseOutlined, RocketOutlined, SwapOutlined, TableOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { DBGetDatabases, DBGetTables, DataSync, DataSyncAnalyze, DataSyncPreview } from '../../wailsjs/go/app/App';
|
||||
import { SavedConnection } from '../types';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Step } = Steps;
|
||||
@@ -21,6 +23,12 @@ type TableDiffSummary = {
|
||||
deletes?: number;
|
||||
same?: number;
|
||||
message?: string;
|
||||
targetTableExists?: boolean;
|
||||
plannedAction?: string;
|
||||
warnings?: string[];
|
||||
unsupportedObjects?: string[];
|
||||
indexesToCreate?: number;
|
||||
indexesSkipped?: number;
|
||||
};
|
||||
type TableOps = {
|
||||
insert: boolean;
|
||||
@@ -31,10 +39,135 @@ type TableOps = {
|
||||
selectedDeletePks?: string[];
|
||||
};
|
||||
|
||||
type WorkflowType = 'sync' | 'migration';
|
||||
|
||||
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 resolveRedisDbIndex = (raw?: string): number => {
|
||||
const value = Number(String(raw || '').trim());
|
||||
return Number.isInteger(value) && value >= 0 && value <= 15 ? value : 0;
|
||||
};
|
||||
|
||||
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 connections = useStore((state) => state.connections);
|
||||
const themeMode = useStore((state) => state.theme);
|
||||
const appearance = useStore((state) => state.appearance);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { token } = antdTheme.useToken();
|
||||
const darkMode = themeMode === 'dark';
|
||||
const resolvedAppearance = resolveAppearanceValues(appearance);
|
||||
const effectiveOpacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
|
||||
|
||||
// Step 1: Config
|
||||
const [sourceConnId, setSourceConnId] = useState<string>('');
|
||||
@@ -50,9 +183,13 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
const [selectedTables, setSelectedTables] = useState<string[]>([]);
|
||||
|
||||
// Options
|
||||
const [workflowType, setWorkflowType] = useState<WorkflowType>('sync');
|
||||
const [syncContent, setSyncContent] = useState<'data' | 'schema' | 'both'>('data');
|
||||
const [syncMode, setSyncMode] = useState<string>('insert_update');
|
||||
const [autoAddColumns, setAutoAddColumns] = useState<boolean>(true);
|
||||
const [targetTableStrategy, setTargetTableStrategy] = useState<'existing_only' | 'auto_create_if_missing' | 'smart'>('existing_only');
|
||||
const [createIndexes, setCreateIndexes] = useState<boolean>(false);
|
||||
const [mongoCollectionName, setMongoCollectionName] = useState<string>('');
|
||||
const [showSameTables, setShowSameTables] = useState<boolean>(false);
|
||||
const [analyzing, setAnalyzing] = useState<boolean>(false);
|
||||
const [diffTables, setDiffTables] = useState<TableDiffSummary[]>([]);
|
||||
@@ -128,9 +265,12 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
setSourceDb('');
|
||||
setTargetDb('');
|
||||
setSelectedTables([]);
|
||||
setWorkflowType('sync');
|
||||
setSyncContent('data');
|
||||
setSyncMode('insert_update');
|
||||
setAutoAddColumns(true);
|
||||
setTargetTableStrategy('existing_only');
|
||||
setCreateIndexes(false);
|
||||
setShowSameTables(false);
|
||||
setAnalyzing(false);
|
||||
setDiffTables([]);
|
||||
@@ -148,36 +288,66 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (workflowType === 'migration') {
|
||||
if (syncMode === 'insert_update') {
|
||||
setSyncMode('insert_only');
|
||||
}
|
||||
if (syncContent === 'schema') {
|
||||
setSyncContent('both');
|
||||
}
|
||||
if (targetTableStrategy === 'existing_only') {
|
||||
setTargetTableStrategy('smart');
|
||||
}
|
||||
if (!createIndexes) {
|
||||
setCreateIndexes(true);
|
||||
}
|
||||
} else {
|
||||
if (targetTableStrategy !== 'existing_only') {
|
||||
setTargetTableStrategy('existing_only');
|
||||
}
|
||||
if (createIndexes) {
|
||||
setCreateIndexes(false);
|
||||
}
|
||||
}
|
||||
}, [workflowType]);
|
||||
|
||||
const handleSourceConnChange = async (connId: string) => {
|
||||
setSourceConnId(connId);
|
||||
setSourceDb('');
|
||||
const conn = connections.find(c => c.id === connId);
|
||||
if (conn) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
|
||||
if (res.success) {
|
||||
setSourceDbs((res.data as any[]).map((r: any) => r.Database || r.database || r.username));
|
||||
}
|
||||
} catch(e) { message.error("Failed to fetch source databases"); }
|
||||
setLoading(false);
|
||||
}
|
||||
if (conn) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
|
||||
if (res.success) {
|
||||
const dbRows = Array.isArray(res.data) ? res.data : [];
|
||||
setSourceDbs(dbRows
|
||||
.map((r: any) => r?.Database || r?.database || r?.username)
|
||||
.filter((name: any) => typeof name === 'string' && name.trim() !== ''));
|
||||
}
|
||||
} catch(e) { message.error("Failed to fetch source databases"); }
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTargetConnChange = async (connId: string) => {
|
||||
setTargetConnId(connId);
|
||||
setTargetDb('');
|
||||
const conn = connections.find(c => c.id === connId);
|
||||
if (conn) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
|
||||
if (res.success) {
|
||||
setTargetDbs((res.data as any[]).map((r: any) => r.Database || r.database || r.username));
|
||||
}
|
||||
} catch(e) { message.error("Failed to fetch target databases"); }
|
||||
setLoading(false);
|
||||
}
|
||||
if (conn) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
|
||||
if (res.success) {
|
||||
const dbRows = Array.isArray(res.data) ? res.data : [];
|
||||
setTargetDbs(dbRows
|
||||
.map((r: any) => r?.Database || r?.database || r?.username)
|
||||
.filter((name: any) => typeof name === 'string' && name.trim() !== ''));
|
||||
}
|
||||
} catch(e) { message.error("Failed to fetch target databases"); }
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const nextToTables = async () => {
|
||||
@@ -189,14 +359,17 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
try {
|
||||
const conn = connections.find(c => c.id === sourceConnId);
|
||||
if (conn) {
|
||||
const config = normalizeConnConfig(conn, sourceDb);
|
||||
const res = await DBGetTables(config as any, sourceDb);
|
||||
if (res.success) {
|
||||
// DBGetTables returns [{Table: "name"}, ...]
|
||||
const tables = (res.data as any[]).map((row: any) => row.Table || row.table || row.TABLE_NAME || Object.values(row)[0]);
|
||||
setAllTables(tables as string[]);
|
||||
setCurrentStep(1);
|
||||
} else {
|
||||
const config = normalizeConnConfig(conn, sourceDb);
|
||||
const res = await DBGetTables(config as any, sourceDb);
|
||||
if (res.success) {
|
||||
// DBGetTables returns [{Table: "name"}, ...]
|
||||
const tableRows = Array.isArray(res.data) ? res.data : [];
|
||||
const tables = tableRows
|
||||
.map((row: any) => row?.Table || row?.table || row?.TABLE_NAME || Object.values(row || {})[0])
|
||||
.filter((name: any) => typeof name === 'string' && name.trim() !== '');
|
||||
setAllTables(tables as string[]);
|
||||
setCurrentStep(1);
|
||||
} else {
|
||||
message.error(res.message);
|
||||
}
|
||||
}
|
||||
@@ -236,6 +409,9 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
content: syncContent,
|
||||
mode: "insert_update",
|
||||
autoAddColumns,
|
||||
targetTableStrategy,
|
||||
createIndexes,
|
||||
mongoCollectionName: mongoCollectionName.trim(),
|
||||
jobId,
|
||||
};
|
||||
|
||||
@@ -286,6 +462,9 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
content: "data",
|
||||
mode: "insert_update",
|
||||
autoAddColumns,
|
||||
targetTableStrategy,
|
||||
createIndexes,
|
||||
mongoCollectionName: mongoCollectionName.trim(),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -362,6 +541,9 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
content: syncContent,
|
||||
mode: syncMode,
|
||||
autoAddColumns,
|
||||
targetTableStrategy,
|
||||
createIndexes,
|
||||
mongoCollectionName: mongoCollectionName.trim(),
|
||||
tableOptions,
|
||||
jobId,
|
||||
};
|
||||
@@ -402,10 +584,139 @@ 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]);
|
||||
|
||||
const analysisWarnings = useMemo(() => {
|
||||
const items: string[] = [];
|
||||
diffTables.forEach((table) => {
|
||||
(table.warnings || []).forEach((warning) => items.push(`${table.table}: ${warning}`));
|
||||
(table.unsupportedObjects || []).forEach((warning) => items.push(`${table.table}: ${warning}`));
|
||||
});
|
||||
return Array.from(new Set(items));
|
||||
}, [diffTables]);
|
||||
|
||||
const isMigrationWorkflow = workflowType === 'migration';
|
||||
const sourceConn = useMemo(() => connections.find(c => c.id === sourceConnId), [connections, sourceConnId]);
|
||||
const targetConn = useMemo(() => connections.find(c => c.id === targetConnId), [connections, targetConnId]);
|
||||
const sourceType = String(sourceConn?.config?.type || '').toLowerCase();
|
||||
const targetType = String(targetConn?.config?.type || '').toLowerCase();
|
||||
const isRedisMongoKeyspaceMigration = isMigrationWorkflow && (
|
||||
(sourceType === 'redis' && targetType === 'mongodb') ||
|
||||
(sourceType === 'mongodb' && targetType === 'redis')
|
||||
);
|
||||
const defaultMongoCollectionName = useMemo(() => {
|
||||
if (sourceType === 'redis' && targetType === 'mongodb') {
|
||||
return `redis_db_${resolveRedisDbIndex(sourceDb || sourceConn?.config?.database)}_keys`;
|
||||
}
|
||||
if (sourceType === 'mongodb' && targetType === 'redis') {
|
||||
return selectedTables[0] || `redis_db_${resolveRedisDbIndex(targetDb || targetConn?.config?.database)}_keys`;
|
||||
}
|
||||
return '';
|
||||
}, [sourceType, targetType, sourceDb, targetDb, sourceConn, targetConn, selectedTables]);
|
||||
|
||||
const modalPanelStyle = useMemo(() => ({
|
||||
background: darkMode
|
||||
? 'linear-gradient(180deg, rgba(16,22,34,0.96) 0%, rgba(10,14,24,0.98) 100%)'
|
||||
: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
|
||||
boxShadow: darkMode ? '0 24px 56px rgba(0,0,0,0.36)' : '0 18px 44px rgba(15,23,42,0.14)',
|
||||
backdropFilter: darkMode ? 'blur(18px)' : 'none',
|
||||
}), [darkMode]);
|
||||
|
||||
const shellCardStyle = useMemo<React.CSSProperties>(() => ({
|
||||
borderRadius: 18,
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.08)',
|
||||
background: darkMode ? 'rgba(255,255,255,0.03)' : `rgba(255,255,255,${Math.max(effectiveOpacity, 0.88)})`,
|
||||
boxShadow: darkMode ? '0 12px 32px rgba(0,0,0,0.22)' : '0 10px 24px rgba(15,23,42,0.08)',
|
||||
overflow: 'hidden',
|
||||
}), [darkMode, effectiveOpacity]);
|
||||
|
||||
const heroPanelStyle = useMemo<React.CSSProperties>(() => ({
|
||||
padding: 18,
|
||||
borderRadius: 18,
|
||||
border: darkMode ? '1px solid rgba(255,214,102,0.12)' : '1px solid rgba(24,144,255,0.12)',
|
||||
background: darkMode
|
||||
? 'linear-gradient(135deg, rgba(255,214,102,0.10) 0%, rgba(255,255,255,0.03) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(24,144,255,0.10) 0%, rgba(255,255,255,0.95) 100%)',
|
||||
marginBottom: 18,
|
||||
}), [darkMode]);
|
||||
|
||||
const badgeStyle = useMemo<React.CSSProperties>(() => ({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '6px 10px',
|
||||
borderRadius: 999,
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.10)' : '1px solid rgba(15,23,42,0.08)',
|
||||
background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.86)',
|
||||
color: darkMode ? 'rgba(255,255,255,0.88)' : '#334155',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}), [darkMode]);
|
||||
|
||||
const quietPanelStyle = useMemo<React.CSSProperties>(() => ({
|
||||
padding: 14,
|
||||
borderRadius: 16,
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.08)',
|
||||
background: darkMode ? 'rgba(255,255,255,0.025)' : 'rgba(248,250,252,0.92)',
|
||||
}), [darkMode]);
|
||||
|
||||
const modalWorkspaceStyle = useMemo<React.CSSProperties>(() => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
minHeight: 0,
|
||||
}), []);
|
||||
|
||||
const modalScrollableContentStyle = useMemo<React.CSSProperties>(() => ({
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
paddingRight: 4,
|
||||
overscrollBehavior: 'contain',
|
||||
}), []);
|
||||
|
||||
const modalFooterBarStyle = useMemo<React.CSSProperties>(() => ({
|
||||
marginTop: 18,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 8,
|
||||
paddingTop: 12,
|
||||
borderTop: darkMode ? '1px solid rgba(255,255,255,0.06)' : '1px solid rgba(15,23,42,0.06)',
|
||||
flex: '0 0 auto',
|
||||
}), [darkMode]);
|
||||
|
||||
const renderModalTitle = (title: string, description: string) => (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 14,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: darkMode ? 'rgba(255,214,102,0.12)' : 'rgba(24,144,255,0.10)',
|
||||
color: darkMode ? '#ffd666' : token.colorPrimary,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{isMigrationWorkflow ? <RocketOutlined /> : <SwapOutlined />}
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: darkMode ? '#f8fafc' : '#0f172a' }}>{title}</div>
|
||||
<div style={{ marginTop: 4, fontSize: 12, lineHeight: 1.6, color: darkMode ? 'rgba(255,255,255,0.56)' : 'rgba(15,23,42,0.58)' }}>{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title="数据同步"
|
||||
title={renderModalTitle(isMigrationWorkflow ? '跨库迁移工作台' : '数据同步工作台', isMigrationWorkflow ? '按源库 → 目标库完成建表、导入与风险预检。' : '按已有目标表完成差异对比、同步执行与结果确认。')}
|
||||
open={open}
|
||||
onCancel={() => {
|
||||
if (syncing) {
|
||||
@@ -414,23 +725,61 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
width={800}
|
||||
width={920}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
closable={!syncing}
|
||||
maskClosable={!syncing}
|
||||
styles={{
|
||||
content: modalPanelStyle,
|
||||
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 10 },
|
||||
body: {
|
||||
paddingTop: 8,
|
||||
height: 760,
|
||||
maxHeight: 'calc(100vh - 120px)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
footer: { background: 'transparent', borderTop: 'none', paddingTop: 12 },
|
||||
}}
|
||||
>
|
||||
<div style={modalWorkspaceStyle}>
|
||||
<div style={{ flex: '0 0 auto' }}>
|
||||
<div style={heroPanelStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: darkMode ? '#f8fafc' : '#0f172a' }}>{isMigrationWorkflow ? '跨数据源迁移' : '数据同步'}</div>
|
||||
<div style={{ marginTop: 6, fontSize: 13, lineHeight: 1.7, color: darkMode ? 'rgba(255,255,255,0.62)' : 'rgba(15,23,42,0.62)' }}>
|
||||
{isMigrationWorkflow
|
||||
? '适合把源表迁移到另一套数据库,可按策略自动建表、导入数据并补建可兼容索引。'
|
||||
: '适合目标表已存在的场景,先做差异分析,再按勾选执行插入、更新或删除。'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
<span style={badgeStyle}>{isMigrationWorkflow ? <RocketOutlined /> : <SwapOutlined />} {isMigrationWorkflow ? '迁移模式' : '同步模式'}</span>
|
||||
<span style={badgeStyle}><DatabaseOutlined /> {sourceConnId ? '已选源连接' : '待选源连接'}</span>
|
||||
<span style={badgeStyle}><TableOutlined /> {selectedTables.length || 0} 张表</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Steps current={currentStep} style={{ marginBottom: 24 }}>
|
||||
<Step title="配置源与目标" />
|
||||
<Step title="选择表" />
|
||||
<Step title="执行结果" />
|
||||
</Steps>
|
||||
</div>
|
||||
|
||||
<div style={modalScrollableContentStyle}>
|
||||
{/* STEP 1: CONFIG */}
|
||||
{currentStep === 0 && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: 24, justifyContent: 'center' }}>
|
||||
<Card title="源数据库" style={{ width: 350 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 44px minmax(0, 1fr)', gap: 18, alignItems: 'stretch' }}>
|
||||
<Card
|
||||
title="源数据库"
|
||||
style={shellCardStyle}
|
||||
styles={{ header: { borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.06)', fontWeight: 700 }, body: { padding: 18 } }}
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="连接">
|
||||
<Select value={sourceConnId} onChange={handleSourceConnChange}>
|
||||
@@ -444,8 +793,16 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>至</div>
|
||||
<Card title="目标数据库" style={{ width: 350 }}>
|
||||
<div style={{ display: 'grid', placeItems: 'center' }}>
|
||||
<div style={{ ...badgeStyle, width: 44, height: 44, borderRadius: 14, justifyContent: 'center', padding: 0 }}>
|
||||
<SwapOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<Card
|
||||
title="目标数据库"
|
||||
style={shellCardStyle}
|
||||
styles={{ header: { borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.06)', fontWeight: 700 }, body: { padding: 18 } }}
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="连接">
|
||||
<Select value={targetConnId} onChange={handleTargetConnChange}>
|
||||
@@ -461,27 +818,94 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title="同步选项" style={{ marginTop: 16 }}>
|
||||
<Card
|
||||
title={isMigrationWorkflow ? '迁移选项' : '同步选项'}
|
||||
style={{ ...shellCardStyle, marginTop: 18 }}
|
||||
styles={{ header: { borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.06)', fontWeight: 700 }, body: { padding: 18 } }}
|
||||
>
|
||||
<div style={{ ...quietPanelStyle, marginBottom: 14 }}>
|
||||
<Text style={{ color: darkMode ? 'rgba(255,255,255,0.72)' : 'rgba(15,23,42,0.68)', lineHeight: 1.7 }}>
|
||||
先明确当前要做的是“已有目标表同步”还是“跨库迁移”,页面会按功能类型自动给出更安全的默认策略。
|
||||
</Text>
|
||||
</div>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="同步内容">
|
||||
<Form.Item label="功能类型">
|
||||
<Select value={workflowType} onChange={setWorkflowType}>
|
||||
<Option value="sync">数据同步(基于已有目标表做差异同步)</Option>
|
||||
<Option value="migration">跨库迁移(可自动建表后导入)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Alert
|
||||
type={isMigrationWorkflow ? 'info' : 'success'}
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message={isMigrationWorkflow
|
||||
? '当前为“跨库迁移”模式:适合将表迁移到另一数据源,可自动建表并导入数据。'
|
||||
: '当前为“数据同步”模式:适合目标表已存在时做增量同步或覆盖导入。'}
|
||||
/>
|
||||
<Form.Item label={isMigrationWorkflow ? '迁移内容' : '同步内容'}>
|
||||
<Select value={syncContent} onChange={setSyncContent}>
|
||||
<Option value="data">仅同步数据</Option>
|
||||
<Option value="schema">仅同步结构</Option>
|
||||
<Option value="both">同步结构 + 数据</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="同步模式">
|
||||
<Form.Item label={isMigrationWorkflow ? '迁移模式' : '同步模式'}>
|
||||
<Select value={syncMode} onChange={setSyncMode} disabled={syncContent === 'schema'}>
|
||||
<Option value="insert_update">增量同步(对比差异,按插入/更新/删除勾选执行)</Option>
|
||||
<Option value="insert_only">仅插入(不对比目标;无主键表将跳过)</Option>
|
||||
<Option value="full_overwrite">全量覆盖(清空目标表后插入)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={isMigrationWorkflow ? '目标表处理策略' : '目标表要求'}>
|
||||
<Select value={targetTableStrategy} onChange={setTargetTableStrategy} disabled={!isMigrationWorkflow}>
|
||||
<Option value="existing_only">仅使用已有目标表</Option>
|
||||
<Option value="auto_create_if_missing">目标表不存在时自动建表后导入</Option>
|
||||
<Option value="smart">智能模式(存在则直接导入,不存在则自动建表)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{isRedisMongoKeyspaceMigration && (
|
||||
<Form.Item
|
||||
label="Mongo 集合名(可选)"
|
||||
extra={sourceType === 'redis'
|
||||
? '为空时沿用默认集合名;填写后本次 Redis 键空间会统一写入该 Mongo 集合。'
|
||||
: 'MongoDB → Redis 场景下通常直接选择源集合;这里留空即可,未显式选集合时才会回退使用该名称。'}
|
||||
>
|
||||
<Input
|
||||
value={mongoCollectionName}
|
||||
onChange={(e) => setMongoCollectionName(e.target.value)}
|
||||
placeholder={defaultMongoCollectionName || '请输入 Mongo 集合名'}
|
||||
allowClear
|
||||
maxLength={128}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item>
|
||||
<Checkbox checked={autoAddColumns} onChange={(e) => setAutoAddColumns(e.target.checked)}>
|
||||
自动补齐目标表缺失字段(仅 MySQL 目标)
|
||||
自动补齐目标表缺失字段(当前支持 MySQL 目标及 MySQL → Kingbase)
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Checkbox checked={createIndexes} onChange={(e) => setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only'}>
|
||||
自动迁移可兼容的普通索引/唯一索引(仅自动建表模式生效)
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
{isMigrationWorkflow && targetTableStrategy !== 'existing_only' && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="自动建表模式首期仅支持 MySQL → Kingbase;将迁移字段、主键、普通/唯一/联合索引,并显式跳过全文、空间、前缀、函数类索引。"
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
{!isMigrationWorkflow && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="数据同步模式默认基于已有目标表执行;如需跨数据源建表导入,请切换到“跨库迁移”。"
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
{syncContent !== 'schema' && syncMode === 'full_overwrite' && (
|
||||
<Alert
|
||||
type="warning"
|
||||
@@ -496,26 +920,42 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
|
||||
{/* STEP 2: TABLES */}
|
||||
{currentStep === 1 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text type="secondary">请选择需要同步的表:</Text>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={quietPanelStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<Text type="secondary">请选择需要同步的表:</Text>
|
||||
<Checkbox checked={showSameTables} onChange={(e) => setShowSameTables(e.target.checked)}>
|
||||
显示相同表
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Transfer
|
||||
</div>
|
||||
<Transfer
|
||||
dataSource={allTables.map(t => ({ key: t, title: t }))}
|
||||
titles={['源表', '已选表']}
|
||||
targetKeys={selectedTables}
|
||||
onChange={(keys) => setSelectedTables(keys as string[])}
|
||||
render={item => item.title}
|
||||
listStyle={{ width: 350, height: 280, marginTop: 0 }}
|
||||
locale={{ itemUnit: '项', itemsUnit: '项', searchPlaceholder: '搜索表', notFoundContent: '暂无数据' }}
|
||||
listStyle={{ width: 390, height: 320, marginTop: 0, borderRadius: 14, overflow: 'hidden' }}
|
||||
locale={{ itemUnit: '项', itemsUnit: '项', searchPlaceholder: '搜索表…', notFoundContent: '暂无数据' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{diffTables.length > 0 && (
|
||||
<div>
|
||||
<Divider orientation="left">对比结果</Divider>
|
||||
<div style={quietPanelStyle}>
|
||||
<Divider orientation="left" style={{ marginTop: 0 }}>对比结果</Divider>
|
||||
{analysisWarnings.length > 0 && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="预检发现风险或降级项,请在执行前确认"
|
||||
description={
|
||||
<ul style={{ margin: 0, paddingLeft: 18 }}>
|
||||
{analysisWarnings.slice(0, 8).map((item) => <li key={item}>{item}</li>)}
|
||||
{analysisWarnings.length > 8 && <li>还有 {analysisWarnings.length - 8} 项未展开</li>}
|
||||
</ul>
|
||||
}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
@@ -527,13 +967,29 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
const same = Number(t.same || 0);
|
||||
const msg = String(t.message || '').trim();
|
||||
const can = !!t.canSync;
|
||||
const warns = Array.isArray(t.warnings) ? t.warnings.length : 0;
|
||||
const unsupported = Array.isArray(t.unsupportedObjects) ? t.unsupportedObjects.length : 0;
|
||||
if (showSameTables) return true;
|
||||
if (!can) return true;
|
||||
if (msg) return true;
|
||||
if (msg || warns > 0 || unsupported > 0) return true;
|
||||
return ins > 0 || upd > 0 || del > 0 || same === 0;
|
||||
})}
|
||||
columns={[
|
||||
{ title: '表名', dataIndex: 'table', key: 'table', ellipsis: true },
|
||||
{
|
||||
title: '目标表',
|
||||
key: 'targetTableExists',
|
||||
width: 90,
|
||||
render: (_: any, r: any) => r.targetTableExists ? '已存在' : '不存在'
|
||||
},
|
||||
{
|
||||
title: '计划',
|
||||
dataIndex: 'plannedAction',
|
||||
key: 'plannedAction',
|
||||
width: 220,
|
||||
ellipsis: true,
|
||||
render: (v: any) => String(v || '')
|
||||
},
|
||||
{
|
||||
title: '插入',
|
||||
key: 'inserts',
|
||||
@@ -542,11 +998,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
const ops = tableOptions[r.table] || { insert: true, update: true, delete: false };
|
||||
const disabled = !r.canSync || analyzing || Number(r.inserts || 0) === 0;
|
||||
return (
|
||||
<Checkbox
|
||||
checked={!!ops.insert}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateTableOption(r.table, 'insert', e.target.checked)}
|
||||
>
|
||||
<Checkbox checked={!!ops.insert} disabled={disabled} onChange={(e) => updateTableOption(r.table, 'insert', e.target.checked)}>
|
||||
{Number(r.inserts || 0)}
|
||||
</Checkbox>
|
||||
);
|
||||
@@ -560,11 +1012,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
const ops = tableOptions[r.table] || { insert: true, update: true, delete: false };
|
||||
const disabled = !r.canSync || analyzing || Number(r.updates || 0) === 0;
|
||||
return (
|
||||
<Checkbox
|
||||
checked={!!ops.update}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateTableOption(r.table, 'update', e.target.checked)}
|
||||
>
|
||||
<Checkbox checked={!!ops.update} disabled={disabled} onChange={(e) => updateTableOption(r.table, 'update', e.target.checked)}>
|
||||
{Number(r.updates || 0)}
|
||||
</Checkbox>
|
||||
);
|
||||
@@ -578,18 +1026,28 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
const ops = tableOptions[r.table] || { insert: true, update: true, delete: false };
|
||||
const disabled = !r.canSync || analyzing || Number(r.deletes || 0) === 0;
|
||||
return (
|
||||
<Checkbox
|
||||
checked={!!ops.delete}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateTableOption(r.table, 'delete', e.target.checked)}
|
||||
>
|
||||
<Checkbox checked={!!ops.delete} disabled={disabled} onChange={(e) => updateTableOption(r.table, 'delete', e.target.checked)}>
|
||||
{Number(r.deletes || 0)}
|
||||
</Checkbox>
|
||||
);
|
||||
}
|
||||
},
|
||||
{ title: '相同', dataIndex: 'same', key: 'same', width: 70, render: (v: any) => Number(v || 0) },
|
||||
{ title: '消息', dataIndex: 'message', key: 'message', ellipsis: true, render: (v: any) => (v ? String(v) : '') },
|
||||
{
|
||||
title: '风险',
|
||||
key: 'warnings',
|
||||
width: 220,
|
||||
render: (_: any, r: any) => {
|
||||
const warns = [...(Array.isArray(r.warnings) ? r.warnings : []), ...(Array.isArray(r.unsupportedObjects) ? r.unsupportedObjects : [])];
|
||||
if (warns.length === 0) return '-';
|
||||
return (
|
||||
<div style={{ color: '#d48806', fontSize: 12, lineHeight: 1.5 }}>
|
||||
{warns.slice(0, 2).map((item: string) => <div key={item}>{item}</div>)}
|
||||
{warns.length > 2 && <div>还有 {warns.length - 2} 项</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '预览',
|
||||
key: 'preview',
|
||||
@@ -613,7 +1071,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
|
||||
{/* STEP 3: RESULT */}
|
||||
{currentStep === 2 && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={quietPanelStyle}>
|
||||
<Alert
|
||||
message={syncing ? "正在同步" : (syncResult?.success ? "同步完成" : "同步失败")}
|
||||
description={
|
||||
@@ -625,7 +1084,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
showIcon
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<Progress
|
||||
percent={syncProgress.percent}
|
||||
status={syncing ? "active" : (syncResult?.success ? "success" : "exception")}
|
||||
@@ -633,7 +1092,9 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider orientation="left">日志</Divider>
|
||||
</div>
|
||||
<div style={quietPanelStyle}>
|
||||
<Divider orientation="left" style={{ marginTop: 0 }}>执行日志</Divider>
|
||||
<div
|
||||
ref={logBoxRef}
|
||||
onScroll={() => {
|
||||
@@ -642,14 +1103,25 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
autoScrollRef.current = nearBottom;
|
||||
}}
|
||||
style={{ background: '#f5f5f5', padding: 12, height: 300, overflowY: 'auto', fontFamily: 'monospace' }}
|
||||
style={{
|
||||
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(248,250,252,0.92)',
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.06)',
|
||||
borderRadius: 14,
|
||||
padding: 12,
|
||||
height: 300,
|
||||
overflowY: 'auto',
|
||||
fontFamily: 'SFMono-Regular, ui-monospace, Menlo, Consolas, monospace'
|
||||
}}
|
||||
>
|
||||
{syncLogs.map((item, i: number) => <div key={i}>{renderSyncLogItem(item)}</div>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 24, textAlign: 'right' }}>
|
||||
</div>
|
||||
|
||||
<div style={modalFooterBarStyle}>
|
||||
{currentStep === 0 && (
|
||||
<Button type="primary" onClick={nextToTables} loading={loading}>下一步</Button>
|
||||
)}
|
||||
@@ -676,14 +1148,16 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Drawer
|
||||
title={`差异预览:${previewTable}`}
|
||||
styles={{ body: { background: darkMode ? 'rgba(9,13,20,0.98)' : '#f8fafc' } }}
|
||||
open={previewOpen}
|
||||
onClose={() => { setPreviewOpen(false); setPreviewTable(''); setPreviewData(null); }}
|
||||
width={900}
|
||||
>
|
||||
{previewLoading && <Alert type="info" showIcon message="正在加载差异预览..." />}
|
||||
{previewLoading && <Alert type="info" showIcon message="正在加载差异预览…" />}
|
||||
{!previewLoading && previewData && (
|
||||
<div>
|
||||
<Alert
|
||||
@@ -794,6 +1268,51 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { TabData, ColumnDefinition } from '../types';
|
||||
import { useStore } from '../store';
|
||||
import { DBQuery, DBGetColumns } from '../../wailsjs/go/app/App';
|
||||
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
|
||||
import { buildOrderBySQL, buildWhereSQL, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
||||
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
||||
import { buildMongoCountCommand, buildMongoFilter, buildMongoFindCommand, buildMongoSort } from '../utils/mongodb';
|
||||
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||
|
||||
@@ -155,6 +155,16 @@ const reverseOrderBySQL = (orderBySQL: string): string => {
|
||||
type ViewerFilterSnapshot = {
|
||||
showFilter: boolean;
|
||||
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>();
|
||||
@@ -175,15 +185,23 @@ const normalizeViewerFilterConditions = (conditions: FilterCondition[] | undefin
|
||||
const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => {
|
||||
const cached = viewerFilterSnapshotsByTab.get(String(tabId || '').trim());
|
||||
if (!cached) {
|
||||
return { showFilter: false, conditions: [] };
|
||||
return { showFilter: false, conditions: [], currentPage: 1, pageSize: 100, sortInfo: null, scrollTop: 0, scrollLeft: 0 };
|
||||
}
|
||||
return {
|
||||
showFilter: cached.showFilter === true,
|
||||
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 initialViewerSnapshot = useMemo(() => getViewerFilterSnapshot(tab.id), [tab.id]);
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [columnNames, setColumnNames] = useState<string[]>([]);
|
||||
const [pkColumns, setPkColumns] = useState<string[]>([]);
|
||||
@@ -204,10 +222,15 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const latestDbNameRef = useRef<string>('');
|
||||
const latestCountSqlRef = 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>({
|
||||
current: 1,
|
||||
pageSize: 100,
|
||||
current: initialViewerSnapshot.currentPage,
|
||||
pageSize: initialViewerSnapshot.pageSize,
|
||||
total: 0,
|
||||
totalKnown: false,
|
||||
totalApprox: false,
|
||||
@@ -215,10 +238,10 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
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 [filterConditions, setFilterConditions] = useState<FilterCondition[]>(() => getViewerFilterSnapshot(tab.id).conditions);
|
||||
const [showFilter, setShowFilter] = useState<boolean>(initialViewerSnapshot.showFilter);
|
||||
const [filterConditions, setFilterConditions] = useState<FilterCondition[]>(initialViewerSnapshot.conditions);
|
||||
const duckdbSafeSelectCacheRef = useRef<Record<string, string>>({});
|
||||
const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config;
|
||||
const currentConnCaps = getDataSourceCapabilities(currentConnConfig);
|
||||
@@ -229,16 +252,25 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const snapshot = getViewerFilterSnapshot(tab.id);
|
||||
setShowFilter(snapshot.showFilter);
|
||||
setFilterConditions(snapshot.conditions);
|
||||
setSortInfo(snapshot.sortInfo);
|
||||
scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft };
|
||||
initialLoadRef.current = false;
|
||||
}, [tab.id]);
|
||||
|
||||
useEffect(() => {
|
||||
viewerFilterSnapshotsByTab.set(tab.id, {
|
||||
showFilter,
|
||||
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(() => {
|
||||
const snapshot = getViewerFilterSnapshot(tab.id);
|
||||
setPkColumns([]);
|
||||
pkKeyRef.current = '';
|
||||
countKeyRef.current = '';
|
||||
@@ -250,16 +282,29 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
latestDbNameRef.current = '';
|
||||
latestCountSqlRef.current = '';
|
||||
latestCountKeyRef.current = '';
|
||||
scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft };
|
||||
initialLoadRef.current = false;
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: 1,
|
||||
current: snapshot.currentPage,
|
||||
pageSize: snapshot.pageSize,
|
||||
total: 0,
|
||||
totalKnown: false,
|
||||
totalApprox: false,
|
||||
totalCountLoading: 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 () => {
|
||||
if (latestDbTypeRef.current !== 'duckdb') {
|
||||
@@ -410,7 +455,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
if (pageRowCount > 0) {
|
||||
const tailOffset = Math.max(0, totalRows - (offset + pageRowCount));
|
||||
if (tailOffset < offset) {
|
||||
sql = `${baseSql}${reverseOrderSQL} LIMIT ${pageRowCount} OFFSET ${tailOffset}`;
|
||||
sql = buildPaginatedSelectSQL(dbType, baseSql, reverseOrderSQL, pageRowCount, tailOffset);
|
||||
useClickHouseReversePagination = true;
|
||||
clickHouseReverseLimit = pageRowCount;
|
||||
clickHouseReverseHasMore = currentPage < totalPages;
|
||||
@@ -419,7 +464,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
}
|
||||
if (!useClickHouseReversePagination) {
|
||||
// 大表性能:打开表不阻塞在 COUNT(*),先通过多取 1 条判断是否还有下一页;总数在后台统计并异步回填。
|
||||
sql += ` LIMIT ${size + 1} OFFSET ${offset}`;
|
||||
sql = buildPaginatedSelectSQL(dbType, baseSql, orderBySQL, size + 1, offset);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,8 +534,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
|
||||
if (safeSelect) {
|
||||
let fallbackSql = `SELECT ${safeSelect} FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
||||
fallbackSql += buildOrderBySQL(dbType, sortInfo, pkColumns);
|
||||
fallbackSql += ` LIMIT ${size + 1} OFFSET ${offset}`;
|
||||
fallbackSql = buildPaginatedSelectSQL(dbType, fallbackSql, buildOrderBySQL(dbType, sortInfo, pkColumns), size + 1, offset);
|
||||
executedSql = fallbackSql;
|
||||
resData = await executeDataQuery(fallbackSql, '复杂类型降级重试');
|
||||
}
|
||||
@@ -765,8 +809,13 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
}, [tab.tableName, currentConnConfig?.type, filterConditions, sortInfo, pkColumns]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(1, pagination.pageSize);
|
||||
}, [tab, sortInfo, filterConditions]); // Initial load and re-load on sort/filter
|
||||
if (!initialLoadRef.current) {
|
||||
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 (
|
||||
<div style={{ flex: '1 1 auto', minHeight: 0, minWidth: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
@@ -792,6 +841,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
readOnly={forceReadOnly}
|
||||
sortInfoExternal={sortInfo}
|
||||
exportSqlWithFilter={exportSqlWithFilter || undefined}
|
||||
scrollSnapshot={scrollSnapshotRef.current}
|
||||
onScrollSnapshotChange={handleTableScrollSnapshotChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Alert, Button, Collapse, Input, Modal, Progress, Select, Space, Switch,
|
||||
import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, FolderOpenOutlined, InfoCircleFilled, ReloadOutlined } from '@ant-design/icons';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { useStore } from '../store';
|
||||
import { normalizeOpacityForPlatform } from '../utils/appearance';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import {
|
||||
CheckDriverNetworkStatus,
|
||||
DownloadDriverPackage,
|
||||
@@ -166,7 +166,8 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
const theme = useStore((state) => state.theme);
|
||||
const appearance = useStore((state) => state.appearance);
|
||||
const darkMode = theme === 'dark';
|
||||
const opacity = normalizeOpacityForPlatform(appearance.opacity);
|
||||
const resolvedAppearance = resolveAppearanceValues(appearance);
|
||||
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
|
||||
const modalContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const tableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const tableScrollTargetsRef = useRef<HTMLElement[]>([]);
|
||||
@@ -1223,7 +1224,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
paddingRight: 18,
|
||||
},
|
||||
}}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
footer={(
|
||||
<div className="driver-manager-footer">
|
||||
<div
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { Table, Tag, Button, Tooltip } from 'antd';
|
||||
import { ClearOutlined, CloseOutlined, CaretRightOutlined, BugOutlined } from '@ant-design/icons';
|
||||
import { Table, Tag, Button, Tooltip, Empty } from 'antd';
|
||||
import { ClearOutlined, CloseOutlined, BugOutlined, ClockCircleOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { normalizeOpacityForPlatform } from '../utils/appearance';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
|
||||
interface LogPanelProps {
|
||||
height: number;
|
||||
@@ -16,7 +16,8 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
||||
const theme = useStore(state => state.theme);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const darkMode = theme === 'dark';
|
||||
const opacity = normalizeOpacityForPlatform(appearance.opacity);
|
||||
const resolvedAppearance = resolveAppearanceValues(appearance);
|
||||
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
|
||||
|
||||
// Background Helper
|
||||
const getBg = (darkHex: string) => {
|
||||
@@ -28,10 +29,25 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
};
|
||||
const bgMain = getBg('#1d1d1d');
|
||||
const panelDividerColor = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
|
||||
const shellOpacity = darkMode ? Math.max(0.18, opacity * 0.82) : Math.max(0.28, opacity * 0.92);
|
||||
const shellOpacityStrong = darkMode ? Math.max(0.22, opacity * 0.9) : Math.max(0.34, opacity * 0.96);
|
||||
const panelDividerColor = darkMode
|
||||
? `rgba(255,255,255,${Math.max(0.04, opacity * 0.10)})`
|
||||
: `rgba(0,0,0,${Math.max(0.04, opacity * 0.08)})`;
|
||||
const panelMutedTextColor = darkMode ? 'rgba(255,255,255,0.62)' : 'rgba(0,0,0,0.58)';
|
||||
const logScrollbarThumb = darkMode ? 'rgba(255, 255, 255, 0.34)' : 'rgba(0, 0, 0, 0.26)';
|
||||
const logScrollbarThumbHover = darkMode ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.36)';
|
||||
const panelShellBg = darkMode
|
||||
? `linear-gradient(180deg, rgba(15,20,30,${shellOpacity}) 0%, rgba(9,13,22,${shellOpacityStrong}) 100%)`
|
||||
: `linear-gradient(180deg, rgba(255,255,255,${shellOpacityStrong}) 0%, rgba(246,248,252,${shellOpacity}) 100%)`;
|
||||
const panelAccentColor = darkMode ? '#ffd666' : '#1677ff';
|
||||
const panelShadow = darkMode
|
||||
? `0 12px 28px rgba(0,0,0,${Math.max(0.05, opacity * 0.18)})`
|
||||
: `0 12px 24px rgba(15,23,42,${Math.max(0.02, opacity * 0.08)})`;
|
||||
const logScrollbarThumb = darkMode
|
||||
? `rgba(255, 255, 255, ${Math.max(0.18, opacity * 0.34)})`
|
||||
: `rgba(0, 0, 0, ${Math.max(0.12, opacity * 0.26)})`;
|
||||
const logScrollbarThumbHover = darkMode
|
||||
? `rgba(255, 255, 255, ${Math.max(0.28, opacity * 0.48)})`
|
||||
: `rgba(0, 0, 0, ${Math.max(0.18, opacity * 0.36)})`;
|
||||
|
||||
const columns = [
|
||||
{
|
||||
@@ -45,7 +61,7 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
||||
dataIndex: 'status',
|
||||
width: 70,
|
||||
render: (status: string) => (
|
||||
<Tag color={status === 'success' ? 'success' : 'error'} style={{ marginRight: 0 }}>
|
||||
<Tag color={status === 'success' ? 'success' : 'error'} style={{ marginRight: 0, borderRadius: 999, paddingInline: 8, fontSize: 11, fontWeight: 700 }}>
|
||||
{status === 'success' ? 'OK' : 'ERR'}
|
||||
</Tag>
|
||||
)
|
||||
@@ -60,7 +76,7 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
||||
title: 'SQL / Message',
|
||||
dataIndex: 'sql',
|
||||
render: (text: string, record: any) => (
|
||||
<div style={{ fontFamily: 'monospace', wordBreak: 'break-all', fontSize: '12px', lineHeight: '1.2' }}>
|
||||
<div style={{ fontFamily: 'monospace', wordBreak: 'break-all', fontSize: '12px', lineHeight: '1.45' }}>
|
||||
<div style={{ color: darkMode ? '#a6e22e' : '#005cc5' }}>{text}</div>
|
||||
{record.message && <div style={{ color: '#ff4d4f', marginTop: 2 }}>{record.message}</div>}
|
||||
{record.affectedRows !== undefined && <div style={{ color: panelMutedTextColor, marginTop: 1 }}>Affected: {record.affectedRows}</div>}
|
||||
@@ -72,12 +88,18 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
||||
return (
|
||||
<div style={{
|
||||
height,
|
||||
borderTop: `1px solid ${panelDividerColor}`,
|
||||
background: bgMain,
|
||||
margin: 0,
|
||||
border: `1px solid ${panelDividerColor}`,
|
||||
borderRadius: 14,
|
||||
background: panelShellBg,
|
||||
WebkitBackdropFilter: opacity < 0.999 ? 'blur(14px)' : 'none',
|
||||
boxShadow: panelShadow,
|
||||
backdropFilter: darkMode && opacity < 0.999 ? 'blur(18px)' : 'none',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
zIndex: 100 // Ensure above other content
|
||||
overflow: 'hidden',
|
||||
zIndex: 100
|
||||
}}>
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
@@ -95,38 +117,53 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
||||
|
||||
{/* Toolbar */}
|
||||
<div style={{
|
||||
padding: '4px 8px',
|
||||
padding: '10px 14px',
|
||||
borderBottom: `1px solid ${panelDividerColor}`,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
height: 32
|
||||
gap: 12,
|
||||
minHeight: 48
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 'bold', fontSize: '12px' }}>
|
||||
<BugOutlined /> SQL 执行日志
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||
<div style={{ width: 30, height: 30, borderRadius: 10, display: 'grid', placeItems: 'center', background: darkMode ? `rgba(255,214,102,${Math.max(0.10, Math.min(0.18, opacity * 0.18))})` : `rgba(24,144,255,${Math.max(0.08, Math.min(0.16, opacity * 0.16))})`, color: panelAccentColor, flexShrink: 0 }}>
|
||||
<BugOutlined />
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 13, color: darkMode ? '#f5f7ff' : '#162033' }}>SQL 执行日志</div>
|
||||
<div style={{ fontSize: 12, color: panelMutedTextColor }}>记录执行状态、耗时与错误信息,便于快速回溯。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Tooltip title="清空日志">
|
||||
<Button type="text" size="small" icon={<ClearOutlined />} onClick={clearSqlLogs} />
|
||||
<Button type="text" size="small" icon={<ClearOutlined />} onClick={clearSqlLogs} style={{ color: panelMutedTextColor }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="关闭面板">
|
||||
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} />
|
||||
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} style={{ color: panelMutedTextColor }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="log-panel-scroll" style={{ flex: 1, overflow: 'auto' }}>
|
||||
<Table
|
||||
className="log-panel-table"
|
||||
dataSource={sqlLogs}
|
||||
columns={columns}
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
showHeader={false}
|
||||
// scroll={{ y: height - 32 }} // Let flex handle it
|
||||
/>
|
||||
<div className="log-panel-scroll" style={{ flex: 1, overflow: 'auto', padding: '8px 10px 10px' }}>
|
||||
{sqlLogs.length === 0 ? (
|
||||
<div style={{ height: '100%', minHeight: 160, display: 'grid', placeItems: 'center' }}>
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={<span style={{ color: panelMutedTextColor }}>暂无 SQL 执行日志</span>}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
className="log-panel-table"
|
||||
dataSource={sqlLogs}
|
||||
columns={columns}
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
showHeader={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<style>{`
|
||||
.log-panel-scroll {
|
||||
@@ -156,6 +193,16 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
||||
.log-panel-table .ant-table-tbody > tr > td {
|
||||
background: transparent !important;
|
||||
}
|
||||
.log-panel-table .ant-table-tbody > tr > td {
|
||||
padding: 8px 10px !important;
|
||||
border-bottom: 1px solid ${panelDividerColor} !important;
|
||||
}
|
||||
.log-panel-table .ant-table-tbody > tr:last-child > td {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
.log-panel-table .ant-table-row:hover > td {
|
||||
background: ${darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(16,24,40,0.03)'} !important;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -48,6 +48,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const [editorHeight, setEditorHeight] = useState(300);
|
||||
const editorRef = useRef<any>(null);
|
||||
const monacoRef = useRef<any>(null);
|
||||
const lastExternalQueryRef = useRef<string>(tab.query || '');
|
||||
const dragRef = useRef<{ startY: number, startHeight: number } | null>(null);
|
||||
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)
|
||||
@@ -95,10 +96,30 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
connectionsRef.current = 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
|
||||
useEffect(() => {
|
||||
if (tab.query) setQuery(tab.query);
|
||||
}, [tab.query]);
|
||||
const incoming = tab.query || '';
|
||||
if (incoming === lastExternalQueryRef.current) {
|
||||
return;
|
||||
}
|
||||
lastExternalQueryRef.current = incoming;
|
||||
syncQueryToEditor(incoming || 'SELECT * FROM ');
|
||||
}, [tab.id, tab.query]);
|
||||
|
||||
// Fetch Database List
|
||||
useEffect(() => {
|
||||
@@ -557,8 +578,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
|
||||
const handleFormat = () => {
|
||||
try {
|
||||
const formatted = format(query, { language: 'mysql', keywordCase: sqlFormatOptions.keywordCase });
|
||||
setQuery(formatted);
|
||||
const formatted = format(getCurrentQuery(), { language: 'mysql', keywordCase: sqlFormatOptions.keywordCase });
|
||||
syncQueryToEditor(formatted);
|
||||
} catch (e) {
|
||||
message.error("格式化失败: SQL 语法可能有误");
|
||||
}
|
||||
@@ -1045,7 +1066,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
};
|
||||
|
||||
const handleRun = async () => {
|
||||
if (!query.trim()) return;
|
||||
const currentQuery = getCurrentQuery();
|
||||
if (!currentQuery.trim()) return;
|
||||
if (!currentDb) {
|
||||
message.error("请先选择数据库");
|
||||
return;
|
||||
@@ -1086,7 +1108,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
};
|
||||
|
||||
try {
|
||||
const rawSQL = getSelectedSQL() || query;
|
||||
const rawSQL = getSelectedSQL() || currentQuery;
|
||||
const dbType = String((config as any).type || 'mysql');
|
||||
const normalizedDbType = dbType.trim().toLowerCase();
|
||||
const normalizedRawSQL = String(rawSQL || '').replace(/;/g, ';');
|
||||
@@ -1367,7 +1389,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
saveQuery({
|
||||
id: tab.id.startsWith('saved-') ? tab.id : `saved-${Date.now()}`,
|
||||
name: values.name,
|
||||
sql: query,
|
||||
sql: getCurrentQuery(),
|
||||
connectionId: currentConnectionId,
|
||||
dbName: currentDb || tab.dbName || '',
|
||||
createdAt: Date.now()
|
||||
@@ -1512,7 +1534,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
height="100%"
|
||||
defaultLanguage="sql"
|
||||
theme={darkMode ? "transparent-dark" : "transparent-light"}
|
||||
value={query}
|
||||
defaultValue={query}
|
||||
onChange={(val) => setQuery(val || '')}
|
||||
onMount={handleEditorDidMount}
|
||||
options={{
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useStore } from '../store';
|
||||
import { RedisKeyInfo, RedisValue, StreamEntry } from '../types';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import { normalizeOpacityForPlatform } from '../utils/appearance';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
@@ -399,7 +399,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const theme = useStore(state => state.theme);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const darkMode = theme === 'dark';
|
||||
const opacity = normalizeOpacityForPlatform(appearance.opacity);
|
||||
const resolvedAppearance = resolveAppearanceValues(appearance);
|
||||
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
|
||||
const connection = connections.find(c => c.id === connectionId);
|
||||
const keyAccentColor = darkMode ? '#ffd666' : '#1677ff';
|
||||
const jsonAccentColor = darkMode ? '#f6c453' : '#1890ff';
|
||||
|
||||
@@ -27,12 +27,15 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
|
||||
DisconnectOutlined,
|
||||
CloudOutlined,
|
||||
CheckSquareOutlined,
|
||||
CodeOutlined
|
||||
CodeOutlined,
|
||||
TagOutlined,
|
||||
CheckOutlined,
|
||||
FilterOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { SavedConnection } from '../types';
|
||||
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView } from '../../wailsjs/go/app/App';
|
||||
import { normalizeOpacityForPlatform } from '../utils/appearance';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
@@ -73,6 +76,15 @@ const SEARCH_SCOPE_LABEL_MAP: Record<SearchScope, string> = SEARCH_SCOPE_OPTIONS
|
||||
return acc;
|
||||
}, {} as Record<SearchScope, string>);
|
||||
|
||||
|
||||
const SEARCH_SCOPE_ICON_MAP: Record<SearchScope, React.ReactNode> = {
|
||||
smart: <ThunderboltOutlined />,
|
||||
object: <TableOutlined />,
|
||||
database: <DatabaseOutlined />,
|
||||
host: <CloudOutlined />,
|
||||
tag: <TagOutlined />,
|
||||
};
|
||||
|
||||
const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> = ({ onEditConnection }) => {
|
||||
const connections = useStore(state => state.connections);
|
||||
const savedQueries = useStore(state => state.savedQueries);
|
||||
@@ -95,7 +107,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const recordTableAccess = useStore(state => state.recordTableAccess);
|
||||
const setTableSortPreference = useStore(state => state.setTableSortPreference);
|
||||
const darkMode = theme === 'dark';
|
||||
const opacity = normalizeOpacityForPlatform(appearance.opacity);
|
||||
const resolvedAppearance = resolveAppearanceValues(appearance);
|
||||
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
|
||||
const [treeData, setTreeData] = useState<TreeNode[]>([]);
|
||||
|
||||
// Background Helper (Duplicate logic for now, ideally shared)
|
||||
@@ -108,6 +121,44 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
};
|
||||
const bgMain = getBg('#141414');
|
||||
const modalPanelStyle = useMemo(() => ({
|
||||
background: darkMode
|
||||
? 'linear-gradient(180deg, rgba(20,26,38,0.96) 0%, rgba(13,17,26,0.98) 100%)'
|
||||
: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
|
||||
boxShadow: darkMode ? '0 20px 48px rgba(0,0,0,0.38)' : '0 18px 42px rgba(15,23,42,0.12)',
|
||||
backdropFilter: darkMode ? 'blur(18px)' : 'none',
|
||||
}), [darkMode]);
|
||||
const modalSectionStyle = useMemo(() => ({
|
||||
padding: 14,
|
||||
borderRadius: 14,
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
|
||||
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.84)',
|
||||
}), [darkMode]);
|
||||
const modalScrollSectionStyle = useMemo(() => ({
|
||||
maxHeight: 400,
|
||||
overflow: 'auto' as const,
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
|
||||
borderRadius: 14,
|
||||
padding: 12,
|
||||
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.8)',
|
||||
}), [darkMode]);
|
||||
const modalHintTextStyle = useMemo(() => ({
|
||||
color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
}), [darkMode]);
|
||||
const renderSidebarModalTitle = (icon: React.ReactNode, title: string, description: string) => (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div style={{ width: 34, height: 34, borderRadius: 12, display: 'grid', placeItems: 'center', background: darkMode ? 'rgba(255,214,102,0.12)' : 'rgba(24,144,255,0.1)', color: darkMode ? '#ffd666' : '#1677ff', flexShrink: 0 }}>
|
||||
{icon}
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: darkMode ? '#f5f7ff' : '#162033' }}>{title}</div>
|
||||
<div style={{ marginTop: 4, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', fontSize: 12, lineHeight: 1.6 }}>{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [searchScopes, setSearchScopes] = useState<SearchScope[]>(['smart']);
|
||||
const [isSearchScopePopoverOpen, setIsSearchScopePopoverOpen] = useState(false);
|
||||
@@ -382,6 +433,16 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
password: readString(rawProxy.password, rawProxy.Password, cloned.proxyPassword, cloned.ProxyPassword),
|
||||
};
|
||||
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)
|
||||
? cloned.hosts
|
||||
@@ -394,8 +455,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
...(cloned as SavedConnection['config']),
|
||||
useSSH: readBool(hasSSHDetail, cloned.useSSH, cloned.UseSSH),
|
||||
ssh: normalizedSSH,
|
||||
useProxy: readBool(hasProxyDetail, cloned.useProxy, cloned.UseProxy),
|
||||
useProxy: normalizedUseProxy,
|
||||
proxy: normalizedProxy,
|
||||
useHttpTunnel: normalizedUseHttpTunnel,
|
||||
httpTunnel: normalizedHttpTunnel,
|
||||
hosts: normalizedHosts,
|
||||
timeout: readNumber(30, cloned.timeout, cloned.Timeout),
|
||||
};
|
||||
@@ -645,10 +708,15 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
}
|
||||
case 'oracle':
|
||||
case 'dm':
|
||||
if (!safeDbName) {
|
||||
return [{ sql: `SELECT VIEW_NAME AS view_name FROM USER_VIEWS 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` }];
|
||||
return normalizeMetadataQuerySpecs([
|
||||
{ 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` },
|
||||
{
|
||||
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':
|
||||
return [{ sql: `SELECT name AS view_name FROM sqlite_master WHERE type = 'view' ORDER BY name` }];
|
||||
case 'duckdb':
|
||||
@@ -731,10 +799,15 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
}
|
||||
case 'oracle':
|
||||
case 'dm':
|
||||
if (!safeDbName) {
|
||||
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` }];
|
||||
}
|
||||
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` }];
|
||||
return normalizeMetadataQuerySpecs([
|
||||
{ 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` },
|
||||
{
|
||||
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':
|
||||
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`,
|
||||
@@ -2449,32 +2522,100 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const searchScopePopoverContent = useMemo(() => {
|
||||
const smartSelected = searchScopes.includes('smart');
|
||||
const scopedOptions = SEARCH_SCOPE_OPTIONS.filter((option) => option.value !== 'smart');
|
||||
const borderColor = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(16,24,40,0.08)';
|
||||
const mutedTextColor = darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)';
|
||||
const titleColor = darkMode ? 'rgba(255,255,255,0.92)' : '#162033';
|
||||
const panelBg = darkMode
|
||||
? 'linear-gradient(180deg, rgba(17,24,39,0.96) 0%, rgba(10,15,26,0.98) 100%)'
|
||||
: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)';
|
||||
const smartBg = smartSelected
|
||||
? (darkMode ? 'linear-gradient(135deg, rgba(255,214,102,0.22) 0%, rgba(255,179,71,0.16) 100%)' : 'linear-gradient(135deg, rgba(255,214,102,0.26) 0%, rgba(255,244,204,0.92) 100%)')
|
||||
: (darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.72)');
|
||||
const smartBorder = smartSelected
|
||||
? (darkMode ? 'rgba(255,214,102,0.42)' : 'rgba(245,176,65,0.34)')
|
||||
: borderColor;
|
||||
const getOptionCardStyle = (checked: boolean) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center' as const,
|
||||
justifyContent: 'space-between' as const,
|
||||
gap: 12,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${checked ? (darkMode ? 'rgba(118,169,250,0.44)' : 'rgba(24,144,255,0.32)') : borderColor}`,
|
||||
background: checked
|
||||
? (darkMode ? 'rgba(64,124,255,0.18)' : 'rgba(24,144,255,0.08)')
|
||||
: (darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.76)'),
|
||||
transition: 'all 120ms ease',
|
||||
});
|
||||
return (
|
||||
<div style={{ minWidth: 220, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontSize: 12, color: '#8c8c8c' }}>搜索范围</div>
|
||||
<Checkbox
|
||||
checked={smartSelected}
|
||||
onChange={(e) => setSearchScopeChecked('smart', e.target.checked)}
|
||||
>
|
||||
智能(推荐)
|
||||
</Checkbox>
|
||||
<div style={{ paddingLeft: 12, display: 'grid', gap: 6 }}>
|
||||
{scopedOptions.map((option) => (
|
||||
<Checkbox
|
||||
key={option.value}
|
||||
checked={searchScopes.includes(option.value)}
|
||||
onChange={(e) => setSearchScopeChecked(option.value, e.target.checked)}
|
||||
>
|
||||
{option.label}
|
||||
</Checkbox>
|
||||
))}
|
||||
<div style={{ minWidth: 280, display: 'flex', flexDirection: 'column', background: panelBg, padding: 14, gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, letterSpacing: 0.4, color: mutedTextColor, textTransform: 'uppercase' }}>搜索范围</div>
|
||||
<div style={{ marginTop: 4, fontSize: 13, lineHeight: 1.5, color: mutedTextColor }}>“智能”自动匹配最可能的命中项;手动模式支持按维度组合筛选。</div>
|
||||
</div>
|
||||
<div style={{ width: 32, height: 32, borderRadius: 10, display: 'grid', placeItems: 'center', background: darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(17,24,39,0.06)', color: darkMode ? '#ffd666' : '#1677ff', flexShrink: 0 }}>
|
||||
<FilterOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#8c8c8c' }}>
|
||||
智能与其他项互斥;其他项支持多选。
|
||||
|
||||
<label style={{ display: 'block', cursor: 'pointer' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px', borderRadius: 14, border: `1px solid ${smartBorder}`, background: smartBg, boxShadow: smartSelected ? (darkMode ? '0 10px 24px rgba(0,0,0,0.24)' : '0 10px 24px rgba(245,176,65,0.14)') : 'none' }}>
|
||||
<Checkbox
|
||||
checked={smartSelected}
|
||||
onChange={(e) => setSearchScopeChecked('smart', e.target.checked)}
|
||||
/>
|
||||
<div style={{ width: 30, height: 30, borderRadius: 10, display: 'grid', placeItems: 'center', background: darkMode ? 'rgba(255,214,102,0.16)' : 'rgba(255,214,102,0.3)', color: darkMode ? '#ffd666' : '#ad6800', flexShrink: 0 }}>
|
||||
{SEARCH_SCOPE_ICON_MAP.smart}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: titleColor }}>智能</span>
|
||||
<span style={{ padding: '2px 8px', borderRadius: 999, fontSize: 11, fontWeight: 700, color: darkMode ? '#ffe58f' : '#ad6800', background: darkMode ? 'rgba(255,214,102,0.16)' : 'rgba(255,214,102,0.35)' }}>推荐</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 3, fontSize: 12, lineHeight: 1.5, color: mutedTextColor }}>适合日常检索,自动覆盖名称、库、Host 和标签等高频维度。</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div style={{ height: 1, background: borderColor, opacity: 0.9 }} />
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, letterSpacing: 0.3, color: mutedTextColor, textTransform: 'uppercase' }}>手动范围</div>
|
||||
<div style={{ fontSize: 12, color: mutedTextColor }}>支持多选组合</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{scopedOptions.map((option) => {
|
||||
const checked = searchScopes.includes(option.value);
|
||||
return (
|
||||
<label key={option.value} style={{ display: 'block', cursor: 'pointer' }}>
|
||||
<div style={getOptionCardStyle(checked)}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 0 }}>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onChange={(e) => setSearchScopeChecked(option.value, e.target.checked)}
|
||||
/>
|
||||
<div style={{ width: 28, height: 28, borderRadius: 9, display: 'grid', placeItems: 'center', background: checked ? (darkMode ? 'rgba(118,169,250,0.2)' : 'rgba(24,144,255,0.12)') : (darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(17,24,39,0.06)'), color: checked ? (darkMode ? '#91caff' : '#1677ff') : mutedTextColor, flexShrink: 0 }}>
|
||||
{SEARCH_SCOPE_ICON_MAP[option.value]}
|
||||
</div>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: titleColor, whiteSpace: 'nowrap' }}>{option.label}</span>
|
||||
</div>
|
||||
<div style={{ width: 18, display: 'flex', justifyContent: 'center', color: checked ? (darkMode ? '#91caff' : '#1677ff') : 'transparent', flexShrink: 0 }}>
|
||||
<CheckOutlined />
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '10px 12px', borderRadius: 12, background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(17,24,39,0.04)', color: mutedTextColor, fontSize: 12, lineHeight: 1.6 }}>
|
||||
智能与其他项互斥。若你明确知道要搜的是对象、库、Host 或标签,建议切到手动范围以减少噪音结果。
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [searchScopes]);
|
||||
}, [darkMode, searchScopes]);
|
||||
|
||||
const parseHostOnlyToken = (value: unknown): string[] => {
|
||||
const raw = String(value || '').trim();
|
||||
@@ -3279,14 +3420,14 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ padding: '4px 8px' }}>
|
||||
<Space.Compact block size="small">
|
||||
<div style={{ padding: '4px 10px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Search
|
||||
ref={searchInputRef}
|
||||
placeholder="搜索..."
|
||||
onChange={onSearch}
|
||||
size="small"
|
||||
style={{ width: '100%' }}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
<Popover
|
||||
content={searchScopePopoverContent}
|
||||
@@ -3294,18 +3435,66 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
placement="bottomRight"
|
||||
open={isSearchScopePopoverOpen}
|
||||
onOpenChange={setIsSearchScopePopoverOpen}
|
||||
styles={{ body: { padding: 0, borderRadius: 18, overflow: 'hidden' } }}
|
||||
>
|
||||
<Tooltip title={`搜索范围:${searchScopeSummary}`}>
|
||||
<Button size="small" icon={<DownOutlined />} style={{ width: 86 }}>
|
||||
范围{searchScopes.includes('smart') ? '(智)' : `(${searchScopes.length})`}
|
||||
<Button
|
||||
size="small"
|
||||
style={{
|
||||
minWidth: 86,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
paddingInline: 10,
|
||||
borderRadius: 10,
|
||||
borderColor: darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(16,24,40,0.12)',
|
||||
background: darkMode ? bgMain : 'rgba(255,255,255,0.92)',
|
||||
color: darkMode ? 'rgba(255,255,255,0.88)' : '#162033',
|
||||
boxShadow: isSearchScopePopoverOpen
|
||||
? (darkMode ? '0 0 0 1px rgba(255,214,102,0.22) inset' : '0 0 0 1px rgba(24,144,255,0.24) inset')
|
||||
: 'none',
|
||||
backdropFilter: darkMode ? 'blur(10px)' : 'none',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', color: searchScopes.includes('smart') ? '#ffd666' : (darkMode ? 'rgba(255,255,255,0.72)' : 'rgba(22,32,51,0.72)') }}>
|
||||
<FilterOutlined />
|
||||
</span>
|
||||
<span style={{ fontWeight: 700, color: darkMode ? 'rgba(255,255,255,0.88)' : '#162033' }}>筛选</span>
|
||||
<span
|
||||
style={{
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
padding: '0 5px',
|
||||
borderRadius: 999,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
background: searchScopes.includes('smart')
|
||||
? (darkMode ? 'rgba(255,214,102,0.16)' : 'rgba(24,144,255,0.12)')
|
||||
: (darkMode ? 'rgba(118,169,250,0.18)' : 'rgba(24,144,255,0.12)'),
|
||||
color: searchScopes.includes('smart')
|
||||
? (darkMode ? '#ffd666' : '#1677ff')
|
||||
: (darkMode ? '#91caff' : '#1677ff'),
|
||||
}}
|
||||
>
|
||||
{searchScopes.includes('smart') ? '智' : searchScopes.length}
|
||||
</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', color: darkMode ? 'rgba(255,255,255,0.48)' : 'rgba(22,32,51,0.4)', fontSize: 12 }}>
|
||||
<DownOutlined />
|
||||
</span>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
</Space.Compact>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div style={{ padding: '4px 8px', borderBottom: 'none', display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
<div style={{ padding: '4px 10px', borderBottom: 'none', display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FolderOpenOutlined />}
|
||||
@@ -3373,8 +3562,14 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title={renameViewTarget?.type === 'tag' ? "编辑标签" : "新建组"}
|
||||
title={renderSidebarModalTitle(
|
||||
<FolderOpenOutlined />,
|
||||
renameViewTarget?.type === 'tag' ? "编辑标签" : "新建组",
|
||||
renameViewTarget?.type === 'tag' ? "调整分组名称和包含的连接。" : "为连接树创建一个更清晰的分组视图。"
|
||||
)}
|
||||
open={isCreateTagModalOpen}
|
||||
centered
|
||||
styles={{ content: modalPanelStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 10 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 12 } }}
|
||||
onOk={() => {
|
||||
createTagForm.validateFields().then(values => {
|
||||
if (renameViewTarget?.type === 'tag') {
|
||||
@@ -3409,20 +3604,24 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
onCancel={() => setIsCreateTagModalOpen(false)}
|
||||
>
|
||||
<Form form={createTagForm} layout="vertical">
|
||||
<Form.Item name="name" label="标签名称" rules={[{ required: true, message: '请输入标签名称' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="connectionIds" label="选择连接">
|
||||
<Checkbox.Group style={{ width: '100%' }}>
|
||||
<Space direction="vertical" style={{ width: '100%', maxHeight: '400px', overflowY: 'auto' }}>
|
||||
{connections.map(conn => (
|
||||
<Checkbox key={conn.id} value={conn.id}>
|
||||
{conn.name} {conn.config.host ? `(${conn.config.host})` : ''}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</Checkbox.Group>
|
||||
</Form.Item>
|
||||
<div style={modalSectionStyle}>
|
||||
<Form.Item name="name" label="标签名称" rules={[{ required: true, message: '请输入标签名称' }]}>
|
||||
<Input placeholder="例如:线上环境 / 核心业务 / 临时调试" />
|
||||
</Form.Item>
|
||||
<Form.Item name="connectionIds" label="选择连接" style={{ marginBottom: 0 }}>
|
||||
<Checkbox.Group style={{ width: '100%' }}>
|
||||
<div style={modalScrollSectionStyle}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{connections.map(conn => (
|
||||
<Checkbox key={conn.id} value={conn.id}>
|
||||
{conn.name} {conn.config.host ? `(${conn.config.host})` : ''}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
</Checkbox.Group>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -3492,10 +3691,12 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="批量操作表"
|
||||
title={renderSidebarModalTitle(<TableOutlined />, "批量操作表", "按对象批量导出结构、数据或完整备份。")}
|
||||
open={isBatchModalOpen}
|
||||
onCancel={() => setIsBatchModalOpen(false)}
|
||||
width={680}
|
||||
width={720}
|
||||
centered
|
||||
styles={{ content: modalPanelStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 10 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 12 } }}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Button key="cancel" onClick={() => setIsBatchModalOpen(false)}>
|
||||
@@ -3531,7 +3732,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ ...modalSectionStyle, marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>选择连接:</label>
|
||||
<Select
|
||||
@@ -3563,10 +3764,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div style={modalHintTextStyle}>先选择连接与数据库,再决定导出范围和目标对象。</div>
|
||||
</div>
|
||||
|
||||
{batchTables.length > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ ...modalSectionStyle, marginBottom: 16 }}>
|
||||
<Space wrap size={8} style={{ width: '100%' }}>
|
||||
<Input
|
||||
allowClear
|
||||
@@ -3604,7 +3806,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
|
||||
{batchTables.length > 0 && (
|
||||
<>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ ...modalSectionStyle, marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
@@ -3632,7 +3834,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
</span>
|
||||
</Space>
|
||||
</div>
|
||||
<div style={{ maxHeight: 400, overflow: 'auto', border: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', borderRadius: 4, padding: 8 }}>
|
||||
<div style={modalScrollSectionStyle}>
|
||||
<Checkbox.Group
|
||||
value={checkedTableKeys}
|
||||
onChange={(values) => setCheckedTableKeys(values as string[])}
|
||||
@@ -3682,10 +3884,12 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="批量操作库"
|
||||
title={renderSidebarModalTitle(<DatabaseOutlined />, "批量操作库", "按数据库批量导出结构,或生成结构加数据的备份。")}
|
||||
open={isBatchDbModalOpen}
|
||||
onCancel={() => setIsBatchDbModalOpen(false)}
|
||||
width={600}
|
||||
width={640}
|
||||
centered
|
||||
styles={{ content: modalPanelStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 10 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 12 } }}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={() => setIsBatchDbModalOpen(false)}>
|
||||
取消
|
||||
@@ -3709,8 +3913,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>选择连接:</label>
|
||||
<div style={{ ...modalSectionStyle, marginBottom: 16 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, color: darkMode ? '#f5f7ff' : '#162033' }}>选择连接:</label>
|
||||
<Select
|
||||
value={selectedDbConnection}
|
||||
onChange={handleDbConnectionChange}
|
||||
@@ -3723,11 +3927,12 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<div style={{ ...modalHintTextStyle, marginTop: 10 }}>连接选定后会加载当前连接下可批量导出的数据库列表。</div>
|
||||
</div>
|
||||
|
||||
{batchDatabases.length > 0 && (
|
||||
<>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ ...modalSectionStyle, marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
@@ -3752,7 +3957,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
</span>
|
||||
</Space>
|
||||
</div>
|
||||
<div style={{ maxHeight: 400, overflow: 'auto', border: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', borderRadius: 4, padding: 8 }}>
|
||||
<div style={modalScrollSectionStyle}>
|
||||
<Checkbox.Group
|
||||
value={checkedDbKeys}
|
||||
onChange={(values) => setCheckedDbKeys(values as string[])}
|
||||
|
||||
@@ -2491,7 +2491,7 @@ END;`;
|
||||
okText="应用"
|
||||
cancelText="取消"
|
||||
width={640}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Input.TextArea
|
||||
value={commentEditorValue}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
sanitizeShortcutOptions,
|
||||
} from './utils/shortcuts';
|
||||
|
||||
const DEFAULT_APPEARANCE = { opacity: 1.0, blur: 0 };
|
||||
const DEFAULT_APPEARANCE = { enabled: true, opacity: 1.0, blur: 0 };
|
||||
const DEFAULT_UI_SCALE = 1.0;
|
||||
const MIN_UI_SCALE = 0.8;
|
||||
const MAX_UI_SCALE = 1.25;
|
||||
@@ -25,7 +25,7 @@ const MAX_HOST_ENTRY_LENGTH = 512;
|
||||
const MAX_HOST_ENTRIES = 64;
|
||||
const DEFAULT_TIMEOUT_SECONDS = 30;
|
||||
const MAX_TIMEOUT_SECONDS = 3600;
|
||||
const PERSIST_VERSION = 5;
|
||||
const PERSIST_VERSION = 6;
|
||||
const DEFAULT_CONNECTION_TYPE = 'mysql';
|
||||
const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = {
|
||||
enabled: false,
|
||||
@@ -231,6 +231,18 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
|
||||
user: toTrimmedString(proxyRaw.user),
|
||||
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> = {
|
||||
...raw,
|
||||
@@ -247,8 +259,10 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
|
||||
sslKeyPath: sslCapable ? toTrimmedString(raw.sslKeyPath) : '',
|
||||
useSSH: !!raw.useSSH,
|
||||
ssh,
|
||||
useProxy: !!raw.useProxy,
|
||||
useProxy,
|
||||
proxy,
|
||||
useHttpTunnel,
|
||||
httpTunnel,
|
||||
uri: toTrimmedString(raw.uri).slice(0, MAX_URI_LENGTH),
|
||||
hosts: sanitizeAddressList(raw.hosts),
|
||||
topology: raw.topology === 'replica' ? 'replica' : (raw.topology === 'cluster' ? 'cluster' : 'single'),
|
||||
@@ -391,7 +405,7 @@ interface AppState {
|
||||
activeContext: { connectionId: string; dbName: string } | null;
|
||||
savedQueries: SavedQuery[];
|
||||
theme: 'light' | 'dark';
|
||||
appearance: { opacity: number; blur: number };
|
||||
appearance: { enabled: boolean; opacity: number; blur: number };
|
||||
uiScale: number;
|
||||
fontSize: number;
|
||||
startupFullscreen: boolean;
|
||||
@@ -429,7 +443,7 @@ interface AppState {
|
||||
deleteQuery: (id: string) => void;
|
||||
|
||||
setTheme: (theme: 'light' | 'dark') => void;
|
||||
setAppearance: (appearance: Partial<{ opacity: number; blur: number }>) => void;
|
||||
setAppearance: (appearance: Partial<{ enabled: boolean; opacity: number; blur: number }>) => void;
|
||||
setUiScale: (scale: number) => void;
|
||||
setFontSize: (size: number) => void;
|
||||
setStartupFullscreen: (enabled: boolean) => void;
|
||||
@@ -508,13 +522,14 @@ const sanitizeTableSortPreference = (value: unknown): Record<string, 'name' | 'f
|
||||
};
|
||||
|
||||
const sanitizeAppearance = (
|
||||
appearance: Partial<{ opacity: number; blur: number }> | undefined,
|
||||
appearance: Partial<{ enabled: boolean; opacity: number; blur: number }> | undefined,
|
||||
version: number
|
||||
): { opacity: number; blur: number } => {
|
||||
): { enabled: boolean; opacity: number; blur: number } => {
|
||||
if (!appearance || typeof appearance !== 'object') {
|
||||
return { ...DEFAULT_APPEARANCE };
|
||||
}
|
||||
const nextAppearance = {
|
||||
enabled: typeof appearance.enabled === 'boolean' ? appearance.enabled : DEFAULT_APPEARANCE.enabled,
|
||||
opacity: typeof appearance.opacity === 'number' ? appearance.opacity : DEFAULT_APPEARANCE.opacity,
|
||||
blur: typeof appearance.blur === 'number' ? appearance.blur : DEFAULT_APPEARANCE.blur,
|
||||
};
|
||||
|
||||
@@ -14,6 +14,13 @@ export interface ProxyConfig {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface HTTPTunnelConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
user?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface ConnectionConfig {
|
||||
type: string;
|
||||
host: string;
|
||||
@@ -30,6 +37,8 @@ export interface ConnectionConfig {
|
||||
ssh?: SSHConfig;
|
||||
useProxy?: boolean;
|
||||
proxy?: ProxyConfig;
|
||||
useHttpTunnel?: boolean;
|
||||
httpTunnel?: HTTPTunnelConfig;
|
||||
driver?: string;
|
||||
dsn?: string;
|
||||
timeout?: number;
|
||||
|
||||
@@ -10,6 +10,22 @@ const WINDOWS_BLUR_FACTOR = 1.00;
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
|
||||
export interface AppearanceSettingsLike {
|
||||
enabled?: boolean;
|
||||
opacity?: number;
|
||||
blur?: number;
|
||||
}
|
||||
|
||||
export const resolveAppearanceValues = (appearance: AppearanceSettingsLike | undefined): { opacity: number; blur: number } => {
|
||||
if (!appearance || appearance.enabled !== false) {
|
||||
return {
|
||||
opacity: appearance?.opacity ?? DEFAULT_OPACITY,
|
||||
blur: appearance?.blur ?? 0,
|
||||
};
|
||||
}
|
||||
return { opacity: DEFAULT_OPACITY, blur: 0 };
|
||||
};
|
||||
|
||||
export const isMacLikePlatform = (): boolean => {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return false;
|
||||
|
||||
@@ -50,6 +50,11 @@ export const quoteIdentPart = (dbType: string, ident: string) => {
|
||||
return raw;
|
||||
}
|
||||
|
||||
// SQL Server 使用 [bracket] 标识符
|
||||
if (dbTypeLower === 'sqlserver' || dbTypeLower === 'mssql') {
|
||||
return `[${raw.replace(/]/g, ']]')}]`;
|
||||
}
|
||||
|
||||
// 其他数据库默认加双引号
|
||||
return `"${raw.replace(/"/g, '""')}"`;
|
||||
};
|
||||
@@ -134,6 +139,42 @@ export const buildOrderBySQL = (
|
||||
return '';
|
||||
};
|
||||
|
||||
export const buildPaginatedSelectSQL = (
|
||||
dbType: string,
|
||||
baseSql: string,
|
||||
orderBySQL: string,
|
||||
limit: number,
|
||||
offset: number,
|
||||
) => {
|
||||
const normalizedType = String(dbType || '').trim().toLowerCase();
|
||||
const safeLimit = Math.max(0, Math.floor(Number(limit) || 0));
|
||||
const safeOffset = Math.max(0, Math.floor(Number(offset) || 0));
|
||||
const base = String(baseSql || '').trim();
|
||||
const orderBy = String(orderBySQL || '');
|
||||
|
||||
if (!base || safeLimit <= 0) {
|
||||
return `${base}${orderBy}`;
|
||||
}
|
||||
|
||||
switch (normalizedType) {
|
||||
case 'oracle': {
|
||||
const orderedSql = `${base}${orderBy}`;
|
||||
const upperBound = safeOffset + safeLimit;
|
||||
if (safeOffset <= 0) {
|
||||
return `SELECT * FROM (${orderedSql}) WHERE ROWNUM <= ${upperBound}`;
|
||||
}
|
||||
return `SELECT * FROM (SELECT "__gonavi_page__".*, ROWNUM "__gonavi_rn__" FROM (${orderedSql}) "__gonavi_page__" WHERE ROWNUM <= ${upperBound}) WHERE "__gonavi_rn__" > ${safeOffset}`;
|
||||
}
|
||||
case 'sqlserver':
|
||||
case 'mssql': {
|
||||
const effectiveOrderBy = orderBy.trim() ? orderBy : ' ORDER BY (SELECT NULL)';
|
||||
return `${base}${effectiveOrderBy} OFFSET ${safeOffset} ROWS FETCH NEXT ${safeLimit} ROWS ONLY`;
|
||||
}
|
||||
default:
|
||||
return `${base}${orderBy} LIMIT ${safeLimit} OFFSET ${safeOffset}`;
|
||||
}
|
||||
};
|
||||
|
||||
export const parseListValues = (val: string) => {
|
||||
const raw = (val || '').trim();
|
||||
if (!raw) return [];
|
||||
|
||||
@@ -48,6 +48,24 @@ export namespace connection {
|
||||
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 {
|
||||
type: string;
|
||||
host: string;
|
||||
@@ -104,6 +122,8 @@ export namespace connection {
|
||||
ssh: SSHConfig;
|
||||
useProxy?: boolean;
|
||||
proxy?: ProxyConfig;
|
||||
useHttpTunnel?: boolean;
|
||||
httpTunnel?: HTTPTunnelConfig;
|
||||
driver?: string;
|
||||
dsn?: string;
|
||||
timeout?: number;
|
||||
@@ -142,6 +162,8 @@ export namespace connection {
|
||||
this.ssh = this.convertValues(source["ssh"], SSHConfig);
|
||||
this.useProxy = source["useProxy"];
|
||||
this.proxy = this.convertValues(source["proxy"], ProxyConfig);
|
||||
this.useHttpTunnel = source["useHttpTunnel"];
|
||||
this.httpTunnel = this.convertValues(source["httpTunnel"], HTTPTunnelConfig);
|
||||
this.driver = source["driver"];
|
||||
this.dsn = source["dsn"];
|
||||
this.timeout = source["timeout"];
|
||||
@@ -179,6 +201,7 @@ export namespace connection {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class QueryResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
@@ -254,6 +277,9 @@ export namespace sync {
|
||||
mode: string;
|
||||
jobId?: string;
|
||||
autoAddColumns?: boolean;
|
||||
targetTableStrategy?: string;
|
||||
createIndexes?: boolean;
|
||||
mongoCollectionName?: string;
|
||||
tableOptions?: Record<string, TableOptions>;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@@ -269,6 +295,9 @@ export namespace sync {
|
||||
this.mode = source["mode"];
|
||||
this.jobId = source["jobId"];
|
||||
this.autoAddColumns = source["autoAddColumns"];
|
||||
this.targetTableStrategy = source["targetTableStrategy"];
|
||||
this.createIndexes = source["createIndexes"];
|
||||
this.mongoCollectionName = source["mongoCollectionName"];
|
||||
this.tableOptions = this.convertValues(source["tableOptions"], TableOptions, true);
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,9 @@ func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.Conn
|
||||
if !normalized.UseProxy {
|
||||
normalized.Proxy = connection.ProxyConfig{}
|
||||
}
|
||||
if !normalized.UseHTTPTunnel {
|
||||
normalized.HTTPTunnel = connection.HTTPTunnelConfig{}
|
||||
}
|
||||
|
||||
if isFileDatabaseType(normalized.Type) {
|
||||
dsn := strings.TrimSpace(normalized.Host)
|
||||
@@ -124,6 +127,8 @@ func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.Conn
|
||||
normalized.MongoAuthMechanism = ""
|
||||
normalized.MongoReplicaUser = ""
|
||||
normalized.MongoReplicaPassword = ""
|
||||
normalized.UseHTTPTunnel = false
|
||||
normalized.HTTPTunnel = connection.HTTPTunnelConfig{}
|
||||
}
|
||||
|
||||
return normalized
|
||||
@@ -303,6 +308,12 @@ func formatConnSummary(config connection.ConnectionConfig) string {
|
||||
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" {
|
||||
driver := strings.TrimSpace(config.Driver)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
@@ -20,6 +21,11 @@ func normalizeRunConfig(config connection.ConnectionConfig, dbName string) conne
|
||||
case "dameng":
|
||||
// 达梦使用 schema 参数,沿用现有行为:dbName 表示 schema。
|
||||
runConfig.Database = name
|
||||
case "redis":
|
||||
runConfig.Database = name
|
||||
if idx, err := strconv.Atoi(name); err == nil && idx >= 0 && idx <= 15 {
|
||||
runConfig.RedisDB = idx
|
||||
}
|
||||
default:
|
||||
// oracle: dbName 表示 schema/owner,不能覆盖 config.Database(服务名)
|
||||
// sqlite: 无需设置 Database
|
||||
|
||||
@@ -12,8 +12,35 @@ import (
|
||||
|
||||
func resolveDialConfigWithProxy(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
|
||||
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 {
|
||||
config.Proxy = connection.ProxyConfig{}
|
||||
config.UseHTTPTunnel = false
|
||||
config.HTTPTunnel = connection.HTTPTunnelConfig{}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
@@ -22,6 +49,8 @@ func resolveDialConfigWithProxy(raw connection.ConnectionConfig) (connection.Con
|
||||
return connection.ConnectionConfig{}, err
|
||||
}
|
||||
config.Proxy = normalizedProxy
|
||||
config.UseHTTPTunnel = false
|
||||
config.HTTPTunnel = connection.HTTPTunnelConfig{}
|
||||
|
||||
if config.UseSSH {
|
||||
sshPort := config.SSH.Port
|
||||
|
||||
@@ -110,7 +110,7 @@ func (a *App) GetGlobalProxyConfig() connection.QueryResult {
|
||||
|
||||
func applyGlobalProxyToConnection(config connection.ConnectionConfig) connection.ConnectionConfig {
|
||||
effective := config
|
||||
if effective.UseProxy {
|
||||
if effective.UseProxy || effective.UseHTTPTunnel {
|
||||
return effective
|
||||
}
|
||||
if isFileDatabaseType(effective.Type) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -416,12 +417,7 @@ func (a *App) DBQueryWithCancel(config connection.ConnectionConfig, dbName strin
|
||||
a.queryMu.Unlock()
|
||||
}()
|
||||
|
||||
lowerQuery := strings.TrimSpace(strings.ToLower(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
|
||||
}
|
||||
isReadQuery := isReadOnlySQLQuery(runConfig.Type, query)
|
||||
|
||||
runReadQuery := func(inst db.Database) ([]map[string]interface{}, []string, error) {
|
||||
if q, ok := inst.(interface {
|
||||
@@ -500,11 +496,7 @@ func (a *App) DBQueryIsolated(config connection.ConnectionConfig, dbName string,
|
||||
ctx, cancel := utils.ContextWithTimeout(time.Duration(timeoutSeconds) * time.Second)
|
||||
defer cancel()
|
||||
|
||||
lowerQuery := strings.TrimSpace(strings.ToLower(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
|
||||
}
|
||||
isReadQuery := isReadOnlySQLQuery(runConfig.Type, query)
|
||||
|
||||
if isReadQuery {
|
||||
var data []map[string]interface{}
|
||||
@@ -547,8 +539,33 @@ func sqlSnippet(query string) string {
|
||||
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 {
|
||||
runConfig := normalizeRunConfig(config, "")
|
||||
if strings.EqualFold(strings.TrimSpace(runConfig.Type), "redis") {
|
||||
runConfig.Type = "redis"
|
||||
client, err := a.getRedisClient(runConfig)
|
||||
if err != nil {
|
||||
logger.Error(err, "DBGetDatabases 获取 Redis 连接失败:%s", formatConnSummary(runConfig))
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
dbs, err := client.GetDatabases()
|
||||
if err != nil {
|
||||
logger.Error(err, "DBGetDatabases 获取 Redis 库列表失败:%s", formatConnSummary(runConfig))
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
resData := make([]map[string]string, 0, len(dbs))
|
||||
for _, item := range dbs {
|
||||
resData = append(resData, map[string]string{"Database": strconv.Itoa(item.Index)})
|
||||
}
|
||||
return connection.QueryResult{Success: true, Data: resData}
|
||||
}
|
||||
dbInst, err := a.getDatabase(runConfig)
|
||||
if err != nil {
|
||||
logger.Error(err, "DBGetDatabases 获取连接失败:%s", formatConnSummary(runConfig))
|
||||
@@ -571,7 +588,7 @@ func (a *App) DBGetDatabases(config connection.ConnectionConfig) connection.Quer
|
||||
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 {
|
||||
resData = append(resData, map[string]string{"Database": name})
|
||||
}
|
||||
@@ -581,6 +598,48 @@ func (a *App) DBGetDatabases(config connection.ConnectionConfig) connection.Quer
|
||||
|
||||
func (a *App) DBGetTables(config connection.ConnectionConfig, dbName string) connection.QueryResult {
|
||||
runConfig := normalizeRunConfig(config, dbName)
|
||||
if strings.EqualFold(strings.TrimSpace(runConfig.Type), "redis") {
|
||||
runConfig.Type = "redis"
|
||||
client, err := a.getRedisClient(runConfig)
|
||||
if err != nil {
|
||||
logger.Error(err, "DBGetTables 获取 Redis 连接失败:%s", formatConnSummary(runConfig))
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
cursor := uint64(0)
|
||||
tables := make([]string, 0, 128)
|
||||
seen := make(map[string]struct{}, 128)
|
||||
for {
|
||||
result, err := client.ScanKeys("*", cursor, 1000)
|
||||
if err != nil {
|
||||
logger.Error(err, "DBGetTables 扫描 Redis Key 失败:%s", formatConnSummary(runConfig))
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
for _, item := range result.Keys {
|
||||
key := strings.TrimSpace(item.Key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
tables = append(tables, key)
|
||||
}
|
||||
if strings.TrimSpace(result.Cursor) == "" || strings.TrimSpace(result.Cursor) == "0" {
|
||||
break
|
||||
}
|
||||
next, err := strconv.ParseUint(strings.TrimSpace(result.Cursor), 10, 64)
|
||||
if err != nil || next == cursor {
|
||||
break
|
||||
}
|
||||
cursor = next
|
||||
}
|
||||
resData := make([]map[string]string, 0, len(tables))
|
||||
for _, name := range tables {
|
||||
resData = append(resData, map[string]string{"Table": name})
|
||||
}
|
||||
return connection.QueryResult{Success: true, Data: resData}
|
||||
}
|
||||
|
||||
dbInst, err := a.getDatabase(runConfig)
|
||||
if err != nil {
|
||||
@@ -604,7 +663,7 @@ func (a *App) DBGetTables(config connection.ConnectionConfig, dbName string) con
|
||||
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 {
|
||||
resData = append(resData, map[string]string{"Table": name})
|
||||
}
|
||||
@@ -786,7 +845,7 @@ func (a *App) DBGetColumns(config connection.ConnectionConfig, dbName string, ta
|
||||
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 {
|
||||
@@ -803,7 +862,7 @@ func (a *App) DBGetIndexes(config connection.ConnectionConfig, dbName string, ta
|
||||
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 {
|
||||
@@ -820,7 +879,7 @@ func (a *App) DBGetForeignKeys(config connection.ConnectionConfig, dbName string
|
||||
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 {
|
||||
@@ -837,7 +896,7 @@ func (a *App) DBGetTriggers(config connection.ConnectionConfig, dbName string, t
|
||||
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 {
|
||||
@@ -975,5 +1034,5 @@ func (a *App) DBGetAllColumns(config connection.ConnectionConfig, dbName string)
|
||||
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)
|
||||
}
|
||||
}
|
||||
if validateErr := db.ValidateOptionalDriverAgentExecutable(driverType, executablePath); validateErr != nil {
|
||||
return installedDriverPackage{}, validateErr
|
||||
}
|
||||
|
||||
hash, hashErr := hashFileSHA256(executablePath)
|
||||
if hashErr != nil {
|
||||
@@ -2793,11 +2796,15 @@ func ensureOptionalDriverAgentBinary(a *App, definition driverDefinition, execut
|
||||
|
||||
info, err := os.Stat(executablePath)
|
||||
if err == nil && !info.IsDir() {
|
||||
hash, hashErr := hashFileSHA256(executablePath)
|
||||
if hashErr != nil {
|
||||
return "", "", fmt.Errorf("读取已安装 %s 驱动代理摘要失败:%w", displayName, hashErr)
|
||||
if validateErr := db.ValidateOptionalDriverAgentExecutable(driverType, executablePath); validateErr != nil {
|
||||
_ = os.Remove(executablePath)
|
||||
} 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() {
|
||||
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 {
|
||||
return "", "", fmt.Errorf("复制预置 %s 驱动代理失败:%w", displayName, copyErr)
|
||||
}
|
||||
if validateErr := db.ValidateOptionalDriverAgentExecutable(driverType, executablePath); validateErr != nil {
|
||||
_ = os.Remove(executablePath)
|
||||
return "", "", validateErr
|
||||
}
|
||||
hash, hashErr := hashFileSHA256(executablePath)
|
||||
if hashErr != nil {
|
||||
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" {
|
||||
return "", fmt.Errorf("设置代理权限失败:%w", chmodErr)
|
||||
}
|
||||
if validateErr := db.ValidateOptionalDriverAgentExecutable(driverType, executablePath); validateErr != nil {
|
||||
_ = os.Remove(executablePath)
|
||||
return "", validateErr
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
@@ -3009,6 +3024,10 @@ func downloadOptionalDriverAgentFromBundle(a *App, definition driverDefinition,
|
||||
if chmodErr := os.Chmod(executablePath, 0o755); chmodErr != nil && stdRuntime.GOOS != "windows" {
|
||||
return "", "", fmt.Errorf("设置驱动代理权限失败:%w", chmodErr)
|
||||
}
|
||||
if validateErr := db.ValidateOptionalDriverAgentExecutable(driverType, executablePath); validateErr != nil {
|
||||
_ = os.Remove(executablePath)
|
||||
return "", "", validateErr
|
||||
}
|
||||
hash, err := hashFileSHA256(executablePath)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("计算驱动代理摘要失败:%w", err)
|
||||
@@ -3334,6 +3353,7 @@ func resolveOptionalDriverAgentDownloadURLs(definition driverDefinition, rawURL
|
||||
}
|
||||
|
||||
func findExistingOptionalDriverAgentCandidate(definition driverDefinition, targetPath string) (string, bool) {
|
||||
driverType := normalizeDriverType(definition.Type)
|
||||
targetAbs, _ := filepath.Abs(targetPath)
|
||||
candidates := resolveOptionalDriverAgentCandidatePaths(definition)
|
||||
for _, candidate := range candidates {
|
||||
@@ -3349,9 +3369,13 @@ func findExistingOptionalDriverAgentCandidate(definition driverDefinition, targe
|
||||
continue
|
||||
}
|
||||
info, statErr := os.Stat(absPath)
|
||||
if statErr == nil && !info.IsDir() {
|
||||
return absPath, true
|
||||
if statErr != nil || info.IsDir() {
|
||||
continue
|
||||
}
|
||||
if validateErr := db.ValidateOptionalDriverAgentExecutable(driverType, absPath); validateErr != nil {
|
||||
continue
|
||||
}
|
||||
return absPath, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -23,12 +23,20 @@ var (
|
||||
|
||||
// getRedisClient gets or creates a Redis client from cache
|
||||
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
|
||||
if len(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()
|
||||
defer redisCacheMu.Unlock()
|
||||
@@ -47,21 +55,20 @@ func (a *App) getRedisClient(config connection.ConnectionConfig) (redis.RedisCli
|
||||
|
||||
logger.Infof("创建 Redis 客户端实例:缓存Key=%s", shortKey)
|
||||
client := redis.NewRedisClient()
|
||||
if err := client.Connect(config); err != nil {
|
||||
logger.Error(err, "Redis 连接失败:%s 缓存Key=%s", formatRedisConnSummary(config), shortKey)
|
||||
return nil, err
|
||||
if err := client.Connect(connectConfig); err != nil {
|
||||
wrapped := wrapConnectError(effectiveConfig, err)
|
||||
logger.Error(wrapped, "Redis 连接失败:%s 缓存Key=%s", formatRedisConnSummary(effectiveConfig), shortKey)
|
||||
return nil, wrapped
|
||||
}
|
||||
|
||||
redisCache[key] = client
|
||||
logger.Infof("Redis 连接成功并写入缓存:%s 缓存Key=%s", formatRedisConnSummary(config), shortKey)
|
||||
logger.Infof("Redis 连接成功并写入缓存:%s 缓存Key=%s", formatRedisConnSummary(effectiveConfig), shortKey)
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func getRedisClientCacheKey(config connection.ConnectionConfig) string {
|
||||
if !config.UseSSH {
|
||||
config.SSH = connection.SSHConfig{}
|
||||
}
|
||||
b, _ := json.Marshal(config)
|
||||
normalized := normalizeCacheKeyConfig(config)
|
||||
b, _ := json.Marshal(normalized)
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -91,6 +98,26 @@ func formatRedisConnSummary(config connection.ConnectionConfig) string {
|
||||
b.WriteString(" 用户=")
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,66 @@ import (
|
||||
"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 {
|
||||
switch strings.ToLower(strings.TrimSpace(dbType)) {
|
||||
case "postgres", "kingbase", "highgo", "vastbase":
|
||||
|
||||
@@ -18,39 +18,49 @@ type ProxyConfig struct {
|
||||
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
|
||||
type ConnectionConfig struct {
|
||||
Type string `json:"type"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection
|
||||
Database string `json:"database"`
|
||||
UseSSL bool `json:"useSSL,omitempty"` // MySQL-like SSL/TLS switch
|
||||
SSLMode string `json:"sslMode,omitempty"` // preferred | required | skip-verify | disable
|
||||
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)
|
||||
UseSSH bool `json:"useSSH"`
|
||||
SSH SSHConfig `json:"ssh"`
|
||||
UseProxy bool `json:"useProxy,omitempty"`
|
||||
Proxy ProxyConfig `json:"proxy,omitempty"`
|
||||
Driver string `json:"driver,omitempty"` // For custom connection
|
||||
DSN string `json:"dsn,omitempty"` // For custom connection
|
||||
Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30)
|
||||
RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15)
|
||||
URI string `json:"uri,omitempty"` // Connection URI for copy/paste
|
||||
Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port
|
||||
Topology string `json:"topology,omitempty"` // single | replica | cluster
|
||||
MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user
|
||||
MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` // MySQL replica auth password
|
||||
ReplicaSet string `json:"replicaSet,omitempty"` // MongoDB replica set name
|
||||
AuthSource string `json:"authSource,omitempty"` // MongoDB authSource
|
||||
ReadPreference string `json:"readPreference,omitempty"` // MongoDB readPreference
|
||||
MongoSRV bool `json:"mongoSrv,omitempty"` // MongoDB use mongodb+srv URI scheme
|
||||
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
|
||||
Type string `json:"type"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection
|
||||
Database string `json:"database"`
|
||||
UseSSL bool `json:"useSSL,omitempty"` // MySQL-like SSL/TLS switch
|
||||
SSLMode string `json:"sslMode,omitempty"` // preferred | required | skip-verify | disable
|
||||
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)
|
||||
UseSSH bool `json:"useSSH"`
|
||||
SSH SSHConfig `json:"ssh"`
|
||||
UseProxy bool `json:"useProxy,omitempty"`
|
||||
Proxy ProxyConfig `json:"proxy,omitempty"`
|
||||
UseHTTPTunnel bool `json:"useHttpTunnel,omitempty"`
|
||||
HTTPTunnel HTTPTunnelConfig `json:"httpTunnel,omitempty"`
|
||||
Driver string `json:"driver,omitempty"` // For custom connection
|
||||
DSN string `json:"dsn,omitempty"` // For custom connection
|
||||
Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30)
|
||||
RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15)
|
||||
URI string `json:"uri,omitempty"` // Connection URI for copy/paste
|
||||
Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port
|
||||
Topology string `json:"topology,omitempty"` // single | replica | cluster
|
||||
MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user
|
||||
MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` // MySQL replica auth password
|
||||
ReplicaSet string `json:"replicaSet,omitempty"` // MongoDB replica set name
|
||||
AuthSource string `json:"authSource,omitempty"` // MongoDB authSource
|
||||
ReadPreference string `json:"readPreference,omitempty"` // MongoDB readPreference
|
||||
MongoSRV bool `json:"mongoSrv,omitempty"` // MongoDB use mongodb+srv URI scheme
|
||||
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
|
||||
@@ -80,6 +90,7 @@ type IndexDefinition struct {
|
||||
NonUnique int `json:"nonUnique"`
|
||||
SeqInIndex int `json:"seqInIndex"`
|
||||
IndexType string `json:"indexType"`
|
||||
SubPart int `json:"subPart,omitempty"`
|
||||
}
|
||||
|
||||
// ForeignKeyDefinition represents a foreign key
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -107,7 +108,9 @@ func (c *ClickHouseDB) buildClickHouseOptions(config connection.ConnectionConfig
|
||||
if readTimeout < minClickHouseReadTimeout {
|
||||
readTimeout = minClickHouseReadTimeout
|
||||
}
|
||||
protocol := detectClickHouseProtocol(config)
|
||||
opts := &clickhouse.Options{
|
||||
Protocol: protocol,
|
||||
Addr: []string{
|
||||
net.JoinHostPort(config.Host, strconv.Itoa(config.Port)),
|
||||
},
|
||||
@@ -125,6 +128,46 @@ func (c *ClickHouseDB) buildClickHouseOptions(config connection.ConnectionConfig
|
||||
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 {
|
||||
if supported, reason := DriverRuntimeSupportStatus("clickhouse"); !supported {
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
@@ -176,23 +219,41 @@ func (c *ClickHouseDB) Connect(config connection.ConnectionConfig) error {
|
||||
|
||||
var failures []string
|
||||
for idx, attempt := range attempts {
|
||||
c.conn = clickhouse.OpenDB(c.buildClickHouseOptions(attempt))
|
||||
if err := c.Ping(); err != nil {
|
||||
failures = append(failures, fmt.Sprintf("第%d次连接验证失败: %v", idx+1, err))
|
||||
if c.conn != nil {
|
||||
_ = c.conn.Close()
|
||||
c.conn = nil
|
||||
primaryProtocol := detectClickHouseProtocol(attempt)
|
||||
protocols := []clickhouse.Protocol{primaryProtocol}
|
||||
if primaryProtocol == clickhouse.Native {
|
||||
protocols = append(protocols, clickhouse.HTTP)
|
||||
} else {
|
||||
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()
|
||||
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 {
|
||||
@@ -618,3 +679,134 @@ func isClickHouseTruthy(value interface{}) bool {
|
||||
return normalized == "1" || normalized == "true" || normalized == "yes" || normalized == "y"
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ClickHouseDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
if c.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
}
|
||||
|
||||
database, table, err := c.resolveDatabaseAndTable(c.database, tableName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
qualifiedTable := fmt.Sprintf("%s.%s", quoteClickHouseIdentifier(database), quoteClickHouseIdentifier(table))
|
||||
|
||||
for _, pk := range changes.Deletes {
|
||||
whereExpr := buildClickHouseWhereClause(pk)
|
||||
if whereExpr == "" {
|
||||
continue
|
||||
}
|
||||
query := fmt.Sprintf("ALTER TABLE %s DELETE WHERE %s", qualifiedTable, whereExpr)
|
||||
if _, err := c.conn.Exec(query); err != nil {
|
||||
return fmt.Errorf("delete error: %v; sql=%s", err, query)
|
||||
}
|
||||
}
|
||||
|
||||
for _, update := range changes.Updates {
|
||||
setExpr := buildClickHouseAssignments(update.Values)
|
||||
whereExpr := buildClickHouseWhereClause(update.Keys)
|
||||
if setExpr == "" || whereExpr == "" {
|
||||
continue
|
||||
}
|
||||
query := fmt.Sprintf("ALTER TABLE %s UPDATE %s WHERE %s", qualifiedTable, setExpr, whereExpr)
|
||||
if _, err := c.conn.Exec(query); err != nil {
|
||||
return fmt.Errorf("update error: %v; sql=%s", err, query)
|
||||
}
|
||||
}
|
||||
|
||||
for _, row := range changes.Inserts {
|
||||
query, err := buildClickHouseInsertSQL(qualifiedTable, row)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if query == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := c.conn.Exec(query); err != nil {
|
||||
return fmt.Errorf("insert error: %v; sql=%s", err, query)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildClickHouseInsertSQL(qualifiedTable string, row map[string]interface{}) (string, error) {
|
||||
if len(row) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
cols := make([]string, 0, len(row))
|
||||
for k := range row {
|
||||
if strings.TrimSpace(k) == "" {
|
||||
continue
|
||||
}
|
||||
cols = append(cols, k)
|
||||
}
|
||||
if len(cols) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
sort.Strings(cols)
|
||||
quotedCols := make([]string, 0, len(cols))
|
||||
values := make([]string, 0, len(cols))
|
||||
for _, col := range cols {
|
||||
quotedCols = append(quotedCols, quoteClickHouseIdentifier(col))
|
||||
values = append(values, clickHouseLiteral(row[col]))
|
||||
}
|
||||
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(quotedCols, ", "), strings.Join(values, ", ")), nil
|
||||
}
|
||||
|
||||
func buildClickHouseAssignments(values map[string]interface{}) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
cols := make([]string, 0, len(values))
|
||||
for k := range values {
|
||||
if strings.TrimSpace(k) == "" {
|
||||
continue
|
||||
}
|
||||
cols = append(cols, k)
|
||||
}
|
||||
sort.Strings(cols)
|
||||
parts := make([]string, 0, len(cols))
|
||||
for _, col := range cols {
|
||||
parts = append(parts, fmt.Sprintf("%s = %s", quoteClickHouseIdentifier(col), clickHouseLiteral(values[col])))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func buildClickHouseWhereClause(keys map[string]interface{}) string {
|
||||
if len(keys) == 0 {
|
||||
return ""
|
||||
}
|
||||
cols := make([]string, 0, len(keys))
|
||||
for k := range keys {
|
||||
if strings.TrimSpace(k) == "" {
|
||||
continue
|
||||
}
|
||||
cols = append(cols, k)
|
||||
}
|
||||
sort.Strings(cols)
|
||||
parts := make([]string, 0, len(cols))
|
||||
for _, col := range cols {
|
||||
parts = append(parts, fmt.Sprintf("%s = %s", quoteClickHouseIdentifier(col), clickHouseLiteral(keys[col])))
|
||||
}
|
||||
return strings.Join(parts, " AND ")
|
||||
}
|
||||
|
||||
func clickHouseLiteral(value interface{}) string {
|
||||
switch val := value.(type) {
|
||||
case nil:
|
||||
return "NULL"
|
||||
case bool:
|
||||
if val {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
|
||||
return fmt.Sprintf("%v", val)
|
||||
case time.Time:
|
||||
return fmt.Sprintf("'%s'", val.Format("2006-01-02 15:04:05"))
|
||||
case []byte:
|
||||
return fmt.Sprintf("'%s'", strings.ReplaceAll(string(val), "'", "''"))
|
||||
default:
|
||||
return fmt.Sprintf("'%s'", strings.ReplaceAll(fmt.Sprintf("%v", val), "'", "''"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,22 +204,9 @@ func (d *DamengDB) Exec(query string) (int64, error) {
|
||||
}
|
||||
|
||||
func (d *DamengDB) GetDatabases() ([]string, error) {
|
||||
// DM: List Users/Schemas
|
||||
data, _, err := d.Query("SELECT username FROM dba_users")
|
||||
if err != nil {
|
||||
// Fallback if dba_users not accessible
|
||||
data, _, err = d.Query("SELECT username FROM all_users")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var dbs []string
|
||||
for _, row := range data {
|
||||
if val, ok := row["USERNAME"]; ok {
|
||||
dbs = append(dbs, fmt.Sprintf("%v", val))
|
||||
}
|
||||
}
|
||||
return dbs, nil
|
||||
// 达梦在本项目中将 schema/owner 作为“数据库”展示口径。
|
||||
// 先查当前 schema / 当前用户,再聚合可见用户与 owner,避免权限受限时返回空列表。
|
||||
return collectDamengDatabaseNames(d.Query)
|
||||
}
|
||||
|
||||
func (d *DamengDB) GetTables(dbName string) ([]string, error) {
|
||||
|
||||
91
internal/db/dameng_metadata.go
Normal file
91
internal/db/dameng_metadata.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var damengDatabaseQueries = []string{
|
||||
"SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') AS DATABASE_NAME FROM DUAL",
|
||||
"SELECT SYS_CONTEXT('USERENV', 'CURRENT_USER') AS DATABASE_NAME FROM DUAL",
|
||||
"SELECT USERNAME AS DATABASE_NAME FROM USER_USERS",
|
||||
"SELECT USERNAME AS DATABASE_NAME FROM ALL_USERS ORDER BY USERNAME",
|
||||
"SELECT USERNAME AS DATABASE_NAME FROM DBA_USERS ORDER BY USERNAME",
|
||||
"SELECT USERNAME AS DATABASE_NAME FROM SYS.DBA_USERS ORDER BY USERNAME",
|
||||
"SELECT DISTINCT OWNER AS DATABASE_NAME FROM ALL_OBJECTS ORDER BY OWNER",
|
||||
"SELECT DISTINCT OWNER AS DATABASE_NAME FROM ALL_TABLES ORDER BY OWNER",
|
||||
}
|
||||
|
||||
type damengQueryFunc func(query string) ([]map[string]interface{}, []string, error)
|
||||
|
||||
func collectDamengDatabaseNames(query damengQueryFunc) ([]string, error) {
|
||||
seen := make(map[string]struct{})
|
||||
dbs := make([]string, 0, 64)
|
||||
var lastErr error
|
||||
|
||||
for _, q := range damengDatabaseQueries {
|
||||
data, _, err := query(q)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
for _, row := range data {
|
||||
name := getDamengRowString(row,
|
||||
"DATABASE_NAME",
|
||||
"USERNAME",
|
||||
"OWNER",
|
||||
"SCHEMA_NAME",
|
||||
"CURRENT_SCHEMA",
|
||||
"CURRENT_USER",
|
||||
)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if len(dbs) == 0 && lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
sort.Slice(dbs, func(i, j int) bool {
|
||||
return strings.ToUpper(dbs[i]) < strings.ToUpper(dbs[j])
|
||||
})
|
||||
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 ""
|
||||
}
|
||||
73
internal/db/dameng_metadata_test.go
Normal file
73
internal/db/dameng_metadata_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCollectDamengDatabaseNames_UsesCurrentSchemaFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := collectDamengDatabaseNames(func(query string) ([]map[string]interface{}, []string, error) {
|
||||
switch query {
|
||||
case damengDatabaseQueries[0]:
|
||||
return []map[string]interface{}{{"DATABASE_NAME": "APP_SCHEMA"}}, nil, nil
|
||||
case damengDatabaseQueries[1]:
|
||||
return []map[string]interface{}{{"DATABASE_NAME": "app_schema"}}, nil, nil
|
||||
default:
|
||||
return nil, nil, errors.New("permission denied")
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("collectDamengDatabaseNames 返回错误: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"APP_SCHEMA"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("unexpected database names, got=%v want=%v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDamengDatabaseNames_CollectsOwnersWhenVisible(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := collectDamengDatabaseNames(func(query string) ([]map[string]interface{}, []string, error) {
|
||||
switch query {
|
||||
case damengDatabaseQueries[0], damengDatabaseQueries[1], damengDatabaseQueries[2], damengDatabaseQueries[3], damengDatabaseQueries[4], damengDatabaseQueries[5]:
|
||||
return []map[string]interface{}{}, nil, nil
|
||||
case damengDatabaseQueries[6]:
|
||||
return []map[string]interface{}{{"OWNER": "BIZ"}, {"OWNER": "audit"}}, nil, nil
|
||||
case damengDatabaseQueries[7]:
|
||||
return []map[string]interface{}{{"OWNER": "BIZ"}}, nil, nil
|
||||
default:
|
||||
return nil, nil, nil
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("collectDamengDatabaseNames 返回错误: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"audit", "BIZ"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("unexpected database names, got=%v want=%v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDamengDatabaseNames_ReturnsErrorWhenNoNameResolved(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expectErr := errors.New("last query failed")
|
||||
got, err := collectDamengDatabaseNames(func(query string) ([]map[string]interface{}, []string, error) {
|
||||
if query == damengDatabaseQueries[len(damengDatabaseQueries)-1] {
|
||||
return nil, nil, expectErr
|
||||
}
|
||||
return nil, nil, errors.New("permission denied")
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("期望返回错误,实际 got=%v", got)
|
||||
}
|
||||
if !errors.Is(err, expectErr) {
|
||||
t.Fatalf("错误不符合预期: %v", err)
|
||||
}
|
||||
}
|
||||
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() {
|
||||
return false, fmt.Sprintf("%s 驱动代理缺失,请在驱动管理中重新安装启用", driverDisplayName(normalized))
|
||||
}
|
||||
if validateErr := ValidateOptionalDriverAgentExecutable(normalized, executablePath); validateErr != nil {
|
||||
return false, fmt.Sprintf("%s;请在驱动管理中重新安装启用", validateErr.Error())
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
|
||||
@@ -65,11 +65,22 @@ func TestManagedDriverRequiresInstallMarker(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("解析 mariadb 代理路径失败: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(executablePath, []byte("placeholder"), 0o755); err != nil {
|
||||
t.Fatalf("写入 mariadb 代理占位文件失败: %v", err)
|
||||
}
|
||||
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")
|
||||
|
||||
@@ -305,10 +305,30 @@ func (k *KingbaseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDe
|
||||
return strings.ReplaceAll(s, "'", "''")
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`SELECT column_name, data_type, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = '%s' AND table_name = '%s'
|
||||
ORDER BY ordinal_position`, esc(schema), esc(table))
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
a.attname AS column_name,
|
||||
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)
|
||||
if err != nil {
|
||||
@@ -321,11 +341,21 @@ func (k *KingbaseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDe
|
||||
Name: fmt.Sprintf("%v", row["column_name"]),
|
||||
Type: fmt.Sprintf("%v", row["data_type"]),
|
||||
Nullable: fmt.Sprintf("%v", row["is_nullable"]),
|
||||
Key: fmt.Sprintf("%v", row["column_key"]),
|
||||
Extra: "",
|
||||
Comment: "",
|
||||
}
|
||||
|
||||
if row["column_default"] != nil {
|
||||
def := fmt.Sprintf("%v", row["column_default"])
|
||||
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)
|
||||
@@ -347,10 +377,30 @@ func (k *KingbaseDB) getColumnsWithCurrentSchema(tableName string) ([]connection
|
||||
}
|
||||
|
||||
// 使用 current_schema() 获取当前schema
|
||||
query := fmt.Sprintf(`SELECT column_name, data_type, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = current_schema() AND table_name = '%s'
|
||||
ORDER BY ordinal_position`, esc(table))
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
a.attname AS column_name,
|
||||
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)
|
||||
if err != nil {
|
||||
@@ -363,11 +413,21 @@ func (k *KingbaseDB) getColumnsWithCurrentSchema(tableName string) ([]connection
|
||||
Name: fmt.Sprintf("%v", row["column_name"]),
|
||||
Type: fmt.Sprintf("%v", row["data_type"]),
|
||||
Nullable: fmt.Sprintf("%v", row["is_nullable"]),
|
||||
Key: fmt.Sprintf("%v", row["column_key"]),
|
||||
Extra: "",
|
||||
Comment: "",
|
||||
}
|
||||
|
||||
if row["column_default"] != nil {
|
||||
def := fmt.Sprintf("%v", row["column_default"])
|
||||
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)
|
||||
@@ -623,28 +683,16 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
quoteIdent := func(name string) string {
|
||||
n := strings.TrimSpace(name)
|
||||
n = strings.Trim(n, "\"")
|
||||
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])
|
||||
schema, table := splitKingbaseQualifiedTable(tableName)
|
||||
if table == "" {
|
||||
return fmt.Errorf("table name required")
|
||||
}
|
||||
|
||||
qualifiedTable := ""
|
||||
if schema != "" {
|
||||
qualifiedTable = fmt.Sprintf("%s.%s", quoteIdent(schema), quoteIdent(table))
|
||||
qualifiedTable = fmt.Sprintf("%s.%s", quoteKingbaseIdent(schema), quoteKingbaseIdent(table))
|
||||
} else {
|
||||
qualifiedTable = quoteIdent(table)
|
||||
qualifiedTable = quoteKingbaseIdent(table)
|
||||
}
|
||||
|
||||
// 1. Deletes
|
||||
@@ -654,7 +702,7 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
|
||||
idx := 0
|
||||
for k, v := range pk {
|
||||
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)
|
||||
}
|
||||
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 "))
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -685,7 +733,7 @@ func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet
|
||||
var wheres []string
|
||||
for k, v := range update.Keys {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 "))
|
||||
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 {
|
||||
idx++
|
||||
cols = append(cols, quoteIdent(k))
|
||||
cols = append(cols, quoteKingbaseIdent(k))
|
||||
placeholders = append(placeholders, fmt.Sprintf("$%d", idx))
|
||||
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, ", "))
|
||||
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()
|
||||
}
|
||||
|
||||
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) {
|
||||
// dbName 在本项目语义里是“数据库”,schema 由 table_schema 决定;这里返回全部用户 schema 的列用于查询提示。
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -250,12 +250,22 @@ func (m *MariaDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefini
|
||||
}
|
||||
}
|
||||
|
||||
subPart := 0
|
||||
if val, ok := row["Sub_part"]; ok && val != nil {
|
||||
if f, ok := val.(float64); ok {
|
||||
subPart = int(f)
|
||||
} else if i, ok := val.(int64); ok {
|
||||
subPart = int(i)
|
||||
}
|
||||
}
|
||||
|
||||
idx := connection.IndexDefinition{
|
||||
Name: fmt.Sprintf("%v", row["Key_name"]),
|
||||
ColumnName: fmt.Sprintf("%v", row["Column_name"]),
|
||||
NonUnique: nonUnique,
|
||||
SeqInIndex: seq,
|
||||
IndexType: fmt.Sprintf("%v", row["Index_type"]),
|
||||
SubPart: subPart,
|
||||
}
|
||||
indexes = append(indexes, idx)
|
||||
}
|
||||
@@ -323,7 +333,7 @@ func (m *MariaDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
var args []interface{}
|
||||
for k, v := range pk {
|
||||
wheres = append(wheres, fmt.Sprintf("`%s` = ?", k))
|
||||
args = append(args, normalizeMySQLDateTimeValue(v))
|
||||
args = append(args, normalizeMySQLComplexValue(normalizeMySQLDateTimeValue(v)))
|
||||
}
|
||||
if len(wheres) == 0 {
|
||||
continue
|
||||
@@ -341,7 +351,7 @@ func (m *MariaDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
|
||||
for k, v := range update.Values {
|
||||
sets = append(sets, fmt.Sprintf("`%s` = ?", k))
|
||||
args = append(args, normalizeMySQLDateTimeValue(v))
|
||||
args = append(args, normalizeMySQLComplexValue(normalizeMySQLDateTimeValue(v)))
|
||||
}
|
||||
|
||||
if len(sets) == 0 {
|
||||
@@ -351,7 +361,7 @@ func (m *MariaDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
var wheres []string
|
||||
for k, v := range update.Keys {
|
||||
wheres = append(wheres, fmt.Sprintf("`%s` = ?", k))
|
||||
args = append(args, normalizeMySQLDateTimeValue(v))
|
||||
args = append(args, normalizeMySQLComplexValue(normalizeMySQLDateTimeValue(v)))
|
||||
}
|
||||
|
||||
if len(wheres) == 0 {
|
||||
@@ -373,7 +383,7 @@ func (m *MariaDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
for k, v := range row {
|
||||
cols = append(cols, fmt.Sprintf("`%s`", k))
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, normalizeMySQLDateTimeValue(v))
|
||||
args = append(args, normalizeMySQLComplexValue(normalizeMySQLDateTimeValue(v)))
|
||||
}
|
||||
|
||||
if len(cols) == 0 {
|
||||
|
||||
@@ -251,6 +251,11 @@ func (m *MongoDB) getURI(config connection.ConnectionConfig) string {
|
||||
params.Set("authMechanism", authMechanism)
|
||||
}
|
||||
|
||||
// 单机模式且未指定副本集名称时,启用 directConnection 避免驱动自动跟随副本集成员发现
|
||||
if strings.TrimSpace(config.Topology) != "replica" && strings.TrimSpace(config.ReplicaSet) == "" && !config.MongoSRV {
|
||||
params.Set("directConnection", "true")
|
||||
}
|
||||
|
||||
if encoded := params.Encode(); encoded != "" {
|
||||
uri += "?" + encoded
|
||||
}
|
||||
|
||||
@@ -252,6 +252,11 @@ func (m *MongoDBV1) getURI(config connection.ConnectionConfig) string {
|
||||
params.Set("authMechanism", authMechanism)
|
||||
}
|
||||
|
||||
// 单机模式且未指定副本集名称时,启用 directConnection 避免驱动自动跟随副本集成员发现
|
||||
if strings.TrimSpace(config.Topology) != "replica" && strings.TrimSpace(config.ReplicaSet) == "" && !config.MongoSRV {
|
||||
params.Set("directConnection", "true")
|
||||
}
|
||||
|
||||
if encoded := params.Encode(); encoded != "" {
|
||||
uri += "?" + encoded
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package db
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -441,12 +442,22 @@ func (m *MySQLDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefini
|
||||
}
|
||||
}
|
||||
|
||||
subPart := 0
|
||||
if val, ok := row["Sub_part"]; ok && val != nil {
|
||||
if f, ok := val.(float64); ok {
|
||||
subPart = int(f)
|
||||
} else if i, ok := val.(int64); ok {
|
||||
subPart = int(i)
|
||||
}
|
||||
}
|
||||
|
||||
idx := connection.IndexDefinition{
|
||||
Name: fmt.Sprintf("%v", row["Key_name"]),
|
||||
ColumnName: fmt.Sprintf("%v", row["Column_name"]),
|
||||
NonUnique: nonUnique,
|
||||
SeqInIndex: seq,
|
||||
IndexType: fmt.Sprintf("%v", row["Index_type"]),
|
||||
SubPart: subPart,
|
||||
}
|
||||
indexes = append(indexes, idx)
|
||||
}
|
||||
@@ -606,6 +617,18 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func normalizeMySQLComplexValue(value interface{}) interface{} {
|
||||
switch v := value.(type) {
|
||||
case map[string]interface{}, []interface{}:
|
||||
if data, err := json.Marshal(v); err == nil {
|
||||
return string(data)
|
||||
}
|
||||
return fmt.Sprintf("%v", value)
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeMySQLDateTimeValue(value interface{}) interface{} {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
@@ -670,7 +693,7 @@ func (m *MySQLDB) loadColumnTypeMap(tableName string) map[string]string {
|
||||
func normalizeMySQLValueForInsert(columnName string, value interface{}, columnTypeMap map[string]string) (interface{}, bool) {
|
||||
columnType := strings.ToLower(strings.TrimSpace(columnTypeMap[strings.ToLower(strings.TrimSpace(columnName))]))
|
||||
if !isMySQLTemporalColumnType(columnType) {
|
||||
return value, false
|
||||
return normalizeMySQLComplexValue(value), false
|
||||
}
|
||||
text, ok := value.(string)
|
||||
if ok && strings.TrimSpace(text) == "" {
|
||||
|
||||
@@ -9,8 +9,10 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -107,6 +112,30 @@ func newOptionalDriverAgentClient(driverType string, executablePath string) (*op
|
||||
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) {
|
||||
scanner := bufio.NewScanner(stderr)
|
||||
buffer := make([]byte, 0, 8<<10)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
@@ -30,12 +31,44 @@ func normalizeQueryValue(v interface{}) 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 {
|
||||
return bytesToDisplayValue(b, databaseTypeName)
|
||||
}
|
||||
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{} {
|
||||
if v == nil {
|
||||
return nil
|
||||
@@ -86,6 +119,16 @@ func normalizeCompositeQueryValue(v interface{}) interface{} {
|
||||
items[i] = normalizeQueryValue(rv.Index(i).Interface())
|
||||
}
|
||||
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:
|
||||
return normalizeUnsafeIntegerForJS(rv, v)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
168
internal/db/tdengine_applychanges_test.go
Normal file
168
internal/db/tdengine_applychanges_test.go
Normal file
@@ -0,0 +1,168 @@
|
||||
//go:build gonavi_full_drivers || gonavi_tdengine_driver
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
const tdengineRecordingDriverName = "gonavi_tdengine_recording"
|
||||
|
||||
var (
|
||||
registerTDengineRecordingDriverOnce sync.Once
|
||||
tdengineRecordingDriverMu sync.Mutex
|
||||
tdengineRecordingDriverSeq int
|
||||
tdengineRecordingDriverStates = map[string]*tdengineRecordingState{}
|
||||
)
|
||||
|
||||
type tdengineRecordingState struct {
|
||||
mu sync.Mutex
|
||||
queries []string
|
||||
execErr error
|
||||
}
|
||||
|
||||
func (s *tdengineRecordingState) snapshotQueries() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
queries := make([]string, len(s.queries))
|
||||
copy(queries, s.queries)
|
||||
return queries
|
||||
}
|
||||
|
||||
type tdengineRecordingDriver struct{}
|
||||
|
||||
func (tdengineRecordingDriver) Open(name string) (driver.Conn, error) {
|
||||
tdengineRecordingDriverMu.Lock()
|
||||
state := tdengineRecordingDriverStates[name]
|
||||
tdengineRecordingDriverMu.Unlock()
|
||||
if state == nil {
|
||||
return nil, fmt.Errorf("recording state not found: %s", name)
|
||||
}
|
||||
return &tdengineRecordingConn{state: state}, nil
|
||||
}
|
||||
|
||||
type tdengineRecordingConn struct {
|
||||
state *tdengineRecordingState
|
||||
}
|
||||
|
||||
func (c *tdengineRecordingConn) Prepare(query string) (driver.Stmt, error) {
|
||||
return nil, fmt.Errorf("prepare not supported in tdengine recording driver: %s", query)
|
||||
}
|
||||
|
||||
func (c *tdengineRecordingConn) Close() error { return nil }
|
||||
|
||||
func (c *tdengineRecordingConn) Begin() (driver.Tx, error) {
|
||||
return nil, fmt.Errorf("transactions not supported in tdengine recording driver")
|
||||
}
|
||||
|
||||
func (c *tdengineRecordingConn) ExecContext(_ context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
|
||||
if len(args) > 0 {
|
||||
return nil, fmt.Errorf("unexpected exec args: %d", len(args))
|
||||
}
|
||||
c.state.mu.Lock()
|
||||
defer c.state.mu.Unlock()
|
||||
if c.state.execErr != nil {
|
||||
return nil, c.state.execErr
|
||||
}
|
||||
c.state.queries = append(c.state.queries, query)
|
||||
return driver.RowsAffected(1), nil
|
||||
}
|
||||
|
||||
var _ driver.ExecerContext = (*tdengineRecordingConn)(nil)
|
||||
|
||||
func openTDengineRecordingDB(t *testing.T) (*sql.DB, *tdengineRecordingState) {
|
||||
t.Helper()
|
||||
registerTDengineRecordingDriverOnce.Do(func() {
|
||||
sql.Register(tdengineRecordingDriverName, tdengineRecordingDriver{})
|
||||
})
|
||||
|
||||
tdengineRecordingDriverMu.Lock()
|
||||
tdengineRecordingDriverSeq++
|
||||
dsn := fmt.Sprintf("tdengine-recording-%d", tdengineRecordingDriverSeq)
|
||||
state := &tdengineRecordingState{}
|
||||
tdengineRecordingDriverStates[dsn] = state
|
||||
tdengineRecordingDriverMu.Unlock()
|
||||
|
||||
dbConn, err := sql.Open(tdengineRecordingDriverName, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("打开 recording db 失败: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = dbConn.Close()
|
||||
tdengineRecordingDriverMu.Lock()
|
||||
delete(tdengineRecordingDriverStates, dsn)
|
||||
tdengineRecordingDriverMu.Unlock()
|
||||
})
|
||||
|
||||
return dbConn, state
|
||||
}
|
||||
|
||||
func TestTDengineApplyChanges_InsertsIntoQualifiedTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbConn, state := openTDengineRecordingDB(t)
|
||||
td := &TDengineDB{conn: dbConn}
|
||||
|
||||
changes := connection.ChangeSet{
|
||||
Inserts: []map[string]interface{}{
|
||||
{
|
||||
"ts": "2026-03-09 10:00:00",
|
||||
"value": 12.5,
|
||||
"device": "sensor-a",
|
||||
"enabled": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := td.ApplyChanges("analytics.metrics", changes); err != nil {
|
||||
t.Fatalf("ApplyChanges 返回错误: %v", err)
|
||||
}
|
||||
|
||||
queries := state.snapshotQueries()
|
||||
if len(queries) != 1 {
|
||||
t.Fatalf("期望执行 1 条 SQL,实际 %d 条: %#v", len(queries), queries)
|
||||
}
|
||||
|
||||
want := "INSERT INTO `analytics`.`metrics` (`device`, `enabled`, `ts`, `value`) VALUES ('sensor-a', 1, '2026-03-09 10:00:00', 12.5)"
|
||||
if queries[0] != want {
|
||||
t.Fatalf("插入 SQL 不符合预期\nwant: %s\n got: %s", want, queries[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTDengineApplyChanges_RejectsMixedUpdatesWithoutPartialWrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbConn, state := openTDengineRecordingDB(t)
|
||||
td := &TDengineDB{conn: dbConn}
|
||||
|
||||
changes := connection.ChangeSet{
|
||||
Inserts: []map[string]interface{}{{
|
||||
"ts": "2026-03-09 10:00:00",
|
||||
"value": 12.5,
|
||||
}},
|
||||
Updates: []connection.UpdateRow{{
|
||||
Keys: map[string]interface{}{"ts": "2026-03-09 10:00:00"},
|
||||
Values: map[string]interface{}{"value": 18.8},
|
||||
}},
|
||||
}
|
||||
|
||||
err := td.ApplyChanges("metrics", changes)
|
||||
if err == nil {
|
||||
t.Fatalf("期望 mixed changes 被拒绝")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "UPDATE/DELETE") {
|
||||
t.Fatalf("错误信息未说明限制边界: %v", err)
|
||||
}
|
||||
if queries := state.snapshotQueries(); len(queries) != 0 {
|
||||
t.Fatalf("期望拒绝 mixed changes 时不执行任何 SQL,实际=%#v", queries)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -362,6 +363,83 @@ func (t *TDengineDB) GetTriggers(dbName, tableName string) ([]connection.Trigger
|
||||
return []connection.TriggerDefinition{}, nil
|
||||
}
|
||||
|
||||
func (t *TDengineDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
if t.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
}
|
||||
if strings.TrimSpace(tableName) == "" {
|
||||
return fmt.Errorf("table name required")
|
||||
}
|
||||
if len(changes.Updates) > 0 || len(changes.Deletes) > 0 {
|
||||
return fmt.Errorf("TDengine 目标端当前仅支持 INSERT 写入,暂不支持 UPDATE/DELETE 差异同步,请改用仅插入或全量覆盖模式")
|
||||
}
|
||||
|
||||
qualifiedTable := quoteTDengineTable("", tableName)
|
||||
for _, row := range changes.Inserts {
|
||||
query, err := buildTDengineInsertSQL(qualifiedTable, row)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if query == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := t.conn.Exec(query); err != nil {
|
||||
return fmt.Errorf("insert error: %v; sql=%s", err, query)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildTDengineInsertSQL(qualifiedTable string, row map[string]interface{}) (string, error) {
|
||||
if strings.TrimSpace(qualifiedTable) == "" {
|
||||
return "", fmt.Errorf("qualified table required")
|
||||
}
|
||||
if len(row) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
cols := make([]string, 0, len(row))
|
||||
for key := range row {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
continue
|
||||
}
|
||||
cols = append(cols, key)
|
||||
}
|
||||
if len(cols) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
sort.Strings(cols)
|
||||
|
||||
quotedCols := make([]string, 0, len(cols))
|
||||
values := make([]string, 0, len(cols))
|
||||
for _, col := range cols {
|
||||
quotedCols = append(quotedCols, fmt.Sprintf("`%s`", escapeBacktickIdent(col)))
|
||||
values = append(values, tdengineLiteral(row[col]))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(quotedCols, ", "), strings.Join(values, ", ")), nil
|
||||
}
|
||||
|
||||
func tdengineLiteral(value interface{}) string {
|
||||
switch val := value.(type) {
|
||||
case nil:
|
||||
return "NULL"
|
||||
case bool:
|
||||
if val {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
|
||||
return fmt.Sprintf("%v", val)
|
||||
case time.Time:
|
||||
return fmt.Sprintf("'%s'", val.Format("2006-01-02 15:04:05"))
|
||||
case []byte:
|
||||
return fmt.Sprintf("'%s'", strings.ReplaceAll(string(val), "'", "''"))
|
||||
default:
|
||||
return fmt.Sprintf("'%s'", strings.ReplaceAll(fmt.Sprintf("%v", val), "'", "''"))
|
||||
}
|
||||
}
|
||||
|
||||
func getValueFromRow(row map[string]interface{}, keys ...string) (interface{}, bool) {
|
||||
if len(row) == 0 {
|
||||
return nil, false
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"GoNavi-Wails/internal/db"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type TableDiffSummary struct {
|
||||
Table string `json:"table"`
|
||||
PKColumn string `json:"pkColumn,omitempty"`
|
||||
CanSync bool `json:"canSync"`
|
||||
Inserts int `json:"inserts"`
|
||||
Updates int `json:"updates"`
|
||||
Deletes int `json:"deletes"`
|
||||
Same int `json:"same"`
|
||||
Message string `json:"message,omitempty"`
|
||||
HasSchema bool `json:"hasSchema,omitempty"`
|
||||
Table string `json:"table"`
|
||||
PKColumn string `json:"pkColumn,omitempty"`
|
||||
CanSync bool `json:"canSync"`
|
||||
Inserts int `json:"inserts"`
|
||||
Updates int `json:"updates"`
|
||||
Deletes int `json:"deletes"`
|
||||
Same int `json:"same"`
|
||||
Message string `json:"message,omitempty"`
|
||||
HasSchema bool `json:"hasSchema,omitempty"`
|
||||
TargetTableExists bool `json:"targetTableExists,omitempty"`
|
||||
PlannedAction string `json:"plannedAction,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
UnsupportedObjects []string `json:"unsupportedObjects,omitempty"`
|
||||
IndexesToCreate int `json:"indexesToCreate,omitempty"`
|
||||
IndexesSkipped int `json:"indexesSkipped,omitempty"`
|
||||
}
|
||||
|
||||
type SyncAnalyzeResult struct {
|
||||
@@ -27,6 +32,12 @@ type SyncAnalyzeResult struct {
|
||||
|
||||
func (s *SyncEngine) Analyze(config SyncConfig) SyncAnalyzeResult {
|
||||
result := SyncAnalyzeResult{Success: true, Tables: []TableDiffSummary{}}
|
||||
if isRedisToMongoKeyspacePair(config) {
|
||||
return s.analyzeRedisToMongo(config)
|
||||
}
|
||||
if isMongoToRedisKeyspacePair(config) {
|
||||
return s.analyzeMongoToRedis(config)
|
||||
}
|
||||
|
||||
contentRaw := strings.ToLower(strings.TrimSpace(config.Content))
|
||||
syncSchema := false
|
||||
@@ -48,25 +59,23 @@ func (s *SyncEngine) Analyze(config SyncConfig) SyncAnalyzeResult {
|
||||
totalTables := len(config.Tables)
|
||||
s.progress(config.JobID, 0, totalTables, "", "差异分析开始")
|
||||
|
||||
sourceDB, err := db.NewDatabase(config.SourceConfig.Type)
|
||||
sourceDB, err := newSyncDatabase(config.SourceConfig.Type)
|
||||
if err != nil {
|
||||
logger.Error(err, "初始化源数据库驱动失败:类型=%s", config.SourceConfig.Type)
|
||||
return SyncAnalyzeResult{Success: false, Message: "初始化源数据库驱动失败: " + err.Error()}
|
||||
}
|
||||
targetDB, err := db.NewDatabase(config.TargetConfig.Type)
|
||||
targetDB, err := newSyncDatabase(config.TargetConfig.Type)
|
||||
if err != nil {
|
||||
logger.Error(err, "初始化目标数据库驱动失败:类型=%s", config.TargetConfig.Type)
|
||||
return SyncAnalyzeResult{Success: false, Message: "初始化目标数据库驱动失败: " + err.Error()}
|
||||
}
|
||||
|
||||
// Connect Source
|
||||
if err := sourceDB.Connect(config.SourceConfig); err != nil {
|
||||
logger.Error(err, "源数据库连接失败:%s", formatConnSummaryForSync(config.SourceConfig))
|
||||
return SyncAnalyzeResult{Success: false, Message: "源数据库连接失败: " + err.Error()}
|
||||
}
|
||||
defer sourceDB.Close()
|
||||
|
||||
// Connect Target
|
||||
if err := targetDB.Connect(config.TargetConfig); err != nil {
|
||||
logger.Error(err, "目标数据库连接失败:%s", formatConnSummaryForSync(config.TargetConfig))
|
||||
return SyncAnalyzeResult{Success: false, Message: "目标数据库连接失败: " + err.Error()}
|
||||
@@ -88,51 +97,76 @@ func (s *SyncEngine) Analyze(config SyncConfig) SyncAnalyzeResult {
|
||||
HasSchema: syncSchema,
|
||||
}
|
||||
|
||||
sourceSchema, sourceTable := normalizeSchemaAndTable(config.SourceConfig.Type, config.SourceConfig.Database, tableName)
|
||||
targetSchema, targetTable := normalizeSchemaAndTable(config.TargetConfig.Type, config.TargetConfig.Database, tableName)
|
||||
sourceQueryTable := qualifiedNameForQuery(config.SourceConfig.Type, sourceSchema, sourceTable, tableName)
|
||||
targetQueryTable := qualifiedNameForQuery(config.TargetConfig.Type, targetSchema, targetTable, tableName)
|
||||
|
||||
cols, err := sourceDB.GetColumns(sourceSchema, sourceTable)
|
||||
plan, cols, _, err := buildSchemaMigrationPlan(config, tableName, sourceDB, targetDB)
|
||||
if err != nil {
|
||||
summary.Message = "获取源表字段失败: " + err.Error()
|
||||
summary.Message = err.Error()
|
||||
result.Tables = append(result.Tables, summary)
|
||||
return
|
||||
}
|
||||
summary.TargetTableExists = plan.TargetTableExists
|
||||
summary.PlannedAction = plan.PlannedAction
|
||||
summary.Warnings = append(summary.Warnings, plan.Warnings...)
|
||||
summary.UnsupportedObjects = append(summary.UnsupportedObjects, plan.UnsupportedObjects...)
|
||||
summary.IndexesToCreate = plan.IndexesToCreate
|
||||
summary.IndexesSkipped = plan.IndexesSkipped
|
||||
|
||||
if !plan.TargetTableExists && !plan.AutoCreate {
|
||||
summary.Message = firstNonEmpty(plan.PlannedAction, "目标表不存在,无法执行同步")
|
||||
result.Tables = append(result.Tables, summary)
|
||||
return
|
||||
}
|
||||
|
||||
if !syncData {
|
||||
summary.CanSync = true
|
||||
summary.Message = "仅同步结构,未执行数据差异分析"
|
||||
summary.Message = firstNonEmpty(plan.PlannedAction, "仅同步结构,未执行数据差异分析")
|
||||
result.Tables = append(result.Tables, summary)
|
||||
return
|
||||
}
|
||||
|
||||
tableMode := normalizeSyncMode(config.Mode)
|
||||
pkCols := make([]string, 0, 2)
|
||||
for _, c := range cols {
|
||||
if c.Key == "PRI" || c.Key == "PK" {
|
||||
pkCols = append(pkCols, c.Name)
|
||||
}
|
||||
}
|
||||
if len(pkCols) == 0 {
|
||||
summary.Message = "无主键,不支持数据对比/同步"
|
||||
result.Tables = append(result.Tables, summary)
|
||||
return
|
||||
}
|
||||
if len(pkCols) > 1 {
|
||||
summary.Message = fmt.Sprintf("复合主键(%s),暂不支持数据对比/同步", strings.Join(pkCols, ","))
|
||||
result.Tables = append(result.Tables, summary)
|
||||
return
|
||||
}
|
||||
summary.PKColumn = pkCols[0]
|
||||
|
||||
// Query data for diff
|
||||
sourceRows, _, err := sourceDB.Query(fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(config.SourceConfig.Type, sourceQueryTable)))
|
||||
sourceRows, _, err := sourceDB.Query(fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(config.SourceConfig.Type, plan.SourceQueryTable)))
|
||||
if err != nil {
|
||||
summary.Message = "读取源表失败: " + err.Error()
|
||||
result.Tables = append(result.Tables, summary)
|
||||
return
|
||||
}
|
||||
targetRows, _, err := targetDB.Query(fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(config.TargetConfig.Type, targetQueryTable)))
|
||||
|
||||
if !plan.TargetTableExists && plan.AutoCreate {
|
||||
summary.CanSync = true
|
||||
summary.Inserts = len(sourceRows)
|
||||
summary.Message = firstNonEmpty(plan.PlannedAction, "目标表不存在,执行时将自动建表并导入全部源数据")
|
||||
result.Tables = append(result.Tables, summary)
|
||||
return
|
||||
}
|
||||
|
||||
if tableMode != "insert_update" {
|
||||
summary.CanSync = true
|
||||
summary.Inserts = len(sourceRows)
|
||||
summary.Message = firstNonEmpty(plan.PlannedAction, "当前模式无需差异对比,将按源表数据执行导入")
|
||||
result.Tables = append(result.Tables, summary)
|
||||
return
|
||||
}
|
||||
|
||||
if len(pkCols) == 0 {
|
||||
summary.Message = "无主键,不支持差异对比同步;如需直接导入请使用仅插入或全量覆盖模式"
|
||||
result.Tables = append(result.Tables, summary)
|
||||
return
|
||||
}
|
||||
if len(pkCols) > 1 {
|
||||
summary.Message = fmt.Sprintf("复合主键(%s),暂不支持差异对比同步", strings.Join(pkCols, ","))
|
||||
result.Tables = append(result.Tables, summary)
|
||||
return
|
||||
}
|
||||
summary.PKColumn = pkCols[0]
|
||||
|
||||
targetRows, _, err := targetDB.Query(fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(config.TargetConfig.Type, plan.TargetQueryTable)))
|
||||
if err != nil {
|
||||
summary.Message = "读取目标表失败: " + err.Error()
|
||||
result.Tables = append(result.Tables, summary)
|
||||
@@ -188,6 +222,9 @@ func (s *SyncEngine) Analyze(config SyncConfig) SyncAnalyzeResult {
|
||||
}
|
||||
|
||||
summary.CanSync = true
|
||||
if strings.TrimSpace(summary.Message) == "" {
|
||||
summary.Message = firstNonEmpty(plan.PlannedAction, "差异分析完成")
|
||||
}
|
||||
result.Tables = append(result.Tables, summary)
|
||||
}()
|
||||
}
|
||||
@@ -196,3 +233,12 @@ func (s *SyncEngine) Analyze(config SyncConfig) SyncAnalyzeResult {
|
||||
result.Message = fmt.Sprintf("已完成 %d 张表的差异分析", len(result.Tables))
|
||||
return result
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
741
internal/sync/migration_clickhouse.go
Normal file
741
internal/sync/migration_clickhouse.go
Normal file
@@ -0,0 +1,741 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func buildMySQLToClickHousePlan(config SyncConfig, tableName string, sourceDB db.Database, targetDB db.Database) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
plan := SchemaMigrationPlan{}
|
||||
plan.SourceSchema, plan.SourceTable = normalizeSchemaAndTable(config.SourceConfig.Type, config.SourceConfig.Database, tableName)
|
||||
plan.TargetSchema, plan.TargetTable = normalizeSchemaAndTable(config.TargetConfig.Type, config.TargetConfig.Database, tableName)
|
||||
plan.SourceQueryTable = qualifiedNameForQuery(config.SourceConfig.Type, plan.SourceSchema, plan.SourceTable, tableName)
|
||||
plan.TargetQueryTable = qualifiedNameForQuery(config.TargetConfig.Type, plan.TargetSchema, plan.TargetTable, tableName)
|
||||
plan.PlannedAction = "使用已有目标表导入"
|
||||
|
||||
sourceCols, sourceExists, err := inspectTableColumns(sourceDB, plan.SourceSchema, plan.SourceTable)
|
||||
if err != nil {
|
||||
return plan, nil, nil, fmt.Errorf("获取源表字段失败: %w", err)
|
||||
}
|
||||
if !sourceExists {
|
||||
return plan, nil, nil, fmt.Errorf("源表不存在或无列定义: %s", tableName)
|
||||
}
|
||||
|
||||
targetCols, targetExists, err := inspectTableColumns(targetDB, plan.TargetSchema, plan.TargetTable)
|
||||
if err != nil {
|
||||
return plan, sourceCols, nil, fmt.Errorf("获取目标表字段失败: %w", err)
|
||||
}
|
||||
plan.TargetTableExists = targetExists
|
||||
|
||||
strategy := normalizeTargetTableStrategy(config.TargetTableStrategy)
|
||||
if targetExists {
|
||||
missing := diffMissingColumnNames(sourceCols, targetCols)
|
||||
if len(missing) > 0 {
|
||||
plan.Warnings = append(plan.Warnings, fmt.Sprintf("目标表缺失字段 %d 个:%s", len(missing), strings.Join(missing, ", ")))
|
||||
}
|
||||
if config.AutoAddColumns {
|
||||
addSQL, addWarnings := buildMySQLToClickHouseAddColumnSQL(plan.TargetQueryTable, sourceCols, targetCols)
|
||||
plan.PreDataSQL = append(plan.PreDataSQL, addSQL...)
|
||||
plan.Warnings = append(plan.Warnings, addWarnings...)
|
||||
if len(addSQL) > 0 {
|
||||
plan.PlannedAction = fmt.Sprintf("补齐缺失字段(%d)后导入", len(addSQL))
|
||||
}
|
||||
}
|
||||
plan.Warnings = append(plan.Warnings, "ClickHouse 目标端建议优先使用仅插入或全量覆盖;更新/删除语义与传统关系型存在差异")
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
}
|
||||
|
||||
switch strategy {
|
||||
case "existing_only":
|
||||
plan.PlannedAction = "目标表不存在,需先手工创建"
|
||||
plan.Warnings = append(plan.Warnings, "当前策略要求目标表已存在,执行时不会自动建表")
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
case "smart", "auto_create_if_missing":
|
||||
plan.AutoCreate = true
|
||||
plan.PlannedAction = "目标表不存在,将自动建表后导入"
|
||||
createSQL, warnings, unsupported := buildMySQLToClickHouseCreateTableSQL(plan.TargetQueryTable, sourceCols)
|
||||
plan.CreateTableSQL = createSQL
|
||||
plan.Warnings = append(plan.Warnings, warnings...)
|
||||
plan.UnsupportedObjects = append(plan.UnsupportedObjects, unsupported...)
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
default:
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildPGLikeToClickHousePlan(config SyncConfig, tableName string, sourceDB db.Database, targetDB db.Database) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
plan := SchemaMigrationPlan{}
|
||||
sourceType := resolveMigrationDBType(config.SourceConfig)
|
||||
targetType := resolveMigrationDBType(config.TargetConfig)
|
||||
plan.SourceSchema, plan.SourceTable = normalizeSchemaAndTable(sourceType, config.SourceConfig.Database, tableName)
|
||||
plan.TargetSchema, plan.TargetTable = normalizeSchemaAndTable(targetType, config.TargetConfig.Database, tableName)
|
||||
plan.SourceQueryTable = qualifiedNameForQuery(sourceType, plan.SourceSchema, plan.SourceTable, tableName)
|
||||
plan.TargetQueryTable = qualifiedNameForQuery(targetType, plan.TargetSchema, plan.TargetTable, tableName)
|
||||
plan.PlannedAction = "使用已有目标表导入"
|
||||
|
||||
sourceCols, sourceExists, err := inspectTableColumns(sourceDB, plan.SourceSchema, plan.SourceTable)
|
||||
if err != nil {
|
||||
return plan, nil, nil, fmt.Errorf("获取源表字段失败: %w", err)
|
||||
}
|
||||
if !sourceExists {
|
||||
return plan, nil, nil, fmt.Errorf("源表不存在或无列定义: %s", tableName)
|
||||
}
|
||||
|
||||
targetCols, targetExists, err := inspectTableColumns(targetDB, plan.TargetSchema, plan.TargetTable)
|
||||
if err != nil {
|
||||
return plan, sourceCols, nil, fmt.Errorf("获取目标表字段失败: %w", err)
|
||||
}
|
||||
plan.TargetTableExists = targetExists
|
||||
|
||||
strategy := normalizeTargetTableStrategy(config.TargetTableStrategy)
|
||||
if targetExists {
|
||||
missing := diffMissingColumnNames(sourceCols, targetCols)
|
||||
if len(missing) > 0 {
|
||||
plan.Warnings = append(plan.Warnings, fmt.Sprintf("目标表缺失字段 %d 个:%s", len(missing), strings.Join(missing, ", ")))
|
||||
}
|
||||
if config.AutoAddColumns {
|
||||
addSQL, addWarnings := buildPGLikeToClickHouseAddColumnSQL(plan.TargetQueryTable, sourceCols, targetCols)
|
||||
plan.PreDataSQL = append(plan.PreDataSQL, addSQL...)
|
||||
plan.Warnings = append(plan.Warnings, addWarnings...)
|
||||
if len(addSQL) > 0 {
|
||||
plan.PlannedAction = fmt.Sprintf("补齐缺失字段(%d)后导入", len(addSQL))
|
||||
}
|
||||
}
|
||||
plan.Warnings = append(plan.Warnings, "ClickHouse 目标端建议优先使用仅插入或全量覆盖;更新/删除语义与传统关系型存在差异")
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
}
|
||||
|
||||
switch strategy {
|
||||
case "existing_only":
|
||||
plan.PlannedAction = "目标表不存在,需先手工创建"
|
||||
plan.Warnings = append(plan.Warnings, "当前策略要求目标表已存在,执行时不会自动建表")
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
case "smart", "auto_create_if_missing":
|
||||
plan.AutoCreate = true
|
||||
plan.PlannedAction = "目标表不存在,将自动建表后导入"
|
||||
createSQL, warnings, unsupported := buildPGLikeToClickHouseCreateTableSQL(plan.TargetQueryTable, sourceCols)
|
||||
plan.CreateTableSQL = createSQL
|
||||
plan.Warnings = append(plan.Warnings, warnings...)
|
||||
plan.UnsupportedObjects = append(plan.UnsupportedObjects, unsupported...)
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
default:
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildClickHouseToMySQLPlan(config SyncConfig, tableName string, sourceDB db.Database, targetDB db.Database) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
plan := SchemaMigrationPlan{}
|
||||
plan.SourceSchema, plan.SourceTable = normalizeSchemaAndTable(config.SourceConfig.Type, config.SourceConfig.Database, tableName)
|
||||
plan.TargetSchema, plan.TargetTable = normalizeSchemaAndTable(config.TargetConfig.Type, config.TargetConfig.Database, tableName)
|
||||
plan.SourceQueryTable = qualifiedNameForQuery(config.SourceConfig.Type, plan.SourceSchema, plan.SourceTable, tableName)
|
||||
plan.TargetQueryTable = qualifiedNameForQuery(config.TargetConfig.Type, plan.TargetSchema, plan.TargetTable, tableName)
|
||||
plan.PlannedAction = "使用已有目标表导入"
|
||||
|
||||
sourceCols, sourceExists, err := inspectTableColumns(sourceDB, plan.SourceSchema, plan.SourceTable)
|
||||
if err != nil {
|
||||
return plan, nil, nil, fmt.Errorf("获取源表字段失败: %w", err)
|
||||
}
|
||||
if !sourceExists {
|
||||
return plan, nil, nil, fmt.Errorf("源表不存在或无列定义: %s", tableName)
|
||||
}
|
||||
|
||||
targetCols, targetExists, err := inspectTableColumns(targetDB, plan.TargetSchema, plan.TargetTable)
|
||||
if err != nil {
|
||||
return plan, sourceCols, nil, fmt.Errorf("获取目标表字段失败: %w", err)
|
||||
}
|
||||
plan.TargetTableExists = targetExists
|
||||
|
||||
strategy := normalizeTargetTableStrategy(config.TargetTableStrategy)
|
||||
if targetExists {
|
||||
missing := diffMissingColumnNames(sourceCols, targetCols)
|
||||
if len(missing) > 0 {
|
||||
plan.Warnings = append(plan.Warnings, fmt.Sprintf("目标表缺失字段 %d 个:%s", len(missing), strings.Join(missing, ", ")))
|
||||
}
|
||||
if config.AutoAddColumns {
|
||||
addSQL, addWarnings := buildClickHouseToMySQLAddColumnSQL(plan.TargetQueryTable, sourceCols, targetCols)
|
||||
plan.PreDataSQL = append(plan.PreDataSQL, addSQL...)
|
||||
plan.Warnings = append(plan.Warnings, addWarnings...)
|
||||
if len(addSQL) > 0 {
|
||||
plan.PlannedAction = fmt.Sprintf("补齐缺失字段(%d)后导入", len(addSQL))
|
||||
}
|
||||
}
|
||||
plan.Warnings = append(plan.Warnings, "ClickHouse 源端索引/约束元数据有限,反向迁移将以字段和数据为主")
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
}
|
||||
|
||||
switch strategy {
|
||||
case "existing_only":
|
||||
plan.PlannedAction = "目标表不存在,需先手工创建"
|
||||
plan.Warnings = append(plan.Warnings, "当前策略要求目标表已存在,执行时不会自动建表")
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
case "smart", "auto_create_if_missing":
|
||||
plan.AutoCreate = true
|
||||
plan.PlannedAction = "目标表不存在,将自动建表后导入"
|
||||
createSQL, warnings := buildClickHouseToMySQLCreateTableSQL(plan.TargetQueryTable, sourceCols)
|
||||
plan.CreateTableSQL = createSQL
|
||||
plan.Warnings = append(plan.Warnings, warnings...)
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
default:
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildClickHouseToPGLikePlan(config SyncConfig, tableName string, sourceDB db.Database, targetDB db.Database) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
plan := SchemaMigrationPlan{}
|
||||
sourceType := resolveMigrationDBType(config.SourceConfig)
|
||||
targetType := resolveMigrationDBType(config.TargetConfig)
|
||||
plan.SourceSchema, plan.SourceTable = normalizeSchemaAndTable(sourceType, config.SourceConfig.Database, tableName)
|
||||
plan.TargetSchema, plan.TargetTable = normalizeSchemaAndTable(targetType, config.TargetConfig.Database, tableName)
|
||||
plan.SourceQueryTable = qualifiedNameForQuery(sourceType, plan.SourceSchema, plan.SourceTable, tableName)
|
||||
plan.TargetQueryTable = qualifiedNameForQuery(targetType, plan.TargetSchema, plan.TargetTable, tableName)
|
||||
plan.PlannedAction = "使用已有目标表导入"
|
||||
|
||||
sourceCols, sourceExists, err := inspectTableColumns(sourceDB, plan.SourceSchema, plan.SourceTable)
|
||||
if err != nil {
|
||||
return plan, nil, nil, fmt.Errorf("获取源表字段失败: %w", err)
|
||||
}
|
||||
if !sourceExists {
|
||||
return plan, nil, nil, fmt.Errorf("源表不存在或无列定义: %s", tableName)
|
||||
}
|
||||
|
||||
targetCols, targetExists, err := inspectTableColumns(targetDB, plan.TargetSchema, plan.TargetTable)
|
||||
if err != nil {
|
||||
return plan, sourceCols, nil, fmt.Errorf("获取目标表字段失败: %w", err)
|
||||
}
|
||||
plan.TargetTableExists = targetExists
|
||||
|
||||
strategy := normalizeTargetTableStrategy(config.TargetTableStrategy)
|
||||
if targetExists {
|
||||
missing := diffMissingColumnNames(sourceCols, targetCols)
|
||||
if len(missing) > 0 {
|
||||
plan.Warnings = append(plan.Warnings, fmt.Sprintf("目标表缺失字段 %d 个:%s", len(missing), strings.Join(missing, ", ")))
|
||||
}
|
||||
if config.AutoAddColumns {
|
||||
addSQL, addWarnings := buildClickHouseToPGLikeAddColumnSQL(targetType, plan.TargetQueryTable, sourceCols, targetCols)
|
||||
plan.PreDataSQL = append(plan.PreDataSQL, addSQL...)
|
||||
plan.Warnings = append(plan.Warnings, addWarnings...)
|
||||
if len(addSQL) > 0 {
|
||||
plan.PlannedAction = fmt.Sprintf("补齐缺失字段(%d)后导入", len(addSQL))
|
||||
}
|
||||
}
|
||||
plan.Warnings = append(plan.Warnings, "ClickHouse 源端索引/约束元数据有限,反向迁移将以字段和数据为主")
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
}
|
||||
|
||||
switch strategy {
|
||||
case "existing_only":
|
||||
plan.PlannedAction = "目标表不存在,需先手工创建"
|
||||
plan.Warnings = append(plan.Warnings, "当前策略要求目标表已存在,执行时不会自动建表")
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
case "smart", "auto_create_if_missing":
|
||||
plan.AutoCreate = true
|
||||
plan.PlannedAction = "目标表不存在,将自动建表后导入"
|
||||
createSQL, warnings, unsupported := buildClickHouseToPGLikeCreateTableSQL(targetType, plan.TargetQueryTable, sourceCols)
|
||||
plan.CreateTableSQL = createSQL
|
||||
plan.Warnings = append(plan.Warnings, warnings...)
|
||||
plan.UnsupportedObjects = append(plan.UnsupportedObjects, unsupported...)
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
default:
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildPGLikeToClickHouseAddColumnSQL(targetQueryTable string, sourceCols, targetCols []connection.ColumnDefinition) ([]string, []string) {
|
||||
targetSet := make(map[string]struct{}, len(targetCols))
|
||||
for _, col := range targetCols {
|
||||
key := strings.ToLower(strings.TrimSpace(col.Name))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
targetSet[key] = struct{}{}
|
||||
}
|
||||
var sqlList []string
|
||||
var warnings []string
|
||||
for _, col := range sourceCols {
|
||||
key := strings.ToLower(strings.TrimSpace(col.Name))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := targetSet[key]; ok {
|
||||
continue
|
||||
}
|
||||
colType, mapWarnings := mapPGLikeColumnToClickHouse(col)
|
||||
warnings = append(warnings, mapWarnings...)
|
||||
sqlList = append(sqlList, fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s",
|
||||
quoteQualifiedIdentByType("clickhouse", targetQueryTable),
|
||||
quoteIdentByType("clickhouse", col.Name),
|
||||
colType,
|
||||
))
|
||||
}
|
||||
return sqlList, dedupeStrings(warnings)
|
||||
}
|
||||
|
||||
func buildMySQLToClickHouseAddColumnSQL(targetQueryTable string, sourceCols, targetCols []connection.ColumnDefinition) ([]string, []string) {
|
||||
targetSet := make(map[string]struct{}, len(targetCols))
|
||||
for _, col := range targetCols {
|
||||
key := strings.ToLower(strings.TrimSpace(col.Name))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
targetSet[key] = struct{}{}
|
||||
}
|
||||
var sqlList []string
|
||||
var warnings []string
|
||||
for _, col := range sourceCols {
|
||||
key := strings.ToLower(strings.TrimSpace(col.Name))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := targetSet[key]; ok {
|
||||
continue
|
||||
}
|
||||
colType, mapWarnings := mapMySQLColumnToClickHouse(col)
|
||||
warnings = append(warnings, mapWarnings...)
|
||||
sqlList = append(sqlList, fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s",
|
||||
quoteQualifiedIdentByType("clickhouse", targetQueryTable),
|
||||
quoteIdentByType("clickhouse", col.Name),
|
||||
colType,
|
||||
))
|
||||
}
|
||||
return sqlList, dedupeStrings(warnings)
|
||||
}
|
||||
|
||||
func buildClickHouseToPGLikeAddColumnSQL(targetType string, targetQueryTable string, sourceCols, targetCols []connection.ColumnDefinition) ([]string, []string) {
|
||||
targetSet := make(map[string]struct{}, len(targetCols))
|
||||
for _, col := range targetCols {
|
||||
key := strings.ToLower(strings.TrimSpace(col.Name))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
targetSet[key] = struct{}{}
|
||||
}
|
||||
var sqlList []string
|
||||
var warnings []string
|
||||
for _, col := range sourceCols {
|
||||
key := strings.ToLower(strings.TrimSpace(col.Name))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := targetSet[key]; ok {
|
||||
continue
|
||||
}
|
||||
colType, mapWarnings := mapClickHouseColumnToPGLike(col)
|
||||
warnings = append(warnings, mapWarnings...)
|
||||
sqlList = append(sqlList, fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s NULL",
|
||||
quoteQualifiedIdentByType(targetType, targetQueryTable),
|
||||
quoteIdentByType(targetType, col.Name),
|
||||
colType,
|
||||
))
|
||||
}
|
||||
return sqlList, dedupeStrings(warnings)
|
||||
}
|
||||
|
||||
func buildClickHouseToMySQLAddColumnSQL(targetQueryTable string, sourceCols, targetCols []connection.ColumnDefinition) ([]string, []string) {
|
||||
targetSet := make(map[string]struct{}, len(targetCols))
|
||||
for _, col := range targetCols {
|
||||
key := strings.ToLower(strings.TrimSpace(col.Name))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
targetSet[key] = struct{}{}
|
||||
}
|
||||
var sqlList []string
|
||||
var warnings []string
|
||||
for _, col := range sourceCols {
|
||||
key := strings.ToLower(strings.TrimSpace(col.Name))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := targetSet[key]; ok {
|
||||
continue
|
||||
}
|
||||
colType, mapWarnings := mapClickHouseColumnToMySQL(col)
|
||||
warnings = append(warnings, mapWarnings...)
|
||||
sqlList = append(sqlList, fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s NULL",
|
||||
quoteQualifiedIdentByType("mysql", targetQueryTable),
|
||||
quoteIdentByType("mysql", col.Name),
|
||||
colType,
|
||||
))
|
||||
}
|
||||
return sqlList, dedupeStrings(warnings)
|
||||
}
|
||||
|
||||
func buildPGLikeToClickHouseCreateTableSQL(targetQueryTable string, sourceCols []connection.ColumnDefinition) (string, []string, []string) {
|
||||
columnDefs := make([]string, 0, len(sourceCols))
|
||||
warnings := make([]string, 0)
|
||||
unsupported := make([]string, 0)
|
||||
orderByCols := make([]string, 0)
|
||||
for _, col := range sourceCols {
|
||||
def, colWarnings := buildPGLikeToClickHouseColumnDefinition(col)
|
||||
warnings = append(warnings, colWarnings...)
|
||||
columnDefs = append(columnDefs, fmt.Sprintf("%s %s", quoteIdentByType("clickhouse", col.Name), def))
|
||||
if col.Key == "PRI" || col.Key == "PK" {
|
||||
orderByCols = append(orderByCols, quoteIdentByType("clickhouse", col.Name))
|
||||
}
|
||||
}
|
||||
orderExpr := "tuple()"
|
||||
if len(orderByCols) > 0 {
|
||||
orderExpr = "(" + strings.Join(orderByCols, ", ") + ")"
|
||||
} else {
|
||||
warnings = append(warnings, "源表未识别到主键,ClickHouse 将使用 ORDER BY tuple() 建表,后续查询性能可能受影响")
|
||||
}
|
||||
warnings = append(warnings, "ClickHouse 不保留关系型外键/唯一约束语义,将仅迁移字段与数据")
|
||||
createSQL := fmt.Sprintf("CREATE TABLE %s (\n %s\n) ENGINE = MergeTree() ORDER BY %s", quoteQualifiedIdentByType("clickhouse", targetQueryTable), strings.Join(columnDefs, ",\n "), orderExpr)
|
||||
return createSQL, dedupeStrings(warnings), dedupeStrings(unsupported)
|
||||
}
|
||||
|
||||
func buildMySQLToClickHouseCreateTableSQL(targetQueryTable string, sourceCols []connection.ColumnDefinition) (string, []string, []string) {
|
||||
columnDefs := make([]string, 0, len(sourceCols))
|
||||
warnings := make([]string, 0)
|
||||
unsupported := make([]string, 0)
|
||||
orderByCols := make([]string, 0)
|
||||
for _, col := range sourceCols {
|
||||
def, colWarnings := buildMySQLToClickHouseColumnDefinition(col)
|
||||
warnings = append(warnings, colWarnings...)
|
||||
columnDefs = append(columnDefs, fmt.Sprintf("%s %s", quoteIdentByType("clickhouse", col.Name), def))
|
||||
if col.Key == "PRI" || col.Key == "PK" {
|
||||
orderByCols = append(orderByCols, quoteIdentByType("clickhouse", col.Name))
|
||||
}
|
||||
}
|
||||
orderExpr := "tuple()"
|
||||
if len(orderByCols) > 0 {
|
||||
orderExpr = "(" + strings.Join(orderByCols, ", ") + ")"
|
||||
} else {
|
||||
warnings = append(warnings, "源表未识别到主键,ClickHouse 将使用 ORDER BY tuple() 建表,后续查询性能可能受影响")
|
||||
}
|
||||
warnings = append(warnings, "ClickHouse 不保留关系型外键/唯一约束语义,将仅迁移字段与数据")
|
||||
createSQL := fmt.Sprintf("CREATE TABLE %s (\n %s\n) ENGINE = MergeTree() ORDER BY %s", quoteQualifiedIdentByType("clickhouse", targetQueryTable), strings.Join(columnDefs, ",\n "), orderExpr)
|
||||
return createSQL, dedupeStrings(warnings), dedupeStrings(unsupported)
|
||||
}
|
||||
|
||||
func buildClickHouseToPGLikeCreateTableSQL(targetType string, targetQueryTable string, sourceCols []connection.ColumnDefinition) (string, []string, []string) {
|
||||
columnDefs := make([]string, 0, len(sourceCols)+1)
|
||||
warnings := make([]string, 0)
|
||||
unsupported := []string{"ClickHouse ORDER BY/PARTITION/TTL/Projection/物化视图 语义当前不会自动迁移到 PG-like"}
|
||||
pkCols := make([]string, 0)
|
||||
for _, col := range sourceCols {
|
||||
def, colWarnings := buildClickHouseToPGLikeColumnDefinition(col)
|
||||
warnings = append(warnings, colWarnings...)
|
||||
columnDefs = append(columnDefs, fmt.Sprintf("%s %s", quoteIdentByType(targetType, col.Name), def))
|
||||
if col.Key == "PRI" || col.Key == "PK" {
|
||||
pkCols = append(pkCols, quoteIdentByType(targetType, col.Name))
|
||||
}
|
||||
}
|
||||
if len(pkCols) > 0 {
|
||||
columnDefs = append(columnDefs, fmt.Sprintf("PRIMARY KEY (%s)", strings.Join(pkCols, ", ")))
|
||||
} else {
|
||||
warnings = append(warnings, "ClickHouse 源端未返回主键信息,目标 PG-like 表将不自动创建主键")
|
||||
}
|
||||
createSQL := fmt.Sprintf("CREATE TABLE %s (\n %s\n)", quoteQualifiedIdentByType(targetType, targetQueryTable), strings.Join(columnDefs, ",\n "))
|
||||
return createSQL, dedupeStrings(warnings), dedupeStrings(unsupported)
|
||||
}
|
||||
|
||||
func buildClickHouseToMySQLCreateTableSQL(targetQueryTable string, sourceCols []connection.ColumnDefinition) (string, []string) {
|
||||
columnDefs := make([]string, 0, len(sourceCols)+1)
|
||||
warnings := make([]string, 0)
|
||||
pkCols := make([]string, 0)
|
||||
for _, col := range sourceCols {
|
||||
def, colWarnings := buildClickHouseToMySQLColumnDefinition(col)
|
||||
warnings = append(warnings, colWarnings...)
|
||||
columnDefs = append(columnDefs, fmt.Sprintf("%s %s", quoteIdentByType("mysql", col.Name), def))
|
||||
if col.Key == "PRI" || col.Key == "PK" {
|
||||
pkCols = append(pkCols, quoteIdentByType("mysql", col.Name))
|
||||
}
|
||||
}
|
||||
if len(pkCols) > 0 {
|
||||
columnDefs = append(columnDefs, fmt.Sprintf("PRIMARY KEY (%s)", strings.Join(pkCols, ", ")))
|
||||
} else {
|
||||
warnings = append(warnings, "ClickHouse 源端未返回主键信息,目标 MySQL 表将不自动创建主键")
|
||||
}
|
||||
createSQL := fmt.Sprintf("CREATE TABLE %s (\n %s\n)", quoteQualifiedIdentByType("mysql", targetQueryTable), strings.Join(columnDefs, ",\n "))
|
||||
return createSQL, dedupeStrings(warnings)
|
||||
}
|
||||
|
||||
func buildPGLikeToClickHouseColumnDefinition(col connection.ColumnDefinition) (string, []string) {
|
||||
targetType, warnings := mapPGLikeColumnToClickHouse(col)
|
||||
parts := []string{targetType}
|
||||
return strings.Join(parts, " "), dedupeStrings(warnings)
|
||||
}
|
||||
|
||||
func buildMySQLToClickHouseColumnDefinition(col connection.ColumnDefinition) (string, []string) {
|
||||
targetType, warnings := mapMySQLColumnToClickHouse(col)
|
||||
parts := []string{targetType}
|
||||
if strings.EqualFold(strings.TrimSpace(col.Nullable), "NO") && !strings.HasPrefix(strings.ToLower(targetType), "nullable(") {
|
||||
return strings.Join(parts, " "), dedupeStrings(warnings)
|
||||
}
|
||||
return strings.Join(parts, " "), dedupeStrings(warnings)
|
||||
}
|
||||
|
||||
func buildClickHouseToPGLikeColumnDefinition(col connection.ColumnDefinition) (string, []string) {
|
||||
targetType, warnings := mapClickHouseColumnToPGLike(col)
|
||||
parts := []string{targetType}
|
||||
if strings.EqualFold(strings.TrimSpace(col.Nullable), "NO") {
|
||||
parts = append(parts, "NOT NULL")
|
||||
}
|
||||
return strings.Join(parts, " "), dedupeStrings(warnings)
|
||||
}
|
||||
|
||||
func buildClickHouseToMySQLColumnDefinition(col connection.ColumnDefinition) (string, []string) {
|
||||
targetType, warnings := mapClickHouseColumnToMySQL(col)
|
||||
parts := []string{targetType}
|
||||
if strings.EqualFold(strings.TrimSpace(col.Nullable), "NO") {
|
||||
parts = append(parts, "NOT NULL")
|
||||
}
|
||||
return strings.Join(parts, " "), dedupeStrings(warnings)
|
||||
}
|
||||
|
||||
func mapPGLikeColumnToClickHouse(col connection.ColumnDefinition) (string, []string) {
|
||||
raw := strings.ToLower(strings.TrimSpace(col.Type))
|
||||
warnings := make([]string, 0)
|
||||
if raw == "" {
|
||||
return "String", []string{fmt.Sprintf("字段 %s 类型为空,已降级为 String", col.Name)}
|
||||
}
|
||||
baseType := "String"
|
||||
switch {
|
||||
case raw == "boolean" || strings.HasPrefix(raw, "bool"):
|
||||
baseType = "UInt8"
|
||||
case raw == "smallint":
|
||||
baseType = "Int16"
|
||||
case raw == "integer" || raw == "int4":
|
||||
baseType = "Int32"
|
||||
case raw == "bigint" || raw == "int8":
|
||||
baseType = "Int64"
|
||||
case strings.HasPrefix(raw, "numeric"), strings.HasPrefix(raw, "decimal"):
|
||||
baseType = replaceTypeBase(raw, []string{"numeric", "decimal"}, "Decimal")
|
||||
case raw == "real" || raw == "float4":
|
||||
baseType = "Float32"
|
||||
case raw == "double precision" || raw == "float8":
|
||||
baseType = "Float64"
|
||||
case raw == "date":
|
||||
baseType = "Date"
|
||||
case strings.HasPrefix(raw, "timestamp") || strings.Contains(raw, "without time zone") || strings.Contains(raw, "with time zone"):
|
||||
baseType = "DateTime"
|
||||
case strings.HasPrefix(raw, "time"):
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 已降级为 String", col.Name, col.Type))
|
||||
baseType = "String"
|
||||
case strings.HasPrefix(raw, "character varying"), strings.HasPrefix(raw, "varchar("), strings.HasPrefix(raw, "character("), strings.HasPrefix(raw, "char("), raw == "character", raw == "text", raw == "uuid":
|
||||
baseType = "String"
|
||||
case raw == "json" || raw == "jsonb" || raw == "bytea":
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 已降级为 String", col.Name, col.Type))
|
||||
baseType = "String"
|
||||
case strings.HasSuffix(raw, "[]") || strings.HasPrefix(raw, "array"):
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 已降级为 String", col.Name, col.Type))
|
||||
baseType = "String"
|
||||
case raw == "user-defined":
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 为用户自定义类型,已降级为 String", col.Name))
|
||||
baseType = "String"
|
||||
default:
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 暂无专门映射,已降级为 String", col.Name, col.Type))
|
||||
baseType = "String"
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(col.Nullable), "YES") && !strings.HasPrefix(strings.ToLower(baseType), "nullable(") {
|
||||
baseType = fmt.Sprintf("Nullable(%s)", baseType)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(strings.TrimSpace(col.Extra)), "identity") || strings.Contains(strings.ToLower(strings.TrimSpace(col.Extra)), "auto_increment") {
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 的 identity/自增语义在 ClickHouse 中不保留", col.Name))
|
||||
}
|
||||
return baseType, dedupeStrings(warnings)
|
||||
}
|
||||
|
||||
func mapMySQLColumnToClickHouse(col connection.ColumnDefinition) (string, []string) {
|
||||
raw := strings.ToLower(strings.TrimSpace(col.Type))
|
||||
warnings := make([]string, 0)
|
||||
if raw == "" {
|
||||
return "String", []string{fmt.Sprintf("字段 %s 类型为空,已降级为 String", col.Name)}
|
||||
}
|
||||
unsigned := strings.Contains(raw, "unsigned")
|
||||
clean := strings.ReplaceAll(raw, " unsigned", "")
|
||||
clean = strings.ReplaceAll(clean, " zerofill", "")
|
||||
baseType := "String"
|
||||
switch {
|
||||
case strings.HasPrefix(clean, "tinyint(1)"):
|
||||
baseType = "UInt8"
|
||||
case strings.HasPrefix(clean, "tinyint"):
|
||||
if unsigned {
|
||||
baseType = "UInt8"
|
||||
} else {
|
||||
baseType = "Int8"
|
||||
}
|
||||
case strings.HasPrefix(clean, "smallint"):
|
||||
if unsigned {
|
||||
baseType = "UInt16"
|
||||
} else {
|
||||
baseType = "Int16"
|
||||
}
|
||||
case strings.HasPrefix(clean, "mediumint"), strings.HasPrefix(clean, "int"), strings.HasPrefix(clean, "integer"):
|
||||
if unsigned {
|
||||
baseType = "UInt32"
|
||||
} else {
|
||||
baseType = "Int32"
|
||||
}
|
||||
case strings.HasPrefix(clean, "bigint"):
|
||||
if unsigned {
|
||||
baseType = "UInt64"
|
||||
} else {
|
||||
baseType = "Int64"
|
||||
}
|
||||
case strings.HasPrefix(clean, "decimal"), strings.HasPrefix(clean, "numeric"):
|
||||
baseType = replaceTypeBase(strings.Title(clean), []string{"Decimal", "Numeric"}, "Decimal")
|
||||
case strings.HasPrefix(clean, "float"):
|
||||
baseType = "Float32"
|
||||
case strings.HasPrefix(clean, "double"):
|
||||
baseType = "Float64"
|
||||
case strings.HasPrefix(clean, "date"):
|
||||
baseType = "Date"
|
||||
case strings.HasPrefix(clean, "datetime"), strings.HasPrefix(clean, "timestamp"):
|
||||
baseType = "DateTime"
|
||||
case strings.HasPrefix(clean, "time"):
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 time 已降级为 String", col.Name))
|
||||
baseType = "String"
|
||||
case strings.HasPrefix(clean, "json"), strings.HasPrefix(clean, "enum"), strings.HasPrefix(clean, "set"), strings.HasPrefix(clean, "char"), strings.HasPrefix(clean, "varchar"), strings.Contains(clean, "text"):
|
||||
baseType = "String"
|
||||
case strings.Contains(clean, "blob"), strings.Contains(clean, "binary"):
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 二进制类型已降级为 String", col.Name))
|
||||
baseType = "String"
|
||||
default:
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 暂无专门映射,已降级为 String", col.Name, col.Type))
|
||||
baseType = "String"
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(col.Nullable), "YES") && !strings.HasPrefix(strings.ToLower(baseType), "nullable(") {
|
||||
baseType = fmt.Sprintf("Nullable(%s)", baseType)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(strings.TrimSpace(col.Extra)), "auto_increment") {
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 的 AUTO_INCREMENT 在 ClickHouse 中不保留自增语义", col.Name))
|
||||
}
|
||||
return baseType, dedupeStrings(warnings)
|
||||
}
|
||||
|
||||
var clickHouseDecimalPattern = regexp.MustCompile(`^(decimal|numeric)\((\d+)\s*,\s*(\d+)\)$`)
|
||||
var clickHouseStringArgsPattern = regexp.MustCompile(`^fixedstring\((\d+)\)$`)
|
||||
|
||||
func mapClickHouseColumnToPGLike(col connection.ColumnDefinition) (string, []string) {
|
||||
raw := strings.TrimSpace(col.Type)
|
||||
lower := strings.ToLower(raw)
|
||||
warnings := make([]string, 0)
|
||||
if strings.HasPrefix(lower, "nullable(") && strings.HasSuffix(lower, ")") {
|
||||
raw = strings.TrimSpace(raw[len("Nullable(") : len(raw)-1])
|
||||
lower = strings.ToLower(raw)
|
||||
}
|
||||
for {
|
||||
if strings.HasPrefix(lower, "lowcardinality(") && strings.HasSuffix(lower, ")") {
|
||||
raw = strings.TrimSpace(raw[len("LowCardinality(") : len(raw)-1])
|
||||
lower = strings.ToLower(raw)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
switch {
|
||||
case lower == "bool" || lower == "boolean":
|
||||
return "boolean", warnings
|
||||
case lower == "int8":
|
||||
return "smallint", warnings
|
||||
case lower == "uint8":
|
||||
return "smallint", warnings
|
||||
case lower == "int16":
|
||||
return "smallint", warnings
|
||||
case lower == "uint16":
|
||||
return "integer", warnings
|
||||
case lower == "int32":
|
||||
return "integer", warnings
|
||||
case lower == "uint32":
|
||||
return "bigint", warnings
|
||||
case lower == "int64":
|
||||
return "bigint", warnings
|
||||
case lower == "uint64":
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 已映射为 numeric(20,0) 以避免无符号溢出", col.Name, col.Type))
|
||||
return "numeric(20,0)", warnings
|
||||
case lower == "float32":
|
||||
return "real", warnings
|
||||
case lower == "float64":
|
||||
return "double precision", warnings
|
||||
case lower == "date":
|
||||
return "date", warnings
|
||||
case strings.HasPrefix(lower, "datetime"):
|
||||
return "timestamp", warnings
|
||||
case lower == "string":
|
||||
return "text", warnings
|
||||
case lower == "uuid":
|
||||
return "uuid", warnings
|
||||
case lower == "json", strings.HasPrefix(lower, "map("), strings.HasPrefix(lower, "array("), strings.HasPrefix(lower, "tuple("), strings.HasPrefix(lower, "nested("):
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 已降级为 jsonb", col.Name, col.Type))
|
||||
return "jsonb", warnings
|
||||
case strings.HasPrefix(lower, "enum8("), strings.HasPrefix(lower, "enum16("):
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 枚举类型 %s 已降级为 varchar(255)", col.Name, col.Type))
|
||||
return "varchar(255)", warnings
|
||||
case clickHouseDecimalPattern.MatchString(lower):
|
||||
parts := clickHouseDecimalPattern.FindStringSubmatch(lower)
|
||||
return fmt.Sprintf("numeric(%s,%s)", parts[2], parts[3]), warnings
|
||||
case clickHouseStringArgsPattern.MatchString(lower):
|
||||
parts := clickHouseStringArgsPattern.FindStringSubmatch(lower)
|
||||
return fmt.Sprintf("varchar(%s)", parts[1]), warnings
|
||||
default:
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 暂无专门 PG-like 映射,已降级为 text", col.Name, col.Type))
|
||||
return "text", warnings
|
||||
}
|
||||
}
|
||||
|
||||
func mapClickHouseColumnToMySQL(col connection.ColumnDefinition) (string, []string) {
|
||||
raw := strings.TrimSpace(col.Type)
|
||||
lower := strings.ToLower(raw)
|
||||
warnings := make([]string, 0)
|
||||
nullable := false
|
||||
if strings.HasPrefix(lower, "nullable(") && strings.HasSuffix(lower, ")") {
|
||||
nullable = true
|
||||
raw = strings.TrimSpace(raw[len("Nullable(") : len(raw)-1])
|
||||
lower = strings.ToLower(raw)
|
||||
}
|
||||
for {
|
||||
if strings.HasPrefix(lower, "lowcardinality(") && strings.HasSuffix(lower, ")") {
|
||||
raw = strings.TrimSpace(raw[len("LowCardinality(") : len(raw)-1])
|
||||
lower = strings.ToLower(raw)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
_ = nullable
|
||||
switch {
|
||||
case lower == "bool" || lower == "boolean" || lower == "uint8":
|
||||
return "tinyint(1)", warnings
|
||||
case lower == "int8":
|
||||
return "tinyint", warnings
|
||||
case lower == "uint16":
|
||||
return "smallint unsigned", warnings
|
||||
case lower == "int16":
|
||||
return "smallint", warnings
|
||||
case lower == "uint32":
|
||||
return "int unsigned", warnings
|
||||
case lower == "int32":
|
||||
return "int", warnings
|
||||
case lower == "uint64":
|
||||
return "bigint unsigned", warnings
|
||||
case lower == "int64":
|
||||
return "bigint", warnings
|
||||
case lower == "float32":
|
||||
return "float", warnings
|
||||
case lower == "float64":
|
||||
return "double", warnings
|
||||
case lower == "date":
|
||||
return "date", warnings
|
||||
case strings.HasPrefix(lower, "datetime"):
|
||||
return "datetime", warnings
|
||||
case lower == "string":
|
||||
return "text", warnings
|
||||
case lower == "uuid":
|
||||
return "char(36)", warnings
|
||||
case lower == "json", strings.HasPrefix(lower, "map("), strings.HasPrefix(lower, "array("), strings.HasPrefix(lower, "tuple("), strings.HasPrefix(lower, "nested("):
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 已降级为 json", col.Name, col.Type))
|
||||
return "json", warnings
|
||||
case clickHouseDecimalPattern.MatchString(lower):
|
||||
parts := clickHouseDecimalPattern.FindStringSubmatch(lower)
|
||||
return fmt.Sprintf("decimal(%s,%s)", parts[2], parts[3]), warnings
|
||||
case clickHouseStringArgsPattern.MatchString(lower):
|
||||
parts := clickHouseStringArgsPattern.FindStringSubmatch(lower)
|
||||
return fmt.Sprintf("varchar(%s)", parts[1]), warnings
|
||||
default:
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 暂无专门映射,已降级为 text", col.Name, col.Type))
|
||||
return "text", warnings
|
||||
}
|
||||
}
|
||||
379
internal/sync/migration_kernel_router.go
Normal file
379
internal/sync/migration_kernel_router.go
Normal file
@@ -0,0 +1,379 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type genericLegacyPlanner struct{}
|
||||
|
||||
type mysqlToPGLikePlanner struct{}
|
||||
|
||||
type mysqlToClickHousePlanner struct{}
|
||||
|
||||
type pgLikeToClickHousePlanner struct{}
|
||||
|
||||
type clickHouseToMySQLPlanner struct{}
|
||||
|
||||
type clickHouseToPGLikePlanner struct{}
|
||||
|
||||
type mysqlToMongoPlanner struct{}
|
||||
|
||||
type pgLikeToMongoPlanner struct{}
|
||||
|
||||
type clickHouseToMongoPlanner struct{}
|
||||
|
||||
type tdengineToMongoPlanner struct{}
|
||||
|
||||
type mongoToMySQLPlanner struct{}
|
||||
|
||||
type mongoToPGLikePlanner struct{}
|
||||
|
||||
type pgLikeToMySQLPlanner struct{}
|
||||
|
||||
type tdengineToMySQLPlanner struct{}
|
||||
|
||||
type tdengineToPGLikePlanner struct{}
|
||||
|
||||
type mongoToRelationalPlanner struct{}
|
||||
|
||||
func buildSchemaMigrationPlan(config SyncConfig, tableName string, sourceDB db.Database, targetDB db.Database) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
ctx := MigrationBuildContext{
|
||||
Config: config,
|
||||
TableName: tableName,
|
||||
SourceDB: sourceDB,
|
||||
TargetDB: targetDB,
|
||||
}
|
||||
planner := resolveMigrationPlanner(ctx)
|
||||
if planner == nil {
|
||||
return buildSchemaMigrationPlanLegacy(config, tableName, sourceDB, targetDB)
|
||||
}
|
||||
return planner.BuildPlan(ctx)
|
||||
}
|
||||
|
||||
func resolveMigrationPlanner(ctx MigrationBuildContext) MigrationPlanner {
|
||||
planners := []MigrationPlanner{
|
||||
mysqlToPGLikePlanner{},
|
||||
mySQLLikeToTDenginePlanner{},
|
||||
pgLikeToTDenginePlanner{},
|
||||
clickHouseToTDenginePlanner{},
|
||||
tdengineToTDenginePlanner{},
|
||||
tdengineToPGLikePlanner{},
|
||||
tdengineToMySQLPlanner{},
|
||||
mysqlToClickHousePlanner{},
|
||||
pgLikeToClickHousePlanner{},
|
||||
clickHouseToMySQLPlanner{},
|
||||
clickHouseToPGLikePlanner{},
|
||||
mysqlToMongoPlanner{},
|
||||
pgLikeToMongoPlanner{},
|
||||
clickHouseToMongoPlanner{},
|
||||
tdengineToMongoPlanner{},
|
||||
mongoToMySQLPlanner{},
|
||||
mongoToPGLikePlanner{},
|
||||
pgLikeToMySQLPlanner{},
|
||||
mongoToRelationalPlanner{},
|
||||
genericLegacyPlanner{},
|
||||
}
|
||||
bestLevel := MigrationSupportLevelUnsupported
|
||||
var bestPlanner MigrationPlanner
|
||||
for _, planner := range planners {
|
||||
level := planner.SupportLevel(ctx)
|
||||
if migrationSupportRank(level) > migrationSupportRank(bestLevel) {
|
||||
bestLevel = level
|
||||
bestPlanner = planner
|
||||
}
|
||||
}
|
||||
return bestPlanner
|
||||
}
|
||||
|
||||
func migrationSupportRank(level MigrationSupportLevel) int {
|
||||
switch level {
|
||||
case MigrationSupportLevelFull:
|
||||
return 4
|
||||
case MigrationSupportLevelPlanned:
|
||||
return 3
|
||||
case MigrationSupportLevelPartial:
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func isMySQLLikeType(dbType string) bool {
|
||||
return isMySQLLikeWritableTargetType(dbType)
|
||||
}
|
||||
|
||||
func classifyMigrationDataModel(dbType string) MigrationDataModel {
|
||||
switch normalizeMigrationDBType(dbType) {
|
||||
case "mysql", "mariadb", "postgres", "kingbase", "highgo", "vastbase", "oracle", "sqlserver", "dameng", "sqlite", "duckdb":
|
||||
return MigrationDataModelRelational
|
||||
case "mongodb":
|
||||
return MigrationDataModelDocument
|
||||
case "clickhouse", "diros", "sphinx":
|
||||
return MigrationDataModelColumnar
|
||||
case "tdengine":
|
||||
return MigrationDataModelTimeSeries
|
||||
case "redis":
|
||||
return MigrationDataModelKeyValue
|
||||
default:
|
||||
return MigrationDataModelCustom
|
||||
}
|
||||
}
|
||||
|
||||
func (genericLegacyPlanner) Name() string { return "generic-legacy-planner" }
|
||||
|
||||
func (genericLegacyPlanner) SupportLevel(ctx MigrationBuildContext) MigrationSupportLevel {
|
||||
_ = ctx
|
||||
return MigrationSupportLevelPartial
|
||||
}
|
||||
|
||||
func (genericLegacyPlanner) BuildPlan(ctx MigrationBuildContext) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
return buildSchemaMigrationPlanLegacy(ctx.Config, ctx.TableName, ctx.SourceDB, ctx.TargetDB)
|
||||
}
|
||||
|
||||
func (mysqlToPGLikePlanner) Name() string { return "mysql-pglike-planner" }
|
||||
|
||||
func (mysqlToPGLikePlanner) SupportLevel(ctx MigrationBuildContext) MigrationSupportLevel {
|
||||
sourceType := resolveMigrationDBType(ctx.Config.SourceConfig)
|
||||
targetType := resolveMigrationDBType(ctx.Config.TargetConfig)
|
||||
if isMySQLLikeSourceType(sourceType) && isPGLikeTarget(targetType) {
|
||||
return MigrationSupportLevelFull
|
||||
}
|
||||
return MigrationSupportLevelUnsupported
|
||||
}
|
||||
|
||||
func (mysqlToPGLikePlanner) BuildPlan(ctx MigrationBuildContext) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
return buildMySQLToPGLikePlan(ctx.Config, ctx.TableName, ctx.SourceDB, ctx.TargetDB)
|
||||
}
|
||||
|
||||
func (tdengineToMySQLPlanner) Name() string { return "tdengine-mysql-planner" }
|
||||
|
||||
func (tdengineToMySQLPlanner) SupportLevel(ctx MigrationBuildContext) MigrationSupportLevel {
|
||||
sourceType := resolveMigrationDBType(ctx.Config.SourceConfig)
|
||||
targetType := resolveMigrationDBType(ctx.Config.TargetConfig)
|
||||
if sourceType == "tdengine" && isMySQLLikeWritableTargetType(targetType) {
|
||||
return MigrationSupportLevelFull
|
||||
}
|
||||
return MigrationSupportLevelUnsupported
|
||||
}
|
||||
|
||||
func (tdengineToMySQLPlanner) BuildPlan(ctx MigrationBuildContext) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
return buildTDengineToMySQLPlan(ctx.Config, ctx.TableName, ctx.SourceDB, ctx.TargetDB)
|
||||
}
|
||||
|
||||
func (tdengineToPGLikePlanner) Name() string { return "tdengine-pglike-planner" }
|
||||
|
||||
func (tdengineToPGLikePlanner) SupportLevel(ctx MigrationBuildContext) MigrationSupportLevel {
|
||||
sourceType := resolveMigrationDBType(ctx.Config.SourceConfig)
|
||||
targetType := resolveMigrationDBType(ctx.Config.TargetConfig)
|
||||
if sourceType == "tdengine" && isPGLikeTarget(targetType) {
|
||||
return MigrationSupportLevelFull
|
||||
}
|
||||
return MigrationSupportLevelUnsupported
|
||||
}
|
||||
|
||||
func (tdengineToPGLikePlanner) BuildPlan(ctx MigrationBuildContext) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
return buildTDengineToPGLikePlan(ctx.Config, ctx.TableName, ctx.SourceDB, ctx.TargetDB)
|
||||
}
|
||||
|
||||
func (mysqlToClickHousePlanner) Name() string { return "mysql-clickhouse-planner" }
|
||||
|
||||
func (mysqlToClickHousePlanner) SupportLevel(ctx MigrationBuildContext) MigrationSupportLevel {
|
||||
sourceType := resolveMigrationDBType(ctx.Config.SourceConfig)
|
||||
targetType := resolveMigrationDBType(ctx.Config.TargetConfig)
|
||||
if isMySQLCoreType(sourceType) && targetType == "clickhouse" {
|
||||
return MigrationSupportLevelFull
|
||||
}
|
||||
return MigrationSupportLevelUnsupported
|
||||
}
|
||||
|
||||
func (mysqlToClickHousePlanner) BuildPlan(ctx MigrationBuildContext) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
return buildMySQLToClickHousePlan(ctx.Config, ctx.TableName, ctx.SourceDB, ctx.TargetDB)
|
||||
}
|
||||
|
||||
func (pgLikeToClickHousePlanner) Name() string { return "pglike-clickhouse-planner" }
|
||||
|
||||
func (pgLikeToClickHousePlanner) SupportLevel(ctx MigrationBuildContext) MigrationSupportLevel {
|
||||
sourceType := resolveMigrationDBType(ctx.Config.SourceConfig)
|
||||
targetType := resolveMigrationDBType(ctx.Config.TargetConfig)
|
||||
if isPGLikeSource(sourceType) && targetType == "clickhouse" {
|
||||
return MigrationSupportLevelFull
|
||||
}
|
||||
return MigrationSupportLevelUnsupported
|
||||
}
|
||||
|
||||
func (pgLikeToClickHousePlanner) BuildPlan(ctx MigrationBuildContext) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
return buildPGLikeToClickHousePlan(ctx.Config, ctx.TableName, ctx.SourceDB, ctx.TargetDB)
|
||||
}
|
||||
|
||||
func (clickHouseToMySQLPlanner) Name() string { return "clickhouse-mysql-planner" }
|
||||
|
||||
func (clickHouseToMySQLPlanner) SupportLevel(ctx MigrationBuildContext) MigrationSupportLevel {
|
||||
sourceType := resolveMigrationDBType(ctx.Config.SourceConfig)
|
||||
targetType := resolveMigrationDBType(ctx.Config.TargetConfig)
|
||||
if sourceType == "clickhouse" && isMySQLLikeWritableTargetType(targetType) {
|
||||
return MigrationSupportLevelFull
|
||||
}
|
||||
return MigrationSupportLevelUnsupported
|
||||
}
|
||||
|
||||
func (clickHouseToMySQLPlanner) BuildPlan(ctx MigrationBuildContext) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
return buildClickHouseToMySQLPlan(ctx.Config, ctx.TableName, ctx.SourceDB, ctx.TargetDB)
|
||||
}
|
||||
|
||||
func (clickHouseToPGLikePlanner) Name() string { return "clickhouse-pglike-planner" }
|
||||
|
||||
func (clickHouseToPGLikePlanner) SupportLevel(ctx MigrationBuildContext) MigrationSupportLevel {
|
||||
sourceType := resolveMigrationDBType(ctx.Config.SourceConfig)
|
||||
targetType := resolveMigrationDBType(ctx.Config.TargetConfig)
|
||||
if sourceType == "clickhouse" && isPGLikeTarget(targetType) {
|
||||
return MigrationSupportLevelFull
|
||||
}
|
||||
return MigrationSupportLevelUnsupported
|
||||
}
|
||||
|
||||
func (clickHouseToPGLikePlanner) BuildPlan(ctx MigrationBuildContext) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
return buildClickHouseToPGLikePlan(ctx.Config, ctx.TableName, ctx.SourceDB, ctx.TargetDB)
|
||||
}
|
||||
|
||||
func (mysqlToMongoPlanner) Name() string { return "mysql-mongo-planner" }
|
||||
|
||||
func (mysqlToMongoPlanner) SupportLevel(ctx MigrationBuildContext) MigrationSupportLevel {
|
||||
sourceType := resolveMigrationDBType(ctx.Config.SourceConfig)
|
||||
targetType := resolveMigrationDBType(ctx.Config.TargetConfig)
|
||||
if isMySQLCoreType(sourceType) && targetType == "mongodb" {
|
||||
return MigrationSupportLevelFull
|
||||
}
|
||||
return MigrationSupportLevelUnsupported
|
||||
}
|
||||
|
||||
func (mysqlToMongoPlanner) BuildPlan(ctx MigrationBuildContext) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
return buildMySQLToMongoPlan(ctx.Config, ctx.TableName, ctx.SourceDB, ctx.TargetDB)
|
||||
}
|
||||
|
||||
func (pgLikeToMongoPlanner) Name() string { return "pglike-mongo-planner" }
|
||||
|
||||
func (pgLikeToMongoPlanner) SupportLevel(ctx MigrationBuildContext) MigrationSupportLevel {
|
||||
sourceType := resolveMigrationDBType(ctx.Config.SourceConfig)
|
||||
targetType := resolveMigrationDBType(ctx.Config.TargetConfig)
|
||||
if isPGLikeSource(sourceType) && targetType == "mongodb" {
|
||||
return MigrationSupportLevelFull
|
||||
}
|
||||
return MigrationSupportLevelUnsupported
|
||||
}
|
||||
|
||||
func (pgLikeToMongoPlanner) BuildPlan(ctx MigrationBuildContext) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
return buildPGLikeToMongoPlan(ctx.Config, ctx.TableName, ctx.SourceDB, ctx.TargetDB)
|
||||
}
|
||||
|
||||
func (clickHouseToMongoPlanner) Name() string { return "clickhouse-mongo-planner" }
|
||||
|
||||
func (clickHouseToMongoPlanner) SupportLevel(ctx MigrationBuildContext) MigrationSupportLevel {
|
||||
sourceType := resolveMigrationDBType(ctx.Config.SourceConfig)
|
||||
targetType := resolveMigrationDBType(ctx.Config.TargetConfig)
|
||||
if sourceType == "clickhouse" && targetType == "mongodb" {
|
||||
return MigrationSupportLevelFull
|
||||
}
|
||||
return MigrationSupportLevelUnsupported
|
||||
}
|
||||
|
||||
func (clickHouseToMongoPlanner) BuildPlan(ctx MigrationBuildContext) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
return buildClickHouseToMongoPlan(ctx.Config, ctx.TableName, ctx.SourceDB, ctx.TargetDB)
|
||||
}
|
||||
|
||||
func (tdengineToMongoPlanner) Name() string { return "tdengine-mongo-planner" }
|
||||
|
||||
func (tdengineToMongoPlanner) SupportLevel(ctx MigrationBuildContext) MigrationSupportLevel {
|
||||
sourceType := resolveMigrationDBType(ctx.Config.SourceConfig)
|
||||
targetType := resolveMigrationDBType(ctx.Config.TargetConfig)
|
||||
if sourceType == "tdengine" && targetType == "mongodb" {
|
||||
return MigrationSupportLevelFull
|
||||
}
|
||||
return MigrationSupportLevelUnsupported
|
||||
}
|
||||
|
||||
func (tdengineToMongoPlanner) BuildPlan(ctx MigrationBuildContext) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
return buildTDengineToMongoPlan(ctx.Config, ctx.TableName, ctx.SourceDB, ctx.TargetDB)
|
||||
}
|
||||
|
||||
func (mongoToMySQLPlanner) Name() string { return "mongo-mysql-planner" }
|
||||
|
||||
func (mongoToMySQLPlanner) SupportLevel(ctx MigrationBuildContext) MigrationSupportLevel {
|
||||
sourceType := resolveMigrationDBType(ctx.Config.SourceConfig)
|
||||
targetType := resolveMigrationDBType(ctx.Config.TargetConfig)
|
||||
if sourceType == "mongodb" && isMySQLLikeWritableTargetType(targetType) {
|
||||
return MigrationSupportLevelFull
|
||||
}
|
||||
return MigrationSupportLevelUnsupported
|
||||
}
|
||||
|
||||
func (mongoToMySQLPlanner) BuildPlan(ctx MigrationBuildContext) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
return buildMongoToMySQLPlan(ctx.Config, ctx.TableName, ctx.SourceDB, ctx.TargetDB)
|
||||
}
|
||||
|
||||
func (mongoToPGLikePlanner) Name() string { return "mongo-pglike-planner" }
|
||||
|
||||
func (mongoToPGLikePlanner) SupportLevel(ctx MigrationBuildContext) MigrationSupportLevel {
|
||||
sourceType := resolveMigrationDBType(ctx.Config.SourceConfig)
|
||||
targetType := resolveMigrationDBType(ctx.Config.TargetConfig)
|
||||
if sourceType == "mongodb" && isPGLikeTarget(targetType) {
|
||||
return MigrationSupportLevelFull
|
||||
}
|
||||
return MigrationSupportLevelUnsupported
|
||||
}
|
||||
|
||||
func (mongoToPGLikePlanner) BuildPlan(ctx MigrationBuildContext) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
return buildMongoToPGLikePlan(ctx.Config, ctx.TableName, ctx.SourceDB, ctx.TargetDB)
|
||||
}
|
||||
|
||||
func (pgLikeToMySQLPlanner) Name() string { return "pglike-mysql-planner" }
|
||||
|
||||
func (pgLikeToMySQLPlanner) SupportLevel(ctx MigrationBuildContext) MigrationSupportLevel {
|
||||
sourceType := resolveMigrationDBType(ctx.Config.SourceConfig)
|
||||
targetType := resolveMigrationDBType(ctx.Config.TargetConfig)
|
||||
if isPGLikeSource(sourceType) && isMySQLLikeWritableTargetType(targetType) {
|
||||
return MigrationSupportLevelFull
|
||||
}
|
||||
return MigrationSupportLevelUnsupported
|
||||
}
|
||||
|
||||
func (pgLikeToMySQLPlanner) BuildPlan(ctx MigrationBuildContext) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
return buildPGLikeToMySQLPlan(ctx.Config, ctx.TableName, ctx.SourceDB, ctx.TargetDB)
|
||||
}
|
||||
|
||||
func (mongoToRelationalPlanner) Name() string { return "mongo-relational-inference-planner" }
|
||||
|
||||
func (mongoToRelationalPlanner) SupportLevel(ctx MigrationBuildContext) MigrationSupportLevel {
|
||||
sourceType := resolveMigrationDBType(ctx.Config.SourceConfig)
|
||||
targetType := resolveMigrationDBType(ctx.Config.TargetConfig)
|
||||
if !shouldUseSchemaInference(sourceType, targetType) {
|
||||
return MigrationSupportLevelUnsupported
|
||||
}
|
||||
return MigrationSupportLevelPlanned
|
||||
}
|
||||
|
||||
func (mongoToRelationalPlanner) BuildPlan(ctx MigrationBuildContext) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
sourceType := resolveMigrationDBType(ctx.Config.SourceConfig)
|
||||
targetType := resolveMigrationDBType(ctx.Config.TargetConfig)
|
||||
inference, err := inferSchemaForPair(sourceType, targetType, ctx.TableName)
|
||||
if err != nil {
|
||||
return SchemaMigrationPlan{}, nil, nil, err
|
||||
}
|
||||
plan := SchemaMigrationPlan{}
|
||||
plan.SourceSchema, plan.SourceTable = normalizeSchemaAndTable(sourceType, ctx.Config.SourceConfig.Database, ctx.TableName)
|
||||
plan.TargetSchema, plan.TargetTable = normalizeSchemaAndTable(targetType, ctx.Config.TargetConfig.Database, ctx.TableName)
|
||||
plan.SourceQueryTable = qualifiedNameForQuery(sourceType, plan.SourceSchema, plan.SourceTable, ctx.TableName)
|
||||
plan.TargetQueryTable = qualifiedNameForQuery(targetType, plan.TargetSchema, plan.TargetTable, ctx.TableName)
|
||||
plan.PlannedAction = "当前库对已进入迁移内核规划阶段,等待 schema 推断与目标方言生成器落地"
|
||||
for _, issue := range inference.Issues {
|
||||
msg := strings.TrimSpace(issue.Message)
|
||||
if msg == "" {
|
||||
continue
|
||||
}
|
||||
plan.Warnings = append(plan.Warnings, msg)
|
||||
}
|
||||
plan.Warnings = append(plan.Warnings, fmt.Sprintf("迁移对象=%s,目标类型=%s,当前仅提供规划入口,暂不执行自动建表", inference.Object.Kind, targetType))
|
||||
return dedupeSchemaMigrationPlan(plan), nil, nil, nil
|
||||
}
|
||||
447
internal/sync/migration_kernel_router_test.go
Normal file
447
internal/sync/migration_kernel_router_test.go
Normal file
@@ -0,0 +1,447 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClassifyMigrationDataModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]MigrationDataModel{
|
||||
"mysql": MigrationDataModelRelational,
|
||||
"postgres": MigrationDataModelRelational,
|
||||
"kingbase": MigrationDataModelRelational,
|
||||
"mongodb": MigrationDataModelDocument,
|
||||
"clickhouse": MigrationDataModelColumnar,
|
||||
"tdengine": MigrationDataModelTimeSeries,
|
||||
"redis": MigrationDataModelKeyValue,
|
||||
"custom": MigrationDataModelCustom,
|
||||
}
|
||||
|
||||
for input, want := range cases {
|
||||
input, want := input, want
|
||||
t.Run(input, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := classifyMigrationDataModel(input)
|
||||
if got != want {
|
||||
t.Fatalf("unexpected data model, input=%s got=%s want=%s", input, got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMigrationPlanner_PrefersMySQLKingbasePlanner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
planner := resolveMigrationPlanner(MigrationBuildContext{
|
||||
Config: SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "mysql"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "kingbase"},
|
||||
},
|
||||
})
|
||||
if planner == nil {
|
||||
t.Fatalf("expected planner")
|
||||
}
|
||||
if planner.Name() != "mysql-pglike-planner" {
|
||||
t.Fatalf("unexpected planner: %s", planner.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMigrationPlanner_UsesSchemaInferencePlannerForMongoToMySQL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
planner := resolveMigrationPlanner(MigrationBuildContext{
|
||||
Config: SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "mongodb"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "mysql"},
|
||||
},
|
||||
})
|
||||
if planner == nil {
|
||||
t.Fatalf("expected planner")
|
||||
}
|
||||
if planner.Name() != "mongo-mysql-planner" {
|
||||
t.Fatalf("unexpected planner: %s", planner.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferSchemaForPair_MongoToMySQLReturnsPlannedWarning(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result, err := inferSchemaForPair("mongodb", "mysql", "users")
|
||||
if err != nil {
|
||||
t.Fatalf("inferSchemaForPair returned error: %v", err)
|
||||
}
|
||||
if !result.NeedsReview {
|
||||
t.Fatalf("expected needs review")
|
||||
}
|
||||
if result.Object.Name != "users" {
|
||||
t.Fatalf("unexpected object name: %s", result.Object.Name)
|
||||
}
|
||||
if len(result.Issues) == 0 || !strings.Contains(result.Issues[0].Message, "schema 推断") {
|
||||
t.Fatalf("unexpected issues: %+v", result.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMigrationPlanner_UsesPGLikeMySQLPlannerForKingbaseToMySQL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
planner := resolveMigrationPlanner(MigrationBuildContext{
|
||||
Config: SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "kingbase"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "mysql"},
|
||||
},
|
||||
})
|
||||
if planner == nil {
|
||||
t.Fatalf("expected planner")
|
||||
}
|
||||
if planner.Name() != "pglike-mysql-planner" {
|
||||
t.Fatalf("unexpected planner: %s", planner.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMigrationPlanner_UsesMySQLPGLikePlannerForMySQLToPostgres(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
planner := resolveMigrationPlanner(MigrationBuildContext{
|
||||
Config: SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "mysql"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "postgres"},
|
||||
},
|
||||
})
|
||||
if planner == nil {
|
||||
t.Fatalf("expected planner")
|
||||
}
|
||||
if planner.Name() != "mysql-pglike-planner" {
|
||||
t.Fatalf("unexpected planner: %s", planner.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMigrationPlanner_UsesMySQLClickHousePlanner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
planner := resolveMigrationPlanner(MigrationBuildContext{
|
||||
Config: SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "mysql"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "clickhouse"},
|
||||
},
|
||||
})
|
||||
if planner == nil {
|
||||
t.Fatalf("expected planner")
|
||||
}
|
||||
if planner.Name() != "mysql-clickhouse-planner" {
|
||||
t.Fatalf("unexpected planner: %s", planner.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMigrationPlanner_UsesClickHouseMySQLPlanner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
planner := resolveMigrationPlanner(MigrationBuildContext{
|
||||
Config: SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "clickhouse"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "mysql"},
|
||||
},
|
||||
})
|
||||
if planner == nil {
|
||||
t.Fatalf("expected planner")
|
||||
}
|
||||
if planner.Name() != "clickhouse-mysql-planner" {
|
||||
t.Fatalf("unexpected planner: %s", planner.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMigrationPlanner_UsesMySQLMongoPlanner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
planner := resolveMigrationPlanner(MigrationBuildContext{
|
||||
Config: SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "mysql"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "mongodb"},
|
||||
},
|
||||
})
|
||||
if planner == nil || planner.Name() != "mysql-mongo-planner" {
|
||||
t.Fatalf("unexpected planner: %v", planner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMigrationPlanner_UsesMongoMySQLPlanner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
planner := resolveMigrationPlanner(MigrationBuildContext{
|
||||
Config: SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "mongodb"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "mysql"},
|
||||
},
|
||||
})
|
||||
if planner == nil || planner.Name() != "mongo-mysql-planner" {
|
||||
t.Fatalf("unexpected planner: %v", planner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMigrationPlanner_UsesMongoPGLikePlanner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
planner := resolveMigrationPlanner(MigrationBuildContext{
|
||||
Config: SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "mongodb"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "postgres"},
|
||||
},
|
||||
})
|
||||
if planner == nil || planner.Name() != "mongo-pglike-planner" {
|
||||
t.Fatalf("unexpected planner: %v", planner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMigrationPlanner_UsesPGLikeMongoPlanner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
planner := resolveMigrationPlanner(MigrationBuildContext{
|
||||
Config: SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "postgres"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "mongodb"},
|
||||
},
|
||||
})
|
||||
if planner == nil || planner.Name() != "pglike-mongo-planner" {
|
||||
t.Fatalf("unexpected planner: %v", planner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMigrationPlanner_UsesClickHouseMongoPlanner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
planner := resolveMigrationPlanner(MigrationBuildContext{
|
||||
Config: SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "clickhouse"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "mongodb"},
|
||||
},
|
||||
})
|
||||
if planner == nil || planner.Name() != "clickhouse-mongo-planner" {
|
||||
t.Fatalf("unexpected planner: %v", planner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMigrationPlanner_UsesTDengineMongoPlanner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
planner := resolveMigrationPlanner(MigrationBuildContext{
|
||||
Config: SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "tdengine"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "mongodb"},
|
||||
},
|
||||
})
|
||||
if planner == nil || planner.Name() != "tdengine-mongo-planner" {
|
||||
t.Fatalf("unexpected planner: %v", planner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMigrationPlanner_UsesMySQLPGLikePlannerForDirosToPostgres(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
planner := resolveMigrationPlanner(MigrationBuildContext{
|
||||
Config: SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "diros"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "postgres"},
|
||||
},
|
||||
})
|
||||
if planner == nil || planner.Name() != "mysql-pglike-planner" {
|
||||
t.Fatalf("unexpected planner: %v", planner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMigrationPlanner_UsesPGLikeMySQLPlannerForPostgresToDiros(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
planner := resolveMigrationPlanner(MigrationBuildContext{
|
||||
Config: SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "postgres"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "diros"},
|
||||
},
|
||||
})
|
||||
if planner == nil || planner.Name() != "pglike-mysql-planner" {
|
||||
t.Fatalf("unexpected planner: %v", planner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMigrationPlanner_UsesMySQLPGLikePlannerForMySQLToDuckDB(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
planner := resolveMigrationPlanner(MigrationBuildContext{
|
||||
Config: SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "mysql"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "duckdb"},
|
||||
},
|
||||
})
|
||||
if planner == nil || planner.Name() != "mysql-pglike-planner" {
|
||||
t.Fatalf("unexpected planner: %v", planner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMigrationPlanner_UsesPGLikeClickHousePlanner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
planner := resolveMigrationPlanner(MigrationBuildContext{
|
||||
Config: SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "postgres"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "clickhouse"},
|
||||
},
|
||||
})
|
||||
if planner == nil || planner.Name() != "pglike-clickhouse-planner" {
|
||||
t.Fatalf("unexpected planner: %v", planner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMigrationPlanner_UsesPGLikeMySQLPlannerForDuckDBToMySQL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
planner := resolveMigrationPlanner(MigrationBuildContext{
|
||||
Config: SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "duckdb"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "mysql"},
|
||||
},
|
||||
})
|
||||
if planner == nil || planner.Name() != "pglike-mysql-planner" {
|
||||
t.Fatalf("unexpected planner: %v", planner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMigrationPlanner_UsesMySQLPGLikePlannerForSphinxToPostgres(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
planner := resolveMigrationPlanner(MigrationBuildContext{
|
||||
Config: SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "sphinx"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "postgres"},
|
||||
},
|
||||
})
|
||||
if planner == nil || planner.Name() != "mysql-pglike-planner" {
|
||||
t.Fatalf("unexpected planner: %v", planner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMigrationPlanner_UsesPGLikeMySQLPlannerForCustomKingbaseToMySQL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
planner := resolveMigrationPlanner(MigrationBuildContext{
|
||||
Config: SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "custom", Driver: "kingbase8"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "mysql"},
|
||||
},
|
||||
})
|
||||
if planner == nil || planner.Name() != "pglike-mysql-planner" {
|
||||
t.Fatalf("unexpected planner: %v", planner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMigrationPlanner_UsesMySQLPGLikePlannerForMySQLToCustomPostgres(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
planner := resolveMigrationPlanner(MigrationBuildContext{
|
||||
Config: SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "mysql"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "custom", Driver: "postgresql"},
|
||||
},
|
||||
})
|
||||
if planner == nil || planner.Name() != "mysql-pglike-planner" {
|
||||
t.Fatalf("unexpected planner: %v", planner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMigrationPlanner_UsesTDengineMySQLPlanner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
planner := resolveMigrationPlanner(MigrationBuildContext{
|
||||
Config: SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "tdengine"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "mysql"},
|
||||
},
|
||||
})
|
||||
if planner == nil || planner.Name() != "tdengine-mysql-planner" {
|
||||
t.Fatalf("unexpected planner: %v", planner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMigrationPlanner_UsesTDenginePGLikePlanner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
planner := resolveMigrationPlanner(MigrationBuildContext{
|
||||
Config: SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "tdengine"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "kingbase"},
|
||||
},
|
||||
})
|
||||
if planner == nil || planner.Name() != "tdengine-pglike-planner" {
|
||||
t.Fatalf("unexpected planner: %v", planner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMigrationPlanner_UsesMySQLLikeTDenginePlanner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
planner := resolveMigrationPlanner(MigrationBuildContext{
|
||||
Config: SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "mysql"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "tdengine"},
|
||||
},
|
||||
})
|
||||
if planner == nil || planner.Name() != "mysqllike-tdengine-planner" {
|
||||
t.Fatalf("unexpected planner: %v", planner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMigrationPlanner_UsesPGLikeTDenginePlanner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
planner := resolveMigrationPlanner(MigrationBuildContext{
|
||||
Config: SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "postgres"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "tdengine"},
|
||||
},
|
||||
})
|
||||
if planner == nil || planner.Name() != "pglike-tdengine-planner" {
|
||||
t.Fatalf("unexpected planner: %v", planner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMigrationPlanner_UsesClickHouseTDenginePlanner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
planner := resolveMigrationPlanner(MigrationBuildContext{
|
||||
Config: SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "clickhouse"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "tdengine"},
|
||||
},
|
||||
})
|
||||
if planner == nil || planner.Name() != "clickhouse-tdengine-planner" {
|
||||
t.Fatalf("unexpected planner: %v", planner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMigrationPlanner_UsesClickHousePGLikePlanner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
planner := resolveMigrationPlanner(MigrationBuildContext{
|
||||
Config: SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "clickhouse"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "postgres"},
|
||||
},
|
||||
})
|
||||
if planner == nil || planner.Name() != "clickhouse-pglike-planner" {
|
||||
t.Fatalf("unexpected planner: %v", planner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMigrationPlanner_UsesTDengineTDenginePlanner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
planner := resolveMigrationPlanner(MigrationBuildContext{
|
||||
Config: SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "tdengine"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "tdengine"},
|
||||
},
|
||||
})
|
||||
if planner == nil || planner.Name() != "tdengine-tdengine-planner" {
|
||||
t.Fatalf("unexpected planner: %v", planner)
|
||||
}
|
||||
}
|
||||
104
internal/sync/migration_kernel_types.go
Normal file
104
internal/sync/migration_kernel_types.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
)
|
||||
|
||||
type MigrationDataModel string
|
||||
|
||||
const (
|
||||
MigrationDataModelRelational MigrationDataModel = "relational"
|
||||
MigrationDataModelDocument MigrationDataModel = "document"
|
||||
MigrationDataModelColumnar MigrationDataModel = "columnar"
|
||||
MigrationDataModelTimeSeries MigrationDataModel = "timeseries"
|
||||
MigrationDataModelKeyValue MigrationDataModel = "keyvalue"
|
||||
MigrationDataModelCustom MigrationDataModel = "custom"
|
||||
)
|
||||
|
||||
type MigrationObjectKind string
|
||||
|
||||
const (
|
||||
MigrationObjectKindTable MigrationObjectKind = "table"
|
||||
MigrationObjectKindCollection MigrationObjectKind = "collection"
|
||||
MigrationObjectKindKeyspace MigrationObjectKind = "keyspace"
|
||||
)
|
||||
|
||||
type MigrationSupportLevel string
|
||||
|
||||
const (
|
||||
MigrationSupportLevelFull MigrationSupportLevel = "full"
|
||||
MigrationSupportLevelPartial MigrationSupportLevel = "partial"
|
||||
MigrationSupportLevelPlanned MigrationSupportLevel = "planned"
|
||||
MigrationSupportLevelUnsupported MigrationSupportLevel = "unsupported"
|
||||
)
|
||||
|
||||
type CanonicalFieldSpec struct {
|
||||
Name string
|
||||
SourceType string
|
||||
CanonicalType string
|
||||
Nullable bool
|
||||
DefaultValue *string
|
||||
AutoIncrement bool
|
||||
Comment string
|
||||
NestedPath string
|
||||
Confidence float64
|
||||
}
|
||||
|
||||
type CanonicalIndexSpec struct {
|
||||
Name string
|
||||
Kind string
|
||||
Columns []string
|
||||
Expression string
|
||||
PrefixLength int
|
||||
Supported bool
|
||||
DegradeStrategy string
|
||||
Unique bool
|
||||
}
|
||||
|
||||
type CanonicalConstraintSpec struct {
|
||||
Name string
|
||||
Kind string
|
||||
Columns []string
|
||||
RefName string
|
||||
}
|
||||
|
||||
type CanonicalObjectSpec struct {
|
||||
Name string
|
||||
Schema string
|
||||
Kind MigrationObjectKind
|
||||
Fields []CanonicalFieldSpec
|
||||
PrimaryKey []string
|
||||
Indexes []CanonicalIndexSpec
|
||||
Constraints []CanonicalConstraintSpec
|
||||
Comments []string
|
||||
SourceHints map[string]string
|
||||
}
|
||||
|
||||
type SchemaInferenceIssue struct {
|
||||
Field string
|
||||
Level string
|
||||
Message string
|
||||
Resolution string
|
||||
}
|
||||
|
||||
type SchemaInferenceResult struct {
|
||||
Object CanonicalObjectSpec
|
||||
Issues []SchemaInferenceIssue
|
||||
SampleSize int
|
||||
Confidence float64
|
||||
NeedsReview bool
|
||||
}
|
||||
|
||||
type MigrationBuildContext struct {
|
||||
Config SyncConfig
|
||||
TableName string
|
||||
SourceDB db.Database
|
||||
TargetDB db.Database
|
||||
}
|
||||
|
||||
type MigrationPlanner interface {
|
||||
Name() string
|
||||
SupportLevel(ctx MigrationBuildContext) MigrationSupportLevel
|
||||
BuildPlan(ctx MigrationBuildContext) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error)
|
||||
}
|
||||
603
internal/sync/migration_mongodb.go
Normal file
603
internal/sync/migration_mongodb.go
Normal file
@@ -0,0 +1,603 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func buildMySQLToMongoPlan(config SyncConfig, tableName string, sourceDB db.Database, targetDB db.Database) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
return buildTabularToMongoPlan(config, tableName, sourceDB, targetDB)
|
||||
}
|
||||
|
||||
func buildPGLikeToMongoPlan(config SyncConfig, tableName string, sourceDB db.Database, targetDB db.Database) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
return buildTabularToMongoPlan(config, tableName, sourceDB, targetDB)
|
||||
}
|
||||
|
||||
func buildClickHouseToMongoPlan(config SyncConfig, tableName string, sourceDB db.Database, targetDB db.Database) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
return buildTabularToMongoPlan(config, tableName, sourceDB, targetDB)
|
||||
}
|
||||
|
||||
func buildTDengineToMongoPlan(config SyncConfig, tableName string, sourceDB db.Database, targetDB db.Database) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
return buildTabularToMongoPlan(config, tableName, sourceDB, targetDB)
|
||||
}
|
||||
|
||||
func buildTabularToMongoPlan(config SyncConfig, tableName string, sourceDB db.Database, targetDB db.Database) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
plan := SchemaMigrationPlan{}
|
||||
sourceType := resolveMigrationDBType(config.SourceConfig)
|
||||
targetType := resolveMigrationDBType(config.TargetConfig)
|
||||
plan.SourceSchema, plan.SourceTable = normalizeSchemaAndTable(sourceType, config.SourceConfig.Database, tableName)
|
||||
plan.TargetSchema, plan.TargetTable = normalizeSchemaAndTable(targetType, config.TargetConfig.Database, tableName)
|
||||
plan.SourceQueryTable = qualifiedNameForQuery(sourceType, plan.SourceSchema, plan.SourceTable, tableName)
|
||||
plan.TargetQueryTable = qualifiedNameForQuery(targetType, plan.TargetSchema, plan.TargetTable, tableName)
|
||||
plan.PlannedAction = "使用已有目标集合导入"
|
||||
|
||||
sourceCols, sourceExists, err := inspectTableColumns(sourceDB, plan.SourceSchema, plan.SourceTable)
|
||||
if err != nil {
|
||||
return plan, nil, nil, fmt.Errorf("获取源表字段失败: %w", err)
|
||||
}
|
||||
if !sourceExists {
|
||||
return plan, nil, nil, fmt.Errorf("源表不存在或无列定义: %s", tableName)
|
||||
}
|
||||
|
||||
targetExists, err := inspectMongoCollection(targetDB, plan.TargetSchema, plan.TargetTable)
|
||||
if err != nil {
|
||||
return plan, sourceCols, nil, fmt.Errorf("检查目标集合失败: %w", err)
|
||||
}
|
||||
plan.TargetTableExists = targetExists
|
||||
|
||||
strategy := normalizeTargetTableStrategy(config.TargetTableStrategy)
|
||||
if targetExists {
|
||||
plan.Warnings = append(plan.Warnings, "MongoDB 为弱 schema 目标,字段结构以写入文档为准,不执行目标列校验")
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, nil, nil
|
||||
}
|
||||
|
||||
switch strategy {
|
||||
case "existing_only":
|
||||
plan.PlannedAction = "目标集合不存在,需先手工创建"
|
||||
plan.Warnings = append(plan.Warnings, "当前策略要求目标集合已存在,执行时不会自动创建")
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, nil, nil
|
||||
case "smart", "auto_create_if_missing":
|
||||
plan.AutoCreate = true
|
||||
plan.PlannedAction = "目标集合不存在,将自动创建集合后导入"
|
||||
createCmd, err := buildMongoCreateCollectionCommand(plan.TargetTable)
|
||||
if err != nil {
|
||||
return plan, sourceCols, nil, err
|
||||
}
|
||||
plan.PreDataSQL = append(plan.PreDataSQL, createCmd)
|
||||
if config.CreateIndexes {
|
||||
indexCmds, warnings, unsupported, created, skipped, err := buildMongoIndexCommands(sourceDB, plan.SourceSchema, plan.SourceTable, plan.TargetTable)
|
||||
if err != nil {
|
||||
plan.Warnings = append(plan.Warnings, fmt.Sprintf("读取源表索引失败,已跳过索引迁移:%v", err))
|
||||
} else {
|
||||
plan.PostDataSQL = append(plan.PostDataSQL, indexCmds...)
|
||||
plan.Warnings = append(plan.Warnings, warnings...)
|
||||
plan.UnsupportedObjects = append(plan.UnsupportedObjects, unsupported...)
|
||||
plan.IndexesToCreate = created
|
||||
plan.IndexesSkipped = skipped
|
||||
}
|
||||
}
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, nil, nil
|
||||
default:
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildMongoToMySQLPlan(config SyncConfig, tableName string, sourceDB db.Database, targetDB db.Database) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
plan := SchemaMigrationPlan{}
|
||||
plan.SourceSchema, plan.SourceTable = normalizeSchemaAndTable(config.SourceConfig.Type, config.SourceConfig.Database, tableName)
|
||||
plan.TargetSchema, plan.TargetTable = normalizeSchemaAndTable(config.TargetConfig.Type, config.TargetConfig.Database, tableName)
|
||||
plan.SourceQueryTable = qualifiedNameForQuery(config.SourceConfig.Type, plan.SourceSchema, plan.SourceTable, tableName)
|
||||
plan.TargetQueryTable = qualifiedNameForQuery(config.TargetConfig.Type, plan.TargetSchema, plan.TargetTable, tableName)
|
||||
plan.PlannedAction = "使用已有目标表导入"
|
||||
|
||||
sourceCols, warnings, err := inferMongoCollectionColumns(sourceDB, plan.SourceTable)
|
||||
if err != nil {
|
||||
return plan, nil, nil, err
|
||||
}
|
||||
plan.Warnings = append(plan.Warnings, warnings...)
|
||||
if len(sourceCols) == 0 {
|
||||
return plan, nil, nil, fmt.Errorf("源集合未推断出可迁移字段: %s", tableName)
|
||||
}
|
||||
|
||||
targetCols, targetExists, err := inspectTableColumns(targetDB, plan.TargetSchema, plan.TargetTable)
|
||||
if err != nil {
|
||||
return plan, sourceCols, nil, fmt.Errorf("获取目标表字段失败: %w", err)
|
||||
}
|
||||
plan.TargetTableExists = targetExists
|
||||
|
||||
strategy := normalizeTargetTableStrategy(config.TargetTableStrategy)
|
||||
if targetExists {
|
||||
missing := diffMissingColumnNames(sourceCols, targetCols)
|
||||
if len(missing) > 0 {
|
||||
plan.Warnings = append(plan.Warnings, fmt.Sprintf("目标表缺失字段 %d 个:%s", len(missing), strings.Join(missing, ", ")))
|
||||
}
|
||||
if config.AutoAddColumns {
|
||||
addSQL, addWarnings := buildMongoToMySQLAddColumnSQL(plan.TargetQueryTable, sourceCols, targetCols)
|
||||
plan.PreDataSQL = append(plan.PreDataSQL, addSQL...)
|
||||
plan.Warnings = append(plan.Warnings, addWarnings...)
|
||||
if len(addSQL) > 0 {
|
||||
plan.PlannedAction = fmt.Sprintf("补齐缺失字段(%d)后导入", len(addSQL))
|
||||
}
|
||||
}
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
}
|
||||
|
||||
switch strategy {
|
||||
case "existing_only":
|
||||
plan.PlannedAction = "目标表不存在,需先手工创建"
|
||||
plan.Warnings = append(plan.Warnings, "当前策略要求目标表已存在,执行时不会自动建表")
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
case "smart", "auto_create_if_missing":
|
||||
plan.AutoCreate = true
|
||||
plan.PlannedAction = "目标表不存在,将自动建表后导入"
|
||||
createSQL, postSQL, moreWarnings, unsupported, idxCreate, idxSkip, err := buildMongoToMySQLCreateTablePlan(config, plan.TargetQueryTable, sourceCols, sourceDB, plan.SourceSchema, plan.SourceTable)
|
||||
if err != nil {
|
||||
return plan, sourceCols, targetCols, err
|
||||
}
|
||||
plan.CreateTableSQL = createSQL
|
||||
plan.PostDataSQL = append(plan.PostDataSQL, postSQL...)
|
||||
plan.Warnings = append(plan.Warnings, moreWarnings...)
|
||||
plan.UnsupportedObjects = append(plan.UnsupportedObjects, unsupported...)
|
||||
plan.IndexesToCreate = idxCreate
|
||||
plan.IndexesSkipped = idxSkip
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
default:
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
}
|
||||
}
|
||||
|
||||
func inspectMongoCollection(database db.Database, dbName, collection string) (bool, error) {
|
||||
items, err := database.GetTables(dbName)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
target := strings.TrimSpace(collection)
|
||||
for _, item := range items {
|
||||
if strings.EqualFold(strings.TrimSpace(item), target) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func buildMongoCreateCollectionCommand(collection string) (string, error) {
|
||||
cmd := map[string]interface{}{"create": strings.TrimSpace(collection)}
|
||||
data, err := json.Marshal(cmd)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func buildMongoIndexCommands(sourceDB db.Database, dbName, tableName, targetCollection string) ([]string, []string, []string, int, int, error) {
|
||||
indexes, err := sourceDB.GetIndexes(dbName, tableName)
|
||||
if err != nil {
|
||||
return nil, nil, nil, 0, 0, err
|
||||
}
|
||||
grouped := groupIndexDefinitions(indexes)
|
||||
cmds := make([]string, 0, len(grouped))
|
||||
warnings := make([]string, 0)
|
||||
unsupported := make([]string, 0)
|
||||
created := 0
|
||||
skipped := 0
|
||||
for _, idx := range grouped {
|
||||
name := strings.TrimSpace(idx.Name)
|
||||
if name == "" || strings.EqualFold(name, "primary") {
|
||||
continue
|
||||
}
|
||||
if len(idx.Columns) == 0 {
|
||||
skipped++
|
||||
unsupported = append(unsupported, fmt.Sprintf("索引 %s 缺少列定义,已跳过", name))
|
||||
continue
|
||||
}
|
||||
kind := strings.ToLower(strings.TrimSpace(idx.IndexType))
|
||||
if idx.SubPart > 0 {
|
||||
skipped++
|
||||
unsupported = append(unsupported, fmt.Sprintf("索引 %s 使用前缀长度,MongoDB 目标暂不支持等价迁移", name))
|
||||
continue
|
||||
}
|
||||
if kind != "" && kind != "btree" {
|
||||
warnings = append(warnings, fmt.Sprintf("索引 %s 类型=%s 将按普通索引迁移到 MongoDB", name, idx.IndexType))
|
||||
}
|
||||
keySpec := make(map[string]int)
|
||||
for _, col := range idx.Columns {
|
||||
keySpec[col] = 1
|
||||
}
|
||||
command := map[string]interface{}{
|
||||
"createIndexes": strings.TrimSpace(targetCollection),
|
||||
"indexes": []map[string]interface{}{{
|
||||
"name": name,
|
||||
"key": keySpec,
|
||||
"unique": idx.Unique,
|
||||
}},
|
||||
}
|
||||
data, err := json.Marshal(command)
|
||||
if err != nil {
|
||||
skipped++
|
||||
unsupported = append(unsupported, fmt.Sprintf("索引 %s 生成 MongoDB createIndexes 命令失败:%v", name, err))
|
||||
continue
|
||||
}
|
||||
cmds = append(cmds, string(data))
|
||||
created++
|
||||
}
|
||||
return cmds, dedupeStrings(warnings), dedupeStrings(unsupported), created, skipped, nil
|
||||
}
|
||||
|
||||
func inferMongoCollectionColumns(sourceDB db.Database, collection string) ([]connection.ColumnDefinition, []string, error) {
|
||||
query := fmt.Sprintf(`{"find":"%s","filter":{},"limit":200}`, strings.TrimSpace(collection))
|
||||
rows, _, err := sourceDB.Query(query)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("读取源集合样本失败: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return []connection.ColumnDefinition{{Name: "_id", Type: "varchar(64)", Nullable: "NO", Key: "PRI"}}, []string{"源集合暂无样本数据,仅按 `_id` 生成基础主键列"}, nil
|
||||
}
|
||||
fieldNames := make(map[string]struct{})
|
||||
for _, row := range rows {
|
||||
for key := range row {
|
||||
fieldNames[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
orderedFields := make([]string, 0, len(fieldNames))
|
||||
for key := range fieldNames {
|
||||
orderedFields = append(orderedFields, key)
|
||||
}
|
||||
sort.Strings(orderedFields)
|
||||
if containsString(orderedFields, "_id") {
|
||||
orderedFields = moveStringToFront(orderedFields, "_id")
|
||||
}
|
||||
columns := make([]connection.ColumnDefinition, 0, len(orderedFields))
|
||||
warnings := make([]string, 0)
|
||||
for _, field := range orderedFields {
|
||||
typeName, nullable, fieldWarnings := inferMongoFieldType(rows, field)
|
||||
warnings = append(warnings, fieldWarnings...)
|
||||
col := connection.ColumnDefinition{
|
||||
Name: field,
|
||||
Type: typeName,
|
||||
Nullable: ternaryString(nullable, "YES", "NO"),
|
||||
Key: "",
|
||||
Extra: "",
|
||||
}
|
||||
if field == "_id" {
|
||||
col.Key = "PRI"
|
||||
col.Nullable = "NO"
|
||||
}
|
||||
columns = append(columns, col)
|
||||
}
|
||||
return columns, dedupeStrings(warnings), nil
|
||||
}
|
||||
|
||||
func inferMongoFieldType(rows []map[string]interface{}, field string) (string, bool, []string) {
|
||||
nullable := false
|
||||
hasString, hasBool, hasInt, hasFloat, hasTime, hasComplex := false, false, false, false, false, false
|
||||
for _, row := range rows {
|
||||
value, ok := row[field]
|
||||
if !ok || value == nil {
|
||||
nullable = true
|
||||
continue
|
||||
}
|
||||
switch value.(type) {
|
||||
case bool:
|
||||
hasBool = true
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
||||
hasInt = true
|
||||
case float32, float64:
|
||||
hasFloat = true
|
||||
case time.Time:
|
||||
hasTime = true
|
||||
case map[string]interface{}, []interface{}:
|
||||
hasComplex = true
|
||||
default:
|
||||
hasString = true
|
||||
}
|
||||
}
|
||||
kinds := 0
|
||||
for _, flag := range []bool{hasString, hasBool, hasInt, hasFloat, hasTime, hasComplex} {
|
||||
if flag {
|
||||
kinds++
|
||||
}
|
||||
}
|
||||
warnings := make([]string, 0)
|
||||
if kinds > 1 {
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 存在多种 BSON 值类型,已按兼容类型降级", field))
|
||||
}
|
||||
if field == "_id" {
|
||||
return "varchar(64)", false, warnings
|
||||
}
|
||||
switch {
|
||||
case hasComplex:
|
||||
return "json", nullable, warnings
|
||||
case hasTime:
|
||||
return "datetime", nullable, warnings
|
||||
case hasFloat:
|
||||
return "double", nullable, warnings
|
||||
case hasInt:
|
||||
return "bigint", nullable, warnings
|
||||
case hasBool:
|
||||
return "tinyint(1)", nullable, warnings
|
||||
default:
|
||||
return "varchar(255)", nullable, warnings
|
||||
}
|
||||
}
|
||||
|
||||
func buildMongoToMySQLAddColumnSQL(targetQueryTable string, sourceCols, targetCols []connection.ColumnDefinition) ([]string, []string) {
|
||||
targetSet := make(map[string]struct{}, len(targetCols))
|
||||
for _, col := range targetCols {
|
||||
key := strings.ToLower(strings.TrimSpace(col.Name))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
targetSet[key] = struct{}{}
|
||||
}
|
||||
var sqlList []string
|
||||
for _, col := range sourceCols {
|
||||
key := strings.ToLower(strings.TrimSpace(col.Name))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := targetSet[key]; ok {
|
||||
continue
|
||||
}
|
||||
sqlList = append(sqlList, fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s NULL",
|
||||
quoteQualifiedIdentByType("mysql", targetQueryTable),
|
||||
quoteIdentByType("mysql", col.Name),
|
||||
strings.TrimSpace(col.Type),
|
||||
))
|
||||
}
|
||||
return sqlList, nil
|
||||
}
|
||||
|
||||
func buildMongoToMySQLCreateTablePlan(config SyncConfig, targetQueryTable string, sourceCols []connection.ColumnDefinition, sourceDB db.Database, sourceSchema, sourceTable string) (string, []string, []string, []string, int, int, error) {
|
||||
columnDefs := make([]string, 0, len(sourceCols)+1)
|
||||
warnings := make([]string, 0)
|
||||
unsupported := make([]string, 0)
|
||||
pkCols := make([]string, 0, 1)
|
||||
for _, col := range sourceCols {
|
||||
columnDef := fmt.Sprintf("%s %s", quoteIdentByType("mysql", col.Name), strings.TrimSpace(col.Type))
|
||||
if strings.EqualFold(strings.TrimSpace(col.Nullable), "NO") {
|
||||
columnDef += " NOT NULL"
|
||||
}
|
||||
columnDefs = append(columnDefs, columnDef)
|
||||
if col.Key == "PRI" || col.Key == "PK" {
|
||||
pkCols = append(pkCols, quoteIdentByType("mysql", col.Name))
|
||||
}
|
||||
}
|
||||
if len(pkCols) > 0 {
|
||||
columnDefs = append(columnDefs, fmt.Sprintf("PRIMARY KEY (%s)", strings.Join(pkCols, ", ")))
|
||||
} else {
|
||||
warnings = append(warnings, "MongoDB 源集合未推断出稳定主键,目标表将不自动创建主键")
|
||||
}
|
||||
createSQL := fmt.Sprintf("CREATE TABLE %s (\n %s\n)", quoteQualifiedIdentByType("mysql", targetQueryTable), strings.Join(columnDefs, ",\n "))
|
||||
if !config.CreateIndexes {
|
||||
return createSQL, nil, dedupeStrings(warnings), dedupeStrings(unsupported), 0, 0, nil
|
||||
}
|
||||
indexes, err := sourceDB.GetIndexes(sourceSchema, sourceTable)
|
||||
if err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("读取源集合索引失败,已跳过索引迁移:%v", err))
|
||||
return createSQL, nil, dedupeStrings(warnings), dedupeStrings(unsupported), 0, 0, nil
|
||||
}
|
||||
grouped := groupIndexDefinitions(indexes)
|
||||
postSQL := make([]string, 0, len(grouped))
|
||||
created := 0
|
||||
skipped := 0
|
||||
for _, idx := range grouped {
|
||||
name := strings.TrimSpace(idx.Name)
|
||||
if name == "" || strings.EqualFold(name, "_id_") || strings.EqualFold(name, "primary") {
|
||||
continue
|
||||
}
|
||||
if len(idx.Columns) == 0 {
|
||||
skipped++
|
||||
unsupported = append(unsupported, fmt.Sprintf("索引 %s 缺少列定义,已跳过", name))
|
||||
continue
|
||||
}
|
||||
quotedCols := make([]string, 0, len(idx.Columns))
|
||||
for _, col := range idx.Columns {
|
||||
quotedCols = append(quotedCols, quoteIdentByType("mysql", col))
|
||||
}
|
||||
prefix := "CREATE INDEX"
|
||||
if idx.Unique {
|
||||
prefix = "CREATE UNIQUE INDEX"
|
||||
}
|
||||
postSQL = append(postSQL, fmt.Sprintf("%s %s ON %s (%s)", prefix, quoteIdentByType("mysql", name), quoteQualifiedIdentByType("mysql", targetQueryTable), strings.Join(quotedCols, ", ")))
|
||||
created++
|
||||
}
|
||||
return createSQL, postSQL, dedupeStrings(warnings), dedupeStrings(unsupported), created, skipped, nil
|
||||
}
|
||||
|
||||
func containsString(items []string, target string) bool {
|
||||
for _, item := range items {
|
||||
if item == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func moveStringToFront(items []string, target string) []string {
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item == target {
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return append([]string{target}, out...)
|
||||
}
|
||||
|
||||
func buildMongoToPGLikePlan(config SyncConfig, tableName string, sourceDB db.Database, targetDB db.Database) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
plan := SchemaMigrationPlan{}
|
||||
targetType := strings.ToLower(strings.TrimSpace(config.TargetConfig.Type))
|
||||
plan.SourceSchema, plan.SourceTable = normalizeSchemaAndTable(config.SourceConfig.Type, config.SourceConfig.Database, tableName)
|
||||
plan.TargetSchema, plan.TargetTable = normalizeSchemaAndTable(config.TargetConfig.Type, config.TargetConfig.Database, tableName)
|
||||
plan.SourceQueryTable = qualifiedNameForQuery(config.SourceConfig.Type, plan.SourceSchema, plan.SourceTable, tableName)
|
||||
plan.TargetQueryTable = qualifiedNameForQuery(config.TargetConfig.Type, plan.TargetSchema, plan.TargetTable, tableName)
|
||||
plan.PlannedAction = "使用已有目标表导入"
|
||||
|
||||
sourceCols, warnings, err := inferMongoCollectionColumns(sourceDB, plan.SourceTable)
|
||||
if err != nil {
|
||||
return plan, nil, nil, err
|
||||
}
|
||||
plan.Warnings = append(plan.Warnings, warnings...)
|
||||
if len(sourceCols) == 0 {
|
||||
return plan, nil, nil, fmt.Errorf("源集合未推断出可迁移字段: %s", tableName)
|
||||
}
|
||||
|
||||
targetCols, targetExists, err := inspectTableColumns(targetDB, plan.TargetSchema, plan.TargetTable)
|
||||
if err != nil {
|
||||
return plan, sourceCols, nil, fmt.Errorf("获取目标表字段失败: %w", err)
|
||||
}
|
||||
plan.TargetTableExists = targetExists
|
||||
|
||||
strategy := normalizeTargetTableStrategy(config.TargetTableStrategy)
|
||||
if targetExists {
|
||||
missing := diffMissingColumnNames(sourceCols, targetCols)
|
||||
if len(missing) > 0 {
|
||||
plan.Warnings = append(plan.Warnings, fmt.Sprintf("目标表缺失字段 %d 个:%s", len(missing), strings.Join(missing, ", ")))
|
||||
}
|
||||
if config.AutoAddColumns {
|
||||
addSQL, addWarnings := buildMongoToPGLikeAddColumnSQL(targetType, plan.TargetQueryTable, sourceCols, targetCols)
|
||||
plan.PreDataSQL = append(plan.PreDataSQL, addSQL...)
|
||||
plan.Warnings = append(plan.Warnings, addWarnings...)
|
||||
if len(addSQL) > 0 {
|
||||
plan.PlannedAction = fmt.Sprintf("补齐缺失字段(%d)后导入", len(addSQL))
|
||||
}
|
||||
}
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
}
|
||||
|
||||
switch strategy {
|
||||
case "existing_only":
|
||||
plan.PlannedAction = "目标表不存在,需先手工创建"
|
||||
plan.Warnings = append(plan.Warnings, "当前策略要求目标表已存在,执行时不会自动建表")
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
case "smart", "auto_create_if_missing":
|
||||
plan.AutoCreate = true
|
||||
plan.PlannedAction = "目标表不存在,将自动建表后导入"
|
||||
createSQL, postSQL, moreWarnings, unsupported, idxCreate, idxSkip, err := buildMongoToPGLikeCreateTablePlan(targetType, config, plan.TargetQueryTable, sourceCols, sourceDB, plan.SourceSchema, plan.SourceTable)
|
||||
if err != nil {
|
||||
return plan, sourceCols, targetCols, err
|
||||
}
|
||||
plan.CreateTableSQL = createSQL
|
||||
plan.PostDataSQL = append(plan.PostDataSQL, postSQL...)
|
||||
plan.Warnings = append(plan.Warnings, moreWarnings...)
|
||||
plan.UnsupportedObjects = append(plan.UnsupportedObjects, unsupported...)
|
||||
plan.IndexesToCreate = idxCreate
|
||||
plan.IndexesSkipped = idxSkip
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
default:
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildMongoToPGLikeAddColumnSQL(targetType string, targetQueryTable string, sourceCols, targetCols []connection.ColumnDefinition) ([]string, []string) {
|
||||
targetSet := make(map[string]struct{}, len(targetCols))
|
||||
for _, col := range targetCols {
|
||||
key := strings.ToLower(strings.TrimSpace(col.Name))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
targetSet[key] = struct{}{}
|
||||
}
|
||||
var sqlList []string
|
||||
var warnings []string
|
||||
for _, col := range sourceCols {
|
||||
key := strings.ToLower(strings.TrimSpace(col.Name))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := targetSet[key]; ok {
|
||||
continue
|
||||
}
|
||||
colType, mapWarnings := mapMongoInferredColumnToPGLike(col)
|
||||
warnings = append(warnings, mapWarnings...)
|
||||
sqlList = append(sqlList, fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s NULL",
|
||||
quoteQualifiedIdentByType(targetType, targetQueryTable),
|
||||
quoteIdentByType(targetType, col.Name),
|
||||
colType,
|
||||
))
|
||||
}
|
||||
return sqlList, dedupeStrings(warnings)
|
||||
}
|
||||
|
||||
func buildMongoToPGLikeCreateTablePlan(targetType string, config SyncConfig, targetQueryTable string, sourceCols []connection.ColumnDefinition, sourceDB db.Database, sourceSchema, sourceTable string) (string, []string, []string, []string, int, int, error) {
|
||||
columnDefs := make([]string, 0, len(sourceCols)+1)
|
||||
warnings := make([]string, 0)
|
||||
unsupported := make([]string, 0)
|
||||
pkCols := make([]string, 0, 1)
|
||||
for _, col := range sourceCols {
|
||||
colType, colWarnings := mapMongoInferredColumnToPGLike(col)
|
||||
warnings = append(warnings, colWarnings...)
|
||||
parts := []string{colType}
|
||||
if strings.EqualFold(strings.TrimSpace(col.Nullable), "NO") {
|
||||
parts = append(parts, "NOT NULL")
|
||||
}
|
||||
columnDefs = append(columnDefs, fmt.Sprintf("%s %s", quoteIdentByType(targetType, col.Name), strings.Join(parts, " ")))
|
||||
if col.Key == "PRI" || col.Key == "PK" {
|
||||
pkCols = append(pkCols, quoteIdentByType(targetType, col.Name))
|
||||
}
|
||||
}
|
||||
if len(pkCols) > 0 {
|
||||
columnDefs = append(columnDefs, fmt.Sprintf("PRIMARY KEY (%s)", strings.Join(pkCols, ", ")))
|
||||
}
|
||||
createSQL := fmt.Sprintf("CREATE TABLE %s (\n %s\n)", quoteQualifiedIdentByType(targetType, targetQueryTable), strings.Join(columnDefs, ",\n "))
|
||||
if !config.CreateIndexes {
|
||||
return createSQL, nil, dedupeStrings(warnings), dedupeStrings(unsupported), 0, 0, nil
|
||||
}
|
||||
indexes, err := sourceDB.GetIndexes(sourceSchema, sourceTable)
|
||||
if err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("读取源集合索引失败,已跳过索引迁移:%v", err))
|
||||
return createSQL, nil, dedupeStrings(warnings), dedupeStrings(unsupported), 0, 0, nil
|
||||
}
|
||||
grouped := groupIndexDefinitions(indexes)
|
||||
postSQL := make([]string, 0, len(grouped))
|
||||
created := 0
|
||||
skipped := 0
|
||||
for _, idx := range grouped {
|
||||
name := strings.TrimSpace(idx.Name)
|
||||
if name == "" || strings.EqualFold(name, "_id_") || strings.EqualFold(name, "primary") {
|
||||
continue
|
||||
}
|
||||
if len(idx.Columns) == 0 {
|
||||
skipped++
|
||||
unsupported = append(unsupported, fmt.Sprintf("索引 %s 缺少列定义,已跳过", name))
|
||||
continue
|
||||
}
|
||||
quotedCols := make([]string, 0, len(idx.Columns))
|
||||
for _, col := range idx.Columns {
|
||||
quotedCols = append(quotedCols, quoteIdentByType(targetType, col))
|
||||
}
|
||||
prefix := "CREATE INDEX"
|
||||
if idx.Unique {
|
||||
prefix = "CREATE UNIQUE INDEX"
|
||||
}
|
||||
postSQL = append(postSQL, fmt.Sprintf("%s %s ON %s (%s)", prefix, quoteIdentByType(targetType, name), quoteQualifiedIdentByType(targetType, targetQueryTable), strings.Join(quotedCols, ", ")))
|
||||
created++
|
||||
}
|
||||
return createSQL, postSQL, dedupeStrings(warnings), dedupeStrings(unsupported), created, skipped, nil
|
||||
}
|
||||
|
||||
func mapMongoInferredColumnToPGLike(col connection.ColumnDefinition) (string, []string) {
|
||||
raw := strings.ToLower(strings.TrimSpace(col.Type))
|
||||
warnings := make([]string, 0)
|
||||
switch {
|
||||
case strings.HasPrefix(raw, "varchar"):
|
||||
return col.Type, warnings
|
||||
case raw == "json":
|
||||
return "jsonb", warnings
|
||||
case raw == "datetime":
|
||||
return "timestamp", warnings
|
||||
case raw == "tinyint(1)":
|
||||
return "boolean", warnings
|
||||
case raw == "double":
|
||||
return "double precision", warnings
|
||||
case raw == "bigint":
|
||||
return "bigint", warnings
|
||||
default:
|
||||
return col.Type, warnings
|
||||
}
|
||||
}
|
||||
1315
internal/sync/migration_redis.go
Normal file
1315
internal/sync/migration_redis.go
Normal file
File diff suppressed because it is too large
Load Diff
58
internal/sync/migration_runtime_helpers.go
Normal file
58
internal/sync/migration_runtime_helpers.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func supportsAutoAddColumnsForPair(sourceType string, targetType string) bool {
|
||||
source := normalizeMigrationDBType(sourceType)
|
||||
target := normalizeMigrationDBType(targetType)
|
||||
if isMySQLLikeWritableTargetType(target) {
|
||||
return isMySQLCoreType(source)
|
||||
}
|
||||
if isPGLikeTarget(target) {
|
||||
return isMySQLLikeSourceType(source)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func buildAddColumnSQLForPair(sourceType string, targetType string, targetQueryTable string, sourceCol connection.ColumnDefinition) (string, error) {
|
||||
source := normalizeMigrationDBType(sourceType)
|
||||
target := normalizeMigrationDBType(targetType)
|
||||
switch {
|
||||
case isMySQLCoreType(source) && isMySQLLikeWritableTargetType(target):
|
||||
colType := sanitizeMySQLColumnType(sourceCol.Type)
|
||||
return fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s NULL",
|
||||
quoteQualifiedIdentByType("mysql", targetQueryTable),
|
||||
quoteIdentByType("mysql", sourceCol.Name),
|
||||
colType,
|
||||
), nil
|
||||
case isMySQLLikeSourceType(source) && isPGLikeTarget(target):
|
||||
colType, _, warnings := mapMySQLColumnToKingbase(sourceCol)
|
||||
if len(warnings) > 0 && strings.Contains(strings.Join(warnings, " "), "identity") {
|
||||
// 对已有目标表补字段时保守处理,不补建自增语义。
|
||||
}
|
||||
return fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s NULL",
|
||||
quoteQualifiedIdentByType(target, targetQueryTable),
|
||||
quoteIdentByType(target, sourceCol.Name),
|
||||
colType,
|
||||
), nil
|
||||
default:
|
||||
return "", fmt.Errorf("当前不支持 source=%s target=%s 的自动补字段", sourceType, targetType)
|
||||
}
|
||||
}
|
||||
|
||||
func executeSQLStatements(execFn func(string) (int64, error), statements []string) error {
|
||||
for _, stmt := range statements {
|
||||
trimmed := strings.TrimSpace(stmt)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := execFn(trimmed); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
53
internal/sync/migration_schema_inference.go
Normal file
53
internal/sync/migration_schema_inference.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SchemaInferenceStrategy string
|
||||
|
||||
const (
|
||||
SchemaInferenceStrategySample SchemaInferenceStrategy = "sample"
|
||||
SchemaInferenceStrategyStrict SchemaInferenceStrategy = "strict"
|
||||
)
|
||||
|
||||
func shouldUseSchemaInference(sourceType string, targetType string) bool {
|
||||
sourceModel := classifyMigrationDataModel(sourceType)
|
||||
targetModel := classifyMigrationDataModel(targetType)
|
||||
return sourceModel == MigrationDataModelDocument && targetModel == MigrationDataModelRelational
|
||||
}
|
||||
|
||||
func inferMigrationObjectKind(sourceType string, targetType string) MigrationObjectKind {
|
||||
sourceModel := classifyMigrationDataModel(sourceType)
|
||||
targetModel := classifyMigrationDataModel(targetType)
|
||||
switch {
|
||||
case sourceModel == MigrationDataModelDocument || targetModel == MigrationDataModelDocument:
|
||||
return MigrationObjectKindCollection
|
||||
case sourceModel == MigrationDataModelKeyValue || targetModel == MigrationDataModelKeyValue:
|
||||
return MigrationObjectKindKeyspace
|
||||
default:
|
||||
return MigrationObjectKindTable
|
||||
}
|
||||
}
|
||||
|
||||
func inferSchemaForPair(sourceType string, targetType string, objectName string) (SchemaInferenceResult, error) {
|
||||
if !shouldUseSchemaInference(sourceType, targetType) {
|
||||
return SchemaInferenceResult{}, fmt.Errorf("当前迁移对 %s -> %s 不需要 schema 推断", sourceType, targetType)
|
||||
}
|
||||
return SchemaInferenceResult{
|
||||
Object: CanonicalObjectSpec{
|
||||
Name: strings.TrimSpace(objectName),
|
||||
Kind: MigrationObjectKindCollection,
|
||||
Fields: []CanonicalFieldSpec{},
|
||||
},
|
||||
Issues: []SchemaInferenceIssue{
|
||||
{
|
||||
Level: "info",
|
||||
Message: "MongoDB -> 关系型数据库的 schema 推断能力尚在建设中,当前仅提供内核入口。",
|
||||
Resolution: "后续将基于样本数据生成列定义与类型降级策略。",
|
||||
},
|
||||
},
|
||||
NeedsReview: true,
|
||||
}, nil
|
||||
}
|
||||
296
internal/sync/migration_tdengine.go
Normal file
296
internal/sync/migration_tdengine.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func buildTDengineToMySQLPlan(config SyncConfig, tableName string, sourceDB db.Database, targetDB db.Database) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
plan := SchemaMigrationPlan{}
|
||||
sourceType := resolveMigrationDBType(config.SourceConfig)
|
||||
targetType := resolveMigrationDBType(config.TargetConfig)
|
||||
plan.SourceSchema, plan.SourceTable = normalizeSchemaAndTable(sourceType, config.SourceConfig.Database, tableName)
|
||||
plan.TargetSchema, plan.TargetTable = normalizeSchemaAndTable(targetType, config.TargetConfig.Database, tableName)
|
||||
plan.SourceQueryTable = qualifiedNameForQuery(sourceType, plan.SourceSchema, plan.SourceTable, tableName)
|
||||
plan.TargetQueryTable = qualifiedNameForQuery(targetType, plan.TargetSchema, plan.TargetTable, tableName)
|
||||
plan.PlannedAction = "使用已有目标表导入"
|
||||
|
||||
sourceCols, sourceExists, err := inspectTableColumns(sourceDB, plan.SourceSchema, plan.SourceTable)
|
||||
if err != nil {
|
||||
return plan, nil, nil, fmt.Errorf("获取源表字段失败: %w", err)
|
||||
}
|
||||
if !sourceExists {
|
||||
return plan, nil, nil, fmt.Errorf("源表不存在或无列定义: %s", tableName)
|
||||
}
|
||||
|
||||
targetCols, targetExists, err := inspectTableColumns(targetDB, plan.TargetSchema, plan.TargetTable)
|
||||
if err != nil {
|
||||
return plan, sourceCols, nil, fmt.Errorf("获取目标表字段失败: %w", err)
|
||||
}
|
||||
plan.TargetTableExists = targetExists
|
||||
plan.Warnings = append(plan.Warnings, tdengineSemanticWarnings(sourceCols)...)
|
||||
|
||||
strategy := normalizeTargetTableStrategy(config.TargetTableStrategy)
|
||||
if targetExists {
|
||||
missing := diffMissingColumnNames(sourceCols, targetCols)
|
||||
if len(missing) > 0 {
|
||||
plan.Warnings = append(plan.Warnings, fmt.Sprintf("目标表缺失字段 %d 个:%s", len(missing), strings.Join(missing, ", ")))
|
||||
}
|
||||
if strategy != "existing_only" {
|
||||
plan.Warnings = append(plan.Warnings, "TDengine 源端当前不自动补齐已有目标表字段,请先确认目标表结构")
|
||||
}
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
}
|
||||
|
||||
switch strategy {
|
||||
case "existing_only":
|
||||
plan.PlannedAction = "目标表不存在,需先手工创建"
|
||||
plan.Warnings = append(plan.Warnings, "当前策略要求目标表已存在,执行时不会自动建表")
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
case "smart", "auto_create_if_missing":
|
||||
plan.AutoCreate = true
|
||||
plan.PlannedAction = "目标表不存在,将自动建表后导入"
|
||||
createSQL, warnings, unsupported := buildTDengineToMySQLCreateTableSQL(plan.TargetQueryTable, sourceCols)
|
||||
plan.CreateTableSQL = createSQL
|
||||
plan.Warnings = append(plan.Warnings, warnings...)
|
||||
plan.UnsupportedObjects = append(plan.UnsupportedObjects, unsupported...)
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
default:
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildTDengineToPGLikePlan(config SyncConfig, tableName string, sourceDB db.Database, targetDB db.Database) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
plan := SchemaMigrationPlan{}
|
||||
sourceType := resolveMigrationDBType(config.SourceConfig)
|
||||
targetType := resolveMigrationDBType(config.TargetConfig)
|
||||
plan.SourceSchema, plan.SourceTable = normalizeSchemaAndTable(sourceType, config.SourceConfig.Database, tableName)
|
||||
plan.TargetSchema, plan.TargetTable = normalizeSchemaAndTable(targetType, config.TargetConfig.Database, tableName)
|
||||
plan.SourceQueryTable = qualifiedNameForQuery(sourceType, plan.SourceSchema, plan.SourceTable, tableName)
|
||||
plan.TargetQueryTable = qualifiedNameForQuery(targetType, plan.TargetSchema, plan.TargetTable, tableName)
|
||||
plan.PlannedAction = "使用已有目标表导入"
|
||||
|
||||
sourceCols, sourceExists, err := inspectTableColumns(sourceDB, plan.SourceSchema, plan.SourceTable)
|
||||
if err != nil {
|
||||
return plan, nil, nil, fmt.Errorf("获取源表字段失败: %w", err)
|
||||
}
|
||||
if !sourceExists {
|
||||
return plan, nil, nil, fmt.Errorf("源表不存在或无列定义: %s", tableName)
|
||||
}
|
||||
|
||||
targetCols, targetExists, err := inspectTableColumns(targetDB, plan.TargetSchema, plan.TargetTable)
|
||||
if err != nil {
|
||||
return plan, sourceCols, nil, fmt.Errorf("获取目标表字段失败: %w", err)
|
||||
}
|
||||
plan.TargetTableExists = targetExists
|
||||
plan.Warnings = append(plan.Warnings, tdengineSemanticWarnings(sourceCols)...)
|
||||
|
||||
strategy := normalizeTargetTableStrategy(config.TargetTableStrategy)
|
||||
if targetExists {
|
||||
missing := diffMissingColumnNames(sourceCols, targetCols)
|
||||
if len(missing) > 0 {
|
||||
plan.Warnings = append(plan.Warnings, fmt.Sprintf("目标表缺失字段 %d 个:%s", len(missing), strings.Join(missing, ", ")))
|
||||
}
|
||||
if strategy != "existing_only" {
|
||||
plan.Warnings = append(plan.Warnings, "TDengine 源端当前不自动补齐已有目标表字段,请先确认目标表结构")
|
||||
}
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
}
|
||||
|
||||
switch strategy {
|
||||
case "existing_only":
|
||||
plan.PlannedAction = "目标表不存在,需先手工创建"
|
||||
plan.Warnings = append(plan.Warnings, "当前策略要求目标表已存在,执行时不会自动建表")
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
case "smart", "auto_create_if_missing":
|
||||
plan.AutoCreate = true
|
||||
plan.PlannedAction = "目标表不存在,将自动建表后导入"
|
||||
createSQL, warnings, unsupported := buildTDengineToPGLikeCreateTableSQL(targetType, plan.TargetQueryTable, sourceCols)
|
||||
plan.CreateTableSQL = createSQL
|
||||
plan.Warnings = append(plan.Warnings, warnings...)
|
||||
plan.UnsupportedObjects = append(plan.UnsupportedObjects, unsupported...)
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
default:
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildTDengineToMySQLCreateTableSQL(targetQueryTable string, sourceCols []connection.ColumnDefinition) (string, []string, []string) {
|
||||
columnDefs := make([]string, 0, len(sourceCols))
|
||||
warnings := make([]string, 0)
|
||||
unsupported := []string{"TDengine 的索引/外键/触发器/超级表/TTL 等时序语义当前不会自动迁移"}
|
||||
for _, col := range sourceCols {
|
||||
def, colWarnings := buildTDengineToMySQLColumnDefinition(col)
|
||||
columnDefs = append(columnDefs, fmt.Sprintf("%s %s", quoteIdentByType("mysql", col.Name), def))
|
||||
warnings = append(warnings, colWarnings...)
|
||||
}
|
||||
createSQL := fmt.Sprintf("CREATE TABLE %s (\n %s\n)", quoteQualifiedIdentByType("mysql", targetQueryTable), strings.Join(columnDefs, ",\n "))
|
||||
return createSQL, dedupeStrings(warnings), dedupeStrings(unsupported)
|
||||
}
|
||||
|
||||
func buildTDengineToPGLikeCreateTableSQL(targetType string, targetQueryTable string, sourceCols []connection.ColumnDefinition) (string, []string, []string) {
|
||||
columnDefs := make([]string, 0, len(sourceCols))
|
||||
warnings := make([]string, 0)
|
||||
unsupported := []string{"TDengine 的索引/外键/触发器/超级表/TTL 等时序语义当前不会自动迁移"}
|
||||
for _, col := range sourceCols {
|
||||
def, colWarnings := buildTDengineToPGLikeColumnDefinition(col)
|
||||
columnDefs = append(columnDefs, fmt.Sprintf("%s %s", quoteIdentByType(targetType, col.Name), def))
|
||||
warnings = append(warnings, colWarnings...)
|
||||
}
|
||||
createSQL := fmt.Sprintf("CREATE TABLE %s (\n %s\n)", quoteQualifiedIdentByType(targetType, targetQueryTable), strings.Join(columnDefs, ",\n "))
|
||||
return createSQL, dedupeStrings(warnings), dedupeStrings(unsupported)
|
||||
}
|
||||
|
||||
func buildTDengineToMySQLColumnDefinition(col connection.ColumnDefinition) (string, []string) {
|
||||
targetType, warnings := mapTDengineColumnToMySQL(col)
|
||||
parts := []string{targetType}
|
||||
if strings.EqualFold(strings.TrimSpace(col.Nullable), "NO") {
|
||||
parts = append(parts, "NOT NULL")
|
||||
} else {
|
||||
parts = append(parts, "NULL")
|
||||
}
|
||||
return strings.Join(parts, " "), warnings
|
||||
}
|
||||
|
||||
func buildTDengineToPGLikeColumnDefinition(col connection.ColumnDefinition) (string, []string) {
|
||||
targetType, warnings := mapTDengineColumnToPGLike(col)
|
||||
parts := []string{targetType}
|
||||
if strings.EqualFold(strings.TrimSpace(col.Nullable), "NO") {
|
||||
parts = append(parts, "NOT NULL")
|
||||
} else {
|
||||
parts = append(parts, "NULL")
|
||||
}
|
||||
return strings.Join(parts, " "), warnings
|
||||
}
|
||||
|
||||
func tdengineSemanticWarnings(sourceCols []connection.ColumnDefinition) []string {
|
||||
warnings := []string{"TDengine 到关系型目标库当前仅迁移列与数据;超级表、TAG 关联、保留策略等时序语义会降级或丢失"}
|
||||
for _, col := range sourceCols {
|
||||
if isTDengineTagColumn(col) {
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 为 TDengine TAG 列,迁移到关系型目标后将降级为普通字段", col.Name))
|
||||
}
|
||||
}
|
||||
return dedupeStrings(warnings)
|
||||
}
|
||||
|
||||
func isTDengineTagColumn(col connection.ColumnDefinition) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(col.Key), "TAG") || strings.Contains(strings.ToUpper(strings.TrimSpace(col.Extra)), "TAG")
|
||||
}
|
||||
|
||||
func parseTDengineType(raw string) (string, int) {
|
||||
cleaned := strings.TrimSpace(strings.ToUpper(raw))
|
||||
if cleaned == "" {
|
||||
return "", 0
|
||||
}
|
||||
base := cleaned
|
||||
length := 0
|
||||
if idx := strings.Index(base, "("); idx >= 0 {
|
||||
end := strings.Index(base[idx+1:], ")")
|
||||
if end >= 0 {
|
||||
lengthText := strings.TrimSpace(base[idx+1 : idx+1+end])
|
||||
if v, err := strconv.Atoi(lengthText); err == nil {
|
||||
length = v
|
||||
}
|
||||
}
|
||||
base = strings.TrimSpace(base[:idx])
|
||||
}
|
||||
return base, length
|
||||
}
|
||||
|
||||
func mapTDengineColumnToMySQL(col connection.ColumnDefinition) (string, []string) {
|
||||
base, length := parseTDengineType(col.Type)
|
||||
warnings := make([]string, 0)
|
||||
if isTDengineTagColumn(col) {
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 为 TDengine TAG 列,已按普通列映射", col.Name))
|
||||
}
|
||||
switch base {
|
||||
case "BOOL", "BOOLEAN":
|
||||
return "tinyint(1)", warnings
|
||||
case "TINYINT":
|
||||
return "tinyint", warnings
|
||||
case "UTINYINT":
|
||||
return "tinyint unsigned", warnings
|
||||
case "SMALLINT":
|
||||
return "smallint", warnings
|
||||
case "USMALLINT":
|
||||
return "smallint unsigned", warnings
|
||||
case "INT", "INTEGER":
|
||||
return "int", warnings
|
||||
case "UINT":
|
||||
return "int unsigned", warnings
|
||||
case "BIGINT":
|
||||
return "bigint", warnings
|
||||
case "UBIGINT":
|
||||
return "bigint unsigned", warnings
|
||||
case "FLOAT":
|
||||
return "float", warnings
|
||||
case "DOUBLE":
|
||||
return "double", warnings
|
||||
case "DECIMAL", "NUMERIC":
|
||||
if length > 0 {
|
||||
return strings.ToLower(strings.TrimSpace(col.Type)), warnings
|
||||
}
|
||||
return "decimal(38,10)", warnings
|
||||
case "TIMESTAMP":
|
||||
return "datetime", warnings
|
||||
case "DATE":
|
||||
return "date", warnings
|
||||
case "JSON":
|
||||
return "json", warnings
|
||||
case "BINARY", "NCHAR", "VARCHAR", "VARBINARY":
|
||||
if length > 0 && length <= 65535 {
|
||||
return fmt.Sprintf("varchar(%d)", length), warnings
|
||||
}
|
||||
return "text", warnings
|
||||
default:
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 暂无专门 MySQL 映射,已降级为 text", col.Name, col.Type))
|
||||
return "text", warnings
|
||||
}
|
||||
}
|
||||
|
||||
func mapTDengineColumnToPGLike(col connection.ColumnDefinition) (string, []string) {
|
||||
base, length := parseTDengineType(col.Type)
|
||||
warnings := make([]string, 0)
|
||||
if isTDengineTagColumn(col) {
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 为 TDengine TAG 列,已按普通列映射", col.Name))
|
||||
}
|
||||
switch base {
|
||||
case "BOOL", "BOOLEAN":
|
||||
return "boolean", warnings
|
||||
case "TINYINT", "UTINYINT", "SMALLINT":
|
||||
return "smallint", warnings
|
||||
case "USMALLINT", "INT", "INTEGER":
|
||||
return "integer", warnings
|
||||
case "UINT", "BIGINT":
|
||||
return "bigint", warnings
|
||||
case "UBIGINT":
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 UBIGINT 已映射为 numeric(20,0) 以避免无符号溢出", col.Name))
|
||||
return "numeric(20,0)", warnings
|
||||
case "FLOAT":
|
||||
return "real", warnings
|
||||
case "DOUBLE":
|
||||
return "double precision", warnings
|
||||
case "DECIMAL", "NUMERIC":
|
||||
if length > 0 {
|
||||
return strings.ToLower(strings.TrimSpace(col.Type)), warnings
|
||||
}
|
||||
return "numeric(38,10)", warnings
|
||||
case "TIMESTAMP":
|
||||
return "timestamp", warnings
|
||||
case "DATE":
|
||||
return "date", warnings
|
||||
case "JSON":
|
||||
return "jsonb", warnings
|
||||
case "BINARY", "NCHAR", "VARCHAR", "VARBINARY":
|
||||
if length > 0 {
|
||||
return fmt.Sprintf("varchar(%d)", length), warnings
|
||||
}
|
||||
return "text", warnings
|
||||
default:
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 暂无专门 PG-like 映射,已降级为 text", col.Name, col.Type))
|
||||
return "text", warnings
|
||||
}
|
||||
}
|
||||
657
internal/sync/migration_tdengine_target.go
Normal file
657
internal/sync/migration_tdengine_target.go
Normal file
@@ -0,0 +1,657 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type mySQLLikeToTDenginePlanner struct{}
|
||||
|
||||
type pgLikeToTDenginePlanner struct{}
|
||||
|
||||
type clickHouseToTDenginePlanner struct{}
|
||||
|
||||
type tdengineToTDenginePlanner struct{}
|
||||
|
||||
func (mySQLLikeToTDenginePlanner) Name() string { return "mysqllike-tdengine-planner" }
|
||||
|
||||
func (mySQLLikeToTDenginePlanner) SupportLevel(ctx MigrationBuildContext) MigrationSupportLevel {
|
||||
sourceType := resolveMigrationDBType(ctx.Config.SourceConfig)
|
||||
targetType := resolveMigrationDBType(ctx.Config.TargetConfig)
|
||||
if isMySQLLikeSourceType(sourceType) && targetType == "tdengine" {
|
||||
return MigrationSupportLevelFull
|
||||
}
|
||||
return MigrationSupportLevelUnsupported
|
||||
}
|
||||
|
||||
func (mySQLLikeToTDenginePlanner) BuildPlan(ctx MigrationBuildContext) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
return buildMySQLLikeToTDenginePlan(ctx.Config, ctx.TableName, ctx.SourceDB, ctx.TargetDB)
|
||||
}
|
||||
|
||||
func (pgLikeToTDenginePlanner) Name() string { return "pglike-tdengine-planner" }
|
||||
|
||||
func (pgLikeToTDenginePlanner) SupportLevel(ctx MigrationBuildContext) MigrationSupportLevel {
|
||||
sourceType := resolveMigrationDBType(ctx.Config.SourceConfig)
|
||||
targetType := resolveMigrationDBType(ctx.Config.TargetConfig)
|
||||
if isPGLikeSource(sourceType) && targetType == "tdengine" {
|
||||
return MigrationSupportLevelFull
|
||||
}
|
||||
return MigrationSupportLevelUnsupported
|
||||
}
|
||||
|
||||
func (pgLikeToTDenginePlanner) BuildPlan(ctx MigrationBuildContext) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
return buildPGLikeToTDenginePlan(ctx.Config, ctx.TableName, ctx.SourceDB, ctx.TargetDB)
|
||||
}
|
||||
|
||||
func buildMySQLLikeToTDenginePlan(config SyncConfig, tableName string, sourceDB db.Database, targetDB db.Database) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
return buildSourceToTDenginePlan(config, tableName, sourceDB, targetDB, isMySQLLikeTDengineTimestampCandidate, buildMySQLLikeToTDengineCreateTableSQL)
|
||||
}
|
||||
|
||||
func buildPGLikeToTDenginePlan(config SyncConfig, tableName string, sourceDB db.Database, targetDB db.Database) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
return buildSourceToTDenginePlan(config, tableName, sourceDB, targetDB, isPGLikeTDengineTimestampCandidate, buildPGLikeToTDengineCreateTableSQL)
|
||||
}
|
||||
|
||||
func buildClickHouseToTDenginePlan(config SyncConfig, tableName string, sourceDB db.Database, targetDB db.Database) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
return buildSourceToTDenginePlan(config, tableName, sourceDB, targetDB, isClickHouseTDengineTimestampCandidate, buildClickHouseToTDengineCreateTableSQL)
|
||||
}
|
||||
|
||||
func buildTDengineToTDenginePlan(config SyncConfig, tableName string, sourceDB db.Database, targetDB db.Database) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
return buildSourceToTDenginePlan(config, tableName, sourceDB, targetDB, isTDengineTDengineTimestampCandidate, buildTDengineToTDengineCreateTableSQL)
|
||||
}
|
||||
|
||||
func (clickHouseToTDenginePlanner) Name() string { return "clickhouse-tdengine-planner" }
|
||||
|
||||
func (clickHouseToTDenginePlanner) SupportLevel(ctx MigrationBuildContext) MigrationSupportLevel {
|
||||
sourceType := resolveMigrationDBType(ctx.Config.SourceConfig)
|
||||
targetType := resolveMigrationDBType(ctx.Config.TargetConfig)
|
||||
if sourceType == "clickhouse" && targetType == "tdengine" {
|
||||
return MigrationSupportLevelFull
|
||||
}
|
||||
return MigrationSupportLevelUnsupported
|
||||
}
|
||||
|
||||
func (clickHouseToTDenginePlanner) BuildPlan(ctx MigrationBuildContext) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
return buildClickHouseToTDenginePlan(ctx.Config, ctx.TableName, ctx.SourceDB, ctx.TargetDB)
|
||||
}
|
||||
|
||||
func (tdengineToTDenginePlanner) Name() string { return "tdengine-tdengine-planner" }
|
||||
|
||||
func (tdengineToTDenginePlanner) SupportLevel(ctx MigrationBuildContext) MigrationSupportLevel {
|
||||
sourceType := resolveMigrationDBType(ctx.Config.SourceConfig)
|
||||
targetType := resolveMigrationDBType(ctx.Config.TargetConfig)
|
||||
if sourceType == "tdengine" && targetType == "tdengine" {
|
||||
return MigrationSupportLevelFull
|
||||
}
|
||||
return MigrationSupportLevelUnsupported
|
||||
}
|
||||
|
||||
func (tdengineToTDenginePlanner) BuildPlan(ctx MigrationBuildContext) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
return buildTDengineToTDenginePlan(ctx.Config, ctx.TableName, ctx.SourceDB, ctx.TargetDB)
|
||||
}
|
||||
|
||||
type tdengineTimestampCandidate func(connection.ColumnDefinition) bool
|
||||
|
||||
type tdengineCreateTableBuilder func(string, []connection.ColumnDefinition, int) (string, []string, []string)
|
||||
|
||||
func buildSourceToTDenginePlan(config SyncConfig, tableName string, sourceDB db.Database, targetDB db.Database, isTimestamp tdengineTimestampCandidate, buildCreateSQL tdengineCreateTableBuilder) (SchemaMigrationPlan, []connection.ColumnDefinition, []connection.ColumnDefinition, error) {
|
||||
plan := SchemaMigrationPlan{}
|
||||
sourceType := resolveMigrationDBType(config.SourceConfig)
|
||||
targetType := resolveMigrationDBType(config.TargetConfig)
|
||||
plan.SourceSchema, plan.SourceTable = normalizeSchemaAndTable(sourceType, config.SourceConfig.Database, tableName)
|
||||
plan.TargetSchema, plan.TargetTable = normalizeSchemaAndTable(targetType, config.TargetConfig.Database, tableName)
|
||||
plan.SourceQueryTable = qualifiedNameForQuery(sourceType, plan.SourceSchema, plan.SourceTable, tableName)
|
||||
plan.TargetQueryTable = qualifiedNameForQuery(targetType, plan.TargetSchema, plan.TargetTable, tableName)
|
||||
plan.PlannedAction = "使用已有目标表导入"
|
||||
|
||||
sourceCols, sourceExists, err := inspectTableColumns(sourceDB, plan.SourceSchema, plan.SourceTable)
|
||||
if err != nil {
|
||||
return plan, nil, nil, fmt.Errorf("获取源表字段失败: %w", err)
|
||||
}
|
||||
if !sourceExists {
|
||||
return plan, nil, nil, fmt.Errorf("源表不存在或无列定义: %s", tableName)
|
||||
}
|
||||
|
||||
plan.Warnings = append(plan.Warnings, tdengineTargetBaseWarnings()...)
|
||||
timestampIndex := findTDengineTimestampColumn(sourceCols, isTimestamp)
|
||||
if timestampIndex < 0 {
|
||||
plan.Warnings = append(plan.Warnings, tdengineTargetMissingTimeWarning())
|
||||
}
|
||||
|
||||
targetCols, targetExists, err := inspectTableColumns(targetDB, plan.TargetSchema, plan.TargetTable)
|
||||
if err != nil {
|
||||
return plan, sourceCols, nil, fmt.Errorf("获取目标表字段失败: %w", err)
|
||||
}
|
||||
plan.TargetTableExists = targetExists
|
||||
|
||||
strategy := normalizeTargetTableStrategy(config.TargetTableStrategy)
|
||||
if targetExists {
|
||||
missing := diffMissingColumnNames(sourceCols, targetCols)
|
||||
if len(missing) > 0 {
|
||||
plan.Warnings = append(plan.Warnings, fmt.Sprintf("目标表缺失字段 %d 个:%s", len(missing), strings.Join(missing, ", ")))
|
||||
}
|
||||
if strategy != "existing_only" {
|
||||
plan.Warnings = append(plan.Warnings, "TDengine 目标端当前不自动补齐已有目标表字段,请先确认目标表结构")
|
||||
}
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
}
|
||||
|
||||
switch strategy {
|
||||
case "existing_only":
|
||||
plan.PlannedAction = "目标表不存在,需先手工创建"
|
||||
plan.Warnings = append(plan.Warnings, "当前策略要求目标表已存在,执行时不会自动建表")
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
case "smart", "auto_create_if_missing":
|
||||
if timestampIndex < 0 {
|
||||
plan.PlannedAction = "源表未识别到可映射为 TDengine 首列的时间列,无法自动建表"
|
||||
plan.UnsupportedObjects = append(plan.UnsupportedObjects, "TDengine regular table 首列必须为 TIMESTAMP,当前源表缺少可直接映射的时间列")
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
}
|
||||
plan.AutoCreate = true
|
||||
plan.PlannedAction = "目标表不存在,将自动建表后导入"
|
||||
createSQL, warnings, unsupported := buildCreateSQL(plan.TargetQueryTable, sourceCols, timestampIndex)
|
||||
plan.CreateTableSQL = createSQL
|
||||
plan.Warnings = append(plan.Warnings, warnings...)
|
||||
plan.UnsupportedObjects = append(plan.UnsupportedObjects, unsupported...)
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
default:
|
||||
return dedupeSchemaMigrationPlan(plan), sourceCols, targetCols, nil
|
||||
}
|
||||
}
|
||||
|
||||
func tdengineTargetBaseWarnings() []string {
|
||||
return []string{
|
||||
"TDengine 目标端当前仅支持 INSERT 写入;若存在差异 update/delete,执行期会被拒绝",
|
||||
"TDengine 目标端 auto-create 当前仅创建基础表;索引、外键、触发器、supertable/TAGS/TTL 不会自动迁移",
|
||||
}
|
||||
}
|
||||
|
||||
func tdengineTargetMissingTimeWarning() string {
|
||||
return "源表缺少可映射的时间列,自动建表将不可用;如需继续,请先人工准备 TDengine 目标表与时间列"
|
||||
}
|
||||
|
||||
func findTDengineTimestampColumn(sourceCols []connection.ColumnDefinition, candidate tdengineTimestampCandidate) int {
|
||||
preferred := []string{"ts", "timestamp", "event_time", "eventtime", "created_at", "create_time", "occurred_at"}
|
||||
for _, name := range preferred {
|
||||
for idx, col := range sourceCols {
|
||||
if !candidate(col) {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(col.Name), name) {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
}
|
||||
for idx, col := range sourceCols {
|
||||
if candidate(col) {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func reorderTDengineColumns(sourceCols []connection.ColumnDefinition, timestampIndex int) []connection.ColumnDefinition {
|
||||
if timestampIndex <= 0 || timestampIndex >= len(sourceCols) {
|
||||
cloned := make([]connection.ColumnDefinition, len(sourceCols))
|
||||
copy(cloned, sourceCols)
|
||||
return cloned
|
||||
}
|
||||
ordered := make([]connection.ColumnDefinition, 0, len(sourceCols))
|
||||
ordered = append(ordered, sourceCols[timestampIndex])
|
||||
for idx, col := range sourceCols {
|
||||
if idx == timestampIndex {
|
||||
continue
|
||||
}
|
||||
ordered = append(ordered, col)
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
func buildMySQLLikeToTDengineCreateTableSQL(targetQueryTable string, sourceCols []connection.ColumnDefinition, timestampIndex int) (string, []string, []string) {
|
||||
ordered := reorderTDengineColumns(sourceCols, timestampIndex)
|
||||
columnDefs := make([]string, 0, len(ordered))
|
||||
warnings := make([]string, 0)
|
||||
unsupported := []string{"源表索引/外键/触发器/唯一约束/自增语义当前不会自动迁移到 TDengine"}
|
||||
if timestampIndex != 0 && timestampIndex >= 0 && timestampIndex < len(sourceCols) {
|
||||
warnings = append(warnings, fmt.Sprintf("TDengine 基础表要求时间列优先,已将字段 %s 调整为首列", sourceCols[timestampIndex].Name))
|
||||
}
|
||||
for idx, col := range ordered {
|
||||
def, colWarnings := mapMySQLLikeColumnToTDengine(col, idx == 0)
|
||||
warnings = append(warnings, colWarnings...)
|
||||
columnDefs = append(columnDefs, fmt.Sprintf("%s %s", quoteIdentByType("tdengine", col.Name), def))
|
||||
}
|
||||
createSQL := fmt.Sprintf("CREATE TABLE %s (\n %s\n)", quoteQualifiedIdentByType("tdengine", targetQueryTable), strings.Join(columnDefs, ",\n "))
|
||||
return createSQL, dedupeStrings(warnings), dedupeStrings(unsupported)
|
||||
}
|
||||
|
||||
func buildPGLikeToTDengineCreateTableSQL(targetQueryTable string, sourceCols []connection.ColumnDefinition, timestampIndex int) (string, []string, []string) {
|
||||
ordered := reorderTDengineColumns(sourceCols, timestampIndex)
|
||||
columnDefs := make([]string, 0, len(ordered))
|
||||
warnings := make([]string, 0)
|
||||
unsupported := []string{"源表索引/外键/触发器/唯一约束/identity/sequence 语义当前不会自动迁移到 TDengine"}
|
||||
if timestampIndex != 0 && timestampIndex >= 0 && timestampIndex < len(sourceCols) {
|
||||
warnings = append(warnings, fmt.Sprintf("TDengine 基础表要求时间列优先,已将字段 %s 调整为首列", sourceCols[timestampIndex].Name))
|
||||
}
|
||||
for idx, col := range ordered {
|
||||
def, colWarnings := mapPGLikeColumnToTDengine(col, idx == 0)
|
||||
warnings = append(warnings, colWarnings...)
|
||||
columnDefs = append(columnDefs, fmt.Sprintf("%s %s", quoteIdentByType("tdengine", col.Name), def))
|
||||
}
|
||||
createSQL := fmt.Sprintf("CREATE TABLE %s (\n %s\n)", quoteQualifiedIdentByType("tdengine", targetQueryTable), strings.Join(columnDefs, ",\n "))
|
||||
return createSQL, dedupeStrings(warnings), dedupeStrings(unsupported)
|
||||
}
|
||||
|
||||
func buildClickHouseToTDengineCreateTableSQL(targetQueryTable string, sourceCols []connection.ColumnDefinition, timestampIndex int) (string, []string, []string) {
|
||||
ordered := reorderTDengineColumns(sourceCols, timestampIndex)
|
||||
columnDefs := make([]string, 0, len(ordered))
|
||||
warnings := make([]string, 0)
|
||||
unsupported := []string{"源表 ORDER BY/PARTITION/TTL/Projection/物化视图 语义当前不会自动迁移到 TDengine"}
|
||||
if timestampIndex != 0 && timestampIndex >= 0 && timestampIndex < len(sourceCols) {
|
||||
warnings = append(warnings, fmt.Sprintf("TDengine 基础表要求时间列优先,已将字段 %s 调整为首列", sourceCols[timestampIndex].Name))
|
||||
}
|
||||
for idx, col := range ordered {
|
||||
def, colWarnings := mapClickHouseColumnToTDengine(col, idx == 0)
|
||||
warnings = append(warnings, colWarnings...)
|
||||
columnDefs = append(columnDefs, fmt.Sprintf("%s %s", quoteIdentByType("tdengine", col.Name), def))
|
||||
}
|
||||
createSQL := fmt.Sprintf("CREATE TABLE %s (\n %s\n)", quoteQualifiedIdentByType("tdengine", targetQueryTable), strings.Join(columnDefs, ",\n "))
|
||||
return createSQL, dedupeStrings(warnings), dedupeStrings(unsupported)
|
||||
}
|
||||
|
||||
func buildTDengineToTDengineCreateTableSQL(targetQueryTable string, sourceCols []connection.ColumnDefinition, timestampIndex int) (string, []string, []string) {
|
||||
ordered := reorderTDengineColumns(sourceCols, timestampIndex)
|
||||
columnDefs := make([]string, 0, len(ordered))
|
||||
warnings := make([]string, 0)
|
||||
unsupported := []string{"源表 supertable/TAGS/TTL/保留策略/索引 语义当前不会自动迁移到 TDengine regular table"}
|
||||
if timestampIndex != 0 && timestampIndex >= 0 && timestampIndex < len(sourceCols) {
|
||||
warnings = append(warnings, fmt.Sprintf("TDengine 基础表要求时间列优先,已将字段 %s 调整为首列", sourceCols[timestampIndex].Name))
|
||||
}
|
||||
for idx, col := range ordered {
|
||||
def, colWarnings := mapTDengineColumnToTDengine(col, idx == 0)
|
||||
warnings = append(warnings, colWarnings...)
|
||||
columnDefs = append(columnDefs, fmt.Sprintf("%s %s", quoteIdentByType("tdengine", col.Name), def))
|
||||
}
|
||||
createSQL := fmt.Sprintf("CREATE TABLE %s (\n %s\n)", quoteQualifiedIdentByType("tdengine", targetQueryTable), strings.Join(columnDefs, ",\n "))
|
||||
return createSQL, dedupeStrings(warnings), dedupeStrings(unsupported)
|
||||
}
|
||||
|
||||
func isMySQLLikeTDengineTimestampCandidate(col connection.ColumnDefinition) bool {
|
||||
raw := strings.ToLower(strings.TrimSpace(col.Type))
|
||||
clean := strings.ReplaceAll(raw, " unsigned", "")
|
||||
clean = strings.ReplaceAll(clean, " zerofill", "")
|
||||
return strings.HasPrefix(clean, "timestamp") || strings.HasPrefix(clean, "datetime")
|
||||
}
|
||||
|
||||
func isPGLikeTDengineTimestampCandidate(col connection.ColumnDefinition) bool {
|
||||
raw := strings.ToLower(strings.TrimSpace(col.Type))
|
||||
return strings.HasPrefix(raw, "timestamp")
|
||||
}
|
||||
|
||||
func isClickHouseTDengineTimestampCandidate(col connection.ColumnDefinition) bool {
|
||||
lower, _ := unwrapClickHouseTDengineType(col.Type)
|
||||
return strings.HasPrefix(lower, "datetime")
|
||||
}
|
||||
|
||||
func isTDengineTDengineTimestampCandidate(col connection.ColumnDefinition) bool {
|
||||
base, _ := parseTDengineType(col.Type)
|
||||
return base == "TIMESTAMP"
|
||||
}
|
||||
|
||||
func mapMySQLLikeColumnToTDengine(col connection.ColumnDefinition, forceTimestamp bool) (string, []string) {
|
||||
warnings := make([]string, 0)
|
||||
if forceTimestamp {
|
||||
if !isMySQLLikeTDengineTimestampCandidate(col) {
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 已提升为 TDengine 首列 TIMESTAMP", col.Name, col.Type))
|
||||
}
|
||||
return "TIMESTAMP", warnings
|
||||
}
|
||||
|
||||
raw := strings.ToLower(strings.TrimSpace(col.Type))
|
||||
if raw == "" {
|
||||
return "VARCHAR(1024)", []string{fmt.Sprintf("字段 %s 类型为空,已降级为 VARCHAR(1024)", col.Name)}
|
||||
}
|
||||
unsigned := strings.Contains(raw, "unsigned")
|
||||
clean := strings.ReplaceAll(raw, " unsigned", "")
|
||||
clean = strings.ReplaceAll(clean, " zerofill", "")
|
||||
isAutoIncrement := strings.Contains(strings.ToLower(strings.TrimSpace(col.Extra)), "auto_increment")
|
||||
if isAutoIncrement {
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 自增语义不会迁移到 TDengine", col.Name))
|
||||
}
|
||||
if col.Key == "PRI" || col.Key == "PK" {
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 主键语义不会按关系型约束迁移到 TDengine", col.Name))
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(clean, "tinyint(1)") && !unsigned && !isAutoIncrement:
|
||||
return "BOOL", warnings
|
||||
case strings.HasPrefix(clean, "tinyint"):
|
||||
if unsigned {
|
||||
return "UTINYINT", warnings
|
||||
}
|
||||
return "TINYINT", warnings
|
||||
case strings.HasPrefix(clean, "smallint"):
|
||||
if unsigned {
|
||||
return "USMALLINT", warnings
|
||||
}
|
||||
return "SMALLINT", warnings
|
||||
case strings.HasPrefix(clean, "mediumint"), strings.HasPrefix(clean, "int"), strings.HasPrefix(clean, "integer"):
|
||||
if unsigned {
|
||||
return "UINT", warnings
|
||||
}
|
||||
return "INT", warnings
|
||||
case strings.HasPrefix(clean, "bigint"):
|
||||
if unsigned {
|
||||
return "UBIGINT", warnings
|
||||
}
|
||||
return "BIGINT", warnings
|
||||
case strings.HasPrefix(clean, "decimal"), strings.HasPrefix(clean, "numeric"):
|
||||
return normalizeTDengineDecimalType(clean), warnings
|
||||
case strings.HasPrefix(clean, "float"):
|
||||
return "FLOAT", warnings
|
||||
case strings.HasPrefix(clean, "double"):
|
||||
return "DOUBLE", warnings
|
||||
case strings.HasPrefix(clean, "date"):
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 date 已降级映射为 TIMESTAMP", col.Name))
|
||||
return "TIMESTAMP", warnings
|
||||
case strings.HasPrefix(clean, "timestamp"), strings.HasPrefix(clean, "datetime"):
|
||||
return "TIMESTAMP", warnings
|
||||
case strings.HasPrefix(clean, "time"):
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 暂无稳定 TDengine 时间-only 映射,已降级为 VARCHAR(64)", col.Name, col.Type))
|
||||
return "VARCHAR(64)", warnings
|
||||
case strings.HasPrefix(clean, "char("), strings.HasPrefix(clean, "varchar("):
|
||||
return fmt.Sprintf("VARCHAR(%d)", normalizeTDengineVarcharLength(extractFirstTypeLength(clean), 255)), warnings
|
||||
case strings.HasPrefix(clean, "tinytext"), strings.HasPrefix(clean, "text"), strings.HasPrefix(clean, "mediumtext"), strings.HasPrefix(clean, "longtext"):
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 已降级为 VARCHAR(4096)", col.Name, col.Type))
|
||||
return "VARCHAR(4096)", warnings
|
||||
case strings.HasPrefix(clean, "json"):
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 因 TDengine JSON 仅适用于 TAG,已降级为 VARCHAR(4096)", col.Name, col.Type))
|
||||
return "VARCHAR(4096)", warnings
|
||||
case strings.HasPrefix(clean, "enum"), strings.HasPrefix(clean, "set"):
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 已降级为 VARCHAR(255)", col.Name, col.Type))
|
||||
return "VARCHAR(255)", warnings
|
||||
case strings.HasPrefix(clean, "binary"), strings.HasPrefix(clean, "varbinary"), strings.HasPrefix(clean, "tinyblob"), strings.HasPrefix(clean, "blob"), strings.HasPrefix(clean, "mediumblob"), strings.HasPrefix(clean, "longblob"):
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 已按字符串语义降级为 VARCHAR(4096)", col.Name, col.Type))
|
||||
return "VARCHAR(4096)", warnings
|
||||
default:
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 暂无专门 TDengine 映射,已降级为 VARCHAR(1024)", col.Name, col.Type))
|
||||
return "VARCHAR(1024)", warnings
|
||||
}
|
||||
}
|
||||
|
||||
func mapPGLikeColumnToTDengine(col connection.ColumnDefinition, forceTimestamp bool) (string, []string) {
|
||||
warnings := make([]string, 0)
|
||||
if forceTimestamp {
|
||||
if raw := strings.ToLower(strings.TrimSpace(col.Type)); !strings.HasPrefix(raw, "timestamp") {
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 已提升为 TDengine 首列 TIMESTAMP", col.Name, col.Type))
|
||||
}
|
||||
return "TIMESTAMP", warnings
|
||||
}
|
||||
|
||||
raw := strings.ToLower(strings.TrimSpace(col.Type))
|
||||
if raw == "" {
|
||||
return "VARCHAR(1024)", []string{fmt.Sprintf("字段 %s 类型为空,已降级为 VARCHAR(1024)", col.Name)}
|
||||
}
|
||||
if col.Key == "PRI" || col.Key == "PK" {
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 主键语义不会按关系型约束迁移到 TDengine", col.Name))
|
||||
}
|
||||
if strings.Contains(strings.ToLower(strings.TrimSpace(col.Extra)), "identity") || strings.Contains(strings.ToLower(strings.TrimSpace(col.Extra)), "auto_increment") {
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 自增/identity 语义不会迁移到 TDengine", col.Name))
|
||||
}
|
||||
|
||||
switch {
|
||||
case raw == "boolean" || strings.HasPrefix(raw, "bool"):
|
||||
return "BOOL", warnings
|
||||
case raw == "smallint":
|
||||
return "SMALLINT", warnings
|
||||
case raw == "integer" || raw == "int4":
|
||||
return "INT", warnings
|
||||
case raw == "bigint" || raw == "int8":
|
||||
return "BIGINT", warnings
|
||||
case strings.HasPrefix(raw, "numeric"), strings.HasPrefix(raw, "decimal"):
|
||||
return normalizeTDengineDecimalType(raw), warnings
|
||||
case raw == "real" || raw == "float4":
|
||||
return "FLOAT", warnings
|
||||
case raw == "double precision" || raw == "float8":
|
||||
return "DOUBLE", warnings
|
||||
case raw == "date":
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 date 已降级映射为 TIMESTAMP", col.Name))
|
||||
return "TIMESTAMP", warnings
|
||||
case strings.HasPrefix(raw, "timestamp"):
|
||||
return "TIMESTAMP", warnings
|
||||
case strings.HasPrefix(raw, "time"):
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 暂无稳定 TDengine 时间-only 映射,已降级为 VARCHAR(64)", col.Name, col.Type))
|
||||
return "VARCHAR(64)", warnings
|
||||
case strings.HasPrefix(raw, "character varying("), strings.HasPrefix(raw, "varchar("), strings.HasPrefix(raw, "character("), strings.HasPrefix(raw, "char("):
|
||||
return fmt.Sprintf("VARCHAR(%d)", normalizeTDengineVarcharLength(extractFirstTypeLength(raw), 255)), warnings
|
||||
case raw == "text":
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 text 已降级为 VARCHAR(4096)", col.Name))
|
||||
return "VARCHAR(4096)", warnings
|
||||
case raw == "uuid":
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 uuid 已降级为 VARCHAR(36)", col.Name))
|
||||
return "VARCHAR(36)", warnings
|
||||
case raw == "json" || raw == "jsonb":
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 因 TDengine JSON 仅适用于 TAG,已降级为 VARCHAR(4096)", col.Name, col.Type))
|
||||
return "VARCHAR(4096)", warnings
|
||||
case raw == "bytea":
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 bytea 已按字符串语义降级为 VARCHAR(4096)", col.Name))
|
||||
return "VARCHAR(4096)", warnings
|
||||
case strings.HasSuffix(raw, "[]") || strings.HasPrefix(raw, "array"):
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 已降级为 VARCHAR(4096)", col.Name, col.Type))
|
||||
return "VARCHAR(4096)", warnings
|
||||
case raw == "user-defined":
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 为用户自定义类型,已降级为 VARCHAR(1024)", col.Name))
|
||||
return "VARCHAR(1024)", warnings
|
||||
default:
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 暂无专门 TDengine 映射,已降级为 VARCHAR(1024)", col.Name, col.Type))
|
||||
return "VARCHAR(1024)", warnings
|
||||
}
|
||||
}
|
||||
|
||||
func mapClickHouseColumnToTDengine(col connection.ColumnDefinition, forceTimestamp bool) (string, []string) {
|
||||
warnings := make([]string, 0)
|
||||
if forceTimestamp {
|
||||
if !isClickHouseTDengineTimestampCandidate(col) {
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 已提升为 TDengine 首列 TIMESTAMP", col.Name, col.Type))
|
||||
}
|
||||
return "TIMESTAMP", warnings
|
||||
}
|
||||
|
||||
lower, _ := unwrapClickHouseTDengineType(col.Type)
|
||||
if lower == "" {
|
||||
return "VARCHAR(1024)", []string{fmt.Sprintf("字段 %s 类型为空,已降级为 VARCHAR(1024)", col.Name)}
|
||||
}
|
||||
|
||||
switch {
|
||||
case lower == "bool" || lower == "boolean":
|
||||
return "BOOL", warnings
|
||||
case lower == "int8":
|
||||
return "TINYINT", warnings
|
||||
case lower == "uint8":
|
||||
return "UTINYINT", warnings
|
||||
case lower == "int16":
|
||||
return "SMALLINT", warnings
|
||||
case lower == "uint16":
|
||||
return "USMALLINT", warnings
|
||||
case lower == "int32":
|
||||
return "INT", warnings
|
||||
case lower == "uint32":
|
||||
return "UINT", warnings
|
||||
case lower == "int64":
|
||||
return "BIGINT", warnings
|
||||
case lower == "uint64":
|
||||
return "UBIGINT", warnings
|
||||
case lower == "float32":
|
||||
return "FLOAT", warnings
|
||||
case lower == "float64":
|
||||
return "DOUBLE", warnings
|
||||
case lower == "date":
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 date 已降级映射为 TIMESTAMP", col.Name))
|
||||
return "TIMESTAMP", warnings
|
||||
case strings.HasPrefix(lower, "datetime"):
|
||||
return "TIMESTAMP", warnings
|
||||
case lower == "string":
|
||||
return "VARCHAR(1024)", warnings
|
||||
case lower == "uuid":
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 uuid 已降级为 VARCHAR(36)", col.Name))
|
||||
return "VARCHAR(36)", warnings
|
||||
case lower == "json", strings.HasPrefix(lower, "map("), strings.HasPrefix(lower, "array("), strings.HasPrefix(lower, "tuple("), strings.HasPrefix(lower, "nested("):
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 已降级为 VARCHAR(4096)", col.Name, col.Type))
|
||||
return "VARCHAR(4096)", warnings
|
||||
case strings.HasPrefix(lower, "enum8("), strings.HasPrefix(lower, "enum16("):
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 枚举类型 %s 已降级为 VARCHAR(255)", col.Name, col.Type))
|
||||
return "VARCHAR(255)", warnings
|
||||
case clickHouseDecimalPattern.MatchString(lower):
|
||||
parts := clickHouseDecimalPattern.FindStringSubmatch(lower)
|
||||
return fmt.Sprintf("DECIMAL(%s,%s)", parts[2], parts[3]), warnings
|
||||
case clickHouseStringArgsPattern.MatchString(lower):
|
||||
parts := clickHouseStringArgsPattern.FindStringSubmatch(lower)
|
||||
length, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s FixedString 长度解析失败,已降级为 VARCHAR(255)", col.Name))
|
||||
return "VARCHAR(255)", warnings
|
||||
}
|
||||
return fmt.Sprintf("VARCHAR(%d)", normalizeTDengineVarcharLength(length, 255)), warnings
|
||||
default:
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 暂无专门 TDengine 映射,已降级为 VARCHAR(1024)", col.Name, col.Type))
|
||||
return "VARCHAR(1024)", warnings
|
||||
}
|
||||
}
|
||||
|
||||
func mapTDengineColumnToTDengine(col connection.ColumnDefinition, forceTimestamp bool) (string, []string) {
|
||||
warnings := make([]string, 0)
|
||||
if forceTimestamp {
|
||||
if !isTDengineTDengineTimestampCandidate(col) {
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 已提升为 TDengine 首列 TIMESTAMP", col.Name, col.Type))
|
||||
}
|
||||
return "TIMESTAMP", warnings
|
||||
}
|
||||
|
||||
base, length := parseTDengineType(col.Type)
|
||||
if base == "" {
|
||||
return "VARCHAR(1024)", []string{fmt.Sprintf("字段 %s 类型为空,已降级为 VARCHAR(1024)", col.Name)}
|
||||
}
|
||||
if isTDengineTagColumn(col) {
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 为 TDengine TAG 列,迁移到 regular table 后将降级为普通字段", col.Name))
|
||||
}
|
||||
|
||||
switch base {
|
||||
case "BOOL", "BOOLEAN":
|
||||
return "BOOL", warnings
|
||||
case "TINYINT":
|
||||
return "TINYINT", warnings
|
||||
case "UTINYINT":
|
||||
return "UTINYINT", warnings
|
||||
case "SMALLINT":
|
||||
return "SMALLINT", warnings
|
||||
case "USMALLINT":
|
||||
return "USMALLINT", warnings
|
||||
case "INT", "INTEGER":
|
||||
return "INT", warnings
|
||||
case "UINT":
|
||||
return "UINT", warnings
|
||||
case "BIGINT":
|
||||
return "BIGINT", warnings
|
||||
case "UBIGINT":
|
||||
return "UBIGINT", warnings
|
||||
case "FLOAT":
|
||||
return "FLOAT", warnings
|
||||
case "DOUBLE":
|
||||
return "DOUBLE", warnings
|
||||
case "DECIMAL", "NUMERIC":
|
||||
return normalizeTDengineDecimalType(col.Type), warnings
|
||||
case "TIMESTAMP":
|
||||
return "TIMESTAMP", warnings
|
||||
case "DATE":
|
||||
return "DATE", warnings
|
||||
case "JSON":
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 JSON 在 TDengine regular table 中不保留 TAG 语义,已降级为 VARCHAR(4096)", col.Name))
|
||||
return "VARCHAR(4096)", warnings
|
||||
case "BINARY", "NCHAR", "VARCHAR", "VARBINARY":
|
||||
if length > 0 {
|
||||
return fmt.Sprintf("%s(%d)", base, normalizeTDengineVarcharLength(length, length)), warnings
|
||||
}
|
||||
fallback := 255
|
||||
if base == "VARCHAR" {
|
||||
fallback = 1024
|
||||
}
|
||||
return fmt.Sprintf("%s(%d)", base, fallback), warnings
|
||||
default:
|
||||
warnings = append(warnings, fmt.Sprintf("字段 %s 类型 %s 暂无专门 TDengine 同库映射,已降级为 VARCHAR(1024)", col.Name, col.Type))
|
||||
return "VARCHAR(1024)", warnings
|
||||
}
|
||||
}
|
||||
|
||||
func unwrapClickHouseTDengineType(raw string) (string, bool) {
|
||||
text := strings.TrimSpace(raw)
|
||||
lower := strings.ToLower(text)
|
||||
nullable := false
|
||||
for {
|
||||
switched := false
|
||||
if strings.HasPrefix(lower, "nullable(") && strings.HasSuffix(lower, ")") {
|
||||
text = strings.TrimSpace(text[len("Nullable(") : len(text)-1])
|
||||
lower = strings.ToLower(text)
|
||||
nullable = true
|
||||
switched = true
|
||||
}
|
||||
if strings.HasPrefix(lower, "lowcardinality(") && strings.HasSuffix(lower, ")") {
|
||||
text = strings.TrimSpace(text[len("LowCardinality(") : len(text)-1])
|
||||
lower = strings.ToLower(text)
|
||||
switched = true
|
||||
}
|
||||
if !switched {
|
||||
break
|
||||
}
|
||||
}
|
||||
return lower, nullable
|
||||
}
|
||||
|
||||
func normalizeTDengineDecimalType(raw string) string {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
return "DECIMAL(38,10)"
|
||||
}
|
||||
lower := strings.ToLower(text)
|
||||
if strings.HasPrefix(lower, "numeric") {
|
||||
return "DECIMAL" + text[len("numeric"):]
|
||||
}
|
||||
if strings.HasPrefix(lower, "decimal") {
|
||||
return "DECIMAL" + text[len("decimal"):]
|
||||
}
|
||||
return "DECIMAL(38,10)"
|
||||
}
|
||||
|
||||
func normalizeTDengineVarcharLength(length int, fallback int) int {
|
||||
if fallback <= 0 {
|
||||
fallback = 255
|
||||
}
|
||||
if length <= 0 {
|
||||
return fallback
|
||||
}
|
||||
if length > 16384 {
|
||||
return 16384
|
||||
}
|
||||
return length
|
||||
}
|
||||
|
||||
func extractFirstTypeLength(raw string) int {
|
||||
start := strings.Index(raw, "(")
|
||||
if start < 0 {
|
||||
return 0
|
||||
}
|
||||
end := strings.Index(raw[start+1:], ")")
|
||||
if end < 0 {
|
||||
return 0
|
||||
}
|
||||
inside := strings.TrimSpace(raw[start+1 : start+1+end])
|
||||
if inside == "" {
|
||||
return 0
|
||||
}
|
||||
parts := strings.SplitN(inside, ",", 2)
|
||||
length, err := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return length
|
||||
}
|
||||
98
internal/sync/migration_type_resolver.go
Normal file
98
internal/sync/migration_type_resolver.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func normalizeMigrationDBType(dbType string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(dbType))
|
||||
switch normalized {
|
||||
case "doris":
|
||||
return "diros"
|
||||
case "postgresql":
|
||||
return "postgres"
|
||||
case "dm", "dm8":
|
||||
return "dameng"
|
||||
case "sqlite3":
|
||||
return "sqlite"
|
||||
default:
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
func resolveMigrationDBType(config connection.ConnectionConfig) string {
|
||||
dbType := normalizeMigrationDBType(config.Type)
|
||||
if dbType != "custom" {
|
||||
return dbType
|
||||
}
|
||||
|
||||
driver := strings.ToLower(strings.TrimSpace(config.Driver))
|
||||
switch driver {
|
||||
case "postgresql", "postgres", "pg", "pq", "pgx":
|
||||
return "postgres"
|
||||
case "dm", "dameng", "dm8":
|
||||
return "dameng"
|
||||
case "sqlite3", "sqlite":
|
||||
return "sqlite"
|
||||
case "sphinxql":
|
||||
return "sphinx"
|
||||
case "diros", "doris":
|
||||
return "diros"
|
||||
case "kingbase", "kingbase8", "kingbasees", "kingbasev8":
|
||||
return "kingbase"
|
||||
case "highgo":
|
||||
return "highgo"
|
||||
case "vastbase":
|
||||
return "vastbase"
|
||||
case "mysql", "mysql2":
|
||||
return "mysql"
|
||||
case "mariadb":
|
||||
return "mariadb"
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(driver, "postgres"):
|
||||
return "postgres"
|
||||
case strings.Contains(driver, "kingbase"):
|
||||
return "kingbase"
|
||||
case strings.Contains(driver, "highgo"):
|
||||
return "highgo"
|
||||
case strings.Contains(driver, "vastbase"):
|
||||
return "vastbase"
|
||||
case strings.Contains(driver, "sqlite"):
|
||||
return "sqlite"
|
||||
case strings.Contains(driver, "sphinx"):
|
||||
return "sphinx"
|
||||
case strings.Contains(driver, "diros"), strings.Contains(driver, "doris"):
|
||||
return "diros"
|
||||
case strings.Contains(driver, "maria"):
|
||||
return "mariadb"
|
||||
case strings.Contains(driver, "mysql"):
|
||||
return "mysql"
|
||||
case strings.Contains(driver, "dameng"), strings.Contains(driver, "dm"):
|
||||
return "dameng"
|
||||
default:
|
||||
return normalizeMigrationDBType(driver)
|
||||
}
|
||||
}
|
||||
|
||||
func isMySQLCoreType(dbType string) bool {
|
||||
switch normalizeMigrationDBType(dbType) {
|
||||
case "mysql", "mariadb", "diros":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isMySQLLikeSourceType(dbType string) bool {
|
||||
if isMySQLCoreType(dbType) {
|
||||
return true
|
||||
}
|
||||
return normalizeMigrationDBType(dbType) == "sphinx"
|
||||
}
|
||||
|
||||
func isMySQLLikeWritableTargetType(dbType string) bool {
|
||||
return isMySQLCoreType(dbType)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"GoNavi-Wails/internal/db"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
@@ -36,12 +36,18 @@ func (s *SyncEngine) Preview(config SyncConfig, tableName string, limit int) (Ta
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
if isRedisToMongoKeyspacePair(config) {
|
||||
return s.previewRedisToMongo(config, tableName, limit)
|
||||
}
|
||||
if isMongoToRedisKeyspacePair(config) {
|
||||
return s.previewMongoToRedis(config, tableName, limit)
|
||||
}
|
||||
|
||||
sourceDB, err := db.NewDatabase(config.SourceConfig.Type)
|
||||
sourceDB, err := newSyncDatabase(config.SourceConfig.Type)
|
||||
if err != nil {
|
||||
return TableDiffPreview{}, fmt.Errorf("初始化源数据库驱动失败: %w", err)
|
||||
}
|
||||
targetDB, err := db.NewDatabase(config.TargetConfig.Type)
|
||||
targetDB, err := newSyncDatabase(config.TargetConfig.Type)
|
||||
if err != nil {
|
||||
return TableDiffPreview{}, fmt.Errorf("初始化目标数据库驱动失败: %w", err)
|
||||
}
|
||||
@@ -56,14 +62,12 @@ func (s *SyncEngine) Preview(config SyncConfig, tableName string, limit int) (Ta
|
||||
}
|
||||
defer targetDB.Close()
|
||||
|
||||
sourceSchema, sourceTable := normalizeSchemaAndTable(config.SourceConfig.Type, config.SourceConfig.Database, tableName)
|
||||
targetSchema, targetTable := normalizeSchemaAndTable(config.TargetConfig.Type, config.TargetConfig.Database, tableName)
|
||||
sourceQueryTable := qualifiedNameForQuery(config.SourceConfig.Type, sourceSchema, sourceTable, tableName)
|
||||
targetQueryTable := qualifiedNameForQuery(config.TargetConfig.Type, targetSchema, targetTable, tableName)
|
||||
|
||||
cols, err := sourceDB.GetColumns(sourceSchema, sourceTable)
|
||||
plan, cols, _, err := buildSchemaMigrationPlan(config, tableName, sourceDB, targetDB)
|
||||
if err != nil {
|
||||
return TableDiffPreview{}, fmt.Errorf("获取源表字段失败: %w", err)
|
||||
return TableDiffPreview{}, err
|
||||
}
|
||||
if !plan.TargetTableExists && !plan.AutoCreate {
|
||||
return TableDiffPreview{}, errors.New(firstNonEmpty(plan.PlannedAction, "目标表不存在,无法预览差异"))
|
||||
}
|
||||
|
||||
pkCols := make([]string, 0, 2)
|
||||
@@ -80,13 +84,17 @@ func (s *SyncEngine) Preview(config SyncConfig, tableName string, limit int) (Ta
|
||||
}
|
||||
pkCol := pkCols[0]
|
||||
|
||||
sourceRows, _, err := sourceDB.Query(fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(config.SourceConfig.Type, sourceQueryTable)))
|
||||
sourceRows, _, err := sourceDB.Query(fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(resolveMigrationDBType(config.SourceConfig), plan.SourceQueryTable)))
|
||||
if err != nil {
|
||||
return TableDiffPreview{}, fmt.Errorf("读取源表失败: %w", err)
|
||||
}
|
||||
targetRows, _, err := targetDB.Query(fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(config.TargetConfig.Type, targetQueryTable)))
|
||||
if err != nil {
|
||||
return TableDiffPreview{}, fmt.Errorf("读取目标表失败: %w", err)
|
||||
|
||||
targetRows := make([]map[string]interface{}, 0)
|
||||
if plan.TargetTableExists {
|
||||
targetRows, _, err = targetDB.Query(fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(resolveMigrationDBType(config.TargetConfig), plan.TargetQueryTable)))
|
||||
if err != nil {
|
||||
return TableDiffPreview{}, fmt.Errorf("读取目标表失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
targetMap := make(map[string]map[string]interface{}, len(targetRows))
|
||||
@@ -133,12 +141,7 @@ func (s *SyncEngine) Preview(config SyncConfig, tableName string, limit int) (Ta
|
||||
if len(changedColumns) > 0 {
|
||||
out.TotalUpdates++
|
||||
if len(out.Updates) < limit {
|
||||
out.Updates = append(out.Updates, PreviewUpdateRow{
|
||||
PK: pkVal,
|
||||
ChangedColumns: changedColumns,
|
||||
Source: sRow,
|
||||
Target: tRow,
|
||||
})
|
||||
out.Updates = append(out.Updates, PreviewUpdateRow{PK: pkVal, ChangedColumns: changedColumns, Source: sRow, Target: tRow})
|
||||
}
|
||||
}
|
||||
continue
|
||||
|
||||
490
internal/sync/redis_migration_test.go
Normal file
490
internal/sync/redis_migration_test.go
Normal file
@@ -0,0 +1,490 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
redispkg "GoNavi-Wails/internal/redis"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeRedisMigrationClient struct {
|
||||
values map[string]*redispkg.RedisValue
|
||||
scannedKeys []string
|
||||
connectConfig connection.ConnectionConfig
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (f *fakeRedisMigrationClient) Connect(config connection.ConnectionConfig) error {
|
||||
f.connectConfig = config
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRedisMigrationClient) Close() error {
|
||||
f.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRedisMigrationClient) ScanKeys(pattern string, cursor uint64, count int64) (*redispkg.RedisScanResult, error) {
|
||||
items := make([]redispkg.RedisKeyInfo, 0, len(f.scannedKeys))
|
||||
for _, key := range f.scannedKeys {
|
||||
items = append(items, redispkg.RedisKeyInfo{Key: key, Type: "string", TTL: -1})
|
||||
}
|
||||
return &redispkg.RedisScanResult{Keys: items, Cursor: "0"}, nil
|
||||
}
|
||||
|
||||
func (f *fakeRedisMigrationClient) GetKeyType(key string) (string, error) {
|
||||
if value, ok := f.values[key]; ok && value != nil {
|
||||
return value.Type, nil
|
||||
}
|
||||
return "none", nil
|
||||
}
|
||||
|
||||
func (f *fakeRedisMigrationClient) GetValue(key string) (*redispkg.RedisValue, error) {
|
||||
if value, ok := f.values[key]; ok {
|
||||
return value, nil
|
||||
}
|
||||
return nil, fmt.Errorf("key not found: %s", key)
|
||||
}
|
||||
|
||||
func (f *fakeRedisMigrationClient) DeleteKeys(keys []string) (int64, error) {
|
||||
var deleted int64
|
||||
for _, key := range keys {
|
||||
if _, ok := f.values[key]; ok {
|
||||
delete(f.values, key)
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func (f *fakeRedisMigrationClient) SetTTL(key string, ttl int64) error {
|
||||
value, ok := f.values[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
value.TTL = ttl
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRedisMigrationClient) SetString(key, value string, ttl int64) error {
|
||||
if f.values == nil {
|
||||
f.values = map[string]*redispkg.RedisValue{}
|
||||
}
|
||||
f.values[key] = &redispkg.RedisValue{Type: "string", TTL: ttl, Value: value, Length: int64(len(value))}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRedisMigrationClient) SetHashField(key, field, value string) error {
|
||||
if f.values == nil {
|
||||
f.values = map[string]*redispkg.RedisValue{}
|
||||
}
|
||||
current, ok := f.values[key]
|
||||
if !ok || current == nil || current.Type != "hash" {
|
||||
current = &redispkg.RedisValue{Type: "hash", TTL: -1, Value: map[string]string{}}
|
||||
f.values[key] = current
|
||||
}
|
||||
hash, _ := current.Value.(map[string]string)
|
||||
if hash == nil {
|
||||
hash = map[string]string{}
|
||||
}
|
||||
hash[field] = value
|
||||
current.Value = hash
|
||||
current.Length = int64(len(hash))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRedisMigrationClient) ListPush(key string, values ...string) error {
|
||||
if f.values == nil {
|
||||
f.values = map[string]*redispkg.RedisValue{}
|
||||
}
|
||||
current, ok := f.values[key]
|
||||
if !ok || current == nil || current.Type != "list" {
|
||||
current = &redispkg.RedisValue{Type: "list", TTL: -1, Value: []string{}}
|
||||
f.values[key] = current
|
||||
}
|
||||
list, _ := current.Value.([]string)
|
||||
list = append(list, values...)
|
||||
current.Value = list
|
||||
current.Length = int64(len(list))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRedisMigrationClient) SetAdd(key string, members ...string) error {
|
||||
if f.values == nil {
|
||||
f.values = map[string]*redispkg.RedisValue{}
|
||||
}
|
||||
current, ok := f.values[key]
|
||||
if !ok || current == nil || current.Type != "set" {
|
||||
current = &redispkg.RedisValue{Type: "set", TTL: -1, Value: []string{}}
|
||||
f.values[key] = current
|
||||
}
|
||||
setValues, _ := current.Value.([]string)
|
||||
seen := make(map[string]struct{}, len(setValues)+len(members))
|
||||
for _, item := range setValues {
|
||||
seen[item] = struct{}{}
|
||||
}
|
||||
for _, item := range members {
|
||||
if _, ok := seen[item]; ok {
|
||||
continue
|
||||
}
|
||||
seen[item] = struct{}{}
|
||||
setValues = append(setValues, item)
|
||||
}
|
||||
sort.Strings(setValues)
|
||||
current.Value = setValues
|
||||
current.Length = int64(len(setValues))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRedisMigrationClient) ZSetAdd(key string, members ...redispkg.ZSetMember) error {
|
||||
if f.values == nil {
|
||||
f.values = map[string]*redispkg.RedisValue{}
|
||||
}
|
||||
copied := append([]redispkg.ZSetMember(nil), members...)
|
||||
sort.Slice(copied, func(i, j int) bool {
|
||||
if copied[i].Score == copied[j].Score {
|
||||
return copied[i].Member < copied[j].Member
|
||||
}
|
||||
return copied[i].Score < copied[j].Score
|
||||
})
|
||||
f.values[key] = &redispkg.RedisValue{Type: "zset", TTL: -1, Value: copied, Length: int64(len(copied))}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRedisMigrationClient) StreamAdd(key string, fields map[string]string, id string) (string, error) {
|
||||
if f.values == nil {
|
||||
f.values = map[string]*redispkg.RedisValue{}
|
||||
}
|
||||
current, ok := f.values[key]
|
||||
if !ok || current == nil || current.Type != "stream" {
|
||||
current = &redispkg.RedisValue{Type: "stream", TTL: -1, Value: []redispkg.StreamEntry{}}
|
||||
f.values[key] = current
|
||||
}
|
||||
entries, _ := current.Value.([]redispkg.StreamEntry)
|
||||
entryID := id
|
||||
if entryID == "" {
|
||||
entryID = fmt.Sprintf("%d-0", len(entries)+1)
|
||||
}
|
||||
entries = append(entries, redispkg.StreamEntry{ID: entryID, Fields: fields})
|
||||
current.Value = entries
|
||||
current.Length = int64(len(entries))
|
||||
return entryID, nil
|
||||
}
|
||||
|
||||
type fakeRedisMongoTargetDB struct {
|
||||
tables []string
|
||||
queryTable string
|
||||
queryRows []map[string]interface{}
|
||||
execs []string
|
||||
applyTable string
|
||||
applySet connection.ChangeSet
|
||||
}
|
||||
|
||||
func (f *fakeRedisMongoTargetDB) Connect(config connection.ConnectionConfig) error { return nil }
|
||||
func (f *fakeRedisMongoTargetDB) Close() error { return nil }
|
||||
func (f *fakeRedisMongoTargetDB) Ping() error { return nil }
|
||||
func (f *fakeRedisMongoTargetDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
queryTable := strings.TrimSpace(f.queryTable)
|
||||
if queryTable == "" {
|
||||
queryTable = "redis_db_0_keys"
|
||||
}
|
||||
if strings.Contains(query, fmt.Sprintf(`"find":"%s"`, queryTable)) {
|
||||
return f.queryRows, []string{"_id", "key", "value"}, nil
|
||||
}
|
||||
return nil, nil, nil
|
||||
}
|
||||
func (f *fakeRedisMongoTargetDB) Exec(query string) (int64, error) {
|
||||
f.execs = append(f.execs, query)
|
||||
return 1, nil
|
||||
}
|
||||
func (f *fakeRedisMongoTargetDB) GetDatabases() ([]string, error) { return []string{"app"}, nil }
|
||||
func (f *fakeRedisMongoTargetDB) GetTables(dbName string) ([]string, error) {
|
||||
return f.tables, nil
|
||||
}
|
||||
func (f *fakeRedisMongoTargetDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (f *fakeRedisMongoTargetDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeRedisMongoTargetDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeRedisMongoTargetDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeRedisMongoTargetDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeRedisMongoTargetDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeRedisMongoTargetDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
f.applyTable = tableName
|
||||
f.applySet = changes
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeMongoRedisSourceDB struct {
|
||||
tables []string
|
||||
rowsByTable map[string][]map[string]interface{}
|
||||
connectConfig connection.ConnectionConfig
|
||||
}
|
||||
|
||||
func (f *fakeMongoRedisSourceDB) Connect(config connection.ConnectionConfig) error {
|
||||
f.connectConfig = config
|
||||
return nil
|
||||
}
|
||||
func (f *fakeMongoRedisSourceDB) Close() error { return nil }
|
||||
func (f *fakeMongoRedisSourceDB) Ping() error { return nil }
|
||||
func (f *fakeMongoRedisSourceDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
for tableName, rows := range f.rowsByTable {
|
||||
if strings.Contains(query, fmt.Sprintf(`"find":"%s"`, tableName)) {
|
||||
return rows, []string{"_id", "key", "type", "ttl", "value"}, nil
|
||||
}
|
||||
}
|
||||
return nil, nil, fmt.Errorf("unexpected query: %s", query)
|
||||
}
|
||||
func (f *fakeMongoRedisSourceDB) Exec(query string) (int64, error) { return 0, nil }
|
||||
func (f *fakeMongoRedisSourceDB) GetDatabases() ([]string, error) { return []string{"app"}, nil }
|
||||
func (f *fakeMongoRedisSourceDB) GetTables(dbName string) ([]string, error) {
|
||||
return f.tables, nil
|
||||
}
|
||||
func (f *fakeMongoRedisSourceDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (f *fakeMongoRedisSourceDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeMongoRedisSourceDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeMongoRedisSourceDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeMongoRedisSourceDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeMongoRedisSourceDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestRunSync_RedisToMongoAppliesInsertAndUpdate(t *testing.T) {
|
||||
fakeRedis := &fakeRedisMigrationClient{
|
||||
values: map[string]*redispkg.RedisValue{
|
||||
"user:1": {Type: "hash", TTL: 120, Length: 2, Value: map[string]string{"name": "alice"}},
|
||||
"user:2": {Type: "string", TTL: -1, Length: 1, Value: "online"},
|
||||
},
|
||||
}
|
||||
fakeTarget := &fakeRedisMongoTargetDB{
|
||||
tables: []string{"redis_db_0_keys"},
|
||||
queryRows: []map[string]interface{}{
|
||||
{"_id": "db0:user:1", "redisDb": 0, "key": "user:1", "type": "hash", "ttl": 120, "length": int64(2), "value": map[string]interface{}{"name": "old"}},
|
||||
},
|
||||
}
|
||||
|
||||
oldNewRedisClient := newRedisSourceClient
|
||||
oldNewDatabase := newSyncDatabase
|
||||
defer func() {
|
||||
newRedisSourceClient = oldNewRedisClient
|
||||
newSyncDatabase = oldNewDatabase
|
||||
}()
|
||||
newRedisSourceClient = func() redisMigrationClient { return fakeRedis }
|
||||
newSyncDatabase = func(dbType string) (db.Database, error) { return fakeTarget, nil }
|
||||
|
||||
engine := NewSyncEngine(Reporter{})
|
||||
result := engine.RunSync(SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "redis", Database: "0"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "mongodb", Database: "app"},
|
||||
Tables: []string{"user:1", "user:2"},
|
||||
Content: "data",
|
||||
Mode: "insert_update",
|
||||
})
|
||||
|
||||
if !result.Success {
|
||||
t.Fatalf("expected success, got: %+v", result)
|
||||
}
|
||||
if fakeRedis.connectConfig.RedisDB != 0 {
|
||||
t.Fatalf("expected redis db 0, got %d", fakeRedis.connectConfig.RedisDB)
|
||||
}
|
||||
if fakeTarget.applyTable != "redis_db_0_keys" {
|
||||
t.Fatalf("unexpected apply table: %s", fakeTarget.applyTable)
|
||||
}
|
||||
if len(fakeTarget.applySet.Inserts) != 1 || len(fakeTarget.applySet.Updates) != 1 {
|
||||
t.Fatalf("unexpected change set: %+v", fakeTarget.applySet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSync_RedisToMongoUsesConfiguredCollectionName(t *testing.T) {
|
||||
fakeRedis := &fakeRedisMigrationClient{
|
||||
values: map[string]*redispkg.RedisValue{
|
||||
"user:1": {Type: "string", TTL: -1, Length: 1, Value: "online"},
|
||||
},
|
||||
}
|
||||
fakeTarget := &fakeRedisMongoTargetDB{
|
||||
tables: []string{"custom_keyspace_docs"},
|
||||
queryTable: "custom_keyspace_docs",
|
||||
}
|
||||
|
||||
oldNewRedisClient := newRedisSourceClient
|
||||
oldNewDatabase := newSyncDatabase
|
||||
defer func() {
|
||||
newRedisSourceClient = oldNewRedisClient
|
||||
newSyncDatabase = oldNewDatabase
|
||||
}()
|
||||
newRedisSourceClient = func() redisMigrationClient { return fakeRedis }
|
||||
newSyncDatabase = func(dbType string) (db.Database, error) { return fakeTarget, nil }
|
||||
|
||||
engine := NewSyncEngine(Reporter{})
|
||||
result := engine.RunSync(SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "redis", Database: "0"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "mongodb", Database: "app"},
|
||||
Tables: []string{"user:1"},
|
||||
Content: "data",
|
||||
Mode: "insert_update",
|
||||
MongoCollectionName: "custom_keyspace_docs",
|
||||
})
|
||||
|
||||
if !result.Success {
|
||||
t.Fatalf("expected success, got: %+v", result)
|
||||
}
|
||||
if fakeTarget.applyTable != "custom_keyspace_docs" {
|
||||
t.Fatalf("unexpected apply table: %s", fakeTarget.applyTable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreview_RedisToMongoReturnsDocumentPreview(t *testing.T) {
|
||||
fakeRedis := &fakeRedisMigrationClient{
|
||||
values: map[string]*redispkg.RedisValue{
|
||||
"session:1": {Type: "string", TTL: 60, Length: 1, Value: "token"},
|
||||
},
|
||||
}
|
||||
fakeTarget := &fakeRedisMongoTargetDB{}
|
||||
|
||||
oldNewRedisClient := newRedisSourceClient
|
||||
oldNewDatabase := newSyncDatabase
|
||||
defer func() {
|
||||
newRedisSourceClient = oldNewRedisClient
|
||||
newSyncDatabase = oldNewDatabase
|
||||
}()
|
||||
newRedisSourceClient = func() redisMigrationClient { return fakeRedis }
|
||||
newSyncDatabase = func(dbType string) (db.Database, error) { return fakeTarget, nil }
|
||||
|
||||
engine := NewSyncEngine(Reporter{})
|
||||
preview, err := engine.Preview(SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "redis", Database: "0"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "mongodb", Database: "app"},
|
||||
Tables: []string{"session:1"},
|
||||
Content: "data",
|
||||
Mode: "insert_update",
|
||||
}, "session:1", 20)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if preview.PKColumn != "_id" {
|
||||
t.Fatalf("unexpected pk column: %s", preview.PKColumn)
|
||||
}
|
||||
if preview.TotalInserts != 1 || len(preview.Inserts) != 1 {
|
||||
t.Fatalf("unexpected preview: %+v", preview)
|
||||
}
|
||||
if preview.Inserts[0].PK != "db0:session:1" {
|
||||
t.Fatalf("unexpected preview pk: %+v", preview.Inserts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSync_MongoToRedisAppliesStringAndHash(t *testing.T) {
|
||||
fakeSource := &fakeMongoRedisSourceDB{
|
||||
tables: []string{"redis_db_0_keys"},
|
||||
rowsByTable: map[string][]map[string]interface{}{
|
||||
"redis_db_0_keys": {
|
||||
{"_id": "db0:session:1", "key": "session:1", "type": "string", "ttl": int64(60), "value": "token"},
|
||||
{"_id": "db0:user:1", "key": "user:1", "type": "hash", "ttl": int64(120), "value": map[string]interface{}{"name": "alice", "role": "admin"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
fakeRedis := &fakeRedisMigrationClient{
|
||||
values: map[string]*redispkg.RedisValue{
|
||||
"user:1": {Type: "hash", TTL: 120, Length: 1, Value: map[string]string{"name": "old"}},
|
||||
},
|
||||
}
|
||||
|
||||
oldNewRedisClient := newRedisSourceClient
|
||||
oldNewDatabase := newSyncDatabase
|
||||
defer func() {
|
||||
newRedisSourceClient = oldNewRedisClient
|
||||
newSyncDatabase = oldNewDatabase
|
||||
}()
|
||||
newRedisSourceClient = func() redisMigrationClient { return fakeRedis }
|
||||
newSyncDatabase = func(dbType string) (db.Database, error) { return fakeSource, nil }
|
||||
|
||||
engine := NewSyncEngine(Reporter{})
|
||||
result := engine.RunSync(SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "mongodb", Database: "app"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "redis", Database: "0"},
|
||||
Tables: []string{"redis_db_0_keys"},
|
||||
Content: "data",
|
||||
Mode: "insert_update",
|
||||
})
|
||||
|
||||
if !result.Success {
|
||||
t.Fatalf("expected success, got: %+v", result)
|
||||
}
|
||||
if fakeRedis.connectConfig.RedisDB != 0 {
|
||||
t.Fatalf("expected redis db 0, got %d", fakeRedis.connectConfig.RedisDB)
|
||||
}
|
||||
if got := fakeRedis.values["session:1"]; got == nil || got.Type != "string" || got.Value != "token" || got.TTL != 60 {
|
||||
t.Fatalf("unexpected string value: %+v", got)
|
||||
}
|
||||
gotHash, _ := fakeRedis.values["user:1"].Value.(map[string]string)
|
||||
if gotHash["name"] != "alice" || gotHash["role"] != "admin" {
|
||||
t.Fatalf("unexpected hash value: %+v", fakeRedis.values["user:1"])
|
||||
}
|
||||
if result.RowsInserted != 1 || result.RowsUpdated != 1 {
|
||||
t.Fatalf("unexpected sync result: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreview_MongoToRedisReturnsCollectionPreview(t *testing.T) {
|
||||
fakeSource := &fakeMongoRedisSourceDB{
|
||||
tables: []string{"redis_db_0_keys"},
|
||||
rowsByTable: map[string][]map[string]interface{}{
|
||||
"redis_db_0_keys": {
|
||||
{"_id": "db0:session:1", "key": "session:1", "type": "string", "ttl": int64(60), "value": "token"},
|
||||
},
|
||||
},
|
||||
}
|
||||
fakeRedis := &fakeRedisMigrationClient{values: map[string]*redispkg.RedisValue{}}
|
||||
|
||||
oldNewRedisClient := newRedisSourceClient
|
||||
oldNewDatabase := newSyncDatabase
|
||||
defer func() {
|
||||
newRedisSourceClient = oldNewRedisClient
|
||||
newSyncDatabase = oldNewDatabase
|
||||
}()
|
||||
newRedisSourceClient = func() redisMigrationClient { return fakeRedis }
|
||||
newSyncDatabase = func(dbType string) (db.Database, error) { return fakeSource, nil }
|
||||
|
||||
engine := NewSyncEngine(Reporter{})
|
||||
preview, err := engine.Preview(SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "mongodb", Database: "app"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "redis", Database: "0"},
|
||||
Tables: []string{"redis_db_0_keys"},
|
||||
Content: "data",
|
||||
Mode: "insert_update",
|
||||
}, "redis_db_0_keys", 20)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if preview.Table != "redis_db_0_keys" || preview.PKColumn != "key" {
|
||||
t.Fatalf("unexpected preview header: %+v", preview)
|
||||
}
|
||||
if preview.TotalInserts != 1 || len(preview.Inserts) != 1 {
|
||||
t.Fatalf("unexpected preview rows: %+v", preview)
|
||||
}
|
||||
if preview.Inserts[0].PK != "session:1" {
|
||||
t.Fatalf("unexpected preview pk: %+v", preview.Inserts[0])
|
||||
}
|
||||
}
|
||||
1014
internal/sync/schema_migration.go
Normal file
1014
internal/sync/schema_migration.go
Normal file
File diff suppressed because it is too large
Load Diff
957
internal/sync/schema_migration_test.go
Normal file
957
internal/sync/schema_migration_test.go
Normal file
@@ -0,0 +1,957 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeMigrationDB struct {
|
||||
columns map[string][]connection.ColumnDefinition
|
||||
indexes map[string][]connection.IndexDefinition
|
||||
queryData map[string][]map[string]interface{}
|
||||
queryCols map[string][]string
|
||||
}
|
||||
|
||||
func (f *fakeMigrationDB) Connect(config connection.ConnectionConfig) error { return nil }
|
||||
func (f *fakeMigrationDB) Close() error { return nil }
|
||||
func (f *fakeMigrationDB) Ping() error { return nil }
|
||||
func (f *fakeMigrationDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
if rows, ok := f.queryData[query]; ok {
|
||||
return rows, f.queryCols[query], nil
|
||||
}
|
||||
return nil, nil, nil
|
||||
}
|
||||
func (f *fakeMigrationDB) Exec(query string) (int64, error) { return 0, nil }
|
||||
func (f *fakeMigrationDB) GetDatabases() ([]string, error) { return nil, nil }
|
||||
func (f *fakeMigrationDB) GetTables(dbName string) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeMigrationDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (f *fakeMigrationDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||
key := dbName + "." + tableName
|
||||
if rows, ok := f.columns[key]; ok {
|
||||
return rows, nil
|
||||
}
|
||||
return []connection.ColumnDefinition{}, nil
|
||||
}
|
||||
func (f *fakeMigrationDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeMigrationDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||
key := dbName + "." + tableName
|
||||
if rows, ok := f.indexes[key]; ok {
|
||||
return rows, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeMigrationDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeMigrationDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeMigrationDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
return f.Query(query)
|
||||
}
|
||||
func (f *fakeMigrationDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func TestBuildMySQLToKingbaseColumnDefinition_AutoIncrementAndBoolean(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
def, warnings := buildMySQLToKingbaseColumnDefinition(connection.ColumnDefinition{
|
||||
Name: "id",
|
||||
Type: "int unsigned",
|
||||
Nullable: "NO",
|
||||
Extra: "auto_increment",
|
||||
})
|
||||
if !strings.Contains(def, "bigint") || !strings.Contains(def, "GENERATED BY DEFAULT AS IDENTITY") || !strings.Contains(def, "NOT NULL") {
|
||||
t.Fatalf("unexpected definition: %s", def)
|
||||
}
|
||||
if len(warnings) != 0 {
|
||||
t.Fatalf("unexpected warnings: %v", warnings)
|
||||
}
|
||||
|
||||
def, warnings = buildMySQLToKingbaseColumnDefinition(connection.ColumnDefinition{
|
||||
Name: "enabled",
|
||||
Type: "tinyint(1)",
|
||||
Nullable: "YES",
|
||||
Default: stringPtr("1"),
|
||||
})
|
||||
if !strings.Contains(def, "boolean") || !strings.Contains(def, "DEFAULT TRUE") {
|
||||
t.Fatalf("unexpected boolean definition: %s", def)
|
||||
}
|
||||
if len(warnings) != 0 {
|
||||
t.Fatalf("unexpected warnings for boolean: %v", warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMySQLToKingbaseCreateTablePlan_GeneratesAndSkipsIndexes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sourceDB := &fakeMigrationDB{
|
||||
indexes: map[string][]connection.IndexDefinition{
|
||||
"shop.orders": {
|
||||
{Name: "PRIMARY", ColumnName: "id", NonUnique: 0, SeqInIndex: 1, IndexType: "BTREE"},
|
||||
{Name: "idx_user_status", ColumnName: "user_id", NonUnique: 1, SeqInIndex: 1, IndexType: "BTREE"},
|
||||
{Name: "idx_user_status", ColumnName: "status", NonUnique: 1, SeqInIndex: 2, IndexType: "BTREE"},
|
||||
{Name: "idx_name_prefix", ColumnName: "name", NonUnique: 1, SeqInIndex: 1, IndexType: "BTREE", SubPart: 12},
|
||||
{Name: "idx_fulltext_note", ColumnName: "note", NonUnique: 1, SeqInIndex: 1, IndexType: "FULLTEXT"},
|
||||
},
|
||||
},
|
||||
}
|
||||
cols := []connection.ColumnDefinition{
|
||||
{Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI", Extra: "auto_increment"},
|
||||
{Name: "user_id", Type: "bigint", Nullable: "NO"},
|
||||
{Name: "status", Type: "varchar(32)", Nullable: "YES"},
|
||||
{Name: "name", Type: "varchar(128)", Nullable: "YES"},
|
||||
{Name: "note", Type: "text", Nullable: "YES"},
|
||||
}
|
||||
cfg := SyncConfig{CreateIndexes: true}
|
||||
createSQL, postSQL, warnings, unsupported, idxCreate, idxSkip, err := buildMySQLToKingbaseCreateTablePlan(cfg, "public.orders", cols, sourceDB, "shop", "orders")
|
||||
if err != nil {
|
||||
t.Fatalf("buildMySQLToKingbaseCreateTablePlan returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(createSQL, `CREATE TABLE "public"."orders"`) {
|
||||
t.Fatalf("unexpected create SQL: %s", createSQL)
|
||||
}
|
||||
if !strings.Contains(createSQL, `PRIMARY KEY ("id")`) {
|
||||
t.Fatalf("create SQL missing primary key: %s", createSQL)
|
||||
}
|
||||
if idxCreate != 1 || idxSkip != 2 {
|
||||
t.Fatalf("unexpected index summary: create=%d skip=%d", idxCreate, idxSkip)
|
||||
}
|
||||
if len(postSQL) != 1 || !strings.Contains(postSQL[0], `CREATE INDEX "idx_user_status"`) {
|
||||
t.Fatalf("unexpected post SQL: %v", postSQL)
|
||||
}
|
||||
if len(warnings) != 0 {
|
||||
t.Fatalf("unexpected warnings: %v", warnings)
|
||||
}
|
||||
wantUnsupported := []string{
|
||||
"索引 idx_name_prefix 使用前缀长度,当前暂不支持迁移",
|
||||
"索引 idx_fulltext_note 类型=FULLTEXT,当前暂不支持自动迁移",
|
||||
}
|
||||
if !reflect.DeepEqual(unsupported, wantUnsupported) {
|
||||
t.Fatalf("unexpected unsupported objects: got=%v want=%v", unsupported, wantUnsupported)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSchemaMigrationPlan_AutoCreateWhenTargetMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sourceDB := &fakeMigrationDB{
|
||||
columns: map[string][]connection.ColumnDefinition{
|
||||
"shop.orders": {
|
||||
{Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI", Extra: "auto_increment"},
|
||||
{Name: "name", Type: "varchar(128)", Nullable: "YES"},
|
||||
},
|
||||
},
|
||||
indexes: map[string][]connection.IndexDefinition{},
|
||||
}
|
||||
targetDB := &fakeMigrationDB{columns: map[string][]connection.ColumnDefinition{}}
|
||||
cfg := SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "mysql", Database: "shop"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "kingbase", Database: "demo"},
|
||||
TargetTableStrategy: "smart",
|
||||
CreateIndexes: true,
|
||||
}
|
||||
plan, sourceCols, targetCols, err := buildSchemaMigrationPlan(cfg, "orders", sourceDB, targetDB)
|
||||
if err != nil {
|
||||
t.Fatalf("buildSchemaMigrationPlan returned error: %v", err)
|
||||
}
|
||||
if len(sourceCols) != 2 || len(targetCols) != 0 {
|
||||
t.Fatalf("unexpected columns lengths: source=%d target=%d", len(sourceCols), len(targetCols))
|
||||
}
|
||||
if plan.TargetTableExists {
|
||||
t.Fatalf("expected target table missing")
|
||||
}
|
||||
if !plan.AutoCreate {
|
||||
t.Fatalf("expected auto create enabled")
|
||||
}
|
||||
if !strings.Contains(plan.PlannedAction, "自动建表") {
|
||||
t.Fatalf("unexpected planned action: %s", plan.PlannedAction)
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, `CREATE TABLE "public"."orders"`) {
|
||||
t.Fatalf("unexpected create table SQL: %s", plan.CreateTableSQL)
|
||||
}
|
||||
}
|
||||
|
||||
func stringPtr(v string) *string { return &v }
|
||||
|
||||
func TestBuildPGLikeToMySQLCreateTablePlan_GeneratesMySQLDDL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sourceDB := &fakeMigrationDB{
|
||||
indexes: map[string][]connection.IndexDefinition{
|
||||
"public.users": {
|
||||
{Name: "users_email_key", ColumnName: "email", NonUnique: 0, SeqInIndex: 1, IndexType: "BTREE"},
|
||||
{Name: "idx_users_name", ColumnName: "name", NonUnique: 1, SeqInIndex: 1, IndexType: "BTREE"},
|
||||
},
|
||||
},
|
||||
}
|
||||
cols := []connection.ColumnDefinition{
|
||||
{Name: "id", Type: "integer", Nullable: "NO", Key: "PRI", Extra: "auto_increment"},
|
||||
{Name: "email", Type: "character varying(120)", Nullable: "NO"},
|
||||
{Name: "name", Type: "text", Nullable: "YES"},
|
||||
{Name: "profile", Type: "jsonb", Nullable: "YES"},
|
||||
}
|
||||
cfg := SyncConfig{CreateIndexes: true}
|
||||
createSQL, postSQL, warnings, unsupported, idxCreate, idxSkip, err := buildPGLikeToMySQLCreateTablePlan(cfg, "app.users", cols, sourceDB, "public", "users")
|
||||
if err != nil {
|
||||
t.Fatalf("buildPGLikeToMySQLCreateTablePlan returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(createSQL, "CREATE TABLE `app`.`users`") {
|
||||
t.Fatalf("unexpected create SQL: %s", createSQL)
|
||||
}
|
||||
if !strings.Contains(createSQL, "`id` int AUTO_INCREMENT NOT NULL") {
|
||||
t.Fatalf("unexpected id definition: %s", createSQL)
|
||||
}
|
||||
if !strings.Contains(createSQL, "`profile` json") {
|
||||
t.Fatalf("unexpected json definition: %s", createSQL)
|
||||
}
|
||||
if idxCreate != 2 || idxSkip != 0 {
|
||||
t.Fatalf("unexpected index summary: create=%d skip=%d", idxCreate, idxSkip)
|
||||
}
|
||||
if len(postSQL) != 2 {
|
||||
t.Fatalf("unexpected post sql length: %v", postSQL)
|
||||
}
|
||||
if len(warnings) != 0 {
|
||||
t.Fatalf("unexpected warnings: %v", warnings)
|
||||
}
|
||||
if len(unsupported) != 0 {
|
||||
t.Fatalf("unexpected unsupported: %v", unsupported)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPGLikeToMySQLPlan_AutoCreateWhenTargetMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sourceDB := &fakeMigrationDB{
|
||||
columns: map[string][]connection.ColumnDefinition{
|
||||
"public.orders": {
|
||||
{Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI", Extra: "auto_increment"},
|
||||
{Name: "amount", Type: "numeric(10,2)", Nullable: "NO"},
|
||||
},
|
||||
},
|
||||
indexes: map[string][]connection.IndexDefinition{},
|
||||
}
|
||||
targetDB := &fakeMigrationDB{columns: map[string][]connection.ColumnDefinition{}}
|
||||
cfg := SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "kingbase", Database: "public"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "mysql", Database: "app"},
|
||||
TargetTableStrategy: "smart",
|
||||
CreateIndexes: true,
|
||||
}
|
||||
plan, sourceCols, targetCols, err := buildPGLikeToMySQLPlan(cfg, "orders", sourceDB, targetDB)
|
||||
if err != nil {
|
||||
t.Fatalf("buildPGLikeToMySQLPlan returned error: %v", err)
|
||||
}
|
||||
if len(sourceCols) != 2 || len(targetCols) != 0 {
|
||||
t.Fatalf("unexpected columns lengths: source=%d target=%d", len(sourceCols), len(targetCols))
|
||||
}
|
||||
if plan.TargetTableExists {
|
||||
t.Fatalf("expected target table missing")
|
||||
}
|
||||
if !plan.AutoCreate {
|
||||
t.Fatalf("expected auto create enabled")
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, "CREATE TABLE `app`.`orders`") {
|
||||
t.Fatalf("unexpected create table SQL: %s", plan.CreateTableSQL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMySQLToPGLikeCreateTablePlan_GeneratesPostgresDDL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sourceDB := &fakeMigrationDB{
|
||||
indexes: map[string][]connection.IndexDefinition{
|
||||
"shop.orders": {
|
||||
{Name: "idx_orders_user", ColumnName: "user_id", NonUnique: 1, SeqInIndex: 1, IndexType: "BTREE"},
|
||||
{Name: "idx_orders_user", ColumnName: "status", NonUnique: 1, SeqInIndex: 2, IndexType: "BTREE"},
|
||||
},
|
||||
},
|
||||
}
|
||||
cols := []connection.ColumnDefinition{
|
||||
{Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI", Extra: "auto_increment"},
|
||||
{Name: "user_id", Type: "bigint", Nullable: "NO"},
|
||||
{Name: "status", Type: "varchar(32)", Nullable: "YES"},
|
||||
{Name: "payload", Type: "json", Nullable: "YES"},
|
||||
}
|
||||
cfg := SyncConfig{CreateIndexes: true}
|
||||
createSQL, postSQL, warnings, unsupported, idxCreate, idxSkip, err := buildMySQLToPGLikeCreateTablePlan("postgres", cfg, "public.orders", cols, sourceDB, "shop", "orders")
|
||||
if err != nil {
|
||||
t.Fatalf("buildMySQLToPGLikeCreateTablePlan returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(createSQL, `CREATE TABLE "public"."orders"`) {
|
||||
t.Fatalf("unexpected create SQL: %s", createSQL)
|
||||
}
|
||||
if !strings.Contains(createSQL, `GENERATED BY DEFAULT AS IDENTITY`) {
|
||||
t.Fatalf("missing identity mapping: %s", createSQL)
|
||||
}
|
||||
if !strings.Contains(createSQL, `jsonb`) {
|
||||
t.Fatalf("missing jsonb mapping: %s", createSQL)
|
||||
}
|
||||
if idxCreate != 1 || idxSkip != 0 {
|
||||
t.Fatalf("unexpected index summary: create=%d skip=%d", idxCreate, idxSkip)
|
||||
}
|
||||
if len(postSQL) != 1 || !strings.Contains(postSQL[0], `CREATE INDEX "idx_orders_user"`) {
|
||||
t.Fatalf("unexpected post SQL: %v", postSQL)
|
||||
}
|
||||
if len(warnings) != 0 || len(unsupported) != 0 {
|
||||
t.Fatalf("unexpected warnings/unsupported: warnings=%v unsupported=%v", warnings, unsupported)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMySQLToClickHouseCreateTableSQL_GeneratesMergeTree(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cols := []connection.ColumnDefinition{
|
||||
{Name: "id", Type: "bigint unsigned", Nullable: "NO", Key: "PRI"},
|
||||
{Name: "name", Type: "varchar(128)", Nullable: "YES"},
|
||||
{Name: "payload", Type: "json", Nullable: "YES"},
|
||||
}
|
||||
createSQL, warnings, unsupported := buildMySQLToClickHouseCreateTableSQL("analytics.orders", cols)
|
||||
if !strings.Contains(createSQL, "ENGINE = MergeTree()") {
|
||||
t.Fatalf("unexpected create SQL: %s", createSQL)
|
||||
}
|
||||
if !strings.Contains(createSQL, "ORDER BY (`id`)") {
|
||||
t.Fatalf("unexpected order by: %s", createSQL)
|
||||
}
|
||||
if !strings.Contains(createSQL, "`payload` Nullable(String)") {
|
||||
t.Fatalf("unexpected json mapping: %s", createSQL)
|
||||
}
|
||||
if len(warnings) == 0 {
|
||||
t.Fatalf("expected warnings for clickhouse semantics")
|
||||
}
|
||||
if len(unsupported) != 0 {
|
||||
t.Fatalf("unexpected unsupported: %v", unsupported)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClickHouseToMySQLCreateTableSQL_GeneratesMySQLDDL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cols := []connection.ColumnDefinition{
|
||||
{Name: "id", Type: "UInt64", Nullable: "NO", Key: "PRI"},
|
||||
{Name: "event_time", Type: "DateTime", Nullable: "NO"},
|
||||
{Name: "payload", Type: "Map(String, String)", Nullable: "YES"},
|
||||
}
|
||||
createSQL, warnings := buildClickHouseToMySQLCreateTableSQL("app.metrics", cols)
|
||||
if !strings.Contains(createSQL, "CREATE TABLE `app`.`metrics`") {
|
||||
t.Fatalf("unexpected create SQL: %s", createSQL)
|
||||
}
|
||||
if !strings.Contains(createSQL, "`id` bigint unsigned NOT NULL") {
|
||||
t.Fatalf("unexpected uint64 mapping: %s", createSQL)
|
||||
}
|
||||
if !strings.Contains(createSQL, "`payload` json") {
|
||||
t.Fatalf("unexpected complex type mapping: %s", createSQL)
|
||||
}
|
||||
if len(warnings) == 0 {
|
||||
t.Fatalf("expected warning for limited clickhouse reverse semantics")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMySQLToMongoPlan_AutoCreateCollection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sourceDB := &fakeMigrationDB{
|
||||
columns: map[string][]connection.ColumnDefinition{
|
||||
"shop.users": {
|
||||
{Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI"},
|
||||
{Name: "name", Type: "varchar(64)", Nullable: "YES"},
|
||||
},
|
||||
},
|
||||
indexes: map[string][]connection.IndexDefinition{
|
||||
"shop.users": {
|
||||
{Name: "idx_users_name", ColumnName: "name", NonUnique: 1, SeqInIndex: 1, IndexType: "BTREE"},
|
||||
},
|
||||
},
|
||||
}
|
||||
targetDB := &fakeMigrationDB{}
|
||||
cfg := SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "mysql", Database: "shop"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "mongodb", Database: "app"},
|
||||
TargetTableStrategy: "smart",
|
||||
CreateIndexes: true,
|
||||
}
|
||||
plan, sourceCols, targetCols, err := buildMySQLToMongoPlan(cfg, "users", sourceDB, targetDB)
|
||||
if err != nil {
|
||||
t.Fatalf("buildMySQLToMongoPlan returned error: %v", err)
|
||||
}
|
||||
if len(sourceCols) != 2 || targetCols != nil {
|
||||
t.Fatalf("unexpected source/target columns: %d / %v", len(sourceCols), targetCols)
|
||||
}
|
||||
if !plan.AutoCreate || len(plan.PreDataSQL) == 0 {
|
||||
t.Fatalf("expected auto create collection command: %+v", plan)
|
||||
}
|
||||
if !strings.Contains(plan.PreDataSQL[0], `"create":"users"`) {
|
||||
t.Fatalf("unexpected create collection command: %v", plan.PreDataSQL)
|
||||
}
|
||||
if len(plan.PostDataSQL) != 1 || !strings.Contains(plan.PostDataSQL[0], `"createIndexes":"users"`) {
|
||||
t.Fatalf("unexpected index commands: %v", plan.PostDataSQL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPGLikeToMongoPlan_AutoCreateCollection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sourceDB := &fakeMigrationDB{
|
||||
columns: map[string][]connection.ColumnDefinition{
|
||||
"public.orders": {
|
||||
{Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI"},
|
||||
{Name: "name", Type: "varchar(64)", Nullable: "YES"},
|
||||
},
|
||||
},
|
||||
indexes: map[string][]connection.IndexDefinition{
|
||||
"public.orders": {
|
||||
{Name: "idx_orders_name", ColumnName: "name", NonUnique: 1, SeqInIndex: 1, IndexType: "BTREE"},
|
||||
},
|
||||
},
|
||||
}
|
||||
targetDB := &fakeMigrationDB{}
|
||||
cfg := SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "postgres", Database: "public"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "mongodb", Database: "app"},
|
||||
TargetTableStrategy: "smart",
|
||||
CreateIndexes: true,
|
||||
}
|
||||
plan, sourceCols, targetCols, err := buildPGLikeToMongoPlan(cfg, "orders", sourceDB, targetDB)
|
||||
if err != nil {
|
||||
t.Fatalf("buildPGLikeToMongoPlan returned error: %v", err)
|
||||
}
|
||||
if len(sourceCols) != 2 || targetCols != nil {
|
||||
t.Fatalf("unexpected source/target columns: %d / %v", len(sourceCols), targetCols)
|
||||
}
|
||||
if !plan.AutoCreate || len(plan.PreDataSQL) == 0 {
|
||||
t.Fatalf("expected auto create collection command: %+v", plan)
|
||||
}
|
||||
if !strings.Contains(plan.PreDataSQL[0], `"create":"orders"`) {
|
||||
t.Fatalf("unexpected create collection command: %v", plan.PreDataSQL)
|
||||
}
|
||||
if len(plan.PostDataSQL) != 1 || !strings.Contains(plan.PostDataSQL[0], `"createIndexes":"orders"`) {
|
||||
t.Fatalf("unexpected index commands: %v", plan.PostDataSQL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClickHouseToMongoPlan_AutoCreateCollection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sourceDB := &fakeMigrationDB{
|
||||
columns: map[string][]connection.ColumnDefinition{
|
||||
"analytics.metrics": {
|
||||
{Name: "id", Type: "UInt64", Nullable: "NO", Key: "PRI"},
|
||||
{Name: "host", Type: "String", Nullable: "YES"},
|
||||
},
|
||||
},
|
||||
}
|
||||
targetDB := &fakeMigrationDB{}
|
||||
cfg := SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "clickhouse", Database: "analytics"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "mongodb", Database: "app"},
|
||||
TargetTableStrategy: "smart",
|
||||
}
|
||||
plan, sourceCols, targetCols, err := buildClickHouseToMongoPlan(cfg, "metrics", sourceDB, targetDB)
|
||||
if err != nil {
|
||||
t.Fatalf("buildClickHouseToMongoPlan returned error: %v", err)
|
||||
}
|
||||
if len(sourceCols) != 2 || targetCols != nil {
|
||||
t.Fatalf("unexpected source/target columns: %d / %v", len(sourceCols), targetCols)
|
||||
}
|
||||
if !plan.AutoCreate || len(plan.PreDataSQL) == 0 {
|
||||
t.Fatalf("expected auto create collection command: %+v", plan)
|
||||
}
|
||||
if !strings.Contains(plan.PreDataSQL[0], `"create":"metrics"`) {
|
||||
t.Fatalf("unexpected create collection command: %v", plan.PreDataSQL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTDengineToMongoPlan_AutoCreateCollection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sourceDB := &fakeMigrationDB{
|
||||
columns: map[string][]connection.ColumnDefinition{
|
||||
"src.cpu": {
|
||||
{Name: "ts", Type: "TIMESTAMP", Nullable: "NO"},
|
||||
{Name: "host", Type: "NCHAR(64)", Nullable: "YES"},
|
||||
},
|
||||
},
|
||||
}
|
||||
targetDB := &fakeMigrationDB{}
|
||||
cfg := SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "tdengine", Database: "src"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "mongodb", Database: "app"},
|
||||
TargetTableStrategy: "smart",
|
||||
}
|
||||
plan, sourceCols, targetCols, err := buildTDengineToMongoPlan(cfg, "cpu", sourceDB, targetDB)
|
||||
if err != nil {
|
||||
t.Fatalf("buildTDengineToMongoPlan returned error: %v", err)
|
||||
}
|
||||
if len(sourceCols) != 2 || targetCols != nil {
|
||||
t.Fatalf("unexpected source/target columns: %d / %v", len(sourceCols), targetCols)
|
||||
}
|
||||
if !plan.AutoCreate || len(plan.PreDataSQL) == 0 {
|
||||
t.Fatalf("expected auto create collection command: %+v", plan)
|
||||
}
|
||||
if !strings.Contains(plan.PreDataSQL[0], `"create":"cpu"`) {
|
||||
t.Fatalf("unexpected create collection command: %v", plan.PreDataSQL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMongoToMySQLPlan_InfersColumnsAndCreatesTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
query := `{"find":"users","filter":{},"limit":200}`
|
||||
sourceDB := &fakeMigrationDB{
|
||||
queryData: map[string][]map[string]interface{}{
|
||||
query: {
|
||||
{"_id": "a1", "name": "alice", "age": int64(18), "profile": map[string]interface{}{"city": "shanghai"}},
|
||||
{"_id": "b2", "name": "bob", "profile": map[string]interface{}{"city": "beijing"}},
|
||||
},
|
||||
},
|
||||
queryCols: map[string][]string{query: {"_id", "name", "age", "profile"}},
|
||||
indexes: map[string][]connection.IndexDefinition{
|
||||
"crm.users": {{Name: "email_1", ColumnName: "name", NonUnique: 1, SeqInIndex: 1, IndexType: "BTREE"}},
|
||||
},
|
||||
}
|
||||
targetDB := &fakeMigrationDB{}
|
||||
cfg := SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "mongodb", Database: "crm"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "mysql", Database: "app"},
|
||||
TargetTableStrategy: "smart",
|
||||
CreateIndexes: true,
|
||||
}
|
||||
plan, sourceCols, _, err := buildMongoToMySQLPlan(cfg, "users", sourceDB, targetDB)
|
||||
if err != nil {
|
||||
t.Fatalf("buildMongoToMySQLPlan returned error: %v", err)
|
||||
}
|
||||
if len(sourceCols) == 0 {
|
||||
t.Fatalf("expected inferred source cols")
|
||||
}
|
||||
if !plan.AutoCreate || !strings.Contains(plan.CreateTableSQL, "CREATE TABLE `app`.`users`") {
|
||||
t.Fatalf("unexpected create table sql: %s", plan.CreateTableSQL)
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, "`_id` text NOT NULL") && !strings.Contains(plan.CreateTableSQL, "`_id` varchar") {
|
||||
t.Fatalf("missing inferred _id column: %s", plan.CreateTableSQL)
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, "`profile` json") {
|
||||
t.Fatalf("expected nested field degrade to json: %s", plan.CreateTableSQL)
|
||||
}
|
||||
if len(plan.PostDataSQL) != 1 {
|
||||
t.Fatalf("expected one post index sql, got=%v", plan.PostDataSQL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTDengineToMySQLPlan_AutoCreateWhenTargetMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sourceDB := &fakeMigrationDB{
|
||||
columns: map[string][]connection.ColumnDefinition{
|
||||
"metrics.cpu": {
|
||||
{Name: "ts", Type: "TIMESTAMP", Nullable: "NO"},
|
||||
{Name: "host", Type: "NCHAR(64)", Nullable: "YES", Key: "TAG", Extra: "TAG"},
|
||||
{Name: "usage", Type: "DOUBLE", Nullable: "YES"},
|
||||
},
|
||||
},
|
||||
}
|
||||
targetDB := &fakeMigrationDB{}
|
||||
cfg := SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "tdengine", Database: "metrics"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "mysql", Database: "app"},
|
||||
TargetTableStrategy: "smart",
|
||||
}
|
||||
plan, sourceCols, targetCols, err := buildTDengineToMySQLPlan(cfg, "cpu", sourceDB, targetDB)
|
||||
if err != nil {
|
||||
t.Fatalf("buildTDengineToMySQLPlan returned error: %v", err)
|
||||
}
|
||||
if len(sourceCols) != 3 || len(targetCols) != 0 {
|
||||
t.Fatalf("unexpected columns lengths: source=%d target=%d", len(sourceCols), len(targetCols))
|
||||
}
|
||||
if !plan.AutoCreate {
|
||||
t.Fatalf("expected auto create enabled")
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, "CREATE TABLE `app`.`cpu`") {
|
||||
t.Fatalf("unexpected create table sql: %s", plan.CreateTableSQL)
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, "`ts` datetime") {
|
||||
t.Fatalf("expected timestamp mapping, got: %s", plan.CreateTableSQL)
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, "`host` varchar(64)") {
|
||||
t.Fatalf("expected nchar mapping, got: %s", plan.CreateTableSQL)
|
||||
}
|
||||
if len(plan.Warnings) == 0 || !strings.Contains(strings.Join(plan.Warnings, " "), "TAG") {
|
||||
t.Fatalf("expected TAG warning, got: %v", plan.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTDengineToPGLikePlan_AutoCreateWhenTargetMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sourceDB := &fakeMigrationDB{
|
||||
columns: map[string][]connection.ColumnDefinition{
|
||||
"metrics.cpu": {
|
||||
{Name: "ts", Type: "TIMESTAMP", Nullable: "NO"},
|
||||
{Name: "payload", Type: "JSON", Nullable: "YES"},
|
||||
{Name: "host", Type: "BINARY(32)", Nullable: "YES", Key: "TAG", Extra: "TAG"},
|
||||
},
|
||||
},
|
||||
}
|
||||
targetDB := &fakeMigrationDB{}
|
||||
cfg := SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "tdengine", Database: "metrics"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "kingbase", Database: "ignored"},
|
||||
TargetTableStrategy: "smart",
|
||||
}
|
||||
plan, sourceCols, targetCols, err := buildTDengineToPGLikePlan(cfg, "cpu", sourceDB, targetDB)
|
||||
if err != nil {
|
||||
t.Fatalf("buildTDengineToPGLikePlan returned error: %v", err)
|
||||
}
|
||||
if len(sourceCols) != 3 || len(targetCols) != 0 {
|
||||
t.Fatalf("unexpected columns lengths: source=%d target=%d", len(sourceCols), len(targetCols))
|
||||
}
|
||||
if !plan.AutoCreate {
|
||||
t.Fatalf("expected auto create enabled")
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, `CREATE TABLE "public"."cpu"`) {
|
||||
t.Fatalf("unexpected create table sql: %s", plan.CreateTableSQL)
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, `"ts" timestamp`) {
|
||||
t.Fatalf("expected timestamp mapping, got: %s", plan.CreateTableSQL)
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, `"payload" jsonb`) {
|
||||
t.Fatalf("expected json mapping, got: %s", plan.CreateTableSQL)
|
||||
}
|
||||
if len(plan.Warnings) == 0 || !strings.Contains(strings.Join(plan.Warnings, " "), "TAG") {
|
||||
t.Fatalf("expected TAG warning, got: %v", plan.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSchemaMigrationPlan_TDengineTargetWarnsInsertOnlyBoundary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sourceDB := &fakeMigrationDB{
|
||||
columns: map[string][]connection.ColumnDefinition{
|
||||
"shop.metrics": {
|
||||
{Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI"},
|
||||
{Name: "ts", Type: "datetime", Nullable: "NO"},
|
||||
{Name: "value", Type: "double", Nullable: "YES"},
|
||||
},
|
||||
},
|
||||
}
|
||||
targetDB := &fakeMigrationDB{
|
||||
columns: map[string][]connection.ColumnDefinition{
|
||||
"taos.metrics": {
|
||||
{Name: "id", Type: "bigint", Nullable: "NO"},
|
||||
{Name: "ts", Type: "timestamp", Nullable: "NO"},
|
||||
{Name: "value", Type: "double", Nullable: "YES"},
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg := SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "mysql", Database: "shop"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "tdengine", Database: "taos"},
|
||||
Mode: "insert_update",
|
||||
}
|
||||
|
||||
plan, _, _, err := buildSchemaMigrationPlan(cfg, "metrics", sourceDB, targetDB)
|
||||
if err != nil {
|
||||
t.Fatalf("buildSchemaMigrationPlan returned error: %v", err)
|
||||
}
|
||||
warnings := strings.Join(plan.Warnings, " ")
|
||||
if !strings.Contains(warnings, "仅支持 INSERT 写入") {
|
||||
t.Fatalf("expected TDengine target warning, got: %v", plan.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMySQLLikeToTDenginePlan_AutoCreateWhenTargetMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sourceDB := &fakeMigrationDB{
|
||||
columns: map[string][]connection.ColumnDefinition{
|
||||
"shop.metrics": {
|
||||
{Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI", Extra: "auto_increment"},
|
||||
{Name: "ts", Type: "datetime", Nullable: "NO"},
|
||||
{Name: "payload", Type: "json", Nullable: "YES"},
|
||||
},
|
||||
},
|
||||
}
|
||||
targetDB := &fakeMigrationDB{}
|
||||
cfg := SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "mysql", Database: "shop"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "tdengine", Database: "taos"},
|
||||
TargetTableStrategy: "smart",
|
||||
}
|
||||
plan, sourceCols, targetCols, err := buildMySQLLikeToTDenginePlan(cfg, "metrics", sourceDB, targetDB)
|
||||
if err != nil {
|
||||
t.Fatalf("buildMySQLLikeToTDenginePlan returned error: %v", err)
|
||||
}
|
||||
if len(sourceCols) != 3 || len(targetCols) != 0 {
|
||||
t.Fatalf("unexpected columns lengths: source=%d target=%d", len(sourceCols), len(targetCols))
|
||||
}
|
||||
if !plan.AutoCreate {
|
||||
t.Fatalf("expected auto create enabled")
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, "CREATE TABLE `taos`.`metrics`") {
|
||||
t.Fatalf("unexpected create sql: %s", plan.CreateTableSQL)
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, "`ts` TIMESTAMP") {
|
||||
t.Fatalf("expected ts first column mapped to TIMESTAMP, got: %s", plan.CreateTableSQL)
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, "`payload` VARCHAR(") {
|
||||
t.Fatalf("expected json degrade to VARCHAR, got: %s", plan.CreateTableSQL)
|
||||
}
|
||||
if !strings.Contains(strings.Join(plan.Warnings, " "), "insert-only") && !strings.Contains(strings.Join(plan.Warnings, " "), "INSERT") {
|
||||
t.Fatalf("expected tdengine target warning, got: %v", plan.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPGLikeToTDenginePlan_AutoCreateWhenTargetMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sourceDB := &fakeMigrationDB{
|
||||
columns: map[string][]connection.ColumnDefinition{
|
||||
"public.metrics": {
|
||||
{Name: "event_time", Type: "timestamp without time zone", Nullable: "NO"},
|
||||
{Name: "name", Type: "character varying(64)", Nullable: "YES"},
|
||||
{Name: "meta", Type: "jsonb", Nullable: "YES"},
|
||||
},
|
||||
},
|
||||
}
|
||||
targetDB := &fakeMigrationDB{}
|
||||
cfg := SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "postgres", Database: "ignored"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "tdengine", Database: "taos"},
|
||||
TargetTableStrategy: "smart",
|
||||
}
|
||||
plan, sourceCols, targetCols, err := buildPGLikeToTDenginePlan(cfg, "metrics", sourceDB, targetDB)
|
||||
if err != nil {
|
||||
t.Fatalf("buildPGLikeToTDenginePlan returned error: %v", err)
|
||||
}
|
||||
if len(sourceCols) != 3 || len(targetCols) != 0 {
|
||||
t.Fatalf("unexpected columns lengths: source=%d target=%d", len(sourceCols), len(targetCols))
|
||||
}
|
||||
if !plan.AutoCreate {
|
||||
t.Fatalf("expected auto create enabled")
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, "CREATE TABLE `taos`.`metrics`") {
|
||||
t.Fatalf("unexpected create sql: %s", plan.CreateTableSQL)
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, "`event_time` TIMESTAMP") {
|
||||
t.Fatalf("expected timestamp mapping, got: %s", plan.CreateTableSQL)
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, "`meta` VARCHAR(") {
|
||||
t.Fatalf("expected jsonb degrade to VARCHAR, got: %s", plan.CreateTableSQL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMySQLLikeToTDenginePlan_RejectsAutoCreateWithoutTimestampColumn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sourceDB := &fakeMigrationDB{
|
||||
columns: map[string][]connection.ColumnDefinition{
|
||||
"shop.metrics": {
|
||||
{Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI"},
|
||||
{Name: "name", Type: "varchar(64)", Nullable: "YES"},
|
||||
},
|
||||
},
|
||||
}
|
||||
targetDB := &fakeMigrationDB{}
|
||||
cfg := SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "mysql", Database: "shop"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "tdengine", Database: "taos"},
|
||||
TargetTableStrategy: "smart",
|
||||
}
|
||||
plan, _, _, err := buildMySQLLikeToTDenginePlan(cfg, "metrics", sourceDB, targetDB)
|
||||
if err != nil {
|
||||
t.Fatalf("buildMySQLLikeToTDenginePlan returned error: %v", err)
|
||||
}
|
||||
if plan.AutoCreate {
|
||||
t.Fatalf("expected auto create disabled when source has no timestamp column")
|
||||
}
|
||||
if !strings.Contains(plan.PlannedAction, "时间列") {
|
||||
t.Fatalf("unexpected planned action: %s", plan.PlannedAction)
|
||||
}
|
||||
if !strings.Contains(strings.Join(plan.Warnings, " "), "时间列") {
|
||||
t.Fatalf("expected missing timestamp warning, got: %v", plan.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClickHouseToTDenginePlan_AutoCreateWhenTargetMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sourceDB := &fakeMigrationDB{
|
||||
columns: map[string][]connection.ColumnDefinition{
|
||||
"analytics.metrics": {
|
||||
{Name: "event_time", Type: "DateTime64(3)", Nullable: "NO"},
|
||||
{Name: "host", Type: "FixedString(64)", Nullable: "YES"},
|
||||
{Name: "payload", Type: "Map(String,String)", Nullable: "YES"},
|
||||
},
|
||||
},
|
||||
}
|
||||
targetDB := &fakeMigrationDB{}
|
||||
cfg := SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "clickhouse", Database: "analytics"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "tdengine", Database: "taos"},
|
||||
TargetTableStrategy: "smart",
|
||||
}
|
||||
plan, sourceCols, targetCols, err := buildClickHouseToTDenginePlan(cfg, "metrics", sourceDB, targetDB)
|
||||
if err != nil {
|
||||
t.Fatalf("buildClickHouseToTDenginePlan returned error: %v", err)
|
||||
}
|
||||
if len(sourceCols) != 3 || len(targetCols) != 0 {
|
||||
t.Fatalf("unexpected columns lengths: source=%d target=%d", len(sourceCols), len(targetCols))
|
||||
}
|
||||
if !plan.AutoCreate {
|
||||
t.Fatalf("expected auto create enabled")
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, "CREATE TABLE `taos`.`metrics`") {
|
||||
t.Fatalf("unexpected create sql: %s", plan.CreateTableSQL)
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, "`event_time` TIMESTAMP") {
|
||||
t.Fatalf("expected datetime64 mapping, got: %s", plan.CreateTableSQL)
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, "`host` VARCHAR(64)") {
|
||||
t.Fatalf("expected fixedstring mapping, got: %s", plan.CreateTableSQL)
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, "`payload` VARCHAR(") {
|
||||
t.Fatalf("expected complex type degrade to VARCHAR, got: %s", plan.CreateTableSQL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClickHouseToPGLikePlan_AutoCreateWhenTargetMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sourceDB := &fakeMigrationDB{
|
||||
columns: map[string][]connection.ColumnDefinition{
|
||||
"analytics.metrics": {
|
||||
{Name: "id", Type: "UInt64", Nullable: "NO", Key: "PRI"},
|
||||
{Name: "event_time", Type: "DateTime64(3)", Nullable: "NO"},
|
||||
{Name: "host", Type: "FixedString(64)", Nullable: "YES"},
|
||||
{Name: "payload", Type: "Map(String,String)", Nullable: "YES"},
|
||||
},
|
||||
},
|
||||
}
|
||||
targetDB := &fakeMigrationDB{}
|
||||
cfg := SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "clickhouse", Database: "analytics"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "postgres", Database: "public"},
|
||||
TargetTableStrategy: "smart",
|
||||
}
|
||||
plan, sourceCols, targetCols, err := buildClickHouseToPGLikePlan(cfg, "metrics", sourceDB, targetDB)
|
||||
if err != nil {
|
||||
t.Fatalf("buildClickHouseToPGLikePlan returned error: %v", err)
|
||||
}
|
||||
if len(sourceCols) != 4 || len(targetCols) != 0 {
|
||||
t.Fatalf("unexpected columns lengths: source=%d target=%d", len(sourceCols), len(targetCols))
|
||||
}
|
||||
if !plan.AutoCreate {
|
||||
t.Fatalf("expected auto create enabled")
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, `CREATE TABLE "public"."metrics"`) {
|
||||
t.Fatalf("unexpected create sql: %s", plan.CreateTableSQL)
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, `"id" numeric(20,0)`) {
|
||||
t.Fatalf("expected uint64 safeguard mapping, got: %s", plan.CreateTableSQL)
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, `"event_time" timestamp`) {
|
||||
t.Fatalf("expected datetime64 mapping, got: %s", plan.CreateTableSQL)
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, `"host" varchar(64)`) {
|
||||
t.Fatalf("expected fixedstring mapping, got: %s", plan.CreateTableSQL)
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, `"payload" jsonb`) {
|
||||
t.Fatalf("expected complex type degrade to jsonb, got: %s", plan.CreateTableSQL)
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, `PRIMARY KEY ("id")`) {
|
||||
t.Fatalf("expected primary key preservation, got: %s", plan.CreateTableSQL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPGLikeToClickHousePlan_AutoCreateWhenTargetMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sourceDB := &fakeMigrationDB{
|
||||
columns: map[string][]connection.ColumnDefinition{
|
||||
"public.orders": {
|
||||
{Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI"},
|
||||
{Name: "created_at", Type: "timestamp without time zone", Nullable: "NO"},
|
||||
{Name: "profile", Type: "jsonb", Nullable: "YES"},
|
||||
},
|
||||
},
|
||||
}
|
||||
targetDB := &fakeMigrationDB{}
|
||||
cfg := SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "postgres", Database: "public"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "clickhouse", Database: "analytics"},
|
||||
TargetTableStrategy: "smart",
|
||||
}
|
||||
plan, sourceCols, targetCols, err := buildPGLikeToClickHousePlan(cfg, "orders", sourceDB, targetDB)
|
||||
if err != nil {
|
||||
t.Fatalf("buildPGLikeToClickHousePlan returned error: %v", err)
|
||||
}
|
||||
if len(sourceCols) != 3 || len(targetCols) != 0 {
|
||||
t.Fatalf("unexpected columns lengths: source=%d target=%d", len(sourceCols), len(targetCols))
|
||||
}
|
||||
if !plan.AutoCreate {
|
||||
t.Fatalf("expected auto create enabled")
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, "CREATE TABLE `analytics`.`orders`") {
|
||||
t.Fatalf("unexpected create sql: %s", plan.CreateTableSQL)
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, "`created_at` DateTime") {
|
||||
t.Fatalf("expected timestamp mapping, got: %s", plan.CreateTableSQL)
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, "`profile` Nullable(String)") {
|
||||
t.Fatalf("expected jsonb degrade to Nullable(String), got: %s", plan.CreateTableSQL)
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, "ORDER BY (`id`)") {
|
||||
t.Fatalf("expected primary key order by, got: %s", plan.CreateTableSQL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTDengineToTDenginePlan_AutoCreateWhenTargetMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sourceDB := &fakeMigrationDB{
|
||||
columns: map[string][]connection.ColumnDefinition{
|
||||
"src.cpu": {
|
||||
{Name: "ts", Type: "TIMESTAMP", Nullable: "NO"},
|
||||
{Name: "host", Type: "NCHAR(64)", Nullable: "YES"},
|
||||
{Name: "region", Type: "NCHAR(32)", Nullable: "YES", Key: "TAG"},
|
||||
},
|
||||
},
|
||||
}
|
||||
targetDB := &fakeMigrationDB{}
|
||||
cfg := SyncConfig{
|
||||
SourceConfig: connection.ConnectionConfig{Type: "tdengine", Database: "src"},
|
||||
TargetConfig: connection.ConnectionConfig{Type: "tdengine", Database: "dst"},
|
||||
TargetTableStrategy: "smart",
|
||||
}
|
||||
plan, sourceCols, targetCols, err := buildTDengineToTDenginePlan(cfg, "cpu", sourceDB, targetDB)
|
||||
if err != nil {
|
||||
t.Fatalf("buildTDengineToTDenginePlan returned error: %v", err)
|
||||
}
|
||||
if len(sourceCols) != 3 || len(targetCols) != 0 {
|
||||
t.Fatalf("unexpected columns lengths: source=%d target=%d", len(sourceCols), len(targetCols))
|
||||
}
|
||||
if !plan.AutoCreate {
|
||||
t.Fatalf("expected auto create enabled")
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, "CREATE TABLE `dst`.`cpu`") {
|
||||
t.Fatalf("unexpected create sql: %s", plan.CreateTableSQL)
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, "`ts` TIMESTAMP") {
|
||||
t.Fatalf("expected timestamp preserved, got: %s", plan.CreateTableSQL)
|
||||
}
|
||||
if !strings.Contains(plan.CreateTableSQL, "`region` NCHAR(32)") {
|
||||
t.Fatalf("expected tag degrade to regular nchar column, got: %s", plan.CreateTableSQL)
|
||||
}
|
||||
if !strings.Contains(strings.Join(plan.Warnings, " "), "TAG") {
|
||||
t.Fatalf("expected TAG degrade warning, got: %v", plan.Warnings)
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,16 @@ import (
|
||||
)
|
||||
|
||||
func (s *SyncEngine) syncTableSchema(config SyncConfig, res *SyncResult, sourceDB db.Database, targetDB db.Database, tableName string) error {
|
||||
targetType := strings.ToLower(strings.TrimSpace(config.TargetConfig.Type))
|
||||
targetType := resolveMigrationDBType(config.TargetConfig)
|
||||
if targetType != "mysql" {
|
||||
s.appendLog(config.JobID, res, "warn", fmt.Sprintf("目标数据库类型=%s 暂不支持结构同步,已跳过表 %s", config.TargetConfig.Type, tableName))
|
||||
return nil
|
||||
}
|
||||
|
||||
sourceSchema, sourceTable := normalizeSchemaAndTable(config.SourceConfig.Type, config.SourceConfig.Database, tableName)
|
||||
targetSchema, targetTable := normalizeSchemaAndTable(config.TargetConfig.Type, config.TargetConfig.Database, tableName)
|
||||
targetQueryTable := qualifiedNameForQuery(config.TargetConfig.Type, targetSchema, targetTable, tableName)
|
||||
sourceType := resolveMigrationDBType(config.SourceConfig)
|
||||
sourceSchema, sourceTable := normalizeSchemaAndTable(sourceType, config.SourceConfig.Database, tableName)
|
||||
targetSchema, targetTable := normalizeSchemaAndTable(targetType, config.TargetConfig.Database, tableName)
|
||||
targetQueryTable := qualifiedNameForQuery(targetType, targetSchema, targetTable, tableName)
|
||||
|
||||
// 1) 获取源表字段
|
||||
sourceCols, err := sourceDB.GetColumns(sourceSchema, sourceTable)
|
||||
@@ -26,7 +27,6 @@ func (s *SyncEngine) syncTableSchema(config SyncConfig, res *SyncResult, sourceD
|
||||
// 2) 确保目标表存在
|
||||
targetCols, err := targetDB.GetColumns(targetSchema, targetTable)
|
||||
if err != nil {
|
||||
sourceType := strings.ToLower(strings.TrimSpace(config.SourceConfig.Type))
|
||||
if sourceType != "mysql" {
|
||||
return fmt.Errorf("目标表不存在且源类型=%s 暂不支持自动建表: %w", config.SourceConfig.Type, err)
|
||||
}
|
||||
@@ -62,7 +62,6 @@ func (s *SyncEngine) syncTableSchema(config SyncConfig, res *SyncResult, sourceD
|
||||
|
||||
// 3) 补齐目标缺失字段(安全策略:新增字段统一允许 NULL)
|
||||
missing := make([]string, 0)
|
||||
sourceType := strings.ToLower(strings.TrimSpace(config.SourceConfig.Type))
|
||||
for _, c := range sourceCols {
|
||||
colName := strings.TrimSpace(c.Name)
|
||||
if colName == "" {
|
||||
|
||||
@@ -22,7 +22,7 @@ func quoteIdentByType(dbType string, ident string) string {
|
||||
}
|
||||
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "diros", "sphinx":
|
||||
case "mysql", "mariadb", "diros", "sphinx", "clickhouse", "tdengine":
|
||||
return "`" + strings.ReplaceAll(ident, "`", "``") + "`"
|
||||
case "sqlserver":
|
||||
escaped := strings.ReplaceAll(ident, "]", "]]")
|
||||
@@ -74,8 +74,10 @@ func normalizeSchemaAndTable(dbType string, dbName string, tableName string) (st
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(dbType)) {
|
||||
case "postgres", "kingbase", "vastbase":
|
||||
case "postgres", "kingbase", "highgo", "vastbase":
|
||||
return "public", rawTable
|
||||
case "duckdb":
|
||||
return "main", rawTable
|
||||
default:
|
||||
return rawDB, rawTable
|
||||
}
|
||||
@@ -91,7 +93,7 @@ func qualifiedNameForQuery(dbType string, schema string, table string, original
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(dbType)) {
|
||||
case "postgres", "kingbase", "vastbase":
|
||||
case "postgres", "kingbase", "highgo", "vastbase":
|
||||
s := strings.TrimSpace(schema)
|
||||
if s == "" {
|
||||
s = "public"
|
||||
@@ -100,7 +102,16 @@ func qualifiedNameForQuery(dbType string, schema string, table string, original
|
||||
return raw
|
||||
}
|
||||
return s + "." + table
|
||||
case "mysql", "mariadb", "diros", "sphinx":
|
||||
case "duckdb":
|
||||
s := strings.TrimSpace(schema)
|
||||
if s == "" {
|
||||
s = "main"
|
||||
}
|
||||
if table == "" {
|
||||
return raw
|
||||
}
|
||||
return s + "." + table
|
||||
case "mysql", "mariadb", "diros", "sphinx", "clickhouse", "tdengine":
|
||||
s := strings.TrimSpace(schema)
|
||||
if s == "" || table == "" {
|
||||
return table
|
||||
|
||||
@@ -12,14 +12,17 @@ import (
|
||||
|
||||
// SyncConfig defines the parameters for a synchronization task
|
||||
type SyncConfig struct {
|
||||
SourceConfig connection.ConnectionConfig `json:"sourceConfig"`
|
||||
TargetConfig connection.ConnectionConfig `json:"targetConfig"`
|
||||
Tables []string `json:"tables"` // Tables to sync
|
||||
Content string `json:"content,omitempty"` // "data", "schema", "both"
|
||||
Mode string `json:"mode"` // "insert_update", "insert_only", "full_overwrite"
|
||||
JobID string `json:"jobId,omitempty"`
|
||||
AutoAddColumns bool `json:"autoAddColumns,omitempty"` // 自动补齐缺失字段(当前仅 MySQL 目标支持)
|
||||
TableOptions map[string]TableOptions `json:"tableOptions,omitempty"`
|
||||
SourceConfig connection.ConnectionConfig `json:"sourceConfig"`
|
||||
TargetConfig connection.ConnectionConfig `json:"targetConfig"`
|
||||
Tables []string `json:"tables"`
|
||||
Content string `json:"content,omitempty"` // "data", "schema", "both"
|
||||
Mode string `json:"mode"` // "insert_update", "insert_only", "full_overwrite"
|
||||
JobID string `json:"jobId,omitempty"`
|
||||
AutoAddColumns bool `json:"autoAddColumns,omitempty"` // 自动补齐缺失字段
|
||||
TargetTableStrategy string `json:"targetTableStrategy,omitempty"`
|
||||
CreateIndexes bool `json:"createIndexes,omitempty"`
|
||||
MongoCollectionName string `json:"mongoCollectionName,omitempty"`
|
||||
TableOptions map[string]TableOptions `json:"tableOptions,omitempty"`
|
||||
}
|
||||
|
||||
// SyncResult holds the result of the sync operation
|
||||
@@ -45,6 +48,13 @@ func NewSyncEngine(reporter Reporter) *SyncEngine {
|
||||
func (s *SyncEngine) RunSync(config SyncConfig) SyncResult {
|
||||
result := SyncResult{Success: true, Logs: []string{}}
|
||||
logger.Infof("开始数据同步:源=%s 目标=%s 表数量=%d", formatConnSummaryForSync(config.SourceConfig), formatConnSummaryForSync(config.TargetConfig), len(config.Tables))
|
||||
if isRedisToMongoKeyspacePair(config) {
|
||||
return s.runRedisToMongoSync(config, result)
|
||||
}
|
||||
if isMongoToRedisKeyspacePair(config) {
|
||||
return s.runMongoToRedisSync(config, result)
|
||||
}
|
||||
|
||||
totalTables := len(config.Tables)
|
||||
s.progress(config.JobID, 0, totalTables, "", "开始同步")
|
||||
|
||||
@@ -70,6 +80,7 @@ func (s *SyncEngine) RunSync(config SyncConfig) SyncResult {
|
||||
s.appendLog(config.JobID, &result, "warn", fmt.Sprintf("未知同步模式 %q,已自动使用 insert_update", config.Mode))
|
||||
}
|
||||
defaultMode := normalizeSyncMode(config.Mode)
|
||||
strategy := normalizeTargetTableStrategy(config.TargetTableStrategy)
|
||||
|
||||
contentLabel := "仅同步数据"
|
||||
if syncSchema && syncData {
|
||||
@@ -77,9 +88,9 @@ func (s *SyncEngine) RunSync(config SyncConfig) SyncResult {
|
||||
} else if syncSchema {
|
||||
contentLabel = "仅同步结构"
|
||||
}
|
||||
s.appendLog(config.JobID, &result, "info", fmt.Sprintf("同步内容:%s;模式:%s;自动补字段:%v", contentLabel, defaultMode, config.AutoAddColumns))
|
||||
s.appendLog(config.JobID, &result, "info", fmt.Sprintf("同步内容:%s;模式:%s;自动补字段:%v;目标表策略:%s;创建索引:%v", contentLabel, defaultMode, config.AutoAddColumns, strategy, config.CreateIndexes))
|
||||
|
||||
sourceDB, err := db.NewDatabase(config.SourceConfig.Type)
|
||||
sourceDB, err := newSyncDatabase(config.SourceConfig.Type)
|
||||
if err != nil {
|
||||
logger.Error(err, "初始化源数据库驱动失败:类型=%s", config.SourceConfig.Type)
|
||||
return s.fail(config.JobID, totalTables, result, "初始化源数据库驱动失败: "+err.Error())
|
||||
@@ -88,7 +99,7 @@ func (s *SyncEngine) RunSync(config SyncConfig) SyncResult {
|
||||
// Custom DB setup would go here if needed
|
||||
}
|
||||
|
||||
targetDB, err := db.NewDatabase(config.TargetConfig.Type)
|
||||
targetDB, err := newSyncDatabase(config.TargetConfig.Type)
|
||||
if err != nil {
|
||||
logger.Error(err, "初始化目标数据库驱动失败:类型=%s", config.TargetConfig.Type)
|
||||
return s.fail(config.JobID, totalTables, result, "初始化目标数据库驱动失败: "+err.Error())
|
||||
@@ -112,7 +123,6 @@ func (s *SyncEngine) RunSync(config SyncConfig) SyncResult {
|
||||
}
|
||||
defer targetDB.Close()
|
||||
|
||||
// Iterate Tables
|
||||
for i, tableName := range config.Tables {
|
||||
func() {
|
||||
tableMode := defaultMode
|
||||
@@ -120,30 +130,82 @@ func (s *SyncEngine) RunSync(config SyncConfig) SyncResult {
|
||||
s.progress(config.JobID, i, totalTables, tableName, fmt.Sprintf("同步表(%d/%d)", i+1, totalTables))
|
||||
defer s.progress(config.JobID, i+1, totalTables, tableName, "表处理完成")
|
||||
|
||||
if syncSchema {
|
||||
s.progress(config.JobID, i, totalTables, tableName, "同步表结构")
|
||||
if err := s.syncTableSchema(config, &result, sourceDB, targetDB, tableName); err != nil {
|
||||
s.appendLog(config.JobID, &result, "error", fmt.Sprintf("表结构同步失败:表=%s 错误=%v", tableName, err))
|
||||
plan, cols, targetCols, err := buildSchemaMigrationPlan(config, tableName, sourceDB, targetDB)
|
||||
if err != nil {
|
||||
s.appendLog(config.JobID, &result, "error", fmt.Sprintf("生成迁移计划失败:表=%s 错误=%v", tableName, err))
|
||||
return
|
||||
}
|
||||
for _, warning := range plan.Warnings {
|
||||
s.appendLog(config.JobID, &result, "warn", fmt.Sprintf(" -> %s", warning))
|
||||
}
|
||||
for _, unsupported := range plan.UnsupportedObjects {
|
||||
s.appendLog(config.JobID, &result, "warn", fmt.Sprintf(" -> %s", unsupported))
|
||||
}
|
||||
if strings.TrimSpace(plan.PlannedAction) != "" {
|
||||
s.appendLog(config.JobID, &result, "info", fmt.Sprintf(" -> %s", plan.PlannedAction))
|
||||
}
|
||||
|
||||
if !plan.TargetTableExists && !plan.AutoCreate {
|
||||
s.appendLog(config.JobID, &result, "warn", fmt.Sprintf("表 %s 目标表不存在,当前策略不允许自动建表,已跳过", tableName))
|
||||
return
|
||||
}
|
||||
|
||||
if !plan.TargetTableExists && plan.AutoCreate {
|
||||
s.progress(config.JobID, i, totalTables, tableName, "创建目标表")
|
||||
if len(plan.PreDataSQL) > 0 {
|
||||
if err := executeSQLStatements(targetDB.Exec, plan.PreDataSQL); err != nil {
|
||||
s.appendLog(config.JobID, &result, "error", fmt.Sprintf("预执行建表 SQL 失败:表=%s 错误=%v", tableName, err))
|
||||
return
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(plan.CreateTableSQL) == "" {
|
||||
s.appendLog(config.JobID, &result, "error", fmt.Sprintf("表 %s 自动建表失败:建表 SQL 为空", tableName))
|
||||
return
|
||||
}
|
||||
if _, err := targetDB.Exec(plan.CreateTableSQL); err != nil {
|
||||
s.appendLog(config.JobID, &result, "error", fmt.Sprintf("创建目标表失败:表=%s 错误=%v", tableName, err))
|
||||
return
|
||||
}
|
||||
s.appendLog(config.JobID, &result, "info", fmt.Sprintf("目标表创建成功:%s", tableName))
|
||||
targetCols, err = targetDB.GetColumns(plan.TargetSchema, plan.TargetTable)
|
||||
if err != nil {
|
||||
s.appendLog(config.JobID, &result, "error", fmt.Sprintf("创建目标表后获取字段失败:表=%s 错误=%v", tableName, err))
|
||||
return
|
||||
}
|
||||
} else if len(plan.PreDataSQL) > 0 {
|
||||
s.progress(config.JobID, i, totalTables, tableName, "同步表结构")
|
||||
if err := executeSQLStatements(targetDB.Exec, plan.PreDataSQL); err != nil {
|
||||
s.appendLog(config.JobID, &result, "error", fmt.Sprintf("同步表结构失败:表=%s 错误=%v", tableName, err))
|
||||
return
|
||||
}
|
||||
targetCols, err = targetDB.GetColumns(plan.TargetSchema, plan.TargetTable)
|
||||
if err != nil {
|
||||
s.appendLog(config.JobID, &result, "warn", fmt.Sprintf("补字段后刷新目标字段失败:表=%s 错误=%v", tableName, err))
|
||||
}
|
||||
}
|
||||
|
||||
if !syncData {
|
||||
if len(plan.PostDataSQL) > 0 {
|
||||
s.progress(config.JobID, i, totalTables, tableName, "创建索引")
|
||||
if err := executeSQLStatements(targetDB.Exec, plan.PostDataSQL); err != nil {
|
||||
s.appendLog(config.JobID, &result, "error", fmt.Sprintf("创建索引失败:表=%s 错误=%v", tableName, err))
|
||||
return
|
||||
}
|
||||
}
|
||||
result.TablesSynced++
|
||||
return
|
||||
}
|
||||
|
||||
sourceSchema, sourceTable := normalizeSchemaAndTable(config.SourceConfig.Type, config.SourceConfig.Database, tableName)
|
||||
targetSchema, targetTable := normalizeSchemaAndTable(config.TargetConfig.Type, config.TargetConfig.Database, tableName)
|
||||
sourceQueryTable := qualifiedNameForQuery(config.SourceConfig.Type, sourceSchema, sourceTable, tableName)
|
||||
targetQueryTable := qualifiedNameForQuery(config.TargetConfig.Type, targetSchema, targetTable, tableName)
|
||||
|
||||
// 1. Get Columns & PKs
|
||||
cols, err := sourceDB.GetColumns(sourceSchema, sourceTable)
|
||||
if err != nil {
|
||||
logger.Error(err, "获取源表列信息失败:表=%s", tableName)
|
||||
s.appendLog(config.JobID, &result, "error", fmt.Sprintf("获取表 %s 的列信息失败: %v", tableName, err))
|
||||
return
|
||||
targetType := resolveMigrationDBType(config.TargetConfig)
|
||||
sourceType := resolveMigrationDBType(config.SourceConfig)
|
||||
targetTable := plan.TargetTable
|
||||
sourceQueryTable, targetQueryTable := plan.SourceQueryTable, plan.TargetQueryTable
|
||||
applyTableName := targetTable
|
||||
switch targetType {
|
||||
case "postgres", "kingbase", "highgo", "vastbase", "sqlserver":
|
||||
applyTableName = targetQueryTable
|
||||
}
|
||||
|
||||
sourceColsByLower := make(map[string]connection.ColumnDefinition, len(cols))
|
||||
for _, col := range cols {
|
||||
if strings.TrimSpace(col.Name) == "" {
|
||||
@@ -158,25 +220,24 @@ func (s *SyncEngine) RunSync(config SyncConfig) SyncResult {
|
||||
pkCols = append(pkCols, col.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(pkCols) == 0 {
|
||||
s.appendLog(config.JobID, &result, "warn", fmt.Sprintf("表 %s 未找到主键,已跳过数据同步(避免产生重复数据)", tableName))
|
||||
return
|
||||
requirePK := tableMode == "insert_update" && plan.TargetTableExists
|
||||
pkCol := ""
|
||||
if requirePK {
|
||||
if len(pkCols) == 0 {
|
||||
s.appendLog(config.JobID, &result, "warn", fmt.Sprintf("表 %s 未找到主键,当前模式需要差异对比,已跳过", tableName))
|
||||
return
|
||||
}
|
||||
if len(pkCols) > 1 {
|
||||
s.appendLog(config.JobID, &result, "warn", fmt.Sprintf("表 %s 为复合主键(%s),当前暂不支持差异同步", tableName, strings.Join(pkCols, ",")))
|
||||
return
|
||||
}
|
||||
pkCol = pkCols[0]
|
||||
}
|
||||
if len(pkCols) > 1 {
|
||||
s.appendLog(config.JobID, &result, "warn", fmt.Sprintf("表 %s 为复合主键(%s),当前暂不支持数据同步", tableName, strings.Join(pkCols, ",")))
|
||||
return
|
||||
}
|
||||
pkCol := pkCols[0]
|
||||
|
||||
opts := TableOptions{Insert: true, Update: true, Delete: false}
|
||||
if config.TableOptions != nil {
|
||||
if t, ok := config.TableOptions[tableName]; ok {
|
||||
opts = t
|
||||
// 默认防护:如用户未设置任意一个字段,保持 insert/update 默认 true、delete 默认 false
|
||||
if !t.Insert && !t.Update && !t.Delete {
|
||||
opts = t
|
||||
}
|
||||
}
|
||||
}
|
||||
if !opts.Insert && !opts.Update && !opts.Delete {
|
||||
@@ -184,10 +245,8 @@ func (s *SyncEngine) RunSync(config SyncConfig) SyncResult {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Fetch Data (MEMORY INTENSIVE - PROTOTYPE ONLY)
|
||||
// TODO: Implement paging/streaming
|
||||
s.progress(config.JobID, i, totalTables, tableName, "读取源表数据")
|
||||
sourceRows, _, err := sourceDB.Query(fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(config.SourceConfig.Type, sourceQueryTable)))
|
||||
sourceRows, _, err := sourceDB.Query(fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(sourceType, sourceQueryTable)))
|
||||
if err != nil {
|
||||
logger.Error(err, "读取源表失败:表=%s", tableName)
|
||||
s.appendLog(config.JobID, &result, "error", fmt.Sprintf("读取源表 %s 失败: %v", tableName, err))
|
||||
@@ -196,19 +255,19 @@ func (s *SyncEngine) RunSync(config SyncConfig) SyncResult {
|
||||
|
||||
var inserts []map[string]interface{}
|
||||
var updates []connection.UpdateRow
|
||||
var deletes []map[string]interface{}
|
||||
|
||||
if tableMode == "insert_update" {
|
||||
if tableMode == "insert_update" && plan.TargetTableExists {
|
||||
s.progress(config.JobID, i, totalTables, tableName, "读取目标表数据")
|
||||
targetRows, _, err := targetDB.Query(fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(config.TargetConfig.Type, targetQueryTable)))
|
||||
targetRows, _, err := targetDB.Query(fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(targetType, targetQueryTable)))
|
||||
if err != nil {
|
||||
logger.Error(err, "读取目标表失败:表=%s", tableName)
|
||||
s.appendLog(config.JobID, &result, "error", fmt.Sprintf("读取目标表 %s 失败: %v", tableName, err))
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Compare (In-Memory Hash Map)
|
||||
s.progress(config.JobID, i, totalTables, tableName, "对比差异")
|
||||
targetMap := make(map[string]map[string]interface{})
|
||||
targetMap := make(map[string]map[string]interface{}, len(targetRows))
|
||||
for _, row := range targetRows {
|
||||
if row[pkCol] == nil {
|
||||
continue
|
||||
@@ -220,7 +279,6 @@ func (s *SyncEngine) RunSync(config SyncConfig) SyncResult {
|
||||
targetMap[pkVal] = row
|
||||
}
|
||||
sourcePKSet := make(map[string]struct{}, len(sourceRows))
|
||||
|
||||
for _, sRow := range sourceRows {
|
||||
if sRow[pkCol] == nil {
|
||||
continue
|
||||
@@ -230,7 +288,6 @@ func (s *SyncEngine) RunSync(config SyncConfig) SyncResult {
|
||||
continue
|
||||
}
|
||||
sourcePKSet[pkVal] = struct{}{}
|
||||
|
||||
if tRow, exists := targetMap[pkVal]; exists {
|
||||
changes := make(map[string]interface{})
|
||||
for k, v := range sRow {
|
||||
@@ -239,17 +296,12 @@ func (s *SyncEngine) RunSync(config SyncConfig) SyncResult {
|
||||
}
|
||||
}
|
||||
if len(changes) > 0 {
|
||||
updates = append(updates, connection.UpdateRow{
|
||||
Keys: map[string]interface{}{pkCol: sRow[pkCol]},
|
||||
Values: changes,
|
||||
})
|
||||
updates = append(updates, connection.UpdateRow{Keys: map[string]interface{}{pkCol: sRow[pkCol]}, Values: changes})
|
||||
}
|
||||
} else {
|
||||
inserts = append(inserts, sRow)
|
||||
}
|
||||
}
|
||||
|
||||
var deletes []map[string]interface{}
|
||||
if opts.Delete {
|
||||
for pkStr, row := range targetMap {
|
||||
if _, ok := sourcePKSet[pkStr]; ok {
|
||||
@@ -258,150 +310,49 @@ func (s *SyncEngine) RunSync(config SyncConfig) SyncResult {
|
||||
deletes = append(deletes, map[string]interface{}{pkCol: row[pkCol]})
|
||||
}
|
||||
}
|
||||
|
||||
// apply operation selection
|
||||
inserts = filterRowsByPKSelection(pkCol, inserts, opts.Insert, opts.SelectedInsertPKs)
|
||||
updates = filterUpdatesByPKSelection(pkCol, updates, opts.Update, opts.SelectedUpdatePKs)
|
||||
deletes = filterRowsByPKSelection(pkCol, deletes, opts.Delete, opts.SelectedDeletePKs)
|
||||
|
||||
changeSet := connection.ChangeSet{
|
||||
Inserts: inserts,
|
||||
Updates: updates,
|
||||
Deletes: deletes,
|
||||
} else {
|
||||
inserts = sourceRows
|
||||
if !opts.Insert {
|
||||
inserts = nil
|
||||
}
|
||||
if tableMode == "full_overwrite" && plan.TargetTableExists {
|
||||
s.appendLog(config.JobID, &result, "warn", fmt.Sprintf(" -> 全量覆盖模式:即将清空目标表 %s", tableName))
|
||||
s.progress(config.JobID, i, totalTables, tableName, "清空目标表")
|
||||
clearSQL := ""
|
||||
if targetType == "mysql" {
|
||||
clearSQL = fmt.Sprintf("TRUNCATE TABLE %s", quoteQualifiedIdentByType(targetType, targetQueryTable))
|
||||
} else {
|
||||
clearSQL = fmt.Sprintf("DELETE FROM %s", quoteQualifiedIdentByType(targetType, targetQueryTable))
|
||||
}
|
||||
if _, err := targetDB.Exec(clearSQL); err != nil {
|
||||
s.appendLog(config.JobID, &result, "error", fmt.Sprintf(" -> 清空目标表失败: %v", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Align schema (target missing columns)
|
||||
s.progress(config.JobID, i, totalTables, tableName, "检查字段一致性")
|
||||
requiredCols := collectRequiredColumns(changeSet.Inserts, changeSet.Updates)
|
||||
targetCols, err := targetDB.GetColumns(targetSchema, targetTable)
|
||||
changeSet := connection.ChangeSet{Inserts: inserts, Updates: updates, Deletes: deletes}
|
||||
s.progress(config.JobID, i, totalTables, tableName, "检查字段一致性")
|
||||
targetColsResolved := targetCols
|
||||
if len(targetColsResolved) == 0 {
|
||||
targetColsResolved, err = targetDB.GetColumns(plan.TargetSchema, plan.TargetTable)
|
||||
if err != nil {
|
||||
s.appendLog(config.JobID, &result, "warn", fmt.Sprintf(" -> 获取目标表字段失败,已跳过字段一致性检查: %v", err))
|
||||
} else {
|
||||
targetColSet := make(map[string]struct{}, len(targetCols))
|
||||
for _, c := range targetCols {
|
||||
name := strings.ToLower(strings.TrimSpace(c.Name))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
targetColSet[name] = struct{}{}
|
||||
}
|
||||
|
||||
missing := make([]string, 0)
|
||||
for lower, original := range requiredCols {
|
||||
if _, ok := targetColSet[lower]; !ok {
|
||||
missing = append(missing, original)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
|
||||
if len(missing) > 0 {
|
||||
if config.AutoAddColumns && strings.ToLower(strings.TrimSpace(config.TargetConfig.Type)) == "mysql" {
|
||||
s.appendLog(config.JobID, &result, "warn", fmt.Sprintf(" -> 目标表缺少字段 %d 个,开始自动补齐: %s", len(missing), strings.Join(missing, ", ")))
|
||||
added := 0
|
||||
for _, colName := range missing {
|
||||
colLower := strings.ToLower(strings.TrimSpace(colName))
|
||||
colType := "TEXT"
|
||||
if strings.ToLower(strings.TrimSpace(config.SourceConfig.Type)) == "mysql" {
|
||||
if srcCol, ok := sourceColsByLower[colLower]; ok {
|
||||
colType = sanitizeMySQLColumnType(srcCol.Type)
|
||||
}
|
||||
}
|
||||
|
||||
alterSQL := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s NULL",
|
||||
quoteQualifiedIdentByType("mysql", targetQueryTable),
|
||||
quoteIdentByType("mysql", colName),
|
||||
colType,
|
||||
)
|
||||
if _, err := targetDB.Exec(alterSQL); err != nil {
|
||||
s.appendLog(config.JobID, &result, "error", fmt.Sprintf(" -> 自动补字段失败:字段=%s 错误=%v", colName, err))
|
||||
continue
|
||||
}
|
||||
added++
|
||||
}
|
||||
s.appendLog(config.JobID, &result, "info", fmt.Sprintf(" -> 自动补字段完成:成功=%d 失败=%d", added, len(missing)-added))
|
||||
|
||||
// refresh columns
|
||||
targetCols, err = targetDB.GetColumns(targetSchema, targetTable)
|
||||
if err == nil {
|
||||
targetColSet = make(map[string]struct{}, len(targetCols))
|
||||
for _, c := range targetCols {
|
||||
name := strings.ToLower(strings.TrimSpace(c.Name))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
targetColSet[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
s.appendLog(config.JobID, &result, "warn", fmt.Sprintf(" -> 目标表缺少字段 %d 个(未开启自动补齐),将自动忽略:%s", len(missing), strings.Join(missing, ", ")))
|
||||
}
|
||||
|
||||
// filter out still-missing columns to avoid apply failure
|
||||
changeSet.Inserts = filterInsertRows(changeSet.Inserts, targetColSet)
|
||||
changeSet.Updates = filterUpdateRows(changeSet.Updates, targetColSet)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Apply Changes
|
||||
s.progress(config.JobID, i, totalTables, tableName, "应用变更")
|
||||
|
||||
if len(changeSet.Inserts) > 0 || len(changeSet.Updates) > 0 || len(changeSet.Deletes) > 0 {
|
||||
s.appendLog(config.JobID, &result, "info", fmt.Sprintf(" -> 需插入: %d 行, 需更新: %d 行, 需删除: %d 行", len(changeSet.Inserts), len(changeSet.Updates), len(changeSet.Deletes)))
|
||||
|
||||
if applier, ok := targetDB.(db.BatchApplier); ok {
|
||||
if err := applier.ApplyChanges(targetTable, changeSet); err != nil {
|
||||
s.appendLog(config.JobID, &result, "error", fmt.Sprintf(" -> 应用变更失败: %v", err))
|
||||
} else {
|
||||
result.RowsInserted += len(changeSet.Inserts)
|
||||
result.RowsUpdated += len(changeSet.Updates)
|
||||
result.RowsDeleted += len(changeSet.Deletes)
|
||||
}
|
||||
} else {
|
||||
s.appendLog(config.JobID, &result, "warn", " -> 目标驱动不支持应用数据变更 (ApplyChanges).")
|
||||
}
|
||||
} else {
|
||||
s.appendLog(config.JobID, &result, "info", " -> 数据一致,无需变更.")
|
||||
}
|
||||
|
||||
result.TablesSynced++
|
||||
return
|
||||
} else {
|
||||
// insert_only / full_overwrite: do not compare target, just insert source rows
|
||||
inserts = sourceRows
|
||||
}
|
||||
|
||||
// full_overwrite: clear target table first
|
||||
if tableMode == "full_overwrite" {
|
||||
s.appendLog(config.JobID, &result, "warn", fmt.Sprintf(" -> 全量覆盖模式:即将清空目标表 %s", tableName))
|
||||
s.progress(config.JobID, i, totalTables, tableName, "清空目标表")
|
||||
clearSQL := ""
|
||||
if strings.ToLower(strings.TrimSpace(config.TargetConfig.Type)) == "mysql" {
|
||||
clearSQL = fmt.Sprintf("TRUNCATE TABLE %s", quoteQualifiedIdentByType(config.TargetConfig.Type, targetQueryTable))
|
||||
} else {
|
||||
clearSQL = fmt.Sprintf("DELETE FROM %s", quoteQualifiedIdentByType(config.TargetConfig.Type, targetQueryTable))
|
||||
}
|
||||
if _, err := targetDB.Exec(clearSQL); err != nil {
|
||||
s.appendLog(config.JobID, &result, "error", fmt.Sprintf(" -> 清空目标表失败: %v", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Align schema (target missing columns)
|
||||
s.progress(config.JobID, i, totalTables, tableName, "检查字段一致性")
|
||||
requiredCols := collectRequiredColumns(inserts, updates)
|
||||
targetCols, err := targetDB.GetColumns(targetSchema, targetTable)
|
||||
if err != nil {
|
||||
s.appendLog(config.JobID, &result, "warn", fmt.Sprintf(" -> 获取目标表字段失败,已跳过字段一致性检查: %v", err))
|
||||
} else {
|
||||
targetColSet := make(map[string]struct{}, len(targetCols))
|
||||
for _, c := range targetCols {
|
||||
if len(targetColsResolved) > 0 {
|
||||
targetColSet := make(map[string]struct{}, len(targetColsResolved))
|
||||
for _, c := range targetColsResolved {
|
||||
name := strings.ToLower(strings.TrimSpace(c.Name))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
targetColSet[name] = struct{}{}
|
||||
}
|
||||
|
||||
requiredCols := collectRequiredColumns(changeSet.Inserts, changeSet.Updates)
|
||||
missing := make([]string, 0)
|
||||
for lower, original := range requiredCols {
|
||||
if _, ok := targetColSet[lower]; !ok {
|
||||
@@ -409,79 +360,64 @@ func (s *SyncEngine) RunSync(config SyncConfig) SyncResult {
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
|
||||
if len(missing) > 0 {
|
||||
if config.AutoAddColumns && strings.ToLower(strings.TrimSpace(config.TargetConfig.Type)) == "mysql" {
|
||||
if config.AutoAddColumns && supportsAutoAddColumnsForPair(sourceType, targetType) {
|
||||
s.appendLog(config.JobID, &result, "warn", fmt.Sprintf(" -> 目标表缺少字段 %d 个,开始自动补齐: %s", len(missing), strings.Join(missing, ", ")))
|
||||
added := 0
|
||||
for _, colName := range missing {
|
||||
colLower := strings.ToLower(strings.TrimSpace(colName))
|
||||
colType := "TEXT"
|
||||
if strings.ToLower(strings.TrimSpace(config.SourceConfig.Type)) == "mysql" {
|
||||
if srcCol, ok := sourceColsByLower[colLower]; ok {
|
||||
colType = sanitizeMySQLColumnType(srcCol.Type)
|
||||
}
|
||||
srcCol, ok := sourceColsByLower[colLower]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
alterSQL, err := buildAddColumnSQLForPair(sourceType, targetType, targetQueryTable, srcCol)
|
||||
if err != nil {
|
||||
s.appendLog(config.JobID, &result, "error", fmt.Sprintf(" -> 自动补字段失败:字段=%s 错误=%v", colName, err))
|
||||
continue
|
||||
}
|
||||
|
||||
alterSQL := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s NULL",
|
||||
quoteQualifiedIdentByType("mysql", targetQueryTable),
|
||||
quoteIdentByType("mysql", colName),
|
||||
colType,
|
||||
)
|
||||
if _, err := targetDB.Exec(alterSQL); err != nil {
|
||||
s.appendLog(config.JobID, &result, "error", fmt.Sprintf(" -> 自动补字段失败:字段=%s 错误=%v", colName, err))
|
||||
continue
|
||||
}
|
||||
added++
|
||||
targetColSet[colLower] = struct{}{}
|
||||
}
|
||||
s.appendLog(config.JobID, &result, "info", fmt.Sprintf(" -> 自动补字段完成:成功=%d 失败=%d", added, len(missing)-added))
|
||||
|
||||
// refresh columns
|
||||
targetCols, err = targetDB.GetColumns(targetSchema, targetTable)
|
||||
if err == nil {
|
||||
targetColSet = make(map[string]struct{}, len(targetCols))
|
||||
for _, c := range targetCols {
|
||||
name := strings.ToLower(strings.TrimSpace(c.Name))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
targetColSet[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
s.appendLog(config.JobID, &result, "warn", fmt.Sprintf(" -> 目标表缺少字段 %d 个(未开启自动补齐),将自动忽略:%s", len(missing), strings.Join(missing, ", ")))
|
||||
}
|
||||
|
||||
// filter out still-missing columns to avoid apply failure
|
||||
inserts = filterInsertRows(inserts, targetColSet)
|
||||
updates = filterUpdateRows(updates, targetColSet)
|
||||
changeSet.Inserts = filterInsertRows(changeSet.Inserts, targetColSet)
|
||||
changeSet.Updates = filterUpdateRows(changeSet.Updates, targetColSet)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Apply Changes
|
||||
s.progress(config.JobID, i, totalTables, tableName, "应用变更")
|
||||
changeSet := connection.ChangeSet{
|
||||
Inserts: inserts,
|
||||
Updates: updates,
|
||||
}
|
||||
|
||||
if len(changeSet.Inserts) > 0 || len(changeSet.Updates) > 0 {
|
||||
s.appendLog(config.JobID, &result, "info", fmt.Sprintf(" -> 需插入: %d 行, 需更新: %d 行", len(changeSet.Inserts), len(changeSet.Updates)))
|
||||
|
||||
if len(changeSet.Inserts) > 0 || len(changeSet.Updates) > 0 || len(changeSet.Deletes) > 0 {
|
||||
s.appendLog(config.JobID, &result, "info", fmt.Sprintf(" -> 需插入: %d 行, 需更新: %d 行, 需删除: %d 行", len(changeSet.Inserts), len(changeSet.Updates), len(changeSet.Deletes)))
|
||||
if applier, ok := targetDB.(db.BatchApplier); ok {
|
||||
if err := applier.ApplyChanges(targetTable, changeSet); err != nil {
|
||||
if err := applier.ApplyChanges(applyTableName, changeSet); err != nil {
|
||||
s.appendLog(config.JobID, &result, "error", fmt.Sprintf(" -> 应用变更失败: %v", err))
|
||||
} else {
|
||||
result.RowsInserted += len(changeSet.Inserts)
|
||||
result.RowsUpdated += len(changeSet.Updates)
|
||||
return
|
||||
}
|
||||
result.RowsInserted += len(changeSet.Inserts)
|
||||
result.RowsUpdated += len(changeSet.Updates)
|
||||
result.RowsDeleted += len(changeSet.Deletes)
|
||||
} else {
|
||||
s.appendLog(config.JobID, &result, "warn", " -> 目标驱动不支持应用数据变更 (ApplyChanges).")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
s.appendLog(config.JobID, &result, "info", " -> 数据一致,无需变更.")
|
||||
}
|
||||
|
||||
if len(plan.PostDataSQL) > 0 {
|
||||
s.progress(config.JobID, i, totalTables, tableName, "创建索引")
|
||||
if err := executeSQLStatements(targetDB.Exec, plan.PostDataSQL); err != nil {
|
||||
s.appendLog(config.JobID, &result, "error", fmt.Sprintf("创建索引失败:表=%s 错误=%v", tableName, err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
result.TablesSynced++
|
||||
}()
|
||||
}
|
||||
@@ -554,3 +490,26 @@ func (s *SyncEngine) fail(jobID string, totalTables int, res SyncResult, msg str
|
||||
s.progress(jobID, res.TablesSynced, totalTables, "", "同步失败")
|
||||
return res
|
||||
}
|
||||
|
||||
func (s *SyncEngine) execDDLStatements(jobID string, res *SyncResult, database db.Database, tableName string, stage string, statements []string) error {
|
||||
for _, statement := range statements {
|
||||
sqlText := strings.TrimSpace(statement)
|
||||
if sqlText == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := database.Exec(sqlText); err != nil {
|
||||
return fmt.Errorf("%s失败: %w", stage, err)
|
||||
}
|
||||
s.appendLog(jobID, res, "info", fmt.Sprintf("表 %s %s成功:%s", tableName, stage, shortenSyncSQL(sqlText)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func shortenSyncSQL(sqlText string) string {
|
||||
text := strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(sqlText, "\n", " "), "\t", " "))
|
||||
text = strings.Join(strings.Fields(text), " ")
|
||||
if len(text) <= 120 {
|
||||
return text
|
||||
}
|
||||
return text[:117] + "..."
|
||||
}
|
||||
|
||||
@@ -27,4 +27,3 @@ type Reporter struct {
|
||||
OnLog func(event SyncLogEvent)
|
||||
OnProgress func(event SyncProgressEvent)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user