mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-12 12:19:47 +08:00
Compare commits
20 Commits
v0.4.2
...
fix/table-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bec5013a44 | ||
|
|
66a3113fa8 | ||
|
|
a435d62d3b | ||
|
|
50d92d3184 | ||
|
|
91658848c9 | ||
|
|
fda30539b6 | ||
|
|
1ba68fcbfe | ||
|
|
f0e1c7e72c | ||
|
|
663717d738 | ||
|
|
5329f212f7 | ||
|
|
d6e967a0d0 | ||
|
|
7ca2d20c17 | ||
|
|
9307ca5e16 | ||
|
|
60a42e3c34 | ||
|
|
26a7aacfec | ||
|
|
8df9ea717c | ||
|
|
31f2a47d26 | ||
|
|
e01ecfc387 | ||
|
|
69d9a0b11e | ||
|
|
33f4208f39 |
253
.github/workflows/release.yml
vendored
253
.github/workflows/release.yml
vendored
@@ -19,23 +19,59 @@ jobs:
|
||||
include:
|
||||
- os: macos-latest
|
||||
platform: darwin/amd64
|
||||
artifact_name: GoNavi-mac-amd64
|
||||
asset_ext: .dmg
|
||||
os_name: MacOS
|
||||
arch_name: Amd64
|
||||
build_name: gonavi-build-darwin-amd64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: ""
|
||||
- os: macos-latest
|
||||
platform: darwin/arm64
|
||||
artifact_name: GoNavi-mac-arm64
|
||||
asset_ext: .dmg
|
||||
os_name: MacOS
|
||||
arch_name: Arm64
|
||||
build_name: gonavi-build-darwin-arm64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: ""
|
||||
- os: windows-latest
|
||||
platform: windows/amd64
|
||||
artifact_name: GoNavi-windows-amd64
|
||||
asset_ext: .exe
|
||||
os_name: Windows
|
||||
arch_name: Amd64
|
||||
build_name: gonavi-build-windows-amd64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: ""
|
||||
- os: windows-latest
|
||||
platform: windows/arm64
|
||||
artifact_name: GoNavi-windows-arm64
|
||||
asset_ext: .exe
|
||||
os_name: Windows
|
||||
arch_name: Arm64
|
||||
build_name: gonavi-build-windows-arm64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: ""
|
||||
- os: ubuntu-22.04
|
||||
platform: linux/amd64
|
||||
artifact_name: GoNavi-linux-amd64
|
||||
os_name: Linux
|
||||
arch_name: Amd64
|
||||
build_name: gonavi-build-linux-amd64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: "4.0"
|
||||
# Debian 13 (trixie) 默认仓库已切到 WebKitGTK 4.1:单独提供 4.1 变体产物
|
||||
- os: ubuntu-24.04
|
||||
platform: linux/amd64
|
||||
os_name: Linux
|
||||
arch_name: Amd64
|
||||
build_name: gonavi-build-linux-amd64-webkit41
|
||||
wails_tags: "webkit2_41"
|
||||
artifact_suffix: "-WebKit41"
|
||||
build_optional_agents: false
|
||||
linux_webkit: "4.1"
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -57,7 +93,17 @@ jobs:
|
||||
if: contains(matrix.platform, 'linux')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libfuse2
|
||||
sudo apt-get install -y libgtk-3-dev
|
||||
|
||||
# WebKitGTK 4.1 需要 libsoup3;4.0 使用 libsoup2(通常由 webkit2gtk 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
|
||||
|
||||
# AppImage 运行/打包可能需要 FUSE2。不同发行版/版本包名不同,做兼容兜底。
|
||||
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
|
||||
|
||||
# Download linuxdeploy tools for AppImage packaging
|
||||
LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
|
||||
@@ -88,13 +134,65 @@ jobs:
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: |
|
||||
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.artifact_name }} -ldflags "-X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
|
||||
set -euo pipefail
|
||||
TAG_ARGS=()
|
||||
if [ -n "${{ matrix.wails_tags }}" ]; then
|
||||
TAG_ARGS+=(-tags "${{ matrix.wails_tags }}")
|
||||
fi
|
||||
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} "${TAG_ARGS[@]}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
|
||||
|
||||
- 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 diros sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine)
|
||||
OUTDIR="drivers/${{ matrix.os_name }}"
|
||||
mkdir -p "$OUTDIR"
|
||||
|
||||
for DRIVER in "${DRIVERS[@]}"; do
|
||||
TAG="gonavi_${DRIVER}_driver"
|
||||
OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}"
|
||||
if [ "$GOOS" = "windows" ]; then
|
||||
OUTPUT="${OUTPUT}.exe"
|
||||
fi
|
||||
OUTPUT_PATH="${OUTDIR}/${OUTPUT}"
|
||||
echo "🔧 构建 ${OUTPUT_PATH} (tag=${TAG})"
|
||||
if [ "$DRIVER" = "duckdb" ]; then
|
||||
set +e
|
||||
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \
|
||||
-tags "${TAG}" \
|
||||
-trimpath \
|
||||
-ldflags "-s -w" \
|
||||
-o "${OUTPUT_PATH}" \
|
||||
./cmd/optional-driver-agent
|
||||
DUCKDB_RC=$?
|
||||
set -e
|
||||
if [ "${DUCKDB_RC}" -ne 0 ]; then
|
||||
echo "⚠️ DuckDB 代理构建失败(平台 ${GOOS}/${GOARCH}),跳过该资产,不阻断发布"
|
||||
rm -f "${OUTPUT_PATH}"
|
||||
continue
|
||||
fi
|
||||
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
|
||||
|
||||
# macOS Packaging
|
||||
- name: Package macOS DMG
|
||||
if: contains(matrix.platform, 'darwin')
|
||||
run: |
|
||||
brew install create-dmg
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION="${VERSION#v}"
|
||||
cd build/bin
|
||||
|
||||
APP_PATH=$(find . -maxdepth 1 -name "*.app" | head -n 1)
|
||||
@@ -107,7 +205,8 @@ jobs:
|
||||
echo "🔏 正在进行 Ad-hoc 签名..."
|
||||
codesign --force --options runtime --deep --sign - "$APP_NAME"
|
||||
|
||||
DMG_NAME="${{ matrix.artifact_name }}.dmg"
|
||||
DMG_NAME="${{ matrix.build_name }}.dmg"
|
||||
FINAL_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.dmg"
|
||||
echo "📦 正在生成 DMG: $DMG_NAME..."
|
||||
|
||||
create-dmg \
|
||||
@@ -121,35 +220,48 @@ jobs:
|
||||
"$DMG_NAME" \
|
||||
"$APP_NAME"
|
||||
|
||||
mv "$DMG_NAME" ../../
|
||||
mv "$DMG_NAME" "../../$FINAL_NAME"
|
||||
|
||||
# Windows Packaging
|
||||
- name: Prepare Windows Exe
|
||||
- name: Package Windows Portable Zip
|
||||
if: contains(matrix.platform, 'windows')
|
||||
shell: bash
|
||||
shell: pwsh
|
||||
run: |
|
||||
cd build/bin
|
||||
TARGET="${{ matrix.artifact_name }}"
|
||||
|
||||
if [ -f "$TARGET.exe" ]; then
|
||||
FINAL_EXE="$TARGET.exe"
|
||||
elif [ -f "$TARGET" ]; then
|
||||
mv "$TARGET" "$TARGET.exe"
|
||||
FINAL_EXE="$TARGET.exe"
|
||||
else
|
||||
echo "❌ 未找到构建产物 '$TARGET'!"
|
||||
Set-Location build/bin
|
||||
$version = "${{ github.ref_name }}"
|
||||
if ($version.StartsWith("v")) {
|
||||
$version = $version.Substring(1)
|
||||
}
|
||||
$target = "${{ matrix.build_name }}"
|
||||
$finalExeName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.exe"
|
||||
$finalZipName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.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
|
||||
fi
|
||||
|
||||
echo "📦 正在移动 $FINAL_EXE 到根目录..."
|
||||
mv "$FINAL_EXE" "../../$FINAL_EXE"
|
||||
}
|
||||
|
||||
Write-Host "📦 生成 Windows 可执行文件 $finalExeName..."
|
||||
Copy-Item -LiteralPath $finalExe -Destination "..\\..\\$finalExeName" -Force
|
||||
|
||||
Write-Host "📦 生成 Windows 压缩包 $finalZipName..."
|
||||
Compress-Archive -LiteralPath $finalExe -DestinationPath "..\\..\\$finalZipName" -Force
|
||||
|
||||
# Linux Packaging (tar.gz and AppImage)
|
||||
- name: Package Linux
|
||||
if: contains(matrix.platform, 'linux')
|
||||
run: |
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION="${VERSION#v}"
|
||||
cd build/bin
|
||||
TARGET="${{ matrix.artifact_name }}"
|
||||
TARGET="${{ matrix.build_name }}"
|
||||
TAR_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.tar.gz"
|
||||
APPIMAGE_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.AppImage"
|
||||
|
||||
if [ ! -f "$TARGET" ]; then
|
||||
echo "❌ 未找到构建产物 '$TARGET'!"
|
||||
@@ -159,9 +271,9 @@ jobs:
|
||||
chmod +x "$TARGET"
|
||||
|
||||
# 1. Create tar.gz
|
||||
echo "📦 正在打包 $TARGET.tar.gz..."
|
||||
tar -czvf "$TARGET.tar.gz" "$TARGET"
|
||||
mv "$TARGET.tar.gz" ../../
|
||||
echo "📦 正在打包 $TAR_NAME..."
|
||||
tar -czvf "$TAR_NAME" "$TARGET"
|
||||
mv "$TAR_NAME" ../../
|
||||
|
||||
# 2. Create AppImage (skip for ARM64 or if tools unavailable)
|
||||
if [ -f /tmp/skip-appimage ]; then
|
||||
@@ -211,13 +323,13 @@ jobs:
|
||||
}
|
||||
|
||||
# Rename output
|
||||
mv GoNavi*.AppImage "$TARGET.AppImage" 2>/dev/null || {
|
||||
mv GoNavi*.AppImage "$APPIMAGE_NAME" 2>/dev/null || {
|
||||
echo "⚠️ AppImage 重命名失败"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if [ -f "$TARGET.AppImage" ]; then
|
||||
mv "$TARGET.AppImage" ../../
|
||||
if [ -f "$APPIMAGE_NAME" ]; then
|
||||
mv "$APPIMAGE_NAME" ../../
|
||||
echo "✅ AppImage 生成成功"
|
||||
fi
|
||||
|
||||
@@ -229,8 +341,10 @@ jobs:
|
||||
path: |
|
||||
GoNavi-*.dmg
|
||||
GoNavi-*.exe
|
||||
GoNavi-*.zip
|
||||
GoNavi-*.tar.gz
|
||||
GoNavi-*.AppImage
|
||||
drivers/**
|
||||
retention-days: 1
|
||||
|
||||
# Phase 2: Collect all artifacts and Publish Release (Single Job)
|
||||
@@ -249,10 +363,75 @@ jobs:
|
||||
- name: List Assets
|
||||
run: ls -R release-assets
|
||||
|
||||
- name: Package Driver Agents Bundle
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd release-assets
|
||||
if [ ! -d drivers ]; then
|
||||
echo "⚠️ 未找到 drivers 目录,跳过驱动总包打包"
|
||||
exit 0
|
||||
fi
|
||||
if [ -z "$(find drivers -type f 2>/dev/null | head -n 1)" ]; then
|
||||
echo "⚠️ drivers 目录为空,跳过驱动总包打包"
|
||||
rm -rf drivers
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "📦 打包驱动总包:GoNavi-DriverAgents.zip"
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
out_name = "GoNavi-DriverAgents.zip"
|
||||
index_name = "GoNavi-DriverAgents-Index.json"
|
||||
base = Path("drivers")
|
||||
out_path = Path(out_name)
|
||||
index_path = Path(index_name)
|
||||
if out_path.exists():
|
||||
out_path.unlink()
|
||||
if index_path.exists():
|
||||
index_path.unlink()
|
||||
|
||||
size_index = {}
|
||||
with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for p in base.rglob("*"):
|
||||
if not p.is_file():
|
||||
continue
|
||||
arcname = p.relative_to(base).as_posix()
|
||||
zf.write(p, arcname)
|
||||
size_index[p.name] = p.stat().st_size
|
||||
|
||||
index_path.write_text(
|
||||
json.dumps({"assets": size_index}, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
print(f"created {out_name} size={out_path.stat().st_size} bytes")
|
||||
print(f"created {index_name} entries={len(size_index)}")
|
||||
PY
|
||||
|
||||
# Release 只发布一个驱动总包,避免大量平铺资产污染 Release 页面
|
||||
rm -rf drivers
|
||||
|
||||
- name: Generate SHA256SUMS
|
||||
shell: bash
|
||||
run: |
|
||||
cd release-assets
|
||||
sha256sum * > SHA256SUMS
|
||||
FILES=()
|
||||
while IFS= read -r file; do
|
||||
if [ -n "$file" ]; then
|
||||
FILES+=("$file")
|
||||
fi
|
||||
done < <(find . -maxdepth 1 -type f ! -name SHA256SUMS -exec basename {} \; | sort)
|
||||
if [ ${#FILES[@]} -eq 0 ]; then
|
||||
echo "⚠️ 未找到可签名资产,生成空 SHA256SUMS"
|
||||
: > SHA256SUMS
|
||||
else
|
||||
sha256sum "${FILES[@]}" > SHA256SUMS
|
||||
fi
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
|
||||
22
README.md
22
README.md
@@ -129,6 +129,7 @@ wails build -clean
|
||||
支持构建:
|
||||
* macOS (AMD64 / ARM64)
|
||||
* Windows (AMD64)
|
||||
* Linux (AMD64,提供 WebKitGTK 4.0 与 4.1 变体产物)
|
||||
|
||||
---
|
||||
|
||||
@@ -146,6 +147,27 @@ wails build -clean
|
||||
```
|
||||
4. 或者:在 Finder 中右键点击应用图标,按住 `Control` 键选择 **打开**,然后在弹出的窗口中再次点击 **打开**。
|
||||
|
||||
### Linux 启动报错缺少 `libwebkit2gtk` / `libjavascriptcoregtk`
|
||||
|
||||
GoNavi 的 Linux 二进制依赖系统 WebKitGTK 运行库。不同发行版默认版本不同:
|
||||
|
||||
- Debian 13 / Ubuntu 24.04 及更新版本:通常为 WebKitGTK 4.1
|
||||
- Ubuntu 22.04 / Debian 12 等:通常为 WebKitGTK 4.0
|
||||
|
||||
如果启动时报错(如 `libwebkit2gtk-4.0.so.37: cannot open shared object file`),请按系统安装对应依赖后重试:
|
||||
|
||||
```bash
|
||||
# Debian 13 / Ubuntu 24.04+
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.1-0 libjavascriptcoregtk-4.1-0
|
||||
|
||||
# Ubuntu 22.04 / Debian 12
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0-18
|
||||
```
|
||||
|
||||
如果你使用的是 Release 中带 `-WebKit41` 后缀的 Linux 产物,请优先在 Debian 13 / Ubuntu 24.04+ 上使用;普通 Linux 产物更适合 WebKitGTK 4.0 运行环境。
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
@@ -12,7 +12,7 @@ if [ -z "$VERSION" ]; then
|
||||
VERSION="0.0.0"
|
||||
fi
|
||||
echo "ℹ️ 检测到版本号: $VERSION"
|
||||
LDFLAGS="-X GoNavi-Wails/internal/app.AppVersion=$VERSION"
|
||||
LDFLAGS="-s -w -X GoNavi-Wails/internal/app.AppVersion=$VERSION"
|
||||
|
||||
# 颜色配置
|
||||
GREEN='\033[0;32m'
|
||||
|
||||
227
cmd/mysql-driver-agent/main.go
Normal file
227
cmd/mysql-driver-agent/main.go
Normal file
@@ -0,0 +1,227 @@
|
||||
//go:build gonavi_mysql_driver
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
)
|
||||
|
||||
type mysqlAgentRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Config *connection.ConnectionConfig `json:"config,omitempty"`
|
||||
Query string `json:"query,omitempty"`
|
||||
DBName string `json:"dbName,omitempty"`
|
||||
TableName string `json:"tableName,omitempty"`
|
||||
Changes *connection.ChangeSet `json:"changes,omitempty"`
|
||||
}
|
||||
|
||||
type mysqlAgentResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Fields []string `json:"fields,omitempty"`
|
||||
RowsAffected int64 `json:"rowsAffected,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
mysqlAgentMethodConnect = "connect"
|
||||
mysqlAgentMethodClose = "close"
|
||||
mysqlAgentMethodPing = "ping"
|
||||
mysqlAgentMethodQuery = "query"
|
||||
mysqlAgentMethodExec = "exec"
|
||||
mysqlAgentMethodGetDatabases = "getDatabases"
|
||||
mysqlAgentMethodGetTables = "getTables"
|
||||
mysqlAgentMethodGetCreateStmt = "getCreateStatement"
|
||||
mysqlAgentMethodGetColumns = "getColumns"
|
||||
mysqlAgentMethodGetAllColumns = "getAllColumns"
|
||||
mysqlAgentMethodGetIndexes = "getIndexes"
|
||||
mysqlAgentMethodGetForeignKey = "getForeignKeys"
|
||||
mysqlAgentMethodGetTriggers = "getTriggers"
|
||||
mysqlAgentMethodApplyChanges = "applyChanges"
|
||||
)
|
||||
|
||||
func main() {
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
scanner.Buffer(make([]byte, 0, 16<<10), 8<<20)
|
||||
writer := bufio.NewWriter(os.Stdout)
|
||||
defer writer.Flush()
|
||||
|
||||
var inst *db.MySQLDB
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var req mysqlAgentRequest
|
||||
if err := json.Unmarshal([]byte(line), &req); err != nil {
|
||||
_ = writeResponse(writer, mysqlAgentResponse{
|
||||
ID: req.ID,
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("解析请求失败:%v", err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
resp := handleRequest(&inst, req)
|
||||
if err := writeResponse(writer, resp); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "写入响应失败:%v\n", err)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if inst != nil {
|
||||
_ = inst.Close()
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "读取请求失败:%v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func handleRequest(inst **db.MySQLDB, req mysqlAgentRequest) mysqlAgentResponse {
|
||||
resp := mysqlAgentResponse{
|
||||
ID: req.ID,
|
||||
Success: true,
|
||||
}
|
||||
|
||||
switch strings.TrimSpace(req.Method) {
|
||||
case mysqlAgentMethodConnect:
|
||||
if req.Config == nil {
|
||||
return fail(resp, "连接配置为空")
|
||||
}
|
||||
if *inst != nil {
|
||||
_ = (*inst).Close()
|
||||
}
|
||||
next := &db.MySQLDB{}
|
||||
if err := next.Connect(*req.Config); err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
*inst = next
|
||||
return resp
|
||||
case mysqlAgentMethodClose:
|
||||
if *inst != nil {
|
||||
if err := (*inst).Close(); err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
*inst = nil
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
if *inst == nil {
|
||||
return fail(resp, "connection not open")
|
||||
}
|
||||
|
||||
switch strings.TrimSpace(req.Method) {
|
||||
case mysqlAgentMethodPing:
|
||||
if err := (*inst).Ping(); err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
case mysqlAgentMethodQuery:
|
||||
data, fields, err := (*inst).Query(req.Query)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
resp.Fields = fields
|
||||
case mysqlAgentMethodExec:
|
||||
affected, err := (*inst).Exec(req.Query)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.RowsAffected = affected
|
||||
case mysqlAgentMethodGetDatabases:
|
||||
data, err := (*inst).GetDatabases()
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case mysqlAgentMethodGetTables:
|
||||
data, err := (*inst).GetTables(req.DBName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case mysqlAgentMethodGetCreateStmt:
|
||||
data, err := (*inst).GetCreateStatement(req.DBName, req.TableName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case mysqlAgentMethodGetColumns:
|
||||
data, err := (*inst).GetColumns(req.DBName, req.TableName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case mysqlAgentMethodGetAllColumns:
|
||||
data, err := (*inst).GetAllColumns(req.DBName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case mysqlAgentMethodGetIndexes:
|
||||
data, err := (*inst).GetIndexes(req.DBName, req.TableName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case mysqlAgentMethodGetForeignKey:
|
||||
data, err := (*inst).GetForeignKeys(req.DBName, req.TableName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case mysqlAgentMethodGetTriggers:
|
||||
data, err := (*inst).GetTriggers(req.DBName, req.TableName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case mysqlAgentMethodApplyChanges:
|
||||
if req.Changes == nil {
|
||||
return fail(resp, "变更集为空")
|
||||
}
|
||||
applier, ok := interface{}(*inst).(interface {
|
||||
ApplyChanges(tableName string, changes connection.ChangeSet) error
|
||||
})
|
||||
if !ok {
|
||||
return fail(resp, "当前驱动不支持 ApplyChanges")
|
||||
}
|
||||
if err := applier.ApplyChanges(req.TableName, *req.Changes); err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
default:
|
||||
return fail(resp, "不支持的方法")
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
func writeResponse(writer *bufio.Writer, resp mysqlAgentResponse) error {
|
||||
payload, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
if _, err := writer.Write(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return writer.Flush()
|
||||
}
|
||||
|
||||
func fail(resp mysqlAgentResponse, errText string) mysqlAgentResponse {
|
||||
resp.Success = false
|
||||
resp.Error = strings.TrimSpace(errText)
|
||||
return resp
|
||||
}
|
||||
236
cmd/optional-driver-agent/main.go
Normal file
236
cmd/optional-driver-agent/main.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
)
|
||||
|
||||
type agentRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Config *connection.ConnectionConfig `json:"config,omitempty"`
|
||||
Query string `json:"query,omitempty"`
|
||||
DBName string `json:"dbName,omitempty"`
|
||||
TableName string `json:"tableName,omitempty"`
|
||||
Changes *connection.ChangeSet `json:"changes,omitempty"`
|
||||
}
|
||||
|
||||
type agentResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Fields []string `json:"fields,omitempty"`
|
||||
RowsAffected int64 `json:"rowsAffected,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
agentMethodConnect = "connect"
|
||||
agentMethodClose = "close"
|
||||
agentMethodPing = "ping"
|
||||
agentMethodQuery = "query"
|
||||
agentMethodExec = "exec"
|
||||
agentMethodGetDatabases = "getDatabases"
|
||||
agentMethodGetTables = "getTables"
|
||||
agentMethodGetCreateStmt = "getCreateStatement"
|
||||
agentMethodGetColumns = "getColumns"
|
||||
agentMethodGetAllColumns = "getAllColumns"
|
||||
agentMethodGetIndexes = "getIndexes"
|
||||
agentMethodGetForeignKey = "getForeignKeys"
|
||||
agentMethodGetTriggers = "getTriggers"
|
||||
agentMethodApplyChanges = "applyChanges"
|
||||
)
|
||||
|
||||
var (
|
||||
agentDriverType string
|
||||
agentDatabaseFactory func() db.Database
|
||||
)
|
||||
|
||||
func main() {
|
||||
if agentDatabaseFactory == nil || strings.TrimSpace(agentDriverType) == "" {
|
||||
fmt.Fprintf(os.Stderr, "未配置驱动代理 provider,请使用 gonavi_<driver>_driver 标签构建\n")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
scanner.Buffer(make([]byte, 0, 16<<10), 8<<20)
|
||||
writer := bufio.NewWriter(os.Stdout)
|
||||
defer writer.Flush()
|
||||
|
||||
var inst db.Database
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var req agentRequest
|
||||
if err := json.Unmarshal([]byte(line), &req); err != nil {
|
||||
_ = writeResponse(writer, agentResponse{
|
||||
ID: req.ID,
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("解析请求失败:%v", err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
resp := handleRequest(&inst, req)
|
||||
if err := writeResponse(writer, resp); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "写入响应失败:%v\n", err)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if inst != nil {
|
||||
_ = inst.Close()
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "读取请求失败:%v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func handleRequest(inst *db.Database, req agentRequest) agentResponse {
|
||||
resp := agentResponse{ID: req.ID, Success: true}
|
||||
method := strings.TrimSpace(req.Method)
|
||||
|
||||
switch method {
|
||||
case agentMethodConnect:
|
||||
if req.Config == nil {
|
||||
return fail(resp, "连接配置为空")
|
||||
}
|
||||
if *inst != nil {
|
||||
_ = (*inst).Close()
|
||||
}
|
||||
next := agentDatabaseFactory()
|
||||
if next == nil {
|
||||
return fail(resp, "驱动代理初始化失败")
|
||||
}
|
||||
if err := next.Connect(*req.Config); err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
*inst = next
|
||||
return resp
|
||||
case agentMethodClose:
|
||||
if *inst != nil {
|
||||
if err := (*inst).Close(); err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
*inst = nil
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
if *inst == nil {
|
||||
return fail(resp, "connection not open")
|
||||
}
|
||||
|
||||
switch method {
|
||||
case agentMethodPing:
|
||||
if err := (*inst).Ping(); err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
case agentMethodQuery:
|
||||
data, fields, err := (*inst).Query(req.Query)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
resp.Fields = fields
|
||||
case agentMethodExec:
|
||||
affected, err := (*inst).Exec(req.Query)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.RowsAffected = affected
|
||||
case agentMethodGetDatabases:
|
||||
data, err := (*inst).GetDatabases()
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case agentMethodGetTables:
|
||||
data, err := (*inst).GetTables(req.DBName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case agentMethodGetCreateStmt:
|
||||
data, err := (*inst).GetCreateStatement(req.DBName, req.TableName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case agentMethodGetColumns:
|
||||
data, err := (*inst).GetColumns(req.DBName, req.TableName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case agentMethodGetAllColumns:
|
||||
data, err := (*inst).GetAllColumns(req.DBName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case agentMethodGetIndexes:
|
||||
data, err := (*inst).GetIndexes(req.DBName, req.TableName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case agentMethodGetForeignKey:
|
||||
data, err := (*inst).GetForeignKeys(req.DBName, req.TableName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case agentMethodGetTriggers:
|
||||
data, err := (*inst).GetTriggers(req.DBName, req.TableName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case agentMethodApplyChanges:
|
||||
if req.Changes == nil {
|
||||
return fail(resp, "变更集为空")
|
||||
}
|
||||
applier, ok := (*inst).(interface {
|
||||
ApplyChanges(tableName string, changes connection.ChangeSet) error
|
||||
})
|
||||
if !ok {
|
||||
return fail(resp, "当前驱动不支持 ApplyChanges")
|
||||
}
|
||||
if err := applier.ApplyChanges(req.TableName, *req.Changes); err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
default:
|
||||
return fail(resp, "不支持的方法")
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
func writeResponse(writer *bufio.Writer, resp agentResponse) error {
|
||||
payload, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
if _, err := writer.Write(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return writer.Flush()
|
||||
}
|
||||
|
||||
func fail(resp agentResponse, errText string) agentResponse {
|
||||
resp.Success = false
|
||||
resp.Error = strings.TrimSpace(errText)
|
||||
return resp
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_dameng.go
Normal file
12
cmd/optional-driver-agent/provider_dameng.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_dameng_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "dameng"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.DamengDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_diros.go
Normal file
12
cmd/optional-driver-agent/provider_diros.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_diros_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "diros"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.DirosDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_duckdb.go
Normal file
12
cmd/optional-driver-agent/provider_duckdb.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_duckdb_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "duckdb"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.DuckDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_highgo.go
Normal file
12
cmd/optional-driver-agent/provider_highgo.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_highgo_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "highgo"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.HighGoDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_kingbase.go
Normal file
12
cmd/optional-driver-agent/provider_kingbase.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_kingbase_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "kingbase"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.KingbaseDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_mariadb.go
Normal file
12
cmd/optional-driver-agent/provider_mariadb.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_mariadb_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "mariadb"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.MariaDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_mongodb.go
Normal file
12
cmd/optional-driver-agent/provider_mongodb.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_mongodb_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "mongodb"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.MongoDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_mysql.go
Normal file
12
cmd/optional-driver-agent/provider_mysql.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_mysql_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "mysql"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.MySQLDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_sphinx.go
Normal file
12
cmd/optional-driver-agent/provider_sphinx.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_sphinx_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "sphinx"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.SphinxDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_sqlite.go
Normal file
12
cmd/optional-driver-agent/provider_sqlite.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_sqlite_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "sqlite"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.SQLiteDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_sqlserver.go
Normal file
12
cmd/optional-driver-agent/provider_sqlserver.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_sqlserver_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "sqlserver"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.SqlServerDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_tdengine.go
Normal file
12
cmd/optional-driver-agent/provider_tdengine.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_tdengine_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "tdengine"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.TDengineDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_vastbase.go
Normal file
12
cmd/optional-driver-agent/provider_vastbase.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_vastbase_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "vastbase"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.VastbaseDB{}
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
# HighGo 可选代码优化建议
|
||||
|
||||
## 一、sslmode 配置优化
|
||||
|
||||
### 当前状态
|
||||
|
||||
**文件**:`internal/db/highgo_impl.go:43`
|
||||
|
||||
**当前代码**:
|
||||
```go
|
||||
q.Set("sslmode", "disable")
|
||||
```
|
||||
|
||||
### 建议修改
|
||||
|
||||
根据瀚高官方文档,sslmode 的默认值应该是 `require`。建议修改为:
|
||||
|
||||
```go
|
||||
q.Set("sslmode", "require")
|
||||
```
|
||||
|
||||
### 修改原因
|
||||
|
||||
1. **符合官方规范**:瀚高官方文档明确指出默认 sslmode 为 `require`
|
||||
2. **安全性提升**:启用 SSL 加密可以保护数据传输安全
|
||||
3. **生产环境最佳实践**:生产环境应该启用 SSL 连接
|
||||
|
||||
### 是否需要修改?
|
||||
|
||||
**不一定需要修改**,取决于您的实际环境:
|
||||
|
||||
#### 保持 `disable` 的场景:
|
||||
- ✅ 开发/测试环境
|
||||
- ✅ HighGo 服务器未配置 SSL 证书
|
||||
- ✅ 内网环境,不需要加密传输
|
||||
- ✅ 快速测试连接功能
|
||||
|
||||
#### 修改为 `require` 的场景:
|
||||
- ✅ 生产环境
|
||||
- ✅ HighGo 服务器已配置 SSL 证书
|
||||
- ✅ 跨网络连接,需要加密保护
|
||||
- ✅ 符合安全合规要求
|
||||
|
||||
### 如何修改
|
||||
|
||||
如果您决定修改,可以使用以下命令:
|
||||
|
||||
**方式 1:直接修改(固定为 require)**
|
||||
```go
|
||||
// 文件:internal/db/highgo_impl.go 第 43 行
|
||||
q.Set("sslmode", "require")
|
||||
```
|
||||
|
||||
**方式 2:可配置(推荐)**
|
||||
|
||||
如果希望让用户可以选择 sslmode,可以修改为:
|
||||
|
||||
```go
|
||||
// 在 getDSN 方法中
|
||||
sslmode := "disable" // 默认值
|
||||
if config.SSLMode != "" {
|
||||
sslmode = config.SSLMode
|
||||
}
|
||||
q.Set("sslmode", sslmode)
|
||||
```
|
||||
|
||||
然后在 `internal/connection/connection.go` 的 `ConnectionConfig` 结构体中添加字段:
|
||||
|
||||
```go
|
||||
type ConnectionConfig struct {
|
||||
// ... 现有字段
|
||||
SSLMode string `json:"sslMode,omitempty"` // SSL 模式:disable, require, verify-ca, verify-full
|
||||
}
|
||||
```
|
||||
|
||||
前端 UI 也需要相应添加 sslmode 选择控件。
|
||||
|
||||
### 测试建议
|
||||
|
||||
修改后请务必测试:
|
||||
|
||||
1. **SSL 启用测试**:
|
||||
- 连接配置了 SSL 的 HighGo 服务器
|
||||
- 验证连接成功
|
||||
|
||||
2. **SSL 禁用测试**:
|
||||
- 连接未配置 SSL 的 HighGo 服务器
|
||||
- 验证是否会报错(如果设置为 `require` 会报错)
|
||||
|
||||
3. **兼容性测试**:
|
||||
- 测试现有的 HighGo 连接配置是否仍然可用
|
||||
|
||||
## 二、其他可选优化
|
||||
|
||||
### 1. 默认端口提示优化
|
||||
|
||||
**文件**:`frontend/src/components/ConnectionModal.tsx`
|
||||
|
||||
**当前状态**:HighGo 的默认端口已正确设置为 5866
|
||||
|
||||
**建议**:无需修改,已符合官方规范
|
||||
|
||||
### 2. 默认数据库名称
|
||||
|
||||
**文件**:`internal/db/highgo_impl.go:33`
|
||||
|
||||
**当前代码**:
|
||||
```go
|
||||
if dbname == "" {
|
||||
dbname = "highgo" // HighGo default database
|
||||
}
|
||||
```
|
||||
|
||||
**建议**:无需修改,已符合官方规范(默认数据库为 `highgo`)
|
||||
|
||||
### 3. 默认用户名
|
||||
|
||||
**当前状态**:未在代码中硬编码默认用户名
|
||||
|
||||
**瀚高官方默认**:`sysdba`
|
||||
|
||||
**建议**:
|
||||
- 可以在前端 UI 的 HighGo 连接表单中,将用户名输入框的 placeholder 设置为 `sysdba`
|
||||
- 但不建议硬编码默认值,让用户自行输入更安全
|
||||
|
||||
## 三、总结
|
||||
|
||||
### 必须修改的项目
|
||||
- ✅ **无**(当前代码已基本符合规范)
|
||||
|
||||
### 建议修改的项目
|
||||
1. **sslmode 配置**(根据实际环境决定)
|
||||
- 开发环境:保持 `disable`
|
||||
- 生产环境:修改为 `require`
|
||||
|
||||
### 可选优化的项目
|
||||
1. 将 sslmode 改为可配置(需要修改前后端)
|
||||
2. 前端 UI 添加 sslmode 选择控件
|
||||
3. 用户名输入框添加 `sysdba` 提示
|
||||
|
||||
## 四、修改优先级
|
||||
|
||||
**优先级 1(高)**:
|
||||
- 集成瀚高 SM3 驱动(参考 `HighGo_SM3_Integration_Guide.md`)
|
||||
|
||||
**优先级 2(中)**:
|
||||
- 根据部署环境调整 sslmode 配置
|
||||
|
||||
**优先级 3(低)**:
|
||||
- 将 sslmode 改为可配置
|
||||
- UI 优化(placeholder 提示等)
|
||||
|
||||
## 五、下一步行动
|
||||
|
||||
建议按以下顺序执行:
|
||||
|
||||
1. **先集成 SM3 驱动**(参考集成指南)
|
||||
2. **测试基本连接功能**(使用 sslmode=disable)
|
||||
3. **如果生产环境需要 SSL**,再修改 sslmode 配置
|
||||
4. **验证所有功能正常**后,考虑可选优化项
|
||||
|
||||
---
|
||||
|
||||
**注意**:所有代码修改都应该在集成 SM3 驱动并验证基本功能正常后再进行。
|
||||
@@ -1,196 +0,0 @@
|
||||
# HighGo SM3 国密驱动集成指南
|
||||
|
||||
## 一、背景说明
|
||||
|
||||
HighGo(瀚高)数据库需要使用支持 SM3 国密认证的 PostgreSQL 驱动。瀚高官方提供了基于 `lib/pq` 的安全增强版本。
|
||||
|
||||
## 二、集成步骤
|
||||
|
||||
### 步骤 1:下载瀚高 pq 驱动
|
||||
|
||||
1. 访问百度网盘链接:
|
||||
```
|
||||
https://pan.baidu.com/s/1xuz6uJz0utRgKWecXhpOiA?pwd=o0tj
|
||||
```
|
||||
|
||||
2. 下载驱动源码压缩包
|
||||
|
||||
### 步骤 2:放置驱动源码
|
||||
|
||||
1. 在项目根目录创建目录(如果不存在):
|
||||
```bash
|
||||
mkdir -p third_party/highgo-pq
|
||||
```
|
||||
|
||||
2. 解压下载的驱动源码到 `third_party/highgo-pq/` 目录
|
||||
|
||||
3. 确保目录结构如下:
|
||||
```
|
||||
GoNavi/
|
||||
├── third_party/
|
||||
│ └── highgo-pq/
|
||||
│ ├── go.mod
|
||||
│ ├── conn.go
|
||||
│ ├── ... (其他 pq 驱动源文件)
|
||||
```
|
||||
|
||||
### 步骤 3:修改 go.mod
|
||||
|
||||
在 `go.mod` 中添加独立的 HighGo 驱动依赖与本地替换:
|
||||
|
||||
```go
|
||||
require github.com/highgo/pq-sm3 v0.0.0
|
||||
replace github.com/highgo/pq-sm3 => ./third_party/highgo-pq
|
||||
```
|
||||
|
||||
完整示例:
|
||||
```go
|
||||
module GoNavi-Wails
|
||||
|
||||
go 1.24.3
|
||||
|
||||
require (
|
||||
// ... 现有依赖
|
||||
github.com/lib/pq v1.11.1
|
||||
github.com/highgo/pq-sm3 v0.0.0
|
||||
// ... 其他依赖
|
||||
)
|
||||
|
||||
// 在文件末尾添加
|
||||
replace github.com/highgo/pq-sm3 => ./third_party/highgo-pq
|
||||
```
|
||||
|
||||
并将 `third_party/highgo-pq/go.mod` 的 module 修改为:
|
||||
|
||||
```go
|
||||
module github.com/highgo/pq-sm3
|
||||
```
|
||||
|
||||
同时在驱动源码中把注册名改为 `highgo`,确保不覆盖 `postgres`:
|
||||
|
||||
```go
|
||||
sql.Register("highgo", &Driver{})
|
||||
```
|
||||
|
||||
### 步骤 4:更新 HighGo 连接配置(可选)
|
||||
|
||||
根据瀚高官方文档,建议修改 `internal/db/highgo_impl.go:43` 的 sslmode:
|
||||
|
||||
**当前代码**:
|
||||
```go
|
||||
q.Set("sslmode", "disable")
|
||||
```
|
||||
|
||||
**建议修改为**(瀚高默认):
|
||||
```go
|
||||
q.Set("sslmode", "require")
|
||||
```
|
||||
|
||||
> ⚠️ 注意:如果您的 HighGo 服务器未配置 SSL,保持 `disable` 即可。
|
||||
|
||||
### 步骤 5:验证集成
|
||||
|
||||
1. 清理依赖缓存:
|
||||
```bash
|
||||
go clean -modcache
|
||||
```
|
||||
|
||||
2. 重新下载依赖:
|
||||
```bash
|
||||
go mod download
|
||||
```
|
||||
|
||||
3. 编译项目:
|
||||
```bash
|
||||
go build ./...
|
||||
```
|
||||
|
||||
4. 测试 HighGo 连接:
|
||||
- 启动应用
|
||||
- 创建 HighGo 连接
|
||||
- 测试连接是否成功
|
||||
|
||||
## 三、重要说明
|
||||
|
||||
### ⚠️ 影响范围
|
||||
|
||||
采用独立驱动名后,影响范围如下:
|
||||
|
||||
1. **PostgreSQL 继续使用原生 `github.com/lib/pq`**
|
||||
2. **HighGo 使用 `github.com/highgo/pq-sm3`(本地替换到官方源码)**
|
||||
3. 两条连接链路互不覆盖,降低兼容性风险
|
||||
|
||||
### 兼容性验证
|
||||
|
||||
集成后,请务必测试:
|
||||
|
||||
1. ✅ HighGo 数据库连接(SM3 认证)
|
||||
2. ✅ 标准 PostgreSQL 连接(确保仍然可用)
|
||||
|
||||
若 PostgreSQL 或 HighGo 任一连接异常,优先检查驱动注册名与 `go.mod` replace 是否一致。
|
||||
|
||||
### 回滚方案
|
||||
|
||||
如果集成后出现问题,可以快速回滚:
|
||||
|
||||
1. 删除 `go.mod` 中的 replace 指令
|
||||
2. 删除 `go.mod` 中 `github.com/highgo/pq-sm3` 的 require
|
||||
3. 删除 `third_party/highgo-pq/` 目录
|
||||
4. 运行 `go mod tidy`
|
||||
5. 重新编译
|
||||
|
||||
## 四、瀚高驱动特性
|
||||
|
||||
根据官方文档:
|
||||
|
||||
- **项目内包路径**:`github.com/highgo/pq-sm3`(映射到本地 `third_party/highgo-pq`)
|
||||
- **驱动名**:`highgo`(项目内独立注册,避免覆盖 `postgres`)
|
||||
- **SM3 支持**:自动启用国密认证
|
||||
- **默认端口**:5866
|
||||
- **默认数据库**:`highgo`
|
||||
- **默认用户**:`sysdba`
|
||||
- **sslmode 默认**:`require`
|
||||
|
||||
## 五、故障排查
|
||||
|
||||
### 问题 1:编译失败
|
||||
|
||||
**现象**:`go build` 报错找不到 `github.com/highgo/pq-sm3`
|
||||
|
||||
**解决**:
|
||||
1. 检查 `third_party/highgo-pq/` 目录是否存在
|
||||
2. 检查 `go.mod` 中 `github.com/highgo/pq-sm3` 的 require/replace 是否正确
|
||||
3. 运行 `go mod download`
|
||||
|
||||
### 问题 2:HighGo 连接失败
|
||||
|
||||
**现象**:连接 HighGo 时报认证错误
|
||||
|
||||
**解决**:
|
||||
1. 确认瀚高驱动已正确替换(检查 `go.mod`)
|
||||
2. 确认项目内驱动注册名为 `highgo`
|
||||
3. 确认 HighGo 服务器支持 SM3 认证
|
||||
4. 检查用户名、密码、端口是否正确
|
||||
|
||||
### 问题 3:PostgreSQL 连接失败
|
||||
|
||||
**现象**:集成后标准 PostgreSQL 无法连接
|
||||
|
||||
**解决**:
|
||||
1. 检查是否误将 `github.com/lib/pq` 全局 replace 到 HighGo 驱动
|
||||
2. 确认 PostgreSQL 仍使用 `sql.Open("postgres", dsn)`
|
||||
3. 确认 HighGo 使用 `sql.Open("highgo", dsn)`
|
||||
|
||||
## 六、后续优化建议
|
||||
|
||||
如果后续需要增强,可考虑:
|
||||
|
||||
1. 将 HighGo `sslmode` 做成可配置项(前后端联动)
|
||||
2. 增加 HighGo/PG 驱动链路健康检查项
|
||||
3. 联系瀚高技术支持确认 SM3 + SSL 最佳参数组合
|
||||
|
||||
## 七、参考资料
|
||||
|
||||
- 瀚高官方文档:https://www.highgo.com/document/zh-cn/application/pq%E6%8E%A5%E5%8F%A3.html
|
||||
- 瀚高驱动下载:https://pan.baidu.com/s/1xuz6uJz0utRgKWecXhpOiA?pwd=o0tj
|
||||
- 标准 lib/pq:https://github.com/lib/pq
|
||||
83
docs/driver-manifest.json
Normal file
83
docs/driver-manifest.json
Normal file
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"engine": "go",
|
||||
"drivers": {
|
||||
"mariadb": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/mariadb"
|
||||
},
|
||||
"diros": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/diros"
|
||||
},
|
||||
"sphinx": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/sphinx"
|
||||
},
|
||||
"sqlserver": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/sqlserver"
|
||||
},
|
||||
"sqlite": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/sqlite"
|
||||
},
|
||||
"duckdb": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/duckdb"
|
||||
},
|
||||
"dameng": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/dameng"
|
||||
},
|
||||
"kingbase": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/kingbase"
|
||||
},
|
||||
"highgo": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/highgo"
|
||||
},
|
||||
"vastbase": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/vastbase"
|
||||
},
|
||||
"mongodb": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/mongodb"
|
||||
},
|
||||
"tdengine": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/tdengine"
|
||||
},
|
||||
"postgres": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/postgres"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import Sidebar from './components/Sidebar';
|
||||
import TabManager from './components/TabManager';
|
||||
import ConnectionModal from './components/ConnectionModal';
|
||||
import DataSyncModal from './components/DataSyncModal';
|
||||
import DriverManagerModal from './components/DriverManagerModal';
|
||||
import LogPanel from './components/LogPanel';
|
||||
import { useStore } from './store';
|
||||
import { SavedConnection } from './types';
|
||||
@@ -19,6 +20,7 @@ const { Sider, Content } = Layout;
|
||||
function App() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isSyncModalOpen, setIsSyncModalOpen] = useState(false);
|
||||
const [isDriverModalOpen, setIsDriverModalOpen] = useState(false);
|
||||
const [editingConnection, setEditingConnection] = useState<SavedConnection | null>(null);
|
||||
const themeMode = useStore(state => state.theme);
|
||||
const setTheme = useStore(state => state.setTheme);
|
||||
@@ -115,6 +117,8 @@ function App() {
|
||||
assetUrl?: string;
|
||||
assetSize?: number;
|
||||
sha256?: string;
|
||||
downloaded?: boolean;
|
||||
downloadPath?: string;
|
||||
};
|
||||
|
||||
type UpdateDownloadProgressEvent = {
|
||||
@@ -247,12 +251,30 @@ function App() {
|
||||
if (!info) return;
|
||||
setLastUpdateInfo(info);
|
||||
if (info.hasUpdate) {
|
||||
const localDownloaded = updateDownloadedVersionRef.current === info.latestVersion;
|
||||
const hasDownloaded = Boolean(info.downloaded) || localDownloaded;
|
||||
if (hasDownloaded) {
|
||||
const downloadPath = info.downloadPath || updateDownloadMetaRef.current?.downloadPath || '';
|
||||
updateDownloadedVersionRef.current = info.latestVersion;
|
||||
updateDownloadMetaRef.current = {
|
||||
...(updateDownloadMetaRef.current || {}),
|
||||
info,
|
||||
downloadPath: downloadPath || undefined,
|
||||
};
|
||||
} else {
|
||||
if (updateDownloadedVersionRef.current !== info.latestVersion) {
|
||||
updateDownloadMetaRef.current = null;
|
||||
}
|
||||
}
|
||||
const statusText = hasDownloaded
|
||||
? `发现新版本 ${info.latestVersion}(已下载,待重启安装)`
|
||||
: `发现新版本 ${info.latestVersion}(未下载)`;
|
||||
if (!silent) {
|
||||
message.info(`发现新版本 ${info.latestVersion}`);
|
||||
setAboutUpdateStatus(`发现新版本 ${info.latestVersion}(未下载)`);
|
||||
setAboutUpdateStatus(statusText);
|
||||
}
|
||||
if (silent && isAboutOpen) {
|
||||
setAboutUpdateStatus(`发现新版本 ${info.latestVersion}(未下载)`);
|
||||
setAboutUpdateStatus(statusText);
|
||||
}
|
||||
if (silent && !isAboutOpen && updateMutedVersionRef.current !== info.latestVersion && updateNotifiedVersionRef.current !== info.latestVersion) {
|
||||
updateNotifiedVersionRef.current = info.latestVersion;
|
||||
@@ -358,6 +380,12 @@ function App() {
|
||||
label: '数据同步',
|
||||
icon: <UploadOutlined rotate={90} />,
|
||||
onClick: () => setIsSyncModalOpen(true)
|
||||
},
|
||||
{
|
||||
key: 'drivers',
|
||||
label: '驱动管理',
|
||||
icon: <SettingOutlined />,
|
||||
onClick: () => setIsDriverModalOpen(true)
|
||||
}
|
||||
];
|
||||
|
||||
@@ -447,6 +475,12 @@ function App() {
|
||||
setEditingConnection(null);
|
||||
};
|
||||
|
||||
const handleOpenDriverManagerFromConnection = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingConnection(null);
|
||||
setIsDriverModalOpen(true);
|
||||
};
|
||||
|
||||
const handleTitleBarDoubleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest('[data-no-titlebar-toggle="true"]')) {
|
||||
@@ -773,11 +807,16 @@ function App() {
|
||||
open={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
initialValues={editingConnection}
|
||||
onOpenDriverManager={handleOpenDriverManagerFromConnection}
|
||||
/>
|
||||
<DataSyncModal
|
||||
open={isSyncModalOpen}
|
||||
onClose={() => setIsSyncModalOpen(false)}
|
||||
/>
|
||||
<DriverManagerModal
|
||||
open={isDriverModalOpen}
|
||||
onClose={() => setIsDriverModalOpen(false)}
|
||||
/>
|
||||
<Modal
|
||||
title="关于 GoNavi"
|
||||
open={isAboutOpen}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Select, Alert, Card, Row, Col, Typography, Collapse, Space, Table, Tag } from 'antd';
|
||||
import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { DBGetDatabases, MongoDiscoverMembers, TestConnection, RedisConnect } from '../../wailsjs/go/app/App';
|
||||
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectSSHKeyFile } from '../../wailsjs/go/app/App';
|
||||
import { MongoMemberInfo, SavedConnection } from '../types';
|
||||
|
||||
const { Meta } = Card;
|
||||
@@ -14,6 +14,7 @@ const MAX_TIMEOUT_SECONDS = 3600;
|
||||
const getDefaultPortByType = (type: string) => {
|
||||
switch (type) {
|
||||
case 'mysql': return 3306;
|
||||
case 'diros': return 9030;
|
||||
case 'sphinx': return 9306;
|
||||
case 'postgres': return 5432;
|
||||
case 'redis': return 6379;
|
||||
@@ -26,11 +27,34 @@ const getDefaultPortByType = (type: string) => {
|
||||
case 'highgo': return 5866;
|
||||
case 'mariadb': return 3306;
|
||||
case 'vastbase': return 5432;
|
||||
case 'sqlite': return 0;
|
||||
case 'duckdb': return 0;
|
||||
default: return 3306;
|
||||
}
|
||||
};
|
||||
|
||||
const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialValues?: SavedConnection | null }> = ({ open, onClose, initialValues }) => {
|
||||
const isFileDatabaseType = (type: string) => type === 'sqlite' || type === 'duckdb';
|
||||
|
||||
type DriverStatusSnapshot = {
|
||||
type: string;
|
||||
name: string;
|
||||
connectable: boolean;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
const normalizeDriverType = (value: string): string => {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'postgresql') return 'postgres';
|
||||
if (normalized === 'doris') return 'diros';
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const ConnectionModal: React.FC<{
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
initialValues?: SavedConnection | null;
|
||||
onOpenDriverManager?: () => void;
|
||||
}> = ({ open, onClose, initialValues, onOpenDriverManager }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [useSSH, setUseSSH] = useState(false);
|
||||
@@ -44,6 +68,10 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
const [mongoMembers, setMongoMembers] = useState<MongoMemberInfo[]>([]);
|
||||
const [discoveringMembers, setDiscoveringMembers] = useState(false);
|
||||
const [uriFeedback, setUriFeedback] = useState<{ type: 'success' | 'warning' | 'error'; message: string } | null>(null);
|
||||
const [typeSelectWarning, setTypeSelectWarning] = useState<{ driverName: string; reason: string } | null>(null);
|
||||
const [driverStatusMap, setDriverStatusMap] = useState<Record<string, DriverStatusSnapshot>>({});
|
||||
const [driverStatusLoaded, setDriverStatusLoaded] = useState(false);
|
||||
const [selectingSSHKey, setSelectingSSHKey] = useState(false);
|
||||
const testInFlightRef = useRef(false);
|
||||
const testTimerRef = useRef<number | null>(null);
|
||||
const addConnection = useStore((state) => state.addConnection);
|
||||
@@ -52,6 +80,70 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
const mongoTopology = Form.useWatch('mongoTopology', form) || 'single';
|
||||
const mongoSrv = Form.useWatch('mongoSrv', form) || false;
|
||||
|
||||
const fetchDriverStatusMap = async (): Promise<Record<string, DriverStatusSnapshot>> => {
|
||||
const result: Record<string, DriverStatusSnapshot> = {};
|
||||
const res = await GetDriverStatusList('', '');
|
||||
if (!res?.success) {
|
||||
return result;
|
||||
}
|
||||
const data = (res?.data || {}) as any;
|
||||
const drivers = Array.isArray(data.drivers) ? data.drivers : [];
|
||||
drivers.forEach((item: any) => {
|
||||
const type = normalizeDriverType(String(item.type || '').trim());
|
||||
if (!type) return;
|
||||
result[type] = {
|
||||
type,
|
||||
name: String(item.name || item.type || type).trim(),
|
||||
connectable: !!item.connectable,
|
||||
message: String(item.message || '').trim() || undefined,
|
||||
};
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const refreshDriverStatus = async () => {
|
||||
try {
|
||||
const next = await fetchDriverStatusMap();
|
||||
setDriverStatusMap(next);
|
||||
} catch {
|
||||
setDriverStatusMap({});
|
||||
} finally {
|
||||
setDriverStatusLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
const resolveDriverUnavailableReason = async (type: string): Promise<string> => {
|
||||
const normalized = normalizeDriverType(type);
|
||||
if (!normalized || normalized === 'custom') {
|
||||
return '';
|
||||
}
|
||||
let snapshot = driverStatusMap;
|
||||
if (!snapshot[normalized]) {
|
||||
snapshot = await fetchDriverStatusMap();
|
||||
setDriverStatusMap(snapshot);
|
||||
}
|
||||
const status = snapshot[normalized];
|
||||
if (!status || status.connectable) {
|
||||
return '';
|
||||
}
|
||||
return status.message || `${status.name || normalized} 驱动未安装启用,请先在驱动管理中安装`;
|
||||
};
|
||||
|
||||
const promptInstallDriver = (driverType: string, reason: string) => {
|
||||
const normalized = normalizeDriverType(driverType);
|
||||
const snapshot = driverStatusMap[normalized];
|
||||
const driverName = snapshot?.name || normalized || '当前';
|
||||
Modal.confirm({
|
||||
title: `${driverName} 驱动不可用`,
|
||||
content: reason || `${driverName} 驱动未安装启用,请先在驱动管理中安装`,
|
||||
okText: '去驱动管理安装',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
onOpenDriverManager?.();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const parseHostPort = (raw: string, defaultPort: number): { host: string; port: number } | null => {
|
||||
const text = String(raw || '').trim();
|
||||
if (!text) {
|
||||
@@ -146,6 +238,23 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeFileDbPath = (rawPath: string): string => {
|
||||
let pathText = String(rawPath || '').trim();
|
||||
if (!pathText) {
|
||||
return '';
|
||||
}
|
||||
// 兼容 sqlite:///C:/... 或 sqlite:///C:\... 解析后多出的前导斜杠。
|
||||
if (/^\/[a-zA-Z]:[\\/]/.test(pathText)) {
|
||||
pathText = pathText.slice(1);
|
||||
}
|
||||
// 兼容历史版本把 Windows 文件路径误拼成 :3306:3306。
|
||||
const legacyMatch = pathText.match(/^([a-zA-Z]:[\\/].*?)(?::\d+)+$/);
|
||||
if (legacyMatch?.[1]) {
|
||||
return legacyMatch[1];
|
||||
}
|
||||
return pathText;
|
||||
};
|
||||
|
||||
const parseMultiHostUri = (uriText: string, expectedScheme: string) => {
|
||||
const prefix = `${expectedScheme}://`;
|
||||
if (!uriText.toLowerCase().startsWith(prefix)) {
|
||||
@@ -209,9 +318,11 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type === 'mysql' || type === 'mariadb' || type === 'sphinx') {
|
||||
if (type === 'mysql' || type === 'mariadb' || type === 'diros' || type === 'sphinx') {
|
||||
const mysqlDefaultPort = getDefaultPortByType(type);
|
||||
const parsed = parseMultiHostUri(trimmedUri, 'mysql');
|
||||
const parsed = parseMultiHostUri(trimmedUri, 'mysql')
|
||||
|| parseMultiHostUri(trimmedUri, 'diros')
|
||||
|| parseMultiHostUri(trimmedUri, 'doris');
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
@@ -242,6 +353,17 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
};
|
||||
}
|
||||
|
||||
if (isFileDatabaseType(type)) {
|
||||
const rawPath = trimmedUri
|
||||
.replace(/^sqlite:\/\//i, '')
|
||||
.replace(/^duckdb:\/\//i, '')
|
||||
.trim();
|
||||
if (!rawPath) {
|
||||
return null;
|
||||
}
|
||||
return { host: normalizeFileDbPath(safeDecode(rawPath)) };
|
||||
}
|
||||
|
||||
if (type === 'mongodb') {
|
||||
const parsed = parseMultiHostUri(trimmedUri, 'mongodb') || parseMultiHostUri(trimmedUri, 'mongodb+srv');
|
||||
if (!parsed) {
|
||||
@@ -305,9 +427,15 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
});
|
||||
|
||||
const getUriPlaceholder = () => {
|
||||
if (dbType === 'mysql' || dbType === 'mariadb' || dbType === 'sphinx') {
|
||||
if (dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') {
|
||||
const defaultPort = getDefaultPortByType(dbType);
|
||||
return `mysql://user:pass@127.0.0.1:${defaultPort},127.0.0.2:${defaultPort}/db_name?topology=replica`;
|
||||
const scheme = dbType === 'diros' ? 'diros' : 'mysql';
|
||||
return `${scheme}://user:pass@127.0.0.1:${defaultPort},127.0.0.2:${defaultPort}/db_name?topology=replica`;
|
||||
}
|
||||
if (isFileDatabaseType(dbType)) {
|
||||
return dbType === 'duckdb'
|
||||
? 'duckdb:///Users/name/demo.duckdb'
|
||||
: 'sqlite:///Users/name/demo.sqlite';
|
||||
}
|
||||
if (dbType === 'mongodb') {
|
||||
return 'mongodb+srv://user:pass@cluster0.example.com/db_name?authSource=admin&authMechanism=SCRAM-SHA-256';
|
||||
@@ -328,7 +456,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
? `${encodeURIComponent(user)}${password ? `:${encodeURIComponent(password)}` : ''}@`
|
||||
: '';
|
||||
|
||||
if (type === 'mysql' || type === 'mariadb' || type === 'sphinx') {
|
||||
if (type === 'mysql' || type === 'mariadb' || type === 'diros' || type === 'sphinx') {
|
||||
const primary = toAddress(host, port, defaultPort);
|
||||
const replicas = values.mysqlTopology === 'replica'
|
||||
? normalizeAddressList(values.mysqlReplicaHosts, defaultPort)
|
||||
@@ -343,7 +471,16 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
}
|
||||
const dbPath = database ? `/${encodeURIComponent(database)}` : '/';
|
||||
const query = params.toString();
|
||||
return `mysql://${encodedAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`;
|
||||
const scheme = type === 'diros' ? 'diros' : 'mysql';
|
||||
return `${scheme}://${encodedAuth}${hosts.join(',')}${dbPath}${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
if (isFileDatabaseType(type)) {
|
||||
const pathText = normalizeFileDbPath(String(values.host || '').trim());
|
||||
if (!pathText) {
|
||||
return `${type}://`;
|
||||
}
|
||||
return `${type}://${encodeURI(pathText)}`;
|
||||
}
|
||||
|
||||
if (type === 'mongodb') {
|
||||
@@ -442,6 +579,30 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectSSHKeyFile = async () => {
|
||||
if (selectingSSHKey) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSelectingSSHKey(true);
|
||||
const currentPath = String(form.getFieldValue('sshKeyPath') || '').trim();
|
||||
const res = await SelectSSHKeyFile(currentPath);
|
||||
if (res?.success) {
|
||||
const data = res.data || {};
|
||||
const selectedPath = typeof data === 'string' ? data : String(data.path || '').trim();
|
||||
if (selectedPath) {
|
||||
form.setFieldValue('sshKeyPath', selectedPath);
|
||||
}
|
||||
} else if (res?.message !== 'Cancelled') {
|
||||
message.error(`选择私钥文件失败: ${res?.message || '未知错误'}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(`选择私钥文件失败: ${e?.message || String(e)}`);
|
||||
} finally {
|
||||
setSelectingSSHKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTestResult(null); // Reset test result
|
||||
@@ -450,20 +611,30 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
setRedisDbList([]);
|
||||
setMongoMembers([]);
|
||||
setUriFeedback(null);
|
||||
setTypeSelectWarning(null);
|
||||
setDriverStatusLoaded(false);
|
||||
void refreshDriverStatus();
|
||||
if (initialValues) {
|
||||
// Edit mode: Go directly to step 2
|
||||
setStep(2);
|
||||
const config: any = initialValues.config || {};
|
||||
const configType = String(config.type || 'mysql');
|
||||
const defaultPort = getDefaultPortByType(configType);
|
||||
const normalizedHosts = normalizeAddressList(config.hosts, defaultPort);
|
||||
const primaryAddress = parseHostPort(
|
||||
normalizedHosts[0] || toAddress(config.host || 'localhost', Number(config.port || defaultPort), defaultPort),
|
||||
defaultPort
|
||||
);
|
||||
const primaryHost = primaryAddress?.host || String(config.host || 'localhost');
|
||||
const primaryPort = primaryAddress?.port || Number(config.port || defaultPort);
|
||||
const mysqlReplicaHosts = (configType === 'mysql' || configType === 'mariadb' || configType === 'sphinx') ? normalizedHosts.slice(1) : [];
|
||||
const isFileDbConfigType = isFileDatabaseType(configType);
|
||||
const normalizedHosts = isFileDbConfigType ? [] : normalizeAddressList(config.hosts, defaultPort);
|
||||
const primaryAddress = isFileDbConfigType
|
||||
? null
|
||||
: parseHostPort(
|
||||
normalizedHosts[0] || toAddress(config.host || 'localhost', Number(config.port || defaultPort), defaultPort),
|
||||
defaultPort
|
||||
);
|
||||
const primaryHost = isFileDbConfigType
|
||||
? normalizeFileDbPath(String(config.host || ''))
|
||||
: (primaryAddress?.host || String(config.host || 'localhost'));
|
||||
const primaryPort = isFileDbConfigType
|
||||
? 0
|
||||
: (primaryAddress?.port || Number(config.port || defaultPort));
|
||||
const mysqlReplicaHosts = (configType === 'mysql' || configType === 'mariadb' || configType === 'diros' || configType === 'sphinx') ? normalizedHosts.slice(1) : [];
|
||||
const mongoHosts = configType === 'mongodb' ? normalizedHosts.slice(1) : [];
|
||||
const mysqlIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mysqlReplicaHosts.length > 0;
|
||||
const mongoIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mongoHosts.length > 0 || !!config.replicaSet;
|
||||
@@ -531,6 +702,12 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const unavailableReason = await resolveDriverUnavailableReason(values.type);
|
||||
if (unavailableReason) {
|
||||
message.warning(unavailableReason);
|
||||
promptInstallDriver(values.type, unavailableReason);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
|
||||
const config = await buildConfig(values, true);
|
||||
@@ -539,7 +716,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
const isRedisType = values.type === 'redis';
|
||||
const newConn = {
|
||||
id: initialValues ? initialValues.id : Date.now().toString(),
|
||||
name: values.name || (values.type === 'sqlite' ? 'SQLite DB' : (values.type === 'redis' ? `Redis ${displayHost}` : displayHost)),
|
||||
name: values.name || (isFileDatabaseType(values.type) ? (values.type === 'duckdb' ? 'DuckDB DB' : 'SQLite DB') : (values.type === 'redis' ? `Redis ${displayHost}` : displayHost)),
|
||||
config: config,
|
||||
includeDatabases: values.includeDatabases,
|
||||
includeRedisDatabases: isRedisType ? values.includeRedisDatabases : undefined
|
||||
@@ -584,6 +761,13 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
testInFlightRef.current = true;
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const unavailableReason = await resolveDriverUnavailableReason(values.type);
|
||||
if (unavailableReason) {
|
||||
const failMessage = buildTestFailureMessage(unavailableReason, '驱动未安装启用');
|
||||
setTestResult({ type: 'error', message: failMessage });
|
||||
promptInstallDriver(values.type, unavailableReason);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setTestResult(null);
|
||||
const config = await buildConfig(values, false);
|
||||
@@ -688,12 +872,22 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
|
||||
const type = String(mergedValues.type || '').toLowerCase();
|
||||
const defaultPort = getDefaultPortByType(type);
|
||||
const parsedPrimary = parseHostPort(
|
||||
toAddress(mergedValues.host || 'localhost', Number(mergedValues.port || defaultPort), defaultPort),
|
||||
defaultPort
|
||||
);
|
||||
const primaryHost = parsedPrimary?.host || 'localhost';
|
||||
const primaryPort = parsedPrimary?.port || defaultPort;
|
||||
const isFileDbType = isFileDatabaseType(type);
|
||||
|
||||
let primaryHost = 'localhost';
|
||||
let primaryPort = defaultPort;
|
||||
if (isFileDbType) {
|
||||
// 文件型数据库(sqlite/duckdb)这里的 host 即数据库文件路径,不应参与 host:port 拼接与解析。
|
||||
primaryHost = normalizeFileDbPath(String(mergedValues.host || '').trim());
|
||||
primaryPort = 0;
|
||||
} else {
|
||||
const parsedPrimary = parseHostPort(
|
||||
toAddress(mergedValues.host || 'localhost', Number(mergedValues.port || defaultPort), defaultPort),
|
||||
defaultPort
|
||||
);
|
||||
primaryHost = parsedPrimary?.host || 'localhost';
|
||||
primaryPort = parsedPrimary?.port || defaultPort;
|
||||
}
|
||||
|
||||
let hosts: string[] = [];
|
||||
let topology: 'single' | 'replica' | undefined;
|
||||
@@ -710,7 +904,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
? mergedValues.savePassword !== false
|
||||
: true;
|
||||
|
||||
if (type === 'mysql' || type === 'mariadb' || type === 'sphinx') {
|
||||
if (type === 'mysql' || type === 'mariadb' || type === 'diros' || type === 'sphinx') {
|
||||
const replicas = mergedValues.mysqlTopology === 'replica'
|
||||
? normalizeAddressList(mergedValues.mysqlReplicaHosts, defaultPort)
|
||||
: [];
|
||||
@@ -788,12 +982,49 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
};
|
||||
};
|
||||
|
||||
const handleTypeSelect = (type: string) => {
|
||||
const handleTypeSelect = async (type: string) => {
|
||||
const unavailableReason = await resolveDriverUnavailableReason(type);
|
||||
if (unavailableReason) {
|
||||
const normalized = normalizeDriverType(type);
|
||||
const driverName = driverStatusMap[normalized]?.name || type;
|
||||
setTypeSelectWarning({ driverName, reason: unavailableReason });
|
||||
return;
|
||||
}
|
||||
setTypeSelectWarning(null);
|
||||
setDbType(type);
|
||||
form.setFieldsValue({ type: type });
|
||||
|
||||
const defaultPort = getDefaultPortByType(type);
|
||||
if (type !== 'sqlite' && type !== 'custom') {
|
||||
if (isFileDatabaseType(type)) {
|
||||
setUseSSH(false);
|
||||
form.setFieldsValue({
|
||||
host: '',
|
||||
port: 0,
|
||||
user: '',
|
||||
password: '',
|
||||
database: '',
|
||||
useSSH: false,
|
||||
sshHost: '',
|
||||
sshPort: 22,
|
||||
sshUser: '',
|
||||
sshPassword: '',
|
||||
sshKeyPath: '',
|
||||
mysqlTopology: 'single',
|
||||
mongoTopology: 'single',
|
||||
mongoSrv: false,
|
||||
mongoReadPreference: 'primary',
|
||||
mongoReplicaSet: '',
|
||||
mongoAuthSource: '',
|
||||
mongoAuthMechanism: '',
|
||||
savePassword: true,
|
||||
mysqlReplicaHosts: [],
|
||||
mongoHosts: [],
|
||||
mysqlReplicaUser: '',
|
||||
mysqlReplicaPassword: '',
|
||||
mongoReplicaUser: '',
|
||||
mongoReplicaPassword: '',
|
||||
});
|
||||
} else if (type !== 'custom') {
|
||||
form.setFieldsValue({
|
||||
port: defaultPort,
|
||||
mysqlTopology: 'single',
|
||||
@@ -817,18 +1048,28 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
setStep(2);
|
||||
};
|
||||
|
||||
const isSqlite = dbType === 'sqlite';
|
||||
const isFileDb = isFileDatabaseType(dbType);
|
||||
const isCustom = dbType === 'custom';
|
||||
const isRedis = dbType === 'redis';
|
||||
const currentDriverType = normalizeDriverType(dbType);
|
||||
const currentDriverSnapshot = driverStatusMap[currentDriverType];
|
||||
const currentDriverUnavailableReason = currentDriverType !== 'custom'
|
||||
&& currentDriverSnapshot
|
||||
&& !currentDriverSnapshot.connectable
|
||||
? (currentDriverSnapshot.message || `${currentDriverSnapshot.name || dbType} 驱动未安装启用`)
|
||||
: '';
|
||||
const driverStatusChecking = currentDriverType !== 'custom' && !driverStatusLoaded && step === 2;
|
||||
|
||||
const dbTypeGroups = [
|
||||
{ label: '关系型数据库', items: [
|
||||
{ key: 'mysql', name: 'MySQL', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#00758F' }} /> },
|
||||
{ key: 'mariadb', name: 'MariaDB', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#003545' }} /> },
|
||||
{ key: 'diros', name: 'Diros', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#0050b3' }} /> },
|
||||
{ key: 'sphinx', name: 'Sphinx', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#2F5D62' }} /> },
|
||||
{ key: 'postgres', name: 'PostgreSQL', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#336791' }} /> },
|
||||
{ key: 'sqlserver', name: 'SQL Server', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#CC2927' }} /> },
|
||||
{ key: 'sqlite', name: 'SQLite', icon: <FileTextOutlined style={{ fontSize: 24, color: '#003B57' }} /> },
|
||||
{ key: 'duckdb', name: 'DuckDB', icon: <FileTextOutlined style={{ fontSize: 24, color: '#f59e0b' }} /> },
|
||||
{ key: 'oracle', name: 'Oracle', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#F80000' }} /> },
|
||||
]},
|
||||
{ label: '国产数据库', items: [
|
||||
@@ -852,6 +1093,24 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
const dbTypes = dbTypeGroups.flatMap(g => g.items);
|
||||
|
||||
const renderStep1 = () => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{typeSelectWarning && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
closable
|
||||
message={`${typeSelectWarning.driverName} 驱动未启用`}
|
||||
description={(
|
||||
<Space size={8}>
|
||||
<span>{typeSelectWarning.reason}</span>
|
||||
<Button type="link" size="small" onClick={() => onOpenDriverManager?.()}>
|
||||
去驱动管理安装
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
onClose={() => setTypeSelectWarning(null)}
|
||||
/>
|
||||
)}
|
||||
<div style={{ display: 'flex', height: 360 }}>
|
||||
{/* 左侧分类导航 */}
|
||||
<div style={{ width: 120, borderRight: '1px solid #f0f0f0', paddingRight: 8, flexShrink: 0 }}>
|
||||
@@ -882,7 +1141,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
<Col span={8} key={item.key}>
|
||||
<Card
|
||||
hoverable
|
||||
onClick={() => handleTypeSelect(item.key)}
|
||||
onClick={() => { void handleTypeSelect(item.key); }}
|
||||
style={{ textAlign: 'center', cursor: 'pointer', height: 100 }}
|
||||
styles={{ body: { padding: '16px 8px', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%' } }}
|
||||
>
|
||||
@@ -894,6 +1153,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderStep2 = () => (
|
||||
@@ -973,6 +1233,22 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
{currentDriverUnavailableReason && (
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
style={{ marginBottom: 12 }}
|
||||
message="当前数据源驱动未启用"
|
||||
description={(
|
||||
<Space size={8}>
|
||||
<span>{currentDriverUnavailableReason}</span>
|
||||
<Button type="link" size="small" onClick={() => onOpenDriverManager?.()}>
|
||||
去驱动管理安装
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isCustom ? (
|
||||
<>
|
||||
@@ -988,16 +1264,16 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item
|
||||
name="host"
|
||||
label={isSqlite ? "文件路径 (绝对路径)" : "主机地址 (Host)"}
|
||||
label={isFileDb ? "文件路径 (绝对路径)" : "主机地址 (Host)"}
|
||||
rules={[createUriAwareRequiredRule('请输入地址/路径')]}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Input
|
||||
placeholder={isSqlite ? "/path/to/db.sqlite" : "localhost"}
|
||||
placeholder={isFileDb ? (dbType === 'duckdb' ? "/path/to/db.duckdb" : "/path/to/db.sqlite") : "localhost"}
|
||||
onDoubleClick={requestTest}
|
||||
/>
|
||||
</Form.Item>
|
||||
{!isSqlite && (
|
||||
{!isFileDb && (
|
||||
<Form.Item
|
||||
name="port"
|
||||
label="端口 (Port)"
|
||||
@@ -1009,7 +1285,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(dbType === 'mysql' || dbType === 'mariadb' || dbType === 'sphinx') && (
|
||||
{(dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') && (
|
||||
<>
|
||||
<Form.Item name="mysqlTopology" label="连接模式">
|
||||
<Select
|
||||
@@ -1174,7 +1450,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
)}
|
||||
|
||||
{/* Non-Redis, non-SQLite: username and password */}
|
||||
{!isSqlite && !isRedis && (
|
||||
{!isFileDb && !isRedis && (
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item
|
||||
name="user"
|
||||
@@ -1209,7 +1485,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{!isSqlite && !isRedis && (
|
||||
{!isFileDb && !isRedis && (
|
||||
<Form.Item name="includeDatabases" label="显示数据库 (留空显示全部)" help="连接测试成功后可选择">
|
||||
<Select mode="multiple" placeholder="选择显示的数据库" allowClear>
|
||||
{dbList.map(db => <Select.Option key={db} value={db}>{db}</Select.Option>)}
|
||||
@@ -1217,7 +1493,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{!isSqlite && (
|
||||
{!isFileDb && (
|
||||
<>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<Form.Item name="useSSH" valuePropName="checked" style={{ marginBottom: 0 }}>
|
||||
@@ -1242,8 +1518,15 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
<Input.Password placeholder="密码" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="sshKeyPath" label="私钥路径 (可选)" help="例如: /Users/name/.ssh/id_rsa">
|
||||
<Input placeholder="绝对路径" />
|
||||
<Form.Item label="私钥路径 (可选)" help="例如: /Users/name/.ssh/id_rsa">
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Form.Item name="sshKeyPath" noStyle>
|
||||
<Input placeholder="绝对路径" />
|
||||
</Form.Item>
|
||||
<Button onClick={handleSelectSSHKeyFile} loading={selectingSSHKey}>
|
||||
浏览...
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
@@ -1283,6 +1566,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
}
|
||||
const isTestSuccess = testResult?.type === 'success';
|
||||
const hasTestError = !!testResult && !isTestSuccess;
|
||||
const operationBlocked = !!currentDriverUnavailableReason || driverStatusChecking;
|
||||
return (
|
||||
<div style={{ display: 'flex', width: '100%', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
|
||||
@@ -1328,9 +1612,9 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
)}
|
||||
</div>
|
||||
<Space size={8} style={{ flexShrink: 0 }}>
|
||||
<Button key="test" loading={loading} onClick={requestTest}>测试连接</Button>
|
||||
<Button key="test" loading={loading} disabled={operationBlocked} onClick={requestTest}>测试连接</Button>
|
||||
<Button key="cancel" onClick={onClose}>取消</Button>
|
||||
<Button key="submit" type="primary" loading={loading} onClick={handleOk}>保存</Button>
|
||||
<Button key="submit" type="primary" loading={loading} disabled={operationBlocked} onClick={handleOk}>保存</Button>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented } from 'antd';
|
||||
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 Editor from '@monaco-editor/react';
|
||||
import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges } from '../../wailsjs/go/app/App';
|
||||
import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges, DBGetColumns } from '../../wailsjs/go/app/App';
|
||||
import ImportPreviewModal from './ImportPreviewModal';
|
||||
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';
|
||||
@@ -89,6 +90,14 @@ const normalizeDateTimeString = (val: string) => {
|
||||
return `${match[1]} ${match[2]}`;
|
||||
};
|
||||
|
||||
const isTemporalColumnType = (columnType?: string): boolean => {
|
||||
const raw = String(columnType || '').trim().toLowerCase();
|
||||
if (!raw) return false;
|
||||
if (raw.includes('datetime') || raw.includes('timestamp')) return true;
|
||||
const base = raw.split(/[ (]/)[0];
|
||||
return base === 'date' || base === 'time' || base === 'year';
|
||||
};
|
||||
|
||||
// --- Helper: Format Value ---
|
||||
const formatCellValue = (val: any) => {
|
||||
try {
|
||||
@@ -292,6 +301,7 @@ const DataContext = React.createContext<{
|
||||
handleExportSelected: (format: string, r: any) => void;
|
||||
copyToClipboard: (t: string) => void;
|
||||
tableName?: string;
|
||||
enableRowContextMenu: boolean;
|
||||
} | null>(null);
|
||||
|
||||
interface Item {
|
||||
@@ -434,7 +444,11 @@ const ContextMenuRow = React.memo(({ children, record, ...props }: any) => {
|
||||
|
||||
if (!record || !context) return <tr {...props}>{children}</tr>;
|
||||
|
||||
const { selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard } = context;
|
||||
const { selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, enableRowContextMenu } = context;
|
||||
|
||||
if (!enableRowContextMenu) {
|
||||
return <tr {...props}>{children}</tr>;
|
||||
}
|
||||
|
||||
const getTargets = () => {
|
||||
const keys = selectedRowKeysRef.current;
|
||||
@@ -513,6 +527,11 @@ type GridFilterCondition = FilterCondition & {
|
||||
|
||||
type GridViewMode = 'table' | 'json' | 'text';
|
||||
|
||||
type ColumnMeta = {
|
||||
type: string;
|
||||
comment: string;
|
||||
};
|
||||
|
||||
const DataGrid: React.FC<DataGridProps> = ({
|
||||
data, columnNames, loading, tableName, dbName, connectionId, pkColumns = [], readOnly = false,
|
||||
onReload, onSort, onPageChange, pagination, sortInfoExternal, showFilter, onToggleFilter, onApplyFilter
|
||||
@@ -521,10 +540,14 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const addSqlLog = useStore(state => state.addSqlLog);
|
||||
const theme = useStore(state => state.theme);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const queryOptions = useStore(state => state.queryOptions);
|
||||
const setQueryOptions = useStore(state => state.setQueryOptions);
|
||||
const isMacLike = useMemo(() => isMacLikePlatform(), []);
|
||||
const darkMode = theme === 'dark';
|
||||
const opacity = normalizeOpacityForPlatform(appearance.opacity);
|
||||
const canModifyData = !readOnly && !!tableName;
|
||||
const showColumnComment = queryOptions?.showColumnComment !== false;
|
||||
const showColumnType = queryOptions?.showColumnType !== false;
|
||||
const selectionColumnWidth = 46;
|
||||
|
||||
// Background Helper
|
||||
@@ -538,7 +561,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
};
|
||||
const bgContent = getBg('#1d1d1d');
|
||||
const bgFilter = getBg('#262626');
|
||||
const bgContextMenu = getBg('#1f1f1f');
|
||||
const bgContextMenu = darkMode ? '#1f1f1f' : '#ffffff';
|
||||
|
||||
// Row Colors with Opacity
|
||||
const getRowBg = (r: number, g: number, b: number) => `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
@@ -661,6 +684,9 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null);
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||
const [columnMetaMap, setColumnMetaMap] = useState<Record<string, ColumnMeta>>({});
|
||||
const columnMetaCacheRef = useRef<Record<string, Record<string, ColumnMeta>>>({});
|
||||
const columnMetaSeqRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const nextOrder = sortInfoExternal?.order === 'ascend' || sortInfoExternal?.order === 'descend'
|
||||
@@ -677,6 +703,158 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
}
|
||||
}, [sortInfoExternal, sortInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedTableName = String(tableName || '').trim();
|
||||
const normalizedDbName = String(dbName || '').trim();
|
||||
if (!connectionId || !normalizedTableName) {
|
||||
setColumnMetaMap({});
|
||||
return;
|
||||
}
|
||||
const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`;
|
||||
setColumnMetaMap(columnMetaCacheRef.current[cacheKey] || {});
|
||||
}, [connectionId, dbName, tableName]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedTableName = String(tableName || '').trim();
|
||||
const normalizedDbName = String(dbName || '').trim();
|
||||
if (!connectionId || !normalizedTableName) return;
|
||||
|
||||
const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`;
|
||||
if (columnMetaCacheRef.current[cacheKey]) return;
|
||||
|
||||
const conn = connections.find(c => c.id === connectionId);
|
||||
if (!conn) {
|
||||
setColumnMetaMap({});
|
||||
return;
|
||||
}
|
||||
|
||||
const config = {
|
||||
...conn.config,
|
||||
port: Number(conn.config.port),
|
||||
password: conn.config.password || "",
|
||||
database: conn.config.database || "",
|
||||
useSSH: conn.config.useSSH || false,
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
|
||||
const seq = ++columnMetaSeqRef.current;
|
||||
DBGetColumns(config as any, normalizedDbName, normalizedTableName)
|
||||
.then((res) => {
|
||||
if (seq !== columnMetaSeqRef.current) return;
|
||||
if (!res.success || !Array.isArray(res.data)) {
|
||||
setColumnMetaMap({});
|
||||
return;
|
||||
}
|
||||
const nextMap: Record<string, ColumnMeta> = {};
|
||||
(res.data as ColumnDefinition[]).forEach((column: any) => {
|
||||
const name = String(column?.name ?? column?.Name ?? '').trim();
|
||||
if (!name) return;
|
||||
const type = String(column?.type ?? column?.Type ?? '').trim();
|
||||
const comment = String(column?.comment ?? column?.Comment ?? '').trim();
|
||||
nextMap[name] = { type, comment };
|
||||
});
|
||||
columnMetaCacheRef.current[cacheKey] = nextMap;
|
||||
setColumnMetaMap(nextMap);
|
||||
})
|
||||
.catch(() => {
|
||||
if (seq !== columnMetaSeqRef.current) return;
|
||||
setColumnMetaMap({});
|
||||
});
|
||||
}, [connections, connectionId, dbName, tableName]);
|
||||
|
||||
const columnMetaMapByLowerName = useMemo(() => {
|
||||
const next: Record<string, ColumnMeta> = {};
|
||||
Object.entries(columnMetaMap).forEach(([name, meta]) => {
|
||||
const lowerName = String(name || '').toLowerCase();
|
||||
if (!lowerName || next[lowerName]) return;
|
||||
next[lowerName] = meta;
|
||||
});
|
||||
return next;
|
||||
}, [columnMetaMap]);
|
||||
|
||||
const normalizeCommitCellValue = useCallback(
|
||||
(columnName: string, value: any, mode: 'insert' | 'update') => {
|
||||
if (value === undefined) return undefined;
|
||||
const normalizedName = String(columnName || '').trim();
|
||||
const meta = columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()];
|
||||
const temporal = isTemporalColumnType(meta?.type);
|
||||
|
||||
if (!temporal) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const raw = value.trim();
|
||||
if (raw === '') {
|
||||
// INSERT 空时间值直接忽略字段,让数据库默认值生效;UPDATE 空时间值转 NULL。
|
||||
return mode === 'insert' ? undefined : null;
|
||||
}
|
||||
return normalizeDateTimeString(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
[columnMetaMap, columnMetaMapByLowerName]
|
||||
);
|
||||
|
||||
const renderColumnTitle = useCallback((name: string): React.ReactNode => {
|
||||
const normalizedName = String(name || '');
|
||||
const meta = columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()];
|
||||
const hoverLines: string[] = [];
|
||||
if (meta?.type) hoverLines.push(`类型:${meta.type}`);
|
||||
if (meta?.comment) hoverLines.push(`备注:${meta.comment}`);
|
||||
|
||||
const titleNode = (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, lineHeight: 1.2 }}>
|
||||
<span style={{ whiteSpace: 'nowrap' }}>{normalizedName}</span>
|
||||
{showColumnType && meta?.type && (
|
||||
<span
|
||||
style={{
|
||||
marginTop: 2,
|
||||
fontSize: 11,
|
||||
color: '#8c8c8c',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{meta.type}
|
||||
</span>
|
||||
)}
|
||||
{showColumnComment && meta?.comment && (
|
||||
<span
|
||||
style={{
|
||||
marginTop: 2,
|
||||
fontSize: 11,
|
||||
color: '#8c8c8c',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{meta.comment}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (hoverLines.length === 0) return titleNode;
|
||||
return (
|
||||
<Tooltip
|
||||
title={<pre style={{ maxHeight: 260, overflow: 'auto', margin: 0, fontSize: 12, whiteSpace: 'pre-wrap' }}>{hoverLines.join('\n')}</pre>}
|
||||
styles={{ root: { maxWidth: 640 } }}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', maxWidth: '100%' }}>{titleNode}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}, [columnMetaMap, columnMetaMapByLowerName, showColumnComment, showColumnType]);
|
||||
|
||||
const closeCellEditor = useCallback(() => {
|
||||
setCellEditorOpen(false);
|
||||
setCellEditorMeta(null);
|
||||
@@ -1592,7 +1770,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return columnNames.map(key => ({
|
||||
title: key,
|
||||
title: renderColumnTitle(key),
|
||||
dataIndex: key,
|
||||
key: key,
|
||||
// 不使用 ellipsis,避免 Ant Design 的 Tooltip 展开行为
|
||||
@@ -1608,9 +1786,29 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
onHeaderCell: (column: any) => ({
|
||||
width: column.width,
|
||||
onResizeStart: handleResizeStart(key), // Only need start
|
||||
onClickCapture: (event: React.MouseEvent<HTMLElement>) => {
|
||||
if (!onSort) return;
|
||||
const headerCell = event.currentTarget as HTMLElement;
|
||||
const upArrow = headerCell.querySelector('.ant-table-column-sorter-up') as HTMLElement | null;
|
||||
const downArrow = headerCell.querySelector('.ant-table-column-sorter-down') as HTMLElement | null;
|
||||
const isInArrow = [upArrow, downArrow].some((el) => {
|
||||
if (!el) return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return (
|
||||
event.clientX >= rect.left &&
|
||||
event.clientX <= rect.right &&
|
||||
event.clientY >= rect.top &&
|
||||
event.clientY <= rect.bottom
|
||||
);
|
||||
});
|
||||
if (isInArrow) return;
|
||||
// 仅允许点击上下箭头触发排序,点击字段名或表头其它区域不触发排序。
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
},
|
||||
}),
|
||||
}));
|
||||
}, [columnNames, columnWidths, sortInfo, handleResizeStart, canModifyData, onSort]);
|
||||
}, [columnNames, columnWidths, sortInfo, handleResizeStart, canModifyData, onSort, renderColumnTitle]);
|
||||
|
||||
const mergedColumns = useMemo(() => columns.map(col => {
|
||||
if (!col.editable) return col;
|
||||
@@ -1620,7 +1818,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
record,
|
||||
editable: col.editable,
|
||||
dataIndex: col.dataIndex,
|
||||
title: col.title,
|
||||
title: String(col.dataIndex),
|
||||
handleSave: handleCellSave,
|
||||
focusCell: openCellEditor,
|
||||
}),
|
||||
@@ -1653,7 +1851,17 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const updates: any[] = [];
|
||||
const deletes: any[] = [];
|
||||
|
||||
addedRows.forEach(row => { const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = row; inserts.push(vals); });
|
||||
addedRows.forEach(row => {
|
||||
const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = row;
|
||||
const normalizedValues: Record<string, any> = {};
|
||||
Object.entries(vals).forEach(([col, val]) => {
|
||||
const normalizedVal = normalizeCommitCellValue(col, val, 'insert');
|
||||
if (normalizedVal !== undefined) {
|
||||
normalizedValues[col] = normalizedVal;
|
||||
}
|
||||
});
|
||||
inserts.push(normalizedValues);
|
||||
});
|
||||
deletedRowKeys.forEach(keyStr => {
|
||||
// Find original data
|
||||
const originalRow = data.find(d => rowKeyStr(d?.[GONAVI_ROW_KEY]) === keyStr) || addedRows.find(d => rowKeyStr(d?.[GONAVI_ROW_KEY]) === keyStr);
|
||||
@@ -1686,8 +1894,16 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
});
|
||||
}
|
||||
|
||||
if (Object.keys(values).length === 0) return;
|
||||
updates.push({ keys: pkData, values });
|
||||
const normalizedValues: Record<string, any> = {};
|
||||
Object.entries(values).forEach(([col, val]) => {
|
||||
const normalizedVal = normalizeCommitCellValue(col, val, 'update');
|
||||
if (normalizedVal !== undefined) {
|
||||
normalizedValues[col] = normalizedVal;
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(normalizedValues).length === 0) return;
|
||||
updates.push({ keys: pkData, values: normalizedValues });
|
||||
});
|
||||
|
||||
if (inserts.length === 0 && updates.length === 0 && deletes.length === 0) {
|
||||
@@ -2037,6 +2253,23 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
{ key: 'md', label: 'Markdown', onClick: () => handleExport('md') },
|
||||
];
|
||||
|
||||
const columnInfoSettingContent = (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minWidth: 168 }}>
|
||||
<Checkbox
|
||||
checked={showColumnComment}
|
||||
onChange={(e) => setQueryOptions({ showColumnComment: e.target.checked })}
|
||||
>
|
||||
下方显示备注
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
checked={showColumnType}
|
||||
onChange={(e) => setQueryOptions({ showColumnType: e.target.checked })}
|
||||
>
|
||||
下方显示类型
|
||||
</Checkbox>
|
||||
</div>
|
||||
);
|
||||
|
||||
const tableComponents = useMemo(() => ({
|
||||
body: { cell: EditableCell, row: ContextMenuRow },
|
||||
header: { cell: ResizableTitle }
|
||||
@@ -2149,6 +2382,15 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
)}
|
||||
|
||||
<div style={{ marginLeft: 'auto' }} />
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
content={columnInfoSettingContent}
|
||||
>
|
||||
<Button icon={<FileTextOutlined />}>字段信息</Button>
|
||||
</Popover>
|
||||
</div>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<Segmented
|
||||
size="small"
|
||||
@@ -2413,13 +2655,14 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
{viewMode === 'table' ? (
|
||||
<Form component={false} form={form}>
|
||||
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, tableName }}>
|
||||
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, tableName, enableRowContextMenu: !canModifyData }}>
|
||||
<CellContextMenuContext.Provider value={{ showMenu: showCellContextMenu, handleBatchFillToSelected }}>
|
||||
<EditableContext.Provider value={form}>
|
||||
<Table
|
||||
components={tableComponents}
|
||||
dataSource={mergedDisplayData}
|
||||
columns={mergedColumns}
|
||||
showSorterTooltip={{ target: 'sorter-icon' }}
|
||||
size="small"
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: tableScrollX, y: tableHeight }}
|
||||
@@ -2721,6 +2964,9 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
.${gridId} .ant-table-tbody > tr > td { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid transparent !important; }
|
||||
.${gridId} .ant-table-thead > tr > th { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid transparent !important; }
|
||||
.${gridId} .ant-table-thead > tr > th::before { display: none !important; }
|
||||
.${gridId} .ant-table-thead > tr > th .ant-table-column-sorters { cursor: default !important; }
|
||||
.${gridId} .ant-table-thead > tr > th .ant-table-column-sorter,
|
||||
.${gridId} .ant-table-thead > tr > th .ant-table-column-sorter * { cursor: pointer !important; }
|
||||
.${gridId} .ant-table-tbody > tr:hover > td { background-color: ${darkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.02)'} !important; }
|
||||
.${gridId} .ant-table-tbody > tr.ant-table-row-selected > td { background-color: ${darkMode ? 'rgba(24, 144, 255, 0.15)' : 'rgba(24, 144, 255, 0.08)'} !important; }
|
||||
.${gridId} .ant-table-tbody > tr.ant-table-row-selected:hover > td { background-color: ${darkMode ? 'rgba(24, 144, 255, 0.25)' : 'rgba(24, 144, 255, 0.12)'} !important; }
|
||||
|
||||
@@ -61,7 +61,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
|
||||
const dbType = config.type || '';
|
||||
const dbTypeLower = String(dbType || '').trim().toLowerCase();
|
||||
const isMySQLFamily = dbTypeLower === 'mysql' || dbTypeLower === 'mariadb';
|
||||
const isMySQLFamily = dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros';
|
||||
|
||||
const dbName = tab.dbName || '';
|
||||
const tableName = tab.tableName || '';
|
||||
|
||||
@@ -23,9 +23,11 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
const getMetadataDialect = (conn: any): string => {
|
||||
const type = String(conn?.config?.type || '').trim().toLowerCase();
|
||||
if (type === 'custom') {
|
||||
return String(conn?.config?.driver || '').trim().toLowerCase();
|
||||
const driver = String(conn?.config?.driver || '').trim().toLowerCase();
|
||||
if (driver === 'diros' || driver === 'doris') return 'mysql';
|
||||
return driver;
|
||||
}
|
||||
if (type === 'mariadb' || type === 'sphinx') return 'mysql';
|
||||
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
|
||||
if (type === 'dameng') return 'dm';
|
||||
return type;
|
||||
};
|
||||
@@ -47,6 +49,55 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
return { schema: '', name: raw };
|
||||
};
|
||||
|
||||
const getCaseInsensitiveRawValue = (row: Record<string, any>, candidateKeys: string[]): any => {
|
||||
const keyMap = new Map<string, any>();
|
||||
Object.keys(row || {}).forEach((key) => keyMap.set(key.toLowerCase(), row[key]));
|
||||
for (const key of candidateKeys) {
|
||||
const value = keyMap.get(key.toLowerCase());
|
||||
if (value !== undefined && value !== null) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const parseDuckDBParameterNames = (raw: any): string[] => {
|
||||
if (Array.isArray(raw)) {
|
||||
return raw
|
||||
.map((item) => String(item ?? '').trim())
|
||||
.filter((item) => item !== '' && item.toLowerCase() !== '<nil>');
|
||||
}
|
||||
const text = String(raw ?? '').trim();
|
||||
if (!text) return [];
|
||||
const normalized = text.startsWith('[') && text.endsWith(']')
|
||||
? text.slice(1, -1)
|
||||
: text;
|
||||
return normalized
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part !== '' && part.toLowerCase() !== '<nil>');
|
||||
};
|
||||
|
||||
const buildDuckDBMacroDDL = (
|
||||
schemaName: string,
|
||||
functionName: string,
|
||||
parametersRaw: any,
|
||||
macroDefinitionRaw: any
|
||||
): string => {
|
||||
const schema = String(schemaName || '').trim();
|
||||
const name = String(functionName || '').trim();
|
||||
const macroDefinition = String(macroDefinitionRaw || '').trim();
|
||||
if (!name || !macroDefinition) return '';
|
||||
|
||||
const parameters = parseDuckDBParameterNames(parametersRaw).join(', ');
|
||||
const qualifiedName = schema ? `${schema}.${name}` : name;
|
||||
const isTableMacro = !macroDefinition.startsWith('(');
|
||||
if (isTableMacro) {
|
||||
return `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS TABLE ${macroDefinition};`;
|
||||
}
|
||||
return `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS ${macroDefinition};`;
|
||||
};
|
||||
|
||||
const buildShowViewQueries = (dialect: string, viewName: string, dbName: string): string[] => {
|
||||
const { schema, name } = parseSchemaAndName(viewName);
|
||||
const safeName = escapeSQLLiteral(name);
|
||||
@@ -81,6 +132,10 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
return [`SELECT TEXT AS view_definition FROM USER_VIEWS WHERE VIEW_NAME = '${safeName.toUpperCase()}'`];
|
||||
case 'sqlite':
|
||||
return [`SELECT sql AS view_definition FROM sqlite_master WHERE type='view' AND name='${safeName}'`];
|
||||
case 'duckdb': {
|
||||
const schemaRef = schema || 'main';
|
||||
return [`SELECT view_definition FROM information_schema.views WHERE table_schema = '${escapeSQLLiteral(schemaRef)}' AND table_name = '${safeName}' LIMIT 1`];
|
||||
}
|
||||
default:
|
||||
return [`-- 暂不支持该数据库类型的视图定义查看`];
|
||||
}
|
||||
@@ -120,8 +175,16 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
}
|
||||
return [`SELECT TEXT FROM USER_SOURCE WHERE NAME = '${safeName.toUpperCase()}' AND TYPE = '${upperType}' ORDER BY LINE`];
|
||||
}
|
||||
case 'duckdb': {
|
||||
const schemaRef = schema || 'main';
|
||||
const safeSchema = escapeSQLLiteral(schemaRef);
|
||||
return [
|
||||
`SELECT schema_name, function_name, parameters, macro_definition FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND schema_name = '${safeSchema}' AND function_name = '${safeName}' LIMIT 1`,
|
||||
`SELECT schema_name, function_name, parameters, macro_definition FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND function_name = '${safeName}' ORDER BY CASE WHEN schema_name = '${safeSchema}' THEN 0 ELSE 1 END, schema_name LIMIT 1`,
|
||||
];
|
||||
}
|
||||
case 'sqlite':
|
||||
return [`-- SQLite 不支持存储函数/存储过程`];
|
||||
return [`-- SQLite 不支持函数/存储过程定义管理`];
|
||||
default:
|
||||
return [`-- 暂不支持该数据库类型的函数/存储过程定义查看`];
|
||||
}
|
||||
@@ -244,6 +307,21 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
// Oracle/DM ALL_SOURCE returns multiple rows, one per line
|
||||
return data.map(row => row.text || row.TEXT || Object.values(row)[0] || '').join('');
|
||||
}
|
||||
case 'duckdb': {
|
||||
const row = data[0] as Record<string, any>;
|
||||
const ddl = buildDuckDBMacroDDL(
|
||||
String(getCaseInsensitiveRawValue(row, ['schema_name']) || '').trim(),
|
||||
String(getCaseInsensitiveRawValue(row, ['function_name', 'routine_name', 'name']) || '').trim(),
|
||||
getCaseInsensitiveRawValue(row, ['parameters']),
|
||||
getCaseInsensitiveRawValue(row, ['macro_definition'])
|
||||
);
|
||||
if (ddl) return ddl;
|
||||
const fallback = getCaseInsensitiveRawValue(row, ['macro_definition', 'routine_definition', 'definition']);
|
||||
if (fallback !== undefined && fallback !== null && String(fallback).trim() !== '') {
|
||||
return String(fallback);
|
||||
}
|
||||
return JSON.stringify(row, null, 2);
|
||||
}
|
||||
default: {
|
||||
const row = data[0];
|
||||
return row.routine_definition || row.ROUTINE_DEFINITION || Object.values(row)[0] || '';
|
||||
|
||||
299
frontend/src/components/DriverManagerModal.tsx
Normal file
299
frontend/src/components/DriverManagerModal.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Modal, Progress, Space, Table, Tag, Typography, message } from 'antd';
|
||||
import { DeleteOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import {
|
||||
DownloadDriverPackage,
|
||||
GetDriverStatusList,
|
||||
RemoveDriverPackage,
|
||||
} from '../../wailsjs/go/app/App';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type DriverStatusRow = {
|
||||
type: string;
|
||||
name: string;
|
||||
builtIn: boolean;
|
||||
packageSizeText?: string;
|
||||
runtimeAvailable: boolean;
|
||||
packageInstalled: boolean;
|
||||
connectable: boolean;
|
||||
defaultDownloadUrl?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type DriverProgressEvent = {
|
||||
driverType?: string;
|
||||
status?: 'start' | 'downloading' | 'done' | 'error';
|
||||
message?: string;
|
||||
percent?: number;
|
||||
};
|
||||
|
||||
type ProgressState = {
|
||||
status: 'start' | 'downloading' | 'done' | 'error';
|
||||
message: string;
|
||||
percent: number;
|
||||
};
|
||||
|
||||
const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [downloadDir, setDownloadDir] = useState('');
|
||||
const [rows, setRows] = useState<DriverStatusRow[]>([]);
|
||||
const [actionDriver, setActionDriver] = useState('');
|
||||
const [progressMap, setProgressMap] = useState<Record<string, ProgressState>>({});
|
||||
|
||||
const refreshStatus = useCallback(async (toastOnError = true) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await GetDriverStatusList(downloadDir, '');
|
||||
if (!res?.success) {
|
||||
if (toastOnError) {
|
||||
message.error(res?.message || '拉取驱动状态失败');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (res?.data || {}) as any;
|
||||
const resolvedDir = String(data.downloadDir || '').trim();
|
||||
const drivers = Array.isArray(data.drivers) ? data.drivers : [];
|
||||
|
||||
if (resolvedDir) {
|
||||
setDownloadDir(resolvedDir);
|
||||
}
|
||||
|
||||
const nextRows: DriverStatusRow[] = drivers.map((item: any) => ({
|
||||
type: String(item.type || '').trim(),
|
||||
name: String(item.name || item.type || '').trim(),
|
||||
builtIn: !!item.builtIn,
|
||||
packageSizeText: String(item.packageSizeText || '').trim() || undefined,
|
||||
runtimeAvailable: !!item.runtimeAvailable,
|
||||
packageInstalled: !!item.packageInstalled,
|
||||
connectable: !!item.connectable,
|
||||
defaultDownloadUrl: String(item.defaultDownloadUrl || '').trim() || undefined,
|
||||
message: String(item.message || '').trim() || undefined,
|
||||
}));
|
||||
setRows(nextRows);
|
||||
} catch (err: any) {
|
||||
if (toastOnError) {
|
||||
message.error(`拉取驱动状态失败:${err?.message || String(err)}`);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [downloadDir]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
refreshStatus(false);
|
||||
}, [open, refreshStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const off = EventsOn('driver:download-progress', (event: DriverProgressEvent) => {
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
const driverType = String(event.driverType || '').trim().toLowerCase();
|
||||
const status = event.status;
|
||||
if (!driverType || !status) {
|
||||
return;
|
||||
}
|
||||
const messageText = String(event.message || '').trim();
|
||||
const percent = Math.max(0, Math.min(100, Number(event.percent || 0)));
|
||||
setProgressMap((prev) => ({
|
||||
...prev,
|
||||
[driverType]: {
|
||||
status,
|
||||
message: messageText,
|
||||
percent,
|
||||
},
|
||||
}));
|
||||
});
|
||||
return () => {
|
||||
off();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const installDriver = useCallback(async (row: DriverStatusRow) => {
|
||||
setActionDriver(row.type);
|
||||
setProgressMap((prev) => ({
|
||||
...prev,
|
||||
[row.type]: {
|
||||
status: 'start',
|
||||
message: '开始安装',
|
||||
percent: 0,
|
||||
},
|
||||
}));
|
||||
try {
|
||||
const result = await DownloadDriverPackage(row.type, '', downloadDir);
|
||||
if (!result?.success) {
|
||||
message.error(result?.message || `安装 ${row.name} 失败`);
|
||||
return;
|
||||
}
|
||||
message.success(`${row.name} 已安装启用`);
|
||||
refreshStatus(false);
|
||||
} finally {
|
||||
setActionDriver('');
|
||||
}
|
||||
}, [downloadDir, refreshStatus]);
|
||||
|
||||
const removeDriver = useCallback(async (row: DriverStatusRow) => {
|
||||
setActionDriver(row.type);
|
||||
try {
|
||||
const result = await RemoveDriverPackage(row.type, downloadDir);
|
||||
if (!result?.success) {
|
||||
message.error(result?.message || `移除 ${row.name} 失败`);
|
||||
return;
|
||||
}
|
||||
message.success(`${row.name} 已移除`);
|
||||
setProgressMap((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[row.type];
|
||||
return next;
|
||||
});
|
||||
refreshStatus(false);
|
||||
} finally {
|
||||
setActionDriver('');
|
||||
}
|
||||
}, [downloadDir, refreshStatus]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
title: '数据源',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '安装包大小',
|
||||
dataIndex: 'packageSizeText',
|
||||
key: 'packageSizeText',
|
||||
width: 120,
|
||||
render: (_: string | undefined, row: DriverStatusRow) => row.packageSizeText || '-',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 140,
|
||||
render: (_: string, row: DriverStatusRow) => {
|
||||
if (row.builtIn) {
|
||||
return <Tag color="success">内置可用</Tag>;
|
||||
}
|
||||
const progress = progressMap[row.type];
|
||||
if (progress && (progress.status === 'start' || progress.status === 'downloading')) {
|
||||
return <Tag color="processing">安装中 {Math.round(progress.percent)}%</Tag>;
|
||||
}
|
||||
if (row.connectable) {
|
||||
return <Tag color="success">已启用</Tag>;
|
||||
}
|
||||
if (row.packageInstalled) {
|
||||
return <Tag color="warning">已安装</Tag>;
|
||||
}
|
||||
return <Tag color="default">未启用</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '安装进度',
|
||||
key: 'progress',
|
||||
width: 170,
|
||||
render: (_: string, row: DriverStatusRow) => {
|
||||
if (row.builtIn) {
|
||||
return <Text type="secondary">-</Text>;
|
||||
}
|
||||
|
||||
const progress = progressMap[row.type];
|
||||
let percent = 0;
|
||||
let status: 'normal' | 'exception' | 'active' | 'success' = 'normal';
|
||||
|
||||
if (progress?.status === 'error') {
|
||||
percent = Math.max(0, Math.min(100, Math.round(progress.percent || 0)));
|
||||
status = 'exception';
|
||||
} else if (progress && (progress.status === 'start' || progress.status === 'downloading')) {
|
||||
percent = Math.max(1, Math.min(99, Math.round(progress.percent || 0)));
|
||||
status = 'active';
|
||||
} else if (row.connectable || row.packageInstalled) {
|
||||
percent = 100;
|
||||
status = 'success';
|
||||
}
|
||||
|
||||
return <Progress percent={percent} status={status} size="small" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 190,
|
||||
render: (_: string, row: DriverStatusRow) => {
|
||||
if (row.builtIn) {
|
||||
return <Text type="secondary">-</Text>;
|
||||
}
|
||||
const isSlimBuildUnavailable = (row.message || '').includes('精简构建');
|
||||
const loadingAction = actionDriver === row.type;
|
||||
if (isSlimBuildUnavailable && !row.packageInstalled) {
|
||||
return <Text type="secondary">需 Full 版</Text>;
|
||||
}
|
||||
if (row.connectable) {
|
||||
return (
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={loadingAction}
|
||||
onClick={() => removeDriver(row)}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
loading={loadingAction}
|
||||
onClick={() => installDriver(row)}
|
||||
>
|
||||
安装启用
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [actionDriver, installDriver, progressMap, removeDriver]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="驱动管理"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width={980}
|
||||
destroyOnClose
|
||||
footer={[
|
||||
<Button key="refresh" icon={<ReloadOutlined />} onClick={() => refreshStatus(true)} loading={loading}>
|
||||
刷新
|
||||
</Button>,
|
||||
<Button key="close" type="primary" onClick={onClose}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Text type="secondary">除 MySQL / Redis / Oracle / PostgreSQL 外,其他数据源需先安装启用后再连接。</Text>
|
||||
|
||||
<Table
|
||||
rowKey="type"
|
||||
loading={loading}
|
||||
columns={columns as any}
|
||||
dataSource={rows}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DriverManagerModal;
|
||||
@@ -922,7 +922,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
|
||||
const applyAutoLimit = (sql: string, dbType: string, maxRows: number): { sql: string; applied: boolean; maxRows: number } => {
|
||||
const normalizedType = (dbType || 'mysql').toLowerCase();
|
||||
const supportsLimit = normalizedType === 'mysql' || normalizedType === 'mariadb' || normalizedType === 'sphinx' || normalizedType === 'postgres' || normalizedType === 'kingbase' || normalizedType === 'sqlite' || normalizedType === 'tdengine' || normalizedType === '';
|
||||
const supportsLimit = normalizedType === 'mysql' || normalizedType === 'mariadb' || normalizedType === 'diros' || normalizedType === 'sphinx' || normalizedType === 'postgres' || normalizedType === 'kingbase' || normalizedType === 'sqlite' || normalizedType === 'duckdb' || normalizedType === 'tdengine' || normalizedType === '';
|
||||
if (!supportsLimit) return { sql, applied: false, maxRows };
|
||||
if (!Number.isFinite(maxRows) || maxRows <= 0) return { sql, applied: false, maxRows };
|
||||
|
||||
|
||||
@@ -46,6 +46,15 @@ interface TreeNode {
|
||||
}
|
||||
|
||||
type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly';
|
||||
type BatchObjectType = 'table' | 'view';
|
||||
|
||||
interface BatchObjectItem {
|
||||
title: string;
|
||||
key: string;
|
||||
objectName: string;
|
||||
objectType: BatchObjectType;
|
||||
dataRef: any;
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> = ({ onEditConnection }) => {
|
||||
const connections = useStore(state => state.connections);
|
||||
@@ -118,12 +127,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
|
||||
// Batch Operations Modal
|
||||
const [isBatchModalOpen, setIsBatchModalOpen] = useState(false);
|
||||
const [batchTables, setBatchTables] = useState<any[]>([]);
|
||||
const [batchTables, setBatchTables] = useState<BatchObjectItem[]>([]);
|
||||
const [checkedTableKeys, setCheckedTableKeys] = useState<string[]>([]);
|
||||
const [batchDbContext, setBatchDbContext] = useState<any>(null);
|
||||
const [selectedConnection, setSelectedConnection] = useState<string>('');
|
||||
const [selectedDatabase, setSelectedDatabase] = useState<string>('');
|
||||
const [availableDatabases, setAvailableDatabases] = useState<any[]>([]);
|
||||
const groupedBatchObjects = useMemo(() => {
|
||||
const tables = batchTables.filter(item => item.objectType === 'table');
|
||||
const views = batchTables.filter(item => item.objectType === 'view');
|
||||
return { tables, views };
|
||||
}, [batchTables]);
|
||||
|
||||
// Batch Database Operations Modal
|
||||
const [isBatchDbModalOpen, setIsBatchDbModalOpen] = useState(false);
|
||||
@@ -228,9 +242,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const getMetadataDialect = (conn: SavedConnection | undefined): string => {
|
||||
const type = String(conn?.config?.type || '').trim().toLowerCase();
|
||||
if (type === 'custom') {
|
||||
return String((conn?.config as any)?.driver || '').trim().toLowerCase();
|
||||
const driver = String((conn?.config as any)?.driver || '').trim().toLowerCase();
|
||||
if (driver === 'diros' || driver === 'doris') return 'mysql';
|
||||
return driver;
|
||||
}
|
||||
if (type === 'mariadb' || type === 'sphinx') return 'mysql';
|
||||
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
|
||||
if (type === 'dameng') return 'dm';
|
||||
return type;
|
||||
};
|
||||
@@ -283,6 +299,18 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
return '';
|
||||
};
|
||||
|
||||
const getCaseInsensitiveRawValue = (row: Record<string, any>, candidateKeys: string[]): any => {
|
||||
const keyMap = new Map<string, any>();
|
||||
Object.keys(row || {}).forEach((key) => keyMap.set(key.toLowerCase(), row[key]));
|
||||
for (const key of candidateKeys) {
|
||||
const value = keyMap.get(key.toLowerCase());
|
||||
if (value !== undefined && value !== null) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getFirstRowValue = (row: Record<string, any>): string => {
|
||||
for (const value of Object.values(row || {})) {
|
||||
if (value !== undefined && value !== null) {
|
||||
@@ -326,6 +354,44 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
};
|
||||
};
|
||||
|
||||
const parseDuckDBParameterNames = (raw: any): string[] => {
|
||||
if (Array.isArray(raw)) {
|
||||
return raw
|
||||
.map((item) => String(item ?? '').trim())
|
||||
.filter((item) => item !== '' && item.toLowerCase() !== '<nil>');
|
||||
}
|
||||
|
||||
const text = String(raw ?? '').trim();
|
||||
if (!text) return [];
|
||||
const normalized = text.startsWith('[') && text.endsWith(']')
|
||||
? text.slice(1, -1)
|
||||
: text;
|
||||
return normalized
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part !== '' && part.toLowerCase() !== '<nil>');
|
||||
};
|
||||
|
||||
const buildDuckDBMacroDDL = (
|
||||
schemaName: string,
|
||||
functionName: string,
|
||||
parametersRaw: any,
|
||||
macroDefinitionRaw: any
|
||||
): string => {
|
||||
const schema = String(schemaName || '').trim();
|
||||
const name = String(functionName || '').trim();
|
||||
const macroDefinition = String(macroDefinitionRaw || '').trim();
|
||||
if (!name || !macroDefinition) return '';
|
||||
|
||||
const parameters = parseDuckDBParameterNames(parametersRaw).join(', ');
|
||||
const qualifiedName = schema ? `${schema}.${name}` : name;
|
||||
const isTableMacro = !macroDefinition.startsWith('(');
|
||||
if (isTableMacro) {
|
||||
return `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS TABLE ${macroDefinition};`;
|
||||
}
|
||||
return `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS ${macroDefinition};`;
|
||||
};
|
||||
|
||||
const buildViewsMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => {
|
||||
const safeDbName = escapeSQLLiteral(dbName);
|
||||
switch (dialect) {
|
||||
@@ -358,6 +424,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
return [{ sql: `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':
|
||||
return [{ sql: `SELECT table_schema AS schema_name, table_name AS view_name FROM information_schema.views WHERE table_schema NOT IN ('information_schema', 'pg_catalog') ORDER BY table_schema, table_name` }];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -395,6 +463,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
return [{ sql: `SELECT OWNER AS schema_name, TABLE_NAME AS table_name, TRIGGER_NAME AS trigger_name FROM ALL_TRIGGERS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY TABLE_NAME, TRIGGER_NAME` }];
|
||||
case 'sqlite':
|
||||
return [{ sql: `SELECT name AS trigger_name, tbl_name AS table_name FROM sqlite_master WHERE type = 'trigger' ORDER BY tbl_name, name` }];
|
||||
case 'duckdb':
|
||||
return [];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -438,6 +508,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
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` }];
|
||||
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`,
|
||||
inferredType: 'FUNCTION',
|
||||
}];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -512,16 +587,33 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
|
||||
results.forEach((queryResult) => {
|
||||
queryResult.rows.forEach((row) => {
|
||||
const triggerName = getCaseInsensitiveValue(row, ['trigger_name', 'triggername', 'trigger', 'name']) || getFirstRowValue(row);
|
||||
if (!triggerName) return;
|
||||
const schemaName = getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'event_object_schema', 'trigger_schema', 'db']);
|
||||
const tableName = getCaseInsensitiveValue(row, ['table_name', 'event_object_table', 'tbl_name', 'table']);
|
||||
const fullTableName = buildQualifiedName(schemaName, tableName);
|
||||
const uniqueKey = `${triggerName}@@${fullTableName}`;
|
||||
const rawTriggerName = getCaseInsensitiveValue(row, ['trigger_name', 'triggername', 'trigger', 'name']) || getFirstRowValue(row);
|
||||
if (!rawTriggerName) return;
|
||||
|
||||
const rawSchemaName = getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'event_object_schema', 'trigger_schema', 'db']);
|
||||
const rawTableName = getCaseInsensitiveValue(row, ['table_name', 'event_object_table', 'tbl_name', 'table']);
|
||||
|
||||
const triggerParts = splitQualifiedName(rawTriggerName);
|
||||
const tableParts = splitQualifiedName(rawTableName);
|
||||
|
||||
const resolvedSchema = (
|
||||
rawSchemaName
|
||||
|| tableParts.schemaName
|
||||
|| triggerParts.schemaName
|
||||
|| dbName
|
||||
).trim();
|
||||
const resolvedTriggerName = (triggerParts.objectName || rawTriggerName).trim();
|
||||
const resolvedTableName = (tableParts.objectName || rawTableName).trim();
|
||||
const fullTableName = buildQualifiedName(resolvedSchema, resolvedTableName);
|
||||
|
||||
// MySQL 下 trigger 名在同 schema 内唯一,直接按 schema+trigger 去重可彻底规避多元数据查询导致的重复
|
||||
const uniqueKey = dialect === 'mysql'
|
||||
? `${resolvedSchema.toLowerCase()}@@${resolvedTriggerName.toLowerCase()}`
|
||||
: `${resolvedSchema.toLowerCase()}@@${resolvedTriggerName.toLowerCase()}@@${resolvedTableName.toLowerCase()}`;
|
||||
if (seen.has(uniqueKey)) return;
|
||||
seen.add(uniqueKey);
|
||||
const displayName = fullTableName ? `${triggerName} (${fullTableName})` : triggerName;
|
||||
triggers.push({ displayName, triggerName, tableName: fullTableName });
|
||||
const displayName = fullTableName ? `${resolvedTriggerName} (${fullTableName})` : resolvedTriggerName;
|
||||
triggers.push({ displayName, triggerName: resolvedTriggerName, tableName: fullTableName || resolvedTableName });
|
||||
});
|
||||
});
|
||||
return { triggers, supported: hasSuccessfulQuery };
|
||||
@@ -694,19 +786,35 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
};
|
||||
});
|
||||
|
||||
const triggerEntries = triggersResult.triggers.map((trigger) => {
|
||||
const triggerParsed = splitQualifiedName(trigger.triggerName);
|
||||
const tableParsed = splitQualifiedName(trigger.tableName);
|
||||
const schemaName = tableParsed.schemaName || triggerParsed.schemaName;
|
||||
const triggerObjectName = triggerParsed.objectName || trigger.triggerName;
|
||||
const tableObjectName = tableParsed.objectName || trigger.tableName;
|
||||
const displayName = tableObjectName ? `${triggerObjectName} (${tableObjectName})` : triggerObjectName;
|
||||
return {
|
||||
...trigger,
|
||||
schemaName,
|
||||
displayName,
|
||||
};
|
||||
});
|
||||
const triggerEntries = (() => {
|
||||
const deduped: Array<{ displayName: string; triggerName: string; tableName: string; schemaName: string }> = [];
|
||||
const triggerSeen = new Set<string>();
|
||||
const metadataDialect = getMetadataDialect(conn as SavedConnection);
|
||||
|
||||
triggersResult.triggers.forEach((trigger) => {
|
||||
const triggerParsed = splitQualifiedName(trigger.triggerName);
|
||||
const tableParsed = splitQualifiedName(trigger.tableName);
|
||||
const schemaName = tableParsed.schemaName || triggerParsed.schemaName || String(conn.dbName || '').trim();
|
||||
const triggerObjectName = (triggerParsed.objectName || trigger.triggerName).trim();
|
||||
const tableObjectName = (tableParsed.objectName || trigger.tableName).trim();
|
||||
const displayName = tableObjectName ? `${triggerObjectName} (${tableObjectName})` : triggerObjectName;
|
||||
const dedupeKey = metadataDialect === 'mysql'
|
||||
? `${schemaName.toLowerCase()}@@${triggerObjectName.toLowerCase()}`
|
||||
: `${schemaName.toLowerCase()}@@${triggerObjectName.toLowerCase()}@@${tableObjectName.toLowerCase()}`;
|
||||
|
||||
if (triggerSeen.has(dedupeKey)) return;
|
||||
triggerSeen.add(dedupeKey);
|
||||
deduped.push({
|
||||
...trigger,
|
||||
schemaName,
|
||||
triggerName: triggerObjectName,
|
||||
tableName: buildQualifiedName(schemaName, tableObjectName) || tableObjectName,
|
||||
displayName,
|
||||
});
|
||||
});
|
||||
|
||||
return deduped;
|
||||
})();
|
||||
|
||||
const routineEntries = routinesResult.routines.map((routine) => {
|
||||
const parsed = splitQualifiedName(routine.routineName);
|
||||
@@ -1000,10 +1108,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` });
|
||||
}
|
||||
|
||||
if (type === 'folder-columns') openDesign(info.node, 'columns', true);
|
||||
else if (type === 'folder-indexes') openDesign(info.node, 'indexes', true);
|
||||
else if (type === 'folder-fks') openDesign(info.node, 'foreignKeys', true);
|
||||
else if (type === 'folder-triggers') openDesign(info.node, 'triggers', true);
|
||||
if (type === 'folder-columns') openDesign(info.node, 'columns', false);
|
||||
else if (type === 'folder-indexes') openDesign(info.node, 'indexes', false);
|
||||
else if (type === 'folder-fks') openDesign(info.node, 'foreignKeys', false);
|
||||
else if (type === 'folder-triggers') openDesign(info.node, 'triggers', false);
|
||||
};
|
||||
|
||||
const onExpand = (newExpandedKeys: React.Key[]) => {
|
||||
@@ -1194,7 +1302,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
if (node.type === 'database') {
|
||||
connId = node.dataRef.id;
|
||||
dbName = node.title;
|
||||
} else if (node.type === 'table') {
|
||||
} else if (node.type === 'table' || node.type === 'view') {
|
||||
connId = node.dataRef.id;
|
||||
dbName = node.dataRef.dbName;
|
||||
}
|
||||
@@ -1262,23 +1370,42 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
|
||||
const res = await DBGetTables(config as any, dbName);
|
||||
if (res.success) {
|
||||
const tables = (res.data as any[]).map((row: any) => {
|
||||
const tableName = Object.values(row)[0] as string;
|
||||
return {
|
||||
title: tableName,
|
||||
key: `${conn.id}-${dbName}-${tableName}`,
|
||||
tableName: tableName,
|
||||
dataRef: { ...conn, tableName, dbName }
|
||||
};
|
||||
});
|
||||
const [res, viewResult] = await Promise.all([
|
||||
DBGetTables(config as any, dbName),
|
||||
loadViews(conn, dbName).catch(() => ({ views: [], supported: false })),
|
||||
]);
|
||||
|
||||
setBatchTables(tables);
|
||||
setCheckedTableKeys([]);
|
||||
} else {
|
||||
if (!res.success) {
|
||||
message.error('获取表列表失败: ' + res.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const viewSet = new Set(viewResult.views.map(view => view.toLowerCase()));
|
||||
|
||||
const tableObjects: BatchObjectItem[] = (res.data as any[])
|
||||
.map((row: any) => Object.values(row)[0] as string)
|
||||
.filter((tableName: string) => !viewSet.has(tableName.toLowerCase()))
|
||||
.map((tableName: string) => ({
|
||||
title: getSidebarTableDisplayName(conn, tableName),
|
||||
key: `${conn.id}-${dbName}-table-${tableName}`,
|
||||
objectName: tableName,
|
||||
objectType: 'table' as const,
|
||||
dataRef: { ...conn, tableName, dbName, objectType: 'table' },
|
||||
}));
|
||||
|
||||
const viewObjects: BatchObjectItem[] = viewResult.views.map((viewName: string) => ({
|
||||
title: getSidebarTableDisplayName(conn, viewName),
|
||||
key: `${conn.id}-${dbName}-view-${viewName}`,
|
||||
objectName: viewName,
|
||||
objectType: 'view' as const,
|
||||
dataRef: { ...conn, tableName: viewName, dbName, objectType: 'view' },
|
||||
}));
|
||||
|
||||
tableObjects.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase()));
|
||||
viewObjects.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase()));
|
||||
|
||||
setBatchTables([...tableObjects, ...viewObjects]);
|
||||
setCheckedTableKeys([]);
|
||||
};
|
||||
|
||||
const handleConnectionChange = async (connId: string) => {
|
||||
@@ -1303,31 +1430,36 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
};
|
||||
|
||||
const handleBatchExport = async (mode: BatchTableExportMode) => {
|
||||
const selectedTables = batchTables.filter(t => checkedTableKeys.includes(t.key));
|
||||
if (selectedTables.length === 0) {
|
||||
message.warning('请至少选择一张表');
|
||||
const selectedObjects = batchTables.filter(t => checkedTableKeys.includes(t.key));
|
||||
if (selectedObjects.length === 0) {
|
||||
message.warning('请至少选择一个对象');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBatchModalOpen(false);
|
||||
|
||||
const { conn, dbName } = batchDbContext;
|
||||
const tableNames = selectedTables.map(t => t.tableName);
|
||||
const objectNames = selectedObjects.map(t => t.objectName);
|
||||
const selectedViewCount = selectedObjects.filter(item => item.objectType === 'view').length;
|
||||
|
||||
const loadingText = mode === 'backup'
|
||||
? `正在备份选中表 (${tableNames.length})...`
|
||||
? `正在备份选中对象 (${objectNames.length})...`
|
||||
: mode === 'dataOnly'
|
||||
? `正在导出选中表数据 (INSERT) (${tableNames.length})...`
|
||||
: `正在导出选中表结构 (${tableNames.length})...`;
|
||||
? `正在导出选中对象数据 (INSERT) (${objectNames.length})...`
|
||||
: `正在导出选中对象结构 (${objectNames.length})...`;
|
||||
const hide = message.loading(loadingText, 0);
|
||||
try {
|
||||
const app = (window as any).go.app.App;
|
||||
const res = mode === 'dataOnly'
|
||||
? await app.ExportTablesDataSQL(normalizeConnConfig(conn.config), dbName, tableNames)
|
||||
: await app.ExportTablesSQL(normalizeConnConfig(conn.config), dbName, tableNames, mode === 'backup');
|
||||
? await app.ExportTablesDataSQL(normalizeConnConfig(conn.config), dbName, objectNames)
|
||||
: await app.ExportTablesSQL(normalizeConnConfig(conn.config), dbName, objectNames, mode === 'backup');
|
||||
hide();
|
||||
if (res.success) {
|
||||
message.success('导出成功');
|
||||
if (mode !== 'schema' && selectedViewCount > 0) {
|
||||
message.success(`导出成功(已自动跳过 ${selectedViewCount} 个视图的数据导出)`);
|
||||
} else {
|
||||
message.success('导出成功');
|
||||
}
|
||||
} else if (res.message !== 'Cancelled') {
|
||||
message.error('导出失败: ' + res.message);
|
||||
}
|
||||
@@ -1697,6 +1829,13 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
case 'sqlite':
|
||||
query = `SELECT sql AS view_definition FROM sqlite_master WHERE type='view' AND name='${escapeSQLLiteral(viewName)}'`;
|
||||
break;
|
||||
case 'duckdb': {
|
||||
const parts = splitQualifiedName(viewName);
|
||||
const viewSchema = escapeSQLLiteral(parts.schemaName || 'main');
|
||||
const viewObject = escapeSQLLiteral(parts.objectName || viewName);
|
||||
query = `SELECT view_definition FROM information_schema.views WHERE table_schema='${viewSchema}' AND table_name='${viewObject}' LIMIT 1`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (query) {
|
||||
const result = await DBQuery(config as any, dbName, query);
|
||||
@@ -1739,6 +1878,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
template = `CREATE OR REPLACE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
|
||||
break;
|
||||
case 'sqlite':
|
||||
case 'duckdb':
|
||||
template = `CREATE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`;
|
||||
break;
|
||||
default:
|
||||
@@ -1831,9 +1971,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
try {
|
||||
const config = buildRuntimeConfig(conn, dbName);
|
||||
let query = '';
|
||||
const parts = routineName.split('.');
|
||||
const name = parts.length > 1 ? parts[1] : routineName;
|
||||
const schema = parts.length > 1 ? parts[0] : '';
|
||||
const parsedRoutine = splitQualifiedName(routineName);
|
||||
const name = parsedRoutine.objectName || routineName;
|
||||
const schema = parsedRoutine.schemaName;
|
||||
|
||||
switch (dialect) {
|
||||
case 'mysql':
|
||||
@@ -1856,6 +1996,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'duckdb': {
|
||||
const schemaRef = schema || 'main';
|
||||
query = `SELECT schema_name, function_name, parameters, macro_definition FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND schema_name = '${escapeSQLLiteral(schemaRef)}' AND function_name = '${escapeSQLLiteral(name)}' LIMIT 1`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (query) {
|
||||
const result = await DBQuery(config as any, dbName, query);
|
||||
@@ -1863,6 +2008,15 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
if (dialect === 'oracle' || dialect === 'dm') {
|
||||
const lines = result.data.map((row: any) => row.text || row.TEXT || Object.values(row)[0] || '').join('');
|
||||
if (lines) template = `-- 编辑${typeLabel} ${routineName}\nCREATE OR REPLACE ${lines}`;
|
||||
} else if (dialect === 'duckdb') {
|
||||
const row = result.data[0] as Record<string, any>;
|
||||
const ddl = buildDuckDBMacroDDL(
|
||||
String(getCaseInsensitiveRawValue(row, ['schema_name']) || schema || '').trim(),
|
||||
String(getCaseInsensitiveRawValue(row, ['function_name']) || name || '').trim(),
|
||||
getCaseInsensitiveRawValue(row, ['parameters']),
|
||||
getCaseInsensitiveRawValue(row, ['macro_definition'])
|
||||
);
|
||||
if (ddl) template = `-- 编辑${typeLabel} ${routineName}\n${ddl}`;
|
||||
} else {
|
||||
const row = result.data[0] as Record<string, any>;
|
||||
const def = row.routine_definition || row.ROUTINE_DEFINITION || Object.values(row).find(v => typeof v === 'string' && String(v).length > 10) || '';
|
||||
@@ -1910,6 +2064,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
? `CREATE OR REPLACE PROCEDURE proc_name(param1 IN NUMBER)\nIS\nBEGIN\n -- procedure body\n NULL;\nEND;`
|
||||
: `CREATE OR REPLACE FUNCTION func_name(param1 IN NUMBER)\nRETURN NUMBER\nIS\nBEGIN\n RETURN param1 * 2;\nEND;`;
|
||||
break;
|
||||
case 'duckdb':
|
||||
template = isProc
|
||||
? `-- DuckDB 暂不支持存储过程\n-- 请使用 SQL Macro 作为函数能力\nCREATE MACRO func_name(param1) AS (param1 * 2);`
|
||||
: `CREATE MACRO func_name(param1) AS (param1 * 2);`;
|
||||
break;
|
||||
default:
|
||||
template = isProc
|
||||
? `CREATE PROCEDURE proc_name()\nBEGIN\n -- procedure body\nEND;`
|
||||
@@ -2031,20 +2190,24 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
|
||||
// 函数分组节点的右键菜单
|
||||
if (node.type === 'object-group' && node.dataRef?.groupKey === 'routines') {
|
||||
return [
|
||||
const dialect = getMetadataDialect(node.dataRef as SavedConnection);
|
||||
const routineMenu: MenuProps['items'] = [
|
||||
{
|
||||
key: 'create-function',
|
||||
label: '新建函数',
|
||||
icon: <PlusOutlined />,
|
||||
onClick: () => openCreateRoutine(node, 'FUNCTION')
|
||||
},
|
||||
{
|
||||
];
|
||||
if (dialect !== 'duckdb') {
|
||||
routineMenu.push({
|
||||
key: 'create-procedure',
|
||||
label: '新建存储过程',
|
||||
icon: <PlusOutlined />,
|
||||
onClick: () => openCreateRoutine(node, 'PROCEDURE')
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
return routineMenu;
|
||||
}
|
||||
|
||||
if (node.type === 'connection') {
|
||||
@@ -2734,7 +2897,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
反选
|
||||
</Button>
|
||||
<span style={{ color: '#999' }}>
|
||||
已选择 {checkedTableKeys.length} / {batchTables.length} 张表
|
||||
已选择 {checkedTableKeys.length} / {batchTables.length} 个对象
|
||||
</span>
|
||||
</Space>
|
||||
</div>
|
||||
@@ -2744,14 +2907,38 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
onChange={(values) => setCheckedTableKeys(values as string[])}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{batchTables.map(table => (
|
||||
<Checkbox key={table.key} value={table.key}>
|
||||
<TableOutlined style={{ marginRight: 8 }} />
|
||||
{table.title}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{groupedBatchObjects.tables.length > 0 && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, color: darkMode ? '#bfbfbf' : '#595959', fontSize: 12 }}>
|
||||
表 ({groupedBatchObjects.tables.length})
|
||||
</div>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{groupedBatchObjects.tables.map(table => (
|
||||
<Checkbox key={table.key} value={table.key}>
|
||||
<TableOutlined style={{ marginRight: 8 }} />
|
||||
{table.title}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
{groupedBatchObjects.views.length > 0 && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, color: darkMode ? '#bfbfbf' : '#595959', fontSize: 12 }}>
|
||||
视图 ({groupedBatchObjects.views.length})
|
||||
</div>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{groupedBatchObjects.views.map(view => (
|
||||
<Checkbox key={view.key} value={view.key}>
|
||||
<EyeOutlined style={{ marginRight: 8 }} />
|
||||
{view.title}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
</>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -50,9 +50,11 @@ const TriggerViewer: React.FC<TriggerViewerProps> = ({ tab }) => {
|
||||
const getMetadataDialect = (conn: any): string => {
|
||||
const type = String(conn?.config?.type || '').trim().toLowerCase();
|
||||
if (type === 'custom') {
|
||||
return String(conn?.config?.driver || '').trim().toLowerCase();
|
||||
const driver = String(conn?.config?.driver || '').trim().toLowerCase();
|
||||
if (driver === 'diros' || driver === 'doris') return 'mysql';
|
||||
return driver;
|
||||
}
|
||||
if (type === 'mariadb' || type === 'sphinx') return 'mysql';
|
||||
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
|
||||
if (type === 'dameng') return 'dm';
|
||||
return type;
|
||||
};
|
||||
@@ -100,6 +102,8 @@ LIMIT 1`];
|
||||
return [`SELECT TRIGGER_BODY FROM ALL_TRIGGERS WHERE OWNER = '${safeDbName.toUpperCase()}' AND TRIGGER_NAME = '${safeTriggerName.toUpperCase()}'`];
|
||||
case 'sqlite':
|
||||
return [`SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = '${safeTriggerName}'`];
|
||||
case 'duckdb':
|
||||
return [`-- DuckDB 不支持触发器`];
|
||||
case 'tdengine':
|
||||
return [`-- TDengine 不支持触发器`];
|
||||
case 'mongodb':
|
||||
|
||||
@@ -6,7 +6,21 @@ import App from './App'
|
||||
// 全局配置 Monaco Editor 使用本地打包的文件,避免从 CDN (jsdelivr) 加载。
|
||||
// Windows WebView2 环境下访问外部 CDN 可能失败,导致编辑器一直显示 Loading。
|
||||
import { loader } from '@monaco-editor/react'
|
||||
import * as monaco from 'monaco-editor'
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
|
||||
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker.js?worker'
|
||||
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker.js?worker'
|
||||
import 'monaco-editor/esm/vs/basic-languages/sql/sql.contribution.js'
|
||||
import 'monaco-editor/esm/vs/language/json/monaco.contribution.js'
|
||||
|
||||
(self as any).MonacoEnvironment = {
|
||||
getWorker(_: unknown, label: string) {
|
||||
if (label === 'json') {
|
||||
return new JsonWorker()
|
||||
}
|
||||
return new EditorWorker()
|
||||
},
|
||||
}
|
||||
|
||||
loader.config({ monaco })
|
||||
|
||||
// 全局注册透明主题,避免每个 Editor 组件 beforeMount 中重复定义
|
||||
|
||||
@@ -14,6 +14,7 @@ const DEFAULT_CONNECTION_TYPE = 'mysql';
|
||||
const SUPPORTED_CONNECTION_TYPES = new Set([
|
||||
'mysql',
|
||||
'mariadb',
|
||||
'diros',
|
||||
'sphinx',
|
||||
'postgres',
|
||||
'redis',
|
||||
@@ -26,6 +27,7 @@ const SUPPORTED_CONNECTION_TYPES = new Set([
|
||||
'highgo',
|
||||
'vastbase',
|
||||
'sqlite',
|
||||
'duckdb',
|
||||
'custom',
|
||||
]);
|
||||
|
||||
@@ -34,6 +36,10 @@ const getDefaultPortByType = (type: string): number => {
|
||||
case 'mysql':
|
||||
case 'mariadb':
|
||||
return 3306;
|
||||
case 'diros':
|
||||
return 9030;
|
||||
case 'duckdb':
|
||||
return 0;
|
||||
case 'sphinx':
|
||||
return 9306;
|
||||
case 'postgres':
|
||||
@@ -246,6 +252,12 @@ export interface SqlLog {
|
||||
affectedRows?: number;
|
||||
}
|
||||
|
||||
export interface QueryOptions {
|
||||
maxRows: number;
|
||||
showColumnComment: boolean;
|
||||
showColumnType: boolean;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
connections: SavedConnection[];
|
||||
tabs: TabData[];
|
||||
@@ -255,7 +267,7 @@ interface AppState {
|
||||
theme: 'light' | 'dark';
|
||||
appearance: { opacity: number; blur: number };
|
||||
sqlFormatOptions: { keywordCase: 'upper' | 'lower' };
|
||||
queryOptions: { maxRows: number };
|
||||
queryOptions: QueryOptions;
|
||||
sqlLogs: SqlLog[];
|
||||
tableAccessCount: Record<string, number>;
|
||||
tableSortPreference: Record<string, 'name' | 'frequency'>;
|
||||
@@ -281,7 +293,7 @@ interface AppState {
|
||||
setTheme: (theme: 'light' | 'dark') => void;
|
||||
setAppearance: (appearance: Partial<{ opacity: number; blur: number }>) => void;
|
||||
setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void;
|
||||
setQueryOptions: (options: Partial<{ maxRows: number }>) => void;
|
||||
setQueryOptions: (options: Partial<QueryOptions>) => void;
|
||||
|
||||
addSqlLog: (log: SqlLog) => void;
|
||||
clearSqlLogs: () => void;
|
||||
@@ -320,13 +332,15 @@ const sanitizeSqlFormatOptions = (value: unknown): { keywordCase: 'upper' | 'low
|
||||
return { keywordCase: raw.keywordCase === 'lower' ? 'lower' : 'upper' };
|
||||
};
|
||||
|
||||
const sanitizeQueryOptions = (value: unknown): { maxRows: number } => {
|
||||
const sanitizeQueryOptions = (value: unknown): QueryOptions => {
|
||||
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
|
||||
const maxRows = Number(raw.maxRows);
|
||||
const showColumnComment = typeof raw.showColumnComment === 'boolean' ? raw.showColumnComment : true;
|
||||
const showColumnType = typeof raw.showColumnType === 'boolean' ? raw.showColumnType : true;
|
||||
if (!Number.isFinite(maxRows) || maxRows <= 0) {
|
||||
return { maxRows: 5000 };
|
||||
return { maxRows: 5000, showColumnComment, showColumnType };
|
||||
}
|
||||
return { maxRows: Math.min(50000, Math.trunc(maxRows)) };
|
||||
return { maxRows: Math.min(50000, Math.trunc(maxRows)), showColumnComment, showColumnType };
|
||||
};
|
||||
|
||||
const sanitizeTableAccessCount = (value: unknown): Record<string, number> => {
|
||||
@@ -377,7 +391,7 @@ export const useStore = create<AppState>()(
|
||||
theme: 'light',
|
||||
appearance: { ...DEFAULT_APPEARANCE },
|
||||
sqlFormatOptions: { keywordCase: 'upper' },
|
||||
queryOptions: { maxRows: 5000 },
|
||||
queryOptions: { maxRows: 5000, showColumnComment: true, showColumnType: true },
|
||||
sqlLogs: [],
|
||||
tableAccessCount: {},
|
||||
tableSortPreference: {},
|
||||
|
||||
@@ -36,7 +36,7 @@ export const quoteIdentPart = (dbType: string, ident: string) => {
|
||||
if (!raw) return raw;
|
||||
const dbTypeLower = (dbType || '').toLowerCase();
|
||||
|
||||
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'sphinx' || dbTypeLower === 'tdengine') {
|
||||
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros' || dbTypeLower === 'sphinx' || dbTypeLower === 'tdengine') {
|
||||
return `\`${raw.replace(/`/g, '``')}\``;
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ export const buildOrderBySQL = (
|
||||
// MySQL/MariaDB 大表在无显式排序需求时强制 ORDER BY(即使按主键)可能触发 filesort,
|
||||
// 导致 `Error 1038 (HY001): Out of sort memory`。
|
||||
// 因此仅在用户主动点击排序时下发 ORDER BY,默认分页查询不加兜底排序。
|
||||
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb') {
|
||||
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros') {
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
2
frontend/src/vite-env.d.ts
vendored
Normal file
2
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
@@ -1,6 +1,54 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
const normalizeModuleId = (id: string): string => id.replace(/\\/g, '/')
|
||||
|
||||
const sanitizeChunkToken = (raw: string): string =>
|
||||
String(raw || '')
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9_-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '') || 'misc'
|
||||
|
||||
const firstSegmentAfter = (id: string, marker: string): string => {
|
||||
const idx = id.indexOf(marker)
|
||||
if (idx < 0) return ''
|
||||
const rest = id.substring(idx + marker.length)
|
||||
const [segment] = rest.split('/')
|
||||
return sanitizeChunkToken(segment)
|
||||
}
|
||||
|
||||
const resolveMonacoChunk = (id: string, prefix: string): string | undefined => {
|
||||
if (!id.includes('/node_modules/monaco-editor/')) return undefined
|
||||
|
||||
if (id.includes('/esm/vs/language/typescript/')) {
|
||||
if (id.includes('typescriptServices')) return `${prefix}-ts-services`
|
||||
return `${prefix}-typescript`
|
||||
}
|
||||
if (id.includes('/esm/vs/language/json/')) return `${prefix}-json`
|
||||
if (id.includes('/esm/vs/language/css/')) return `${prefix}-css`
|
||||
if (id.includes('/esm/vs/language/html/')) return `${prefix}-html`
|
||||
|
||||
if (id.includes('/esm/vs/editor/contrib/')) {
|
||||
return `${prefix}-editor-contrib-${firstSegmentAfter(id, '/esm/vs/editor/contrib/')}`
|
||||
}
|
||||
if (id.includes('/esm/vs/editor/browser/')) {
|
||||
return `${prefix}-editor-browser-${firstSegmentAfter(id, '/esm/vs/editor/browser/')}`
|
||||
}
|
||||
if (id.includes('/esm/vs/editor/common/')) {
|
||||
return `${prefix}-editor-common-${firstSegmentAfter(id, '/esm/vs/editor/common/')}`
|
||||
}
|
||||
if (id.includes('/esm/vs/editor/')) return `${prefix}-editor`
|
||||
|
||||
if (id.includes('/esm/vs/base/browser/')) return `${prefix}-base-browser`
|
||||
if (id.includes('/esm/vs/base/common/')) return `${prefix}-base-common`
|
||||
if (id.includes('/esm/vs/base/')) return `${prefix}-base`
|
||||
|
||||
if (id.includes('/esm/vs/platform/')) return `${prefix}-platform`
|
||||
|
||||
return `${prefix}-misc`
|
||||
}
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
@@ -11,5 +59,61 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist', // Standard Wails output directory
|
||||
emptyOutDir: true,
|
||||
}
|
||||
})
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
const moduleId = normalizeModuleId(id)
|
||||
if (!moduleId.includes('node_modules')) return undefined
|
||||
|
||||
const monacoChunk = resolveMonacoChunk(moduleId, 'vendor-monaco')
|
||||
if (monacoChunk) {
|
||||
return monacoChunk
|
||||
}
|
||||
if (moduleId.includes('/node_modules/@monaco-editor/react/')) return 'vendor-monaco-react'
|
||||
|
||||
if (moduleId.includes('/node_modules/antd/es/')) {
|
||||
return `vendor-antd-${firstSegmentAfter(moduleId, '/node_modules/antd/es/')}`
|
||||
}
|
||||
if (moduleId.includes('/node_modules/antd/')) return 'vendor-antd'
|
||||
if (moduleId.includes('/node_modules/@ant-design/icons/')) return 'vendor-antd-icons'
|
||||
if (moduleId.includes('/node_modules/@ant-design/cssinjs/')) return 'vendor-antd-css'
|
||||
if (moduleId.includes('/node_modules/rc-')) return 'vendor-antd-rc'
|
||||
|
||||
if (moduleId.includes('/node_modules/@dnd-kit/')) return 'vendor-dnd-kit'
|
||||
if (moduleId.includes('/node_modules/sql-formatter/')) return 'vendor-sql-formatter'
|
||||
|
||||
if (
|
||||
moduleId.includes('/node_modules/react/')
|
||||
|| moduleId.includes('/node_modules/react-dom/')
|
||||
|| moduleId.includes('/node_modules/scheduler/')
|
||||
) {
|
||||
return 'vendor-react'
|
||||
}
|
||||
|
||||
if (
|
||||
moduleId.includes('/node_modules/zustand/')
|
||||
|| moduleId.includes('/node_modules/uuid/')
|
||||
|| moduleId.includes('/node_modules/clsx/')
|
||||
|| moduleId.includes('/node_modules/react-resizable/')
|
||||
) {
|
||||
return 'vendor-utils'
|
||||
}
|
||||
|
||||
return 'vendor-misc'
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
worker: {
|
||||
format: 'es',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
const moduleId = normalizeModuleId(id)
|
||||
if (!moduleId.includes('node_modules')) return undefined
|
||||
return resolveMonacoChunk(moduleId, 'worker-monaco')
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
22
frontend/wailsjs/go/app/App.d.ts
vendored
22
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -8,6 +8,8 @@ export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:s
|
||||
|
||||
export function CheckForUpdates():Promise<connection.QueryResult>;
|
||||
|
||||
export function ConfigureDriverRuntimeDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function CreateDatabase(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DBConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||
@@ -36,6 +38,8 @@ export function DataSyncAnalyze(arg1:sync.SyncConfig):Promise<connection.QueryRe
|
||||
|
||||
export function DataSyncPreview(arg1:sync.SyncConfig,arg2:string,arg3:number):Promise<connection.QueryResult>;
|
||||
|
||||
export function DownloadDriverPackage(arg1:string,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DownloadUpdate():Promise<connection.QueryResult>;
|
||||
|
||||
export function DropDatabase(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
|
||||
@@ -60,12 +64,16 @@ export function ExportTablesSQL(arg1:connection.ConnectionConfig,arg2:string,arg
|
||||
|
||||
export function GetAppInfo():Promise<connection.QueryResult>;
|
||||
|
||||
export function GetDriverStatusList(arg1:string,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ImportConfigFile():Promise<connection.QueryResult>;
|
||||
|
||||
export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ImportDataWithProgress(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function InstallLocalDriverPackage(arg1:string,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function InstallUpdateAndRestart():Promise<connection.QueryResult>;
|
||||
|
||||
export function MongoDiscoverMembers(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||
@@ -130,12 +138,26 @@ export function RedisZSetAdd(arg1:connection.ConnectionConfig,arg2:string,arg3:A
|
||||
|
||||
export function RedisZSetRemove(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
|
||||
|
||||
export function RemoveDriverPackage(arg1:string,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function RenameDatabase(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function RenameTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function RenameView(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ResolveDriverDownloadDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ResolveDriverPackageDownloadURL(arg1:string,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ResolveDriverRepositoryURL(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SelectDriverDownloadDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SelectDriverPackageFile(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SelectSSHKeyFile(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SetWindowTranslucency(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function TestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||
|
||||
@@ -10,6 +10,10 @@ export function CheckForUpdates() {
|
||||
return window['go']['app']['App']['CheckForUpdates']();
|
||||
}
|
||||
|
||||
export function ConfigureDriverRuntimeDirectory(arg1) {
|
||||
return window['go']['app']['App']['ConfigureDriverRuntimeDirectory'](arg1);
|
||||
}
|
||||
|
||||
export function CreateDatabase(arg1, arg2) {
|
||||
return window['go']['app']['App']['CreateDatabase'](arg1, arg2);
|
||||
}
|
||||
@@ -66,6 +70,10 @@ export function DataSyncPreview(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['DataSyncPreview'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function DownloadDriverPackage(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['DownloadDriverPackage'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function DownloadUpdate() {
|
||||
return window['go']['app']['App']['DownloadUpdate']();
|
||||
}
|
||||
@@ -114,6 +122,10 @@ export function GetAppInfo() {
|
||||
return window['go']['app']['App']['GetAppInfo']();
|
||||
}
|
||||
|
||||
export function GetDriverStatusList(arg1, arg2) {
|
||||
return window['go']['app']['App']['GetDriverStatusList'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ImportConfigFile() {
|
||||
return window['go']['app']['App']['ImportConfigFile']();
|
||||
}
|
||||
@@ -126,6 +138,10 @@ export function ImportDataWithProgress(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['ImportDataWithProgress'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function InstallLocalDriverPackage(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['InstallLocalDriverPackage'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function InstallUpdateAndRestart() {
|
||||
return window['go']['app']['App']['InstallUpdateAndRestart']();
|
||||
}
|
||||
@@ -254,6 +270,10 @@ export function RedisZSetRemove(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['RedisZSetRemove'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function RemoveDriverPackage(arg1, arg2) {
|
||||
return window['go']['app']['App']['RemoveDriverPackage'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function RenameDatabase(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['RenameDatabase'](arg1, arg2, arg3);
|
||||
}
|
||||
@@ -266,6 +286,30 @@ export function RenameView(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['RenameView'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function ResolveDriverDownloadDirectory(arg1) {
|
||||
return window['go']['app']['App']['ResolveDriverDownloadDirectory'](arg1);
|
||||
}
|
||||
|
||||
export function ResolveDriverPackageDownloadURL(arg1, arg2) {
|
||||
return window['go']['app']['App']['ResolveDriverPackageDownloadURL'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ResolveDriverRepositoryURL(arg1) {
|
||||
return window['go']['app']['App']['ResolveDriverRepositoryURL'](arg1);
|
||||
}
|
||||
|
||||
export function SelectDriverDownloadDirectory(arg1) {
|
||||
return window['go']['app']['App']['SelectDriverDownloadDirectory'](arg1);
|
||||
}
|
||||
|
||||
export function SelectDriverPackageFile(arg1) {
|
||||
return window['go']['app']['App']['SelectDriverPackageFile'](arg1);
|
||||
}
|
||||
|
||||
export function SelectSSHKeyFile(arg1) {
|
||||
return window['go']['app']['App']['SelectSSHKeyFile'](arg1);
|
||||
}
|
||||
|
||||
export function SetWindowTranslucency(arg1, arg2) {
|
||||
return window['go']['app']['App']['SetWindowTranslucency'](arg1, arg2);
|
||||
}
|
||||
|
||||
28
go.mod
28
go.mod
@@ -5,6 +5,7 @@ go 1.24.3
|
||||
require (
|
||||
gitea.com/kingbase/gokb v0.0.0-20201021123113-29bd62a876c3
|
||||
gitee.com/chunanyong/dm v1.8.22
|
||||
github.com/duckdb/duckdb-go/v2 v2.5.5
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/highgo/pq-sm3 v0.0.0
|
||||
github.com/lib/pq v1.11.1
|
||||
@@ -22,21 +23,32 @@ require (
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/apache/arrow-go/v18 v18.5.1 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/duckdb/duckdb-go-bindings v0.3.3 // indirect
|
||||
github.com/duckdb/duckdb-go-bindings/lib/darwin-amd64 v0.3.3 // indirect
|
||||
github.com/duckdb/duckdb-go-bindings/lib/darwin-arm64 v0.3.3 // indirect
|
||||
github.com/duckdb/duckdb-go-bindings/lib/linux-amd64 v0.3.3 // indirect
|
||||
github.com/duckdb/duckdb-go-bindings/lib/linux-arm64 v0.3.3 // indirect
|
||||
github.com/duckdb/duckdb-go-bindings/lib/windows-amd64 v0.3.3 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/google/flatbuffers v25.12.19+incompatible // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.17.6 // indirect
|
||||
github.com/klauspost/compress v1.18.3 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||
@@ -45,9 +57,10 @@ require (
|
||||
github.com/leaanthony/u v1.1.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.25 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
@@ -68,10 +81,15 @@ require (
|
||||
github.com/xuri/efp v0.0.1 // indirect
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260116145544-c6413dc483f5 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
82
go.sum
82
go.sum
@@ -16,6 +16,12 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuo
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/apache/arrow-go/v18 v18.5.1 h1:yaQ6zxMGgf9YCYw4/oaeOU3AULySDlAYDOcnr4LdHdI=
|
||||
github.com/apache/arrow-go/v18 v18.5.1/go.mod h1:OCCJsmdq8AsRm8FkBSSmYTwL/s4zHW9CqxeBxEytkNE=
|
||||
github.com/apache/thrift v0.22.0 h1:r7mTJdj51TMDe6RtcmNdQxgn9XcyfGDOzegMDRg47uc=
|
||||
github.com/apache/thrift v0.22.0/go.mod h1:1e7J/O1Ae6ZQMTYdy9xa3w9k+XHWPfRvdPyJeynQ+/g=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
@@ -25,16 +31,35 @@ github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/duckdb/duckdb-go-bindings v0.3.3 h1:lXogtCY8hiGLQvTfK55HcgvaA3K2MrwKeZGqhIin35U=
|
||||
github.com/duckdb/duckdb-go-bindings v0.3.3/go.mod h1:zS7OpBP8zwVlP38OljRZOnqWYlNd4KLcVfMoA1JFzpk=
|
||||
github.com/duckdb/duckdb-go-bindings/lib/darwin-amd64 v0.3.3 h1:ue8BtIOSt+2Bt2fEfTAvBcQLxzBFhgfCcyzPtqQWTRA=
|
||||
github.com/duckdb/duckdb-go-bindings/lib/darwin-amd64 v0.3.3/go.mod h1:EnAvZh1kNJHp5yF+M1ZHNEvapnmt6anq1xXHVrAGqMo=
|
||||
github.com/duckdb/duckdb-go-bindings/lib/darwin-arm64 v0.3.3 h1:2TrSeTgtwi3WIvub9ba0mny+AClSNo1w0Ghszc2B8lQ=
|
||||
github.com/duckdb/duckdb-go-bindings/lib/darwin-arm64 v0.3.3/go.mod h1:IGLSeEcFhNeZF16aVjQCULD7TsFZKG5G7SyKJAXKp5c=
|
||||
github.com/duckdb/duckdb-go-bindings/lib/linux-amd64 v0.3.3 h1:GN0cexhfE7uLb7qgDmsYG324wKF15nW+O7v5+NGalS4=
|
||||
github.com/duckdb/duckdb-go-bindings/lib/linux-amd64 v0.3.3/go.mod h1:KAIynZ0GHCS7X5fRyuFnQMg/SZBPK/bS9OCOVojClxw=
|
||||
github.com/duckdb/duckdb-go-bindings/lib/linux-arm64 v0.3.3 h1:bIJV+ct6yvMXjy+N3bfILFd0fkTK50AUhUTerkY40/8=
|
||||
github.com/duckdb/duckdb-go-bindings/lib/linux-arm64 v0.3.3/go.mod h1:81SGOYoEUs8qaAfSk1wRfM5oobrIJ5KI7AzYhK6/bvQ=
|
||||
github.com/duckdb/duckdb-go-bindings/lib/windows-amd64 v0.3.3 h1:SK2sunA/MPb2T3113iFzHv6DWeu+qrsw0DizTFrvM+Q=
|
||||
github.com/duckdb/duckdb-go-bindings/lib/windows-amd64 v0.3.3/go.mod h1:K25pJL26ARblGDeuAkrdblFvUen92+CwksLtPEHRqqQ=
|
||||
github.com/duckdb/duckdb-go/v2 v2.5.5 h1:TlK8ipnzoKW2aNrjGqRkFWLCDpJDxR/VwH8ezEcvVhw=
|
||||
github.com/duckdb/duckdb-go/v2 v2.5.5/go.mod h1:6uIbC3gz36NCEygECzboygOo/Z9TeVwox/puG+ohWV0=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
@@ -44,8 +69,10 @@ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2V
|
||||
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs=
|
||||
github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -64,8 +91,12 @@ github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4P
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
|
||||
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
|
||||
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
|
||||
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||
@@ -94,18 +125,26 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/microsoft/go-mssqldb v1.9.6 h1:1MNQg5UiSsokiPz3++K2KPx4moKrwIqly1wv+RyCKTw=
|
||||
github.com/microsoft/go-mssqldb v1.9.6/go.mod h1:yYMPDufyoF2vVuVCUGtZARr06DKFIhMrluTcgWlXpr4=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs=
|
||||
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
|
||||
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
|
||||
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
|
||||
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
|
||||
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
@@ -126,8 +165,9 @@ github.com/sijms/go-ora/v2 v2.9.0 h1:+iQbUeTeCOFMb5BsOMgUhV8KWyrv9yjKpcK4x7+MFrg
|
||||
github.com/sijms/go-ora/v2 v2.9.0/go.mod h1:QgFInVi3ZWyqAiJwzBQA+nbKYKH77tdp1PYoCqhR2dU=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
@@ -165,25 +205,29 @@ github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/T
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
||||
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
@@ -200,6 +244,8 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20260116145544-c6413dc483f5 h1:i0p03B68+xC1kD2QUO8JzDTPXCzhN56OLJ+IhHY8U3A=
|
||||
golang.org/x/telemetry v0.0.0-20260116145544-c6413dc483f5/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
@@ -215,9 +261,13 @@ golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -133,8 +133,17 @@ func formatConnSummary(config connection.ConnectionConfig) string {
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("类型=%s 地址=%s:%d 数据库=%s 用户=%s 超时=%ds",
|
||||
config.Type, config.Host, config.Port, dbName, config.User, timeoutSeconds))
|
||||
normalizedType := strings.ToLower(strings.TrimSpace(config.Type))
|
||||
if normalizedType == "sqlite" || normalizedType == "duckdb" {
|
||||
path := strings.TrimSpace(config.Host)
|
||||
if path == "" {
|
||||
path = "(未配置)"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("类型=%s 路径=%s 超时=%ds", config.Type, path, timeoutSeconds))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("类型=%s 地址=%s:%d 数据库=%s 用户=%s 超时=%ds",
|
||||
config.Type, config.Host, config.Port, dbName, config.User, timeoutSeconds))
|
||||
}
|
||||
|
||||
if len(config.Hosts) > 0 {
|
||||
b.WriteString(fmt.Sprintf(" 节点数=%d", len(config.Hosts)))
|
||||
@@ -198,6 +207,20 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
|
||||
shortKey = shortKey[:12]
|
||||
}
|
||||
|
||||
if supported, reason := db.DriverRuntimeSupportStatus(config.Type); !supported {
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
reason = fmt.Sprintf("%s 驱动未启用,请先在驱动管理中安装启用", strings.TrimSpace(config.Type))
|
||||
}
|
||||
// Best-effort cleanup: if cached instance exists for this exact config, close it.
|
||||
a.mu.Lock()
|
||||
if cur, exists := a.dbCache[key]; exists && cur.inst != nil {
|
||||
_ = cur.inst.Close()
|
||||
delete(a.dbCache, key)
|
||||
}
|
||||
a.mu.Unlock()
|
||||
return nil, withLogHint{err: fmt.Errorf("%s", reason), logPath: logger.Path()}
|
||||
}
|
||||
|
||||
a.mu.RLock()
|
||||
entry, ok := a.dbCache[key]
|
||||
a.mu.RUnlock()
|
||||
|
||||
@@ -14,7 +14,7 @@ func normalizeRunConfig(config connection.ConnectionConfig, dbName string) conne
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(config.Type)) {
|
||||
case "mysql", "mariadb", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "sqlserver", "mongodb", "tdengine":
|
||||
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "sqlserver", "mongodb", "tdengine":
|
||||
// 这些类型的 dbName 表示"数据库",需要写入连接配置以选择目标库。
|
||||
runConfig.Database = name
|
||||
case "dameng":
|
||||
|
||||
@@ -88,7 +88,7 @@ func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string)
|
||||
query = fmt.Sprintf("CREATE DATABASE \"%s\"", escapedDbName)
|
||||
} else if dbType == "tdengine" {
|
||||
query = fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", quoteIdentByType(dbType, dbName))
|
||||
} else if dbType == "mariadb" {
|
||||
} else if dbType == "mariadb" || dbType == "diros" {
|
||||
// MariaDB uses same syntax as MySQL
|
||||
} else if dbType == "sphinx" {
|
||||
return connection.QueryResult{Success: false, Message: "Sphinx 暂不支持创建数据库"}
|
||||
@@ -118,6 +118,8 @@ func resolveDDLDBType(config connection.ConnectionConfig) string {
|
||||
return "sqlite"
|
||||
case "sphinxql":
|
||||
return "sphinx"
|
||||
case "diros", "doris":
|
||||
return "diros"
|
||||
default:
|
||||
return driver
|
||||
}
|
||||
@@ -160,7 +162,7 @@ func buildRunConfigForDDL(config connection.ConnectionConfig, dbType string, dbN
|
||||
if strings.EqualFold(strings.TrimSpace(config.Type), "custom") {
|
||||
// custom 连接的 dbName 语义依赖 driver,尽量在常见驱动上对齐内置类型行为。
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "sphinx", "postgres", "kingbase", "vastbase", "dameng":
|
||||
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "vastbase", "dameng":
|
||||
if strings.TrimSpace(dbName) != "" {
|
||||
runConfig.Database = strings.TrimSpace(dbName)
|
||||
}
|
||||
@@ -181,8 +183,8 @@ func (a *App) RenameDatabase(config connection.ConnectionConfig, oldName string,
|
||||
|
||||
dbType := resolveDDLDBType(config)
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "sphinx":
|
||||
return connection.QueryResult{Success: false, Message: "MySQL/MariaDB/Sphinx 不支持直接重命名数据库,请新建库后迁移数据"}
|
||||
case "mysql", "mariadb", "diros", "sphinx":
|
||||
return connection.QueryResult{Success: false, Message: "MySQL/MariaDB/Diros/Sphinx 不支持直接重命名数据库,请新建库后迁移数据"}
|
||||
case "postgres", "kingbase", "highgo", "vastbase":
|
||||
if strings.EqualFold(strings.TrimSpace(config.Database), oldName) {
|
||||
return connection.QueryResult{Success: false, Message: "当前连接正在使用目标数据库,请先连接到其他数据库后再重命名"}
|
||||
@@ -217,7 +219,7 @@ func (a *App) DropDatabase(config connection.ConnectionConfig, dbName string) co
|
||||
sql string
|
||||
)
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "tdengine":
|
||||
case "mysql", "mariadb", "diros", "tdengine":
|
||||
runConfig = config
|
||||
runConfig.Database = ""
|
||||
sql = fmt.Sprintf("DROP DATABASE %s", quoteIdentByType(dbType, dbName))
|
||||
@@ -259,7 +261,7 @@ func (a *App) RenameTable(config connection.ConnectionConfig, dbName string, old
|
||||
|
||||
dbType := resolveDDLDBType(config)
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "sphinx", "postgres", "kingbase", "sqlite", "oracle", "dameng", "highgo", "vastbase", "sqlserver":
|
||||
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "sqlserver":
|
||||
default:
|
||||
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持重命名表", dbType)}
|
||||
}
|
||||
@@ -273,7 +275,7 @@ func (a *App) RenameTable(config connection.ConnectionConfig, dbName string, old
|
||||
|
||||
var sql string
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "sphinx":
|
||||
case "mysql", "mariadb", "diros", "sphinx":
|
||||
newQualifiedTable := quoteTableIdentByType(dbType, schemaName, newTableName)
|
||||
sql = fmt.Sprintf("RENAME TABLE %s TO %s", oldQualifiedTable, newQualifiedTable)
|
||||
case "sqlserver":
|
||||
@@ -305,7 +307,7 @@ func (a *App) DropTable(config connection.ConnectionConfig, dbName string, table
|
||||
|
||||
dbType := resolveDDLDBType(config)
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "sphinx", "postgres", "kingbase", "sqlite", "oracle", "dameng", "highgo", "vastbase", "sqlserver", "tdengine":
|
||||
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "sqlserver", "tdengine":
|
||||
default:
|
||||
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除表", dbType)}
|
||||
}
|
||||
@@ -463,6 +465,7 @@ func (a *App) DBGetTables(config connection.ConnectionConfig, dbName string) con
|
||||
|
||||
func (a *App) DBShowCreateTable(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
|
||||
runConfig := normalizeRunConfig(config, dbName)
|
||||
dbType := resolveDDLDBType(config)
|
||||
|
||||
dbInst, err := a.getDatabase(runConfig)
|
||||
if err != nil {
|
||||
@@ -476,10 +479,120 @@ func (a *App) DBShowCreateTable(config connection.ConnectionConfig, dbName strin
|
||||
logger.Error(err, "DBShowCreateTable 获取建表语句失败:%s 表=%s", formatConnSummary(runConfig), tableName)
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if shouldFallbackCreateStatement(dbType, sqlStr) {
|
||||
columns, colErr := dbInst.GetColumns(schemaName, pureTableName)
|
||||
if colErr != nil {
|
||||
logger.Error(colErr, "DBShowCreateTable 兜底加载字段失败:%s 表=%s", formatConnSummary(runConfig), tableName)
|
||||
return connection.QueryResult{Success: false, Message: colErr.Error()}
|
||||
}
|
||||
fallbackDDL, buildErr := buildFallbackCreateStatement(dbType, schemaName, pureTableName, columns)
|
||||
if buildErr != nil {
|
||||
logger.Error(buildErr, "DBShowCreateTable 兜底生成 DDL 失败:%s 表=%s", formatConnSummary(runConfig), tableName)
|
||||
return connection.QueryResult{Success: false, Message: buildErr.Error()}
|
||||
}
|
||||
sqlStr = fallbackDDL
|
||||
}
|
||||
|
||||
return connection.QueryResult{Success: true, Data: sqlStr}
|
||||
}
|
||||
|
||||
func shouldFallbackCreateStatement(dbType string, ddl string) bool {
|
||||
switch dbType {
|
||||
case "postgres", "kingbase", "highgo", "vastbase":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(ddl)
|
||||
if trimmed == "" {
|
||||
return true
|
||||
}
|
||||
if hasCreateTableHead(trimmed) {
|
||||
return false
|
||||
}
|
||||
|
||||
lower := strings.ToLower(trimmed)
|
||||
if strings.Contains(lower, "not fully supported") ||
|
||||
strings.Contains(lower, "not directly supported") ||
|
||||
strings.Contains(lower, "not supported") {
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hasCreateTableHead(sqlText string) bool {
|
||||
lines := strings.Split(sqlText, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "--") || strings.HasPrefix(line, "/*") || strings.HasPrefix(line, "*") {
|
||||
continue
|
||||
}
|
||||
return strings.HasPrefix(strings.ToLower(line), "create table")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func buildFallbackCreateStatement(dbType string, schemaName string, tableName string, columns []connection.ColumnDefinition) (string, error) {
|
||||
table := strings.TrimSpace(tableName)
|
||||
if table == "" {
|
||||
return "", fmt.Errorf("表名不能为空")
|
||||
}
|
||||
if len(columns) == 0 {
|
||||
return "", fmt.Errorf("未获取到字段定义,无法生成建表语句")
|
||||
}
|
||||
|
||||
qualifiedTable := quoteTableIdentByType(dbType, schemaName, table)
|
||||
columnLines := make([]string, 0, len(columns)+1)
|
||||
primaryKeys := make([]string, 0, 2)
|
||||
|
||||
for _, col := range columns {
|
||||
colNameRaw := strings.TrimSpace(col.Name)
|
||||
if colNameRaw == "" {
|
||||
continue
|
||||
}
|
||||
colType := strings.TrimSpace(col.Type)
|
||||
if colType == "" {
|
||||
colType = "text"
|
||||
}
|
||||
|
||||
colName := quoteIdentByType(dbType, colNameRaw)
|
||||
defParts := []string{fmt.Sprintf("%s %s", colName, colType)}
|
||||
|
||||
if strings.EqualFold(strings.TrimSpace(col.Nullable), "NO") {
|
||||
defParts = append(defParts, "NOT NULL")
|
||||
}
|
||||
if col.Default != nil {
|
||||
defVal := strings.TrimSpace(*col.Default)
|
||||
if defVal != "" {
|
||||
defParts = append(defParts, "DEFAULT "+defVal)
|
||||
}
|
||||
}
|
||||
|
||||
columnLines = append(columnLines, " "+strings.Join(defParts, " "))
|
||||
if strings.EqualFold(strings.TrimSpace(col.Key), "PRI") {
|
||||
primaryKeys = append(primaryKeys, colName)
|
||||
}
|
||||
}
|
||||
|
||||
if len(columnLines) == 0 {
|
||||
return "", fmt.Errorf("字段定义为空,无法生成建表语句")
|
||||
}
|
||||
if len(primaryKeys) > 0 {
|
||||
columnLines = append(columnLines, " PRIMARY KEY ("+strings.Join(primaryKeys, ", ")+")")
|
||||
}
|
||||
|
||||
ddl := strings.Builder{}
|
||||
ddl.WriteString("CREATE TABLE ")
|
||||
ddl.WriteString(qualifiedTable)
|
||||
ddl.WriteString(" (\n")
|
||||
ddl.WriteString(strings.Join(columnLines, ",\n"))
|
||||
ddl.WriteString("\n);")
|
||||
return ddl.String(), nil
|
||||
}
|
||||
|
||||
func (a *App) DBGetColumns(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
|
||||
runConfig := normalizeRunConfig(config, dbName)
|
||||
|
||||
@@ -556,7 +669,7 @@ func (a *App) DropView(config connection.ConnectionConfig, dbName string, viewNa
|
||||
|
||||
dbType := resolveDDLDBType(config)
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "sphinx", "postgres", "kingbase", "sqlite", "oracle", "dameng", "highgo", "vastbase", "sqlserver":
|
||||
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "sqlserver":
|
||||
default:
|
||||
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除视图", dbType)}
|
||||
}
|
||||
@@ -591,10 +704,13 @@ func (a *App) DropFunction(config connection.ConnectionConfig, dbName string, ro
|
||||
|
||||
dbType := resolveDDLDBType(config)
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "sphinx", "postgres", "kingbase", "oracle", "dameng", "highgo", "vastbase", "sqlserver":
|
||||
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "oracle", "dameng", "highgo", "vastbase", "sqlserver", "duckdb":
|
||||
default:
|
||||
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除函数/存储过程", dbType)}
|
||||
}
|
||||
if dbType == "duckdb" && routineType == "PROCEDURE" {
|
||||
return connection.QueryResult{Success: false, Message: "DuckDB 暂不支持存储过程"}
|
||||
}
|
||||
|
||||
schemaName, pureName := normalizeSchemaAndTableByType(dbType, dbName, routineName)
|
||||
if pureName == "" {
|
||||
@@ -642,7 +758,7 @@ func (a *App) RenameView(config connection.ConnectionConfig, dbName string, oldN
|
||||
|
||||
var sql string
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "sphinx":
|
||||
case "mysql", "mariadb", "diros", "sphinx":
|
||||
newQualified := quoteTableIdentByType(dbType, schemaName, newName)
|
||||
sql = fmt.Sprintf("RENAME TABLE %s TO %s", oldQualified, newQualified)
|
||||
case "postgres", "kingbase", "highgo", "vastbase":
|
||||
|
||||
1877
internal/app/methods_driver.go
Normal file
1877
internal/app/methods_driver.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -77,6 +78,48 @@ func (a *App) ImportConfigFile() connection.QueryResult {
|
||||
return connection.QueryResult{Success: true, Data: string(content)}
|
||||
}
|
||||
|
||||
func (a *App) SelectSSHKeyFile(currentPath string) connection.QueryResult {
|
||||
defaultDir := strings.TrimSpace(currentPath)
|
||||
if defaultDir == "" {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
defaultDir = filepath.Join(home, ".ssh")
|
||||
}
|
||||
}
|
||||
if filepath.Ext(defaultDir) != "" {
|
||||
defaultDir = filepath.Dir(defaultDir)
|
||||
}
|
||||
if defaultDir != "" && !filepath.IsAbs(defaultDir) {
|
||||
if abs, err := filepath.Abs(defaultDir); err == nil {
|
||||
defaultDir = abs
|
||||
}
|
||||
}
|
||||
|
||||
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
|
||||
Title: "选择 SSH 私钥文件",
|
||||
DefaultDirectory: defaultDir,
|
||||
Filters: []runtime.FileFilter{
|
||||
{
|
||||
DisplayName: "私钥文件",
|
||||
Pattern: "*.pem;*.key;*.ppk;*id_rsa*",
|
||||
},
|
||||
{
|
||||
DisplayName: "所有文件",
|
||||
Pattern: "*",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if strings.TrimSpace(selection) == "" {
|
||||
return connection.QueryResult{Success: false, Message: "Cancelled"}
|
||||
}
|
||||
if abs, err := filepath.Abs(selection); err == nil {
|
||||
selection = abs
|
||||
}
|
||||
return connection.QueryResult{Success: true, Data: map[string]interface{}{"path": selection}}
|
||||
}
|
||||
|
||||
// PreviewImportFile 解析导入文件,返回字段列表、总行数、前 5 行预览数据
|
||||
func (a *App) PreviewImportFile(filePath string) connection.QueryResult {
|
||||
if filePath == "" {
|
||||
@@ -485,7 +528,8 @@ func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tab
|
||||
if err := writeSQLHeader(w, runConfig, dbName); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if err := dumpTableSQL(w, dbInst, runConfig, dbName, tableName, true, true); err != nil {
|
||||
viewLookup := listViewNameLookup(dbInst, runConfig, dbName)
|
||||
if err := dumpTableSQL(w, dbInst, runConfig, dbName, tableName, true, true, viewLookup); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if err := writeSQLFooter(w, runConfig); err != nil {
|
||||
@@ -556,7 +600,7 @@ func (a *App) exportTablesSQL(config connection.ConnectionConfig, dbName string,
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
tables := make([]string, 0, len(tableNames))
|
||||
objects := make([]string, 0, len(tableNames))
|
||||
seen := make(map[string]struct{}, len(tableNames))
|
||||
for _, t := range tableNames {
|
||||
t = strings.TrimSpace(t)
|
||||
@@ -567,9 +611,10 @@ func (a *App) exportTablesSQL(config connection.ConnectionConfig, dbName string,
|
||||
continue
|
||||
}
|
||||
seen[t] = struct{}{}
|
||||
tables = append(tables, t)
|
||||
objects = append(objects, t)
|
||||
}
|
||||
sort.Strings(tables)
|
||||
viewLookup := listViewNameLookup(dbInst, runConfig, dbName)
|
||||
objects = buildExportObjectOrder(runConfig, dbName, objects, viewLookup, false)
|
||||
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
@@ -583,8 +628,8 @@ func (a *App) exportTablesSQL(config connection.ConnectionConfig, dbName string,
|
||||
if err := writeSQLHeader(w, runConfig, dbName); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
for _, t := range tables {
|
||||
if err := dumpTableSQL(w, dbInst, runConfig, dbName, t, includeSchema, includeData); err != nil {
|
||||
for _, objectName := range objects {
|
||||
if err := dumpTableSQL(w, dbInst, runConfig, dbName, objectName, includeSchema, includeData, viewLookup); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
}
|
||||
@@ -623,7 +668,8 @@ func (a *App) ExportDatabaseSQL(config connection.ConnectionConfig, dbName strin
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
sort.Strings(tables)
|
||||
viewLookup := listViewNameLookup(dbInst, runConfig, dbName)
|
||||
objects := buildExportObjectOrder(runConfig, dbName, tables, viewLookup, true)
|
||||
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
@@ -637,8 +683,8 @@ func (a *App) ExportDatabaseSQL(config connection.ConnectionConfig, dbName strin
|
||||
if err := writeSQLHeader(w, runConfig, dbName); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
for _, t := range tables {
|
||||
if err := dumpTableSQL(w, dbInst, runConfig, dbName, t, true, includeData); err != nil {
|
||||
for _, objectName := range objects {
|
||||
if err := dumpTableSQL(w, dbInst, runConfig, dbName, objectName, true, includeData, viewLookup); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
}
|
||||
@@ -655,7 +701,7 @@ func quoteIdentByType(dbType string, ident string) string {
|
||||
}
|
||||
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "sphinx", "tdengine":
|
||||
case "mysql", "mariadb", "diros", "sphinx", "tdengine":
|
||||
return "`" + strings.ReplaceAll(ident, "`", "``") + "`"
|
||||
case "sqlserver":
|
||||
escaped := strings.ReplaceAll(ident, "]", "]]")
|
||||
@@ -743,6 +789,404 @@ func ensureSQLTerminator(sql string) string {
|
||||
return sql + ";"
|
||||
}
|
||||
|
||||
func buildExportObjectOrder(
|
||||
config connection.ConnectionConfig,
|
||||
dbName string,
|
||||
rawObjects []string,
|
||||
viewLookup map[string]string,
|
||||
includeAllViews bool,
|
||||
) []string {
|
||||
tableSet := make(map[string]string, len(rawObjects))
|
||||
viewSet := make(map[string]string, len(rawObjects))
|
||||
|
||||
for _, rawName := range rawObjects {
|
||||
objectName := strings.TrimSpace(rawName)
|
||||
if objectName == "" {
|
||||
continue
|
||||
}
|
||||
key := normalizeExportObjectKey(config, dbName, objectName)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if canonicalViewName, ok := viewLookup[key]; ok {
|
||||
if strings.TrimSpace(canonicalViewName) == "" {
|
||||
canonicalViewName = objectName
|
||||
}
|
||||
viewSet[key] = canonicalViewName
|
||||
delete(tableSet, key)
|
||||
continue
|
||||
}
|
||||
if _, isView := viewSet[key]; isView {
|
||||
continue
|
||||
}
|
||||
if _, exists := tableSet[key]; !exists {
|
||||
tableSet[key] = objectName
|
||||
}
|
||||
}
|
||||
|
||||
if includeAllViews {
|
||||
for key, viewName := range viewLookup {
|
||||
canonicalViewName := strings.TrimSpace(viewName)
|
||||
if canonicalViewName == "" {
|
||||
continue
|
||||
}
|
||||
viewSet[key] = canonicalViewName
|
||||
delete(tableSet, key)
|
||||
}
|
||||
}
|
||||
|
||||
tables := mapValuesSorted(tableSet)
|
||||
views := mapValuesSorted(viewSet)
|
||||
return append(tables, views...)
|
||||
}
|
||||
|
||||
func mapValuesSorted(values map[string]string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, value)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeExportObjectKey(config connection.ConnectionConfig, dbName string, objectName string) string {
|
||||
schemaName, pureName := normalizeSchemaAndTable(config, dbName, objectName)
|
||||
return normalizeExportObjectKeyByParts(schemaName, pureName)
|
||||
}
|
||||
|
||||
func normalizeExportObjectKeyByParts(schemaName, objectName string) string {
|
||||
return strings.ToLower(strings.TrimSpace(qualifyTable(schemaName, objectName)))
|
||||
}
|
||||
|
||||
func listViewNameLookup(dbInst db.Database, config connection.ConnectionConfig, dbName string) map[string]string {
|
||||
viewLookup := make(map[string]string)
|
||||
queries := buildListViewQueries(config, dbName)
|
||||
for _, query := range queries {
|
||||
if strings.TrimSpace(query) == "" {
|
||||
continue
|
||||
}
|
||||
rows, _, err := dbInst.Query(query)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, row := range rows {
|
||||
tableType := strings.ToUpper(exportRowValueCI(row, "table_type", "type"))
|
||||
if tableType != "" && tableType != "VIEW" {
|
||||
continue
|
||||
}
|
||||
schemaName := exportRowValueCI(row, "schema_name", "table_schema", "owner", "schema", "db")
|
||||
viewName := exportRowValueCI(row, "object_name", "view_name", "table_name", "name")
|
||||
if viewName == "" {
|
||||
viewName = exportInferObjectName(row)
|
||||
}
|
||||
if strings.TrimSpace(viewName) == "" {
|
||||
continue
|
||||
}
|
||||
fullName := strings.TrimSpace(qualifyTable(schemaName, viewName))
|
||||
if fullName == "" {
|
||||
fullName = strings.TrimSpace(viewName)
|
||||
}
|
||||
key := normalizeExportObjectKey(config, dbName, fullName)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := viewLookup[key]; !exists {
|
||||
viewLookup[key] = fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
return viewLookup
|
||||
}
|
||||
|
||||
func buildListViewQueries(config connection.ConnectionConfig, dbName string) []string {
|
||||
dbType := resolveDDLDBType(config)
|
||||
escapedDbName := escapeSQLLiteral(dbName)
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "diros", "sphinx":
|
||||
queries := []string{
|
||||
fmt.Sprintf(`SELECT TABLE_SCHEMA AS schema_name, TABLE_NAME AS object_name, TABLE_TYPE AS table_type FROM information_schema.tables WHERE TABLE_TYPE='VIEW' AND TABLE_SCHEMA='%s' ORDER BY TABLE_NAME`, escapedDbName),
|
||||
}
|
||||
if strings.TrimSpace(dbName) != "" {
|
||||
queries = append(queries, fmt.Sprintf("SHOW FULL TABLES FROM %s WHERE Table_type = 'VIEW'", quoteIdentByType("mysql", dbName)))
|
||||
}
|
||||
return queries
|
||||
case "postgres", "kingbase", "highgo", "vastbase":
|
||||
return []string{
|
||||
`SELECT table_schema AS schema_name, table_name AS object_name FROM information_schema.views WHERE table_schema NOT IN ('pg_catalog', 'information_schema') ORDER BY table_schema, table_name`,
|
||||
}
|
||||
case "sqlserver":
|
||||
safeDBName := strings.TrimSpace(config.Database)
|
||||
if safeDBName == "" {
|
||||
safeDBName = strings.TrimSpace(dbName)
|
||||
}
|
||||
if safeDBName == "" {
|
||||
return nil
|
||||
}
|
||||
safeDB := quoteIdentByType("sqlserver", safeDBName)
|
||||
return []string{
|
||||
fmt.Sprintf(`SELECT s.name AS schema_name, v.name AS object_name FROM %s.sys.views v JOIN %s.sys.schemas s ON v.schema_id = s.schema_id ORDER BY s.name, v.name`, safeDB, safeDB),
|
||||
}
|
||||
case "oracle", "dameng":
|
||||
if strings.TrimSpace(dbName) == "" {
|
||||
return []string{
|
||||
`SELECT VIEW_NAME AS object_name FROM user_views ORDER BY VIEW_NAME`,
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
fmt.Sprintf("SELECT OWNER AS schema_name, VIEW_NAME AS object_name FROM all_views WHERE OWNER = '%s' ORDER BY VIEW_NAME", strings.ToUpper(escapedDbName)),
|
||||
}
|
||||
case "sqlite":
|
||||
return []string{
|
||||
"SELECT name AS object_name FROM sqlite_master WHERE type='view' ORDER BY name",
|
||||
}
|
||||
case "duckdb":
|
||||
return []string{
|
||||
`SELECT table_schema AS schema_name, table_name AS object_name FROM information_schema.views WHERE table_schema NOT IN ('information_schema', 'pg_catalog') ORDER BY table_schema, table_name`,
|
||||
}
|
||||
default:
|
||||
if strings.TrimSpace(dbName) == "" {
|
||||
return []string{
|
||||
`SELECT table_schema AS schema_name, table_name AS object_name FROM information_schema.views`,
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
fmt.Sprintf(`SELECT table_schema AS schema_name, table_name AS object_name FROM information_schema.views WHERE table_schema='%s'`, escapedDbName),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tryGetViewCreateStatement(
|
||||
dbInst db.Database,
|
||||
config connection.ConnectionConfig,
|
||||
dbName string,
|
||||
schemaName string,
|
||||
viewName string,
|
||||
) (string, bool) {
|
||||
queries := buildViewCreateQueries(config, dbName, schemaName, viewName)
|
||||
for _, query := range queries {
|
||||
if strings.TrimSpace(query) == "" {
|
||||
continue
|
||||
}
|
||||
rows, _, err := dbInst.Query(query)
|
||||
if err != nil || len(rows) == 0 {
|
||||
continue
|
||||
}
|
||||
createSQL := strings.TrimSpace(extractViewCreateSQL(rows[0]))
|
||||
if createSQL == "" {
|
||||
continue
|
||||
}
|
||||
if looksLikeSelectOrWith(createSQL) {
|
||||
qualifiedView := qualifyTable(schemaName, viewName)
|
||||
createSQL = fmt.Sprintf("CREATE VIEW %s AS %s", quoteQualifiedIdentByType(config.Type, qualifiedView), strings.TrimSuffix(strings.TrimSpace(createSQL), ";"))
|
||||
}
|
||||
return ensureSQLTerminator(createSQL), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func buildViewCreateQueries(config connection.ConnectionConfig, dbName, schemaName, viewName string) []string {
|
||||
dbType := resolveDDLDBType(config)
|
||||
safeSchema := strings.TrimSpace(schemaName)
|
||||
safeView := strings.TrimSpace(viewName)
|
||||
if safeView == "" {
|
||||
return nil
|
||||
}
|
||||
escapedSchema := escapeSQLLiteral(safeSchema)
|
||||
escapedView := escapeSQLLiteral(safeView)
|
||||
escapedDB := escapeSQLLiteral(dbName)
|
||||
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "diros", "sphinx":
|
||||
if safeSchema == "" {
|
||||
safeSchema = strings.TrimSpace(dbName)
|
||||
}
|
||||
if safeSchema != "" {
|
||||
return []string{
|
||||
fmt.Sprintf("SHOW CREATE VIEW %s.%s", quoteIdentByType("mysql", safeSchema), quoteIdentByType("mysql", safeView)),
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
fmt.Sprintf("SHOW CREATE VIEW %s", quoteIdentByType("mysql", safeView)),
|
||||
}
|
||||
case "postgres", "kingbase", "highgo", "vastbase":
|
||||
if safeSchema == "" {
|
||||
safeSchema = "public"
|
||||
}
|
||||
regClassName := fmt.Sprintf(`"%s"."%s"`, strings.ReplaceAll(safeSchema, `"`, `""`), strings.ReplaceAll(safeView, `"`, `""`))
|
||||
regClassName = strings.ReplaceAll(regClassName, "'", "''")
|
||||
return []string{
|
||||
fmt.Sprintf("SELECT pg_get_viewdef('%s'::regclass, true) AS ddl", regClassName),
|
||||
}
|
||||
case "sqlserver":
|
||||
schema := safeSchema
|
||||
if schema == "" {
|
||||
schema = "dbo"
|
||||
}
|
||||
safeDBName := strings.TrimSpace(config.Database)
|
||||
if safeDBName == "" {
|
||||
safeDBName = strings.TrimSpace(dbName)
|
||||
}
|
||||
if safeDBName == "" {
|
||||
return nil
|
||||
}
|
||||
safeDB := quoteIdentByType("sqlserver", safeDBName)
|
||||
return []string{
|
||||
fmt.Sprintf(`SELECT m.definition AS ddl
|
||||
FROM %s.sys.views v
|
||||
JOIN %s.sys.schemas s ON v.schema_id = s.schema_id
|
||||
JOIN %s.sys.sql_modules m ON v.object_id = m.object_id
|
||||
WHERE s.name = '%s' AND v.name = '%s'`,
|
||||
safeDB, safeDB, safeDB, escapeSQLLiteral(schema), escapedView),
|
||||
}
|
||||
case "oracle", "dameng":
|
||||
if safeSchema == "" {
|
||||
safeSchema = strings.TrimSpace(dbName)
|
||||
}
|
||||
if safeSchema != "" {
|
||||
return []string{
|
||||
fmt.Sprintf("SELECT DBMS_METADATA.GET_DDL('VIEW', '%s', '%s') AS ddl FROM DUAL", strings.ToUpper(escapedView), strings.ToUpper(escapeSQLLiteral(safeSchema))),
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
fmt.Sprintf("SELECT DBMS_METADATA.GET_DDL('VIEW', '%s') AS ddl FROM DUAL", strings.ToUpper(escapedView)),
|
||||
}
|
||||
case "sqlite":
|
||||
return []string{
|
||||
fmt.Sprintf("SELECT sql AS ddl FROM sqlite_master WHERE type='view' AND name='%s'", escapedView),
|
||||
}
|
||||
case "duckdb":
|
||||
if safeSchema == "" {
|
||||
safeSchema = "main"
|
||||
escapedSchema = "main"
|
||||
}
|
||||
return []string{
|
||||
fmt.Sprintf("SELECT sql AS ddl FROM duckdb_views() WHERE view_name = '%s' AND schema_name = '%s' LIMIT 1", escapedView, escapedSchema),
|
||||
fmt.Sprintf("SELECT view_definition AS ddl FROM information_schema.views WHERE table_name = '%s' AND table_schema = '%s' LIMIT 1", escapedView, escapedSchema),
|
||||
}
|
||||
default:
|
||||
if safeSchema != "" {
|
||||
return []string{
|
||||
fmt.Sprintf("SELECT view_definition AS ddl FROM information_schema.views WHERE table_name = '%s' AND table_schema = '%s' LIMIT 1", escapedView, escapedSchema),
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(dbName) != "" {
|
||||
return []string{
|
||||
fmt.Sprintf("SELECT view_definition AS ddl FROM information_schema.views WHERE table_name = '%s' AND table_schema = '%s' LIMIT 1", escapedView, escapedDB),
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
fmt.Sprintf("SELECT view_definition AS ddl FROM information_schema.views WHERE table_name = '%s' LIMIT 1", escapedView),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractViewCreateSQL(row map[string]interface{}) string {
|
||||
if row == nil {
|
||||
return ""
|
||||
}
|
||||
ddl := exportRowValueCI(row, "create view", "create_statement", "create_sql", "ddl", "sql", "view_definition", "definition")
|
||||
if ddl != "" {
|
||||
return ddl
|
||||
}
|
||||
for _, value := range row {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
if text == "" || text == "<nil>" {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(text)
|
||||
if strings.HasPrefix(lower, "create ") || strings.HasPrefix(lower, "select ") || strings.HasPrefix(lower, "with ") {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func exportRowValueCI(row map[string]interface{}, candidates ...string) string {
|
||||
if len(row) == 0 || len(candidates) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
candidate = strings.ToLower(strings.TrimSpace(candidate))
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
for key, value := range row {
|
||||
normalizedKey := strings.ToLower(strings.TrimSpace(key))
|
||||
if normalizedKey != candidate {
|
||||
continue
|
||||
}
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
if text == "<nil>" {
|
||||
return ""
|
||||
}
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func exportInferObjectName(row map[string]interface{}) string {
|
||||
if len(row) == 0 {
|
||||
return ""
|
||||
}
|
||||
for key, value := range row {
|
||||
normalizedKey := strings.ToLower(strings.TrimSpace(key))
|
||||
if normalizedKey == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(normalizedKey, "type") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(normalizedKey, "table") || strings.Contains(normalizedKey, "view") || strings.Contains(normalizedKey, "name") || strings.Contains(normalizedKey, "ddl") || strings.Contains(normalizedKey, "sql") {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
if text == "" || text == "<nil>" {
|
||||
continue
|
||||
}
|
||||
return text
|
||||
}
|
||||
}
|
||||
for _, value := range row {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
if text == "" || text == "<nil>" {
|
||||
continue
|
||||
}
|
||||
return text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func looksLikeSelectOrWith(sql string) bool {
|
||||
trimmed := strings.TrimSpace(strings.TrimSuffix(sql, ";"))
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(trimmed)
|
||||
return strings.HasPrefix(lower, "select ") || strings.HasPrefix(lower, "with ") || lower == "select" || lower == "with"
|
||||
}
|
||||
|
||||
func escapeSQLLiteral(value string) string {
|
||||
return strings.ReplaceAll(strings.TrimSpace(value), "'", "''")
|
||||
}
|
||||
|
||||
func isMySQLHexLiteral(s string) bool {
|
||||
if len(s) < 3 || !(strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X")) {
|
||||
return false
|
||||
@@ -787,7 +1231,7 @@ func formatSQLValue(dbType string, v interface{}) string {
|
||||
case time.Time:
|
||||
return "'" + val.Format("2006-01-02 15:04:05") + "'"
|
||||
case string:
|
||||
if strings.ToLower(strings.TrimSpace(dbType)) == "mysql" && isMySQLHexLiteral(val) {
|
||||
if (strings.ToLower(strings.TrimSpace(dbType)) == "mysql" || strings.ToLower(strings.TrimSpace(dbType)) == "diros") && isMySQLHexLiteral(val) {
|
||||
return val
|
||||
}
|
||||
escaped := strings.ReplaceAll(val, "'", "''")
|
||||
@@ -798,13 +1242,63 @@ func formatSQLValue(dbType string, v interface{}) string {
|
||||
}
|
||||
}
|
||||
|
||||
func dumpTableSQL(w *bufio.Writer, dbInst db.Database, config connection.ConnectionConfig, dbName, tableName string, includeSchema bool, includeData bool) error {
|
||||
func dumpTableSQL(
|
||||
w *bufio.Writer,
|
||||
dbInst db.Database,
|
||||
config connection.ConnectionConfig,
|
||||
dbName,
|
||||
tableName string,
|
||||
includeSchema bool,
|
||||
includeData bool,
|
||||
viewLookup map[string]string,
|
||||
) error {
|
||||
schemaName, pureTableName := normalizeSchemaAndTable(config, dbName, tableName)
|
||||
objectKey := normalizeExportObjectKeyByParts(schemaName, pureTableName)
|
||||
_, isView := viewLookup[objectKey]
|
||||
var createSQL string
|
||||
|
||||
if includeSchema {
|
||||
if isView {
|
||||
viewDDL, ok := tryGetViewCreateStatement(dbInst, config, dbName, schemaName, pureTableName)
|
||||
if ok {
|
||||
createSQL = viewDDL
|
||||
} else {
|
||||
ddl, err := dbInst.GetCreateStatement(schemaName, pureTableName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
createSQL = ddl
|
||||
}
|
||||
} else {
|
||||
ddl, err := dbInst.GetCreateStatement(schemaName, pureTableName)
|
||||
if err != nil {
|
||||
if viewDDL, ok := tryGetViewCreateStatement(dbInst, config, dbName, schemaName, pureTableName); ok {
|
||||
createSQL = viewDDL
|
||||
isView = true
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
createSQL = ddl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if includeData && !includeSchema && !isView {
|
||||
if _, ok := tryGetViewCreateStatement(dbInst, config, dbName, schemaName, pureTableName); ok {
|
||||
isView = true
|
||||
}
|
||||
}
|
||||
|
||||
objectLabel := "Table"
|
||||
if isView {
|
||||
objectLabel = "View"
|
||||
}
|
||||
|
||||
if _, err := w.WriteString("\n-- ----------------------------\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.WriteString(fmt.Sprintf("-- Table: %s\n", qualifyTable(schemaName, pureTableName))); err != nil {
|
||||
if _, err := w.WriteString(fmt.Sprintf("-- %s: %s\n", objectLabel, qualifyTable(schemaName, pureTableName))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.WriteString("-- ----------------------------\n\n"); err != nil {
|
||||
@@ -812,10 +1306,6 @@ func dumpTableSQL(w *bufio.Writer, dbInst db.Database, config connection.Connect
|
||||
}
|
||||
|
||||
if includeSchema {
|
||||
createSQL, err := dbInst.GetCreateStatement(schemaName, pureTableName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.WriteString(ensureSQLTerminator(createSQL)); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -828,6 +1318,13 @@ func dumpTableSQL(w *bufio.Writer, dbInst db.Database, config connection.Connect
|
||||
return nil
|
||||
}
|
||||
|
||||
if isView {
|
||||
if _, err := w.WriteString("-- View data export skipped (INSERT for views is not emitted).\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
qualified := qualifyTable(schemaName, pureTableName)
|
||||
selectSQL := fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(config.Type, qualified))
|
||||
data, columns, err := dbInst.Query(selectSQL)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
stdRuntime "runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -45,6 +46,8 @@ type UpdateInfo struct {
|
||||
AssetURL string `json:"assetUrl"`
|
||||
AssetSize int64 `json:"assetSize"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Downloaded bool `json:"downloaded"`
|
||||
DownloadPath string `json:"downloadPath,omitempty"`
|
||||
}
|
||||
|
||||
type AppInfo struct {
|
||||
@@ -102,8 +105,27 @@ func (a *App) CheckForUpdates() connection.QueryResult {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
var currentStaged *stagedUpdate
|
||||
a.updateMu.Lock()
|
||||
currentStaged = a.updateState.staged
|
||||
a.updateMu.Unlock()
|
||||
|
||||
if info.HasUpdate {
|
||||
reusable := resolveReusableStagedUpdate(info, currentStaged)
|
||||
if reusable != nil {
|
||||
info.Downloaded = true
|
||||
info.DownloadPath = reusable.FilePath
|
||||
currentStaged = reusable
|
||||
} else if currentStaged != nil && currentStaged.Version != info.LatestVersion {
|
||||
currentStaged = nil
|
||||
}
|
||||
} else {
|
||||
currentStaged = nil
|
||||
}
|
||||
|
||||
a.updateMu.Lock()
|
||||
a.updateState.lastCheck = &info
|
||||
a.updateState.staged = currentStaged
|
||||
a.updateMu.Unlock()
|
||||
|
||||
msg := "已是最新版本"
|
||||
@@ -144,11 +166,13 @@ func (a *App) DownloadUpdate() connection.QueryResult {
|
||||
a.updateMu.Unlock()
|
||||
return connection.QueryResult{Success: false, Message: "未找到可用的更新包"}
|
||||
}
|
||||
staged := a.updateState.staged
|
||||
if staged != nil && staged.Version == info.LatestVersion {
|
||||
staged := resolveReusableStagedUpdate(*info, a.updateState.staged)
|
||||
if staged != nil {
|
||||
a.updateState.staged = staged
|
||||
a.updateMu.Unlock()
|
||||
return connection.QueryResult{Success: true, Message: "更新包已下载完成", Data: buildUpdateDownloadResult(*info, staged)}
|
||||
}
|
||||
a.updateState.staged = nil
|
||||
a.updateState.downloading = true
|
||||
a.updateMu.Unlock()
|
||||
|
||||
@@ -210,7 +234,7 @@ func (a *App) InstallUpdateAndRestart() connection.QueryResult {
|
||||
}
|
||||
|
||||
func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult {
|
||||
workspaceDir := strings.TrimSpace(resolveUpdateWorkspaceDir())
|
||||
workspaceDir := strings.TrimSpace(resolveUpdateWorkspaceDir(info.LatestVersion))
|
||||
if workspaceDir == "" {
|
||||
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, "无法确定当前应用目录")
|
||||
return connection.QueryResult{Success: false, Message: "无法确定当前应用目录,无法下载更新"}
|
||||
@@ -243,8 +267,8 @@ func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult {
|
||||
return connection.QueryResult{Success: false, Message: errMsg}
|
||||
}
|
||||
|
||||
// 下载到 staging 目录,避免覆盖正在运行的可执行文件
|
||||
assetPath := filepath.Join(stagedDir, info.AssetName)
|
||||
// macOS 下载包放在桌面版本目录根级;其他平台继续放在 staging 目录。
|
||||
assetPath := resolveUpdateAssetPath(workspaceDir, stagedDir, info.AssetName)
|
||||
actualHash, err := downloadFileWithHash(info.AssetURL, assetPath, func(downloaded, total int64) {
|
||||
reportTotal := total
|
||||
if reportTotal <= 0 {
|
||||
@@ -279,6 +303,8 @@ func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult {
|
||||
StagedDir: stagedDir,
|
||||
InstallLogPath: buildUpdateInstallLogPath(workspaceDir),
|
||||
}
|
||||
info.Downloaded = true
|
||||
info.DownloadPath = assetPath
|
||||
a.updateMu.Lock()
|
||||
a.updateState.staged = staged
|
||||
a.updateMu.Unlock()
|
||||
@@ -299,7 +325,11 @@ func fetchLatestUpdateInfo() (UpdateInfo, error) {
|
||||
return UpdateInfo{}, errors.New("无法解析最新版本号")
|
||||
}
|
||||
|
||||
assetName, err := expectedAssetName(stdRuntime.GOOS, stdRuntime.GOARCH)
|
||||
assetVersion := strings.TrimSpace(release.TagName)
|
||||
if assetVersion == "" {
|
||||
assetVersion = latestVersion
|
||||
}
|
||||
assetName, err := expectedAssetName(stdRuntime.GOOS, stdRuntime.GOARCH, assetVersion)
|
||||
if err != nil {
|
||||
return UpdateInfo{}, err
|
||||
}
|
||||
@@ -369,25 +399,32 @@ func fetchLatestRelease() (*githubRelease, error) {
|
||||
return &release, nil
|
||||
}
|
||||
|
||||
func expectedAssetName(goos, goarch string) (string, error) {
|
||||
func expectedAssetName(goos, goarch, version string) (string, error) {
|
||||
version = strings.TrimSpace(version)
|
||||
version = strings.TrimPrefix(version, "v")
|
||||
version = strings.TrimPrefix(version, "V")
|
||||
if version == "" {
|
||||
return "", errors.New("无法解析发布版本号")
|
||||
}
|
||||
|
||||
switch goos {
|
||||
case "windows":
|
||||
if goarch == "amd64" {
|
||||
return "GoNavi-windows-amd64.exe", nil
|
||||
return fmt.Sprintf("GoNavi-%s-Windows-Amd64.exe", version), nil
|
||||
}
|
||||
if goarch == "arm64" {
|
||||
return "GoNavi-windows-arm64.exe", nil
|
||||
return fmt.Sprintf("GoNavi-%s-Windows-Arm64.exe", version), nil
|
||||
}
|
||||
case "darwin":
|
||||
if goarch == "amd64" {
|
||||
return "GoNavi-mac-amd64.dmg", nil
|
||||
return fmt.Sprintf("GoNavi-%s-MacOS-Amd64.dmg", version), nil
|
||||
}
|
||||
if goarch == "arm64" {
|
||||
return "GoNavi-mac-arm64.dmg", nil
|
||||
return fmt.Sprintf("GoNavi-%s-MacOS-Arm64.dmg", version), nil
|
||||
}
|
||||
case "linux":
|
||||
if goarch == "amd64" {
|
||||
return "GoNavi-linux-amd64.tar.gz", nil
|
||||
return fmt.Sprintf("GoNavi-%s-Linux-Amd64.tar.gz", version), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("当前平台暂不支持在线更新:%s/%s", goos, goarch)
|
||||
@@ -575,14 +612,146 @@ func buildUpdateInstallLogPath(baseDir string) string {
|
||||
return filepath.Join(logDir, fmt.Sprintf("gonavi-update-%s-%d.log", platform, time.Now().UnixNano()))
|
||||
}
|
||||
|
||||
func resolveUpdateWorkspaceDir() string {
|
||||
// 使用系统临时目录作为更新工作区,避免以下问题:
|
||||
// 1. Windows: exe 所在目录可能被杀毒软件/索引服务锁定,或缺少写权限(如 Program Files)
|
||||
// 2. macOS: /Applications 需要管理员权限才能写入
|
||||
// 3. 运行中的 exe 文件锁与 staging 文件冲突
|
||||
dir := filepath.Join(os.TempDir(), "gonavi-updates")
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
return dir
|
||||
func sanitizeVersionForPath(version string) string {
|
||||
trimmed := strings.TrimSpace(version)
|
||||
if trimmed == "" {
|
||||
return "latest"
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
lastDash := false
|
||||
for _, r := range trimmed {
|
||||
isAllowed := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-'
|
||||
if isAllowed {
|
||||
builder.WriteRune(r)
|
||||
lastDash = false
|
||||
continue
|
||||
}
|
||||
if !lastDash {
|
||||
builder.WriteRune('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
|
||||
result := strings.Trim(builder.String(), "-")
|
||||
if result == "" {
|
||||
return "latest"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func resolveLegacyUpdateWorkspaceDir() string {
|
||||
return filepath.Join(os.TempDir(), "gonavi-updates")
|
||||
}
|
||||
|
||||
func resolveUpdateWorkspaceDir(version string) string {
|
||||
// 默认使用系统临时目录作为更新工作区,避免目录权限与锁冲突。
|
||||
// macOS 用户要求更新包默认保存在桌面:Desktop/GoNavi-<version>/。
|
||||
if stdRuntime.GOOS == "darwin" {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err == nil && strings.TrimSpace(homeDir) != "" {
|
||||
desktopDir := filepath.Join(homeDir, "Desktop")
|
||||
if st, statErr := os.Stat(desktopDir); statErr == nil && st.IsDir() {
|
||||
return filepath.Join(desktopDir, fmt.Sprintf("GoNavi-%s", sanitizeVersionForPath(version)))
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolveLegacyUpdateWorkspaceDir()
|
||||
}
|
||||
|
||||
func resolveUpdateAssetPath(workspaceDir string, stagedDir string, assetName string) string {
|
||||
name := strings.TrimSpace(assetName)
|
||||
if stdRuntime.GOOS == "darwin" {
|
||||
return filepath.Join(workspaceDir, name)
|
||||
}
|
||||
return filepath.Join(stagedDir, name)
|
||||
}
|
||||
|
||||
func isExistingDownloadedAsset(filePath string, expectedSize int64) bool {
|
||||
path := strings.TrimSpace(filePath)
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
stat, err := os.Stat(path)
|
||||
if err != nil || stat.IsDir() {
|
||||
return false
|
||||
}
|
||||
if expectedSize > 0 && stat.Size() != expectedSize {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func resolveReusableStagedUpdate(info UpdateInfo, current *stagedUpdate) *stagedUpdate {
|
||||
version := strings.TrimSpace(info.LatestVersion)
|
||||
assetName := strings.TrimSpace(info.AssetName)
|
||||
if version == "" || assetName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if current != nil && strings.TrimSpace(current.Version) == version {
|
||||
currentPath := strings.TrimSpace(current.FilePath)
|
||||
if isExistingDownloadedAsset(currentPath, info.AssetSize) {
|
||||
if strings.TrimSpace(current.InstallLogPath) == "" {
|
||||
current.InstallLogPath = buildUpdateInstallLogPath(filepath.Dir(currentPath))
|
||||
}
|
||||
return current
|
||||
}
|
||||
}
|
||||
|
||||
type pathCandidate struct {
|
||||
workspaceDir string
|
||||
stagedDir string
|
||||
assetPath string
|
||||
}
|
||||
stagedDirName := fmt.Sprintf(".gonavi-update-%s-%s", stdRuntime.GOOS, version)
|
||||
workspaceCandidates := []string{
|
||||
resolveUpdateWorkspaceDir(version),
|
||||
resolveLegacyUpdateWorkspaceDir(),
|
||||
}
|
||||
seenWorkspace := make(map[string]struct{}, len(workspaceCandidates))
|
||||
candidates := make([]pathCandidate, 0, 4)
|
||||
for _, workspaceDir := range workspaceCandidates {
|
||||
workspaceDir = strings.TrimSpace(workspaceDir)
|
||||
if workspaceDir == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seenWorkspace[workspaceDir]; exists {
|
||||
continue
|
||||
}
|
||||
seenWorkspace[workspaceDir] = struct{}{}
|
||||
|
||||
stagedDir := filepath.Join(workspaceDir, stagedDirName)
|
||||
assetPath := resolveUpdateAssetPath(workspaceDir, stagedDir, assetName)
|
||||
candidates = append(candidates, pathCandidate{
|
||||
workspaceDir: workspaceDir,
|
||||
stagedDir: stagedDir,
|
||||
assetPath: assetPath,
|
||||
})
|
||||
legacyAssetPath := filepath.Join(stagedDir, assetName)
|
||||
if legacyAssetPath != assetPath {
|
||||
candidates = append(candidates, pathCandidate{
|
||||
workspaceDir: workspaceDir,
|
||||
stagedDir: stagedDir,
|
||||
assetPath: legacyAssetPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if !isExistingDownloadedAsset(candidate.assetPath, info.AssetSize) {
|
||||
continue
|
||||
}
|
||||
return &stagedUpdate{
|
||||
Version: version,
|
||||
AssetName: assetName,
|
||||
FilePath: candidate.assetPath,
|
||||
StagedDir: candidate.stagedDir,
|
||||
InstallLogPath: buildUpdateInstallLogPath(candidate.workspaceDir),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveUpdateInstallTarget() string {
|
||||
@@ -689,23 +858,55 @@ func launchLinuxUpdate(staged *stagedUpdate, targetExe string, pid int) error {
|
||||
}
|
||||
|
||||
func buildWindowsScript(source, target, stagedDir, logPath string, pid int) string {
|
||||
return fmt.Sprintf(`@echo off
|
||||
script := `@echo off
|
||||
setlocal EnableExtensions EnableDelayedExpansion
|
||||
set "SOURCE=%s"
|
||||
set "TARGET=%s"
|
||||
set "STAGED=%s"
|
||||
set "LOG_FILE=%s"
|
||||
set PID=%d
|
||||
set "SOURCE=__GONAVI_UPDATE_SOURCE__"
|
||||
set "TARGET=__GONAVI_UPDATE_TARGET__"
|
||||
set "STAGED=__GONAVI_UPDATE_STAGED__"
|
||||
set "LOG_FILE=__GONAVI_UPDATE_LOG__"
|
||||
set PID=__GONAVI_UPDATE_PID__
|
||||
|
||||
call :log updater started
|
||||
if not exist "%%SOURCE%%" (
|
||||
call :log source file not found: %%SOURCE%%
|
||||
if not exist "%SOURCE%" (
|
||||
call :log source file not found: %SOURCE%
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
for %%I in ("%TARGET%") do set "TARGET_NAME=%%~nxI"
|
||||
for %%I in ("%SOURCE%") do set "SOURCE_EXT=%%~xI"
|
||||
set "SOURCE_EXE="
|
||||
|
||||
if /I "%SOURCE_EXT%"==".zip" (
|
||||
set "EXTRACT_DIR=%STAGED%\_extract"
|
||||
if exist "%EXTRACT_DIR%" (
|
||||
rmdir /S /Q "%EXTRACT_DIR%" >> "%LOG_FILE%" 2>&1
|
||||
)
|
||||
mkdir "%EXTRACT_DIR%" >> "%LOG_FILE%" 2>&1
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "$src=$env:SOURCE; $dst=$env:EXTRACT_DIR; Expand-Archive -LiteralPath $src -DestinationPath $dst -Force" >> "%LOG_FILE%" 2>&1
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
call :log expand zip failed: %SOURCE%
|
||||
exit /b 1
|
||||
)
|
||||
if exist "%EXTRACT_DIR%\%TARGET_NAME%" (
|
||||
set "SOURCE_EXE=%EXTRACT_DIR%\%TARGET_NAME%"
|
||||
) else (
|
||||
for /R "%EXTRACT_DIR%" %%F in (*.exe) do (
|
||||
if not defined SOURCE_EXE (
|
||||
set "SOURCE_EXE=%%~fF"
|
||||
)
|
||||
)
|
||||
)
|
||||
if not defined SOURCE_EXE (
|
||||
call :log no executable found in portable zip: %SOURCE%
|
||||
exit /b 1
|
||||
)
|
||||
) else (
|
||||
set "SOURCE_EXE=%SOURCE%"
|
||||
)
|
||||
|
||||
:waitloop
|
||||
tasklist /FI "PID eq %%PID%%" | find "%%PID%%" >nul
|
||||
if %%ERRORLEVEL%%==0 (
|
||||
tasklist /FI "PID eq %PID%" | find "%PID%" >nul
|
||||
if %ERRORLEVEL%==0 (
|
||||
timeout /t 1 /nobreak >nul
|
||||
goto waitloop
|
||||
)
|
||||
@@ -713,11 +914,11 @@ call :log host process exited
|
||||
|
||||
set /a RETRY=0
|
||||
:move_retry
|
||||
move /Y "%%SOURCE%%" "%%TARGET%%" >> "%%LOG_FILE%%" 2>&1
|
||||
if %%ERRORLEVEL%%==0 goto move_done
|
||||
move /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
|
||||
if %ERRORLEVEL%==0 goto move_done
|
||||
|
||||
copy /Y "%%SOURCE%%" "%%TARGET%%" >> "%%LOG_FILE%%" 2>&1
|
||||
if %%ERRORLEVEL%%==0 goto move_done
|
||||
copy /Y "%SOURCE_EXE%" "%TARGET%" >> "%LOG_FILE%" 2>&1
|
||||
if %ERRORLEVEL%==0 goto move_done
|
||||
|
||||
set /a RETRY+=1
|
||||
if !RETRY! LSS 20 (
|
||||
@@ -729,23 +930,30 @@ call :log replace failed after retries (portable mode, no elevation): check dire
|
||||
exit /b 1
|
||||
|
||||
:move_done
|
||||
start "" "%%TARGET%%" >> "%%LOG_FILE%%" 2>&1
|
||||
if %%ERRORLEVEL%% NEQ 0 (
|
||||
start "" "%TARGET%" >> "%LOG_FILE%" 2>&1
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
call :log cmd start failed, trying powershell Start-Process
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%%TARGET%%'" >> "%%LOG_FILE%%" 2>&1
|
||||
if %%ERRORLEVEL%% NEQ 0 (
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%TARGET%'" >> "%LOG_FILE%" 2>&1
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
call :log relaunch failed
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
rmdir /S /Q "%%STAGED%%" >> "%%LOG_FILE%%" 2>&1
|
||||
rmdir /S /Q "%STAGED%" >> "%LOG_FILE%" 2>&1
|
||||
call :log update finished
|
||||
exit /b 0
|
||||
|
||||
:log
|
||||
echo [%%date%% %%time%%] %%*>>"%%LOG_FILE%%"
|
||||
echo [%date% %time%] %*>>"%LOG_FILE%"
|
||||
exit /b 0
|
||||
`, source, target, stagedDir, logPath, pid)
|
||||
`
|
||||
return strings.NewReplacer(
|
||||
"__GONAVI_UPDATE_SOURCE__", source,
|
||||
"__GONAVI_UPDATE_TARGET__", target,
|
||||
"__GONAVI_UPDATE_STAGED__", stagedDir,
|
||||
"__GONAVI_UPDATE_LOG__", logPath,
|
||||
"__GONAVI_UPDATE_PID__", strconv.Itoa(pid),
|
||||
).Replace(script)
|
||||
}
|
||||
|
||||
func buildMacScript(dmgPath, targetApp, stagedDir, mountDir, logPath string, pid int) string {
|
||||
|
||||
40
internal/app/methods_update_windows_script_test.go
Normal file
40
internal/app/methods_update_windows_script_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildWindowsScriptKeepsBatchForSyntax(t *testing.T) {
|
||||
script := buildWindowsScript(
|
||||
`C:\tmp\GoNavi-v0.4.0-windows-amd64.zip`,
|
||||
`C:\Program Files\GoNavi\GoNavi.exe`,
|
||||
`C:\Program Files\GoNavi\.gonavi-update-windows-v0.4.0`,
|
||||
`C:\Program Files\GoNavi\logs\update-install.log`,
|
||||
13579,
|
||||
)
|
||||
|
||||
mustContain := []string{
|
||||
`for %%I in ("%TARGET%") do set "TARGET_NAME=%%~nxI"`,
|
||||
`for %%I in ("%SOURCE%") do set "SOURCE_EXT=%%~xI"`,
|
||||
`for /R "%EXTRACT_DIR%" %%F in (*.exe) do (`,
|
||||
`set "SOURCE_EXE=%%~fF"`,
|
||||
}
|
||||
for _, want := range mustContain {
|
||||
if !strings.Contains(script, want) {
|
||||
t.Fatalf("windows update script missing required token: %s\nscript:\n%s", want, script)
|
||||
}
|
||||
}
|
||||
|
||||
mustNotContain := []string{
|
||||
`for %I in ("%TARGET%") do set "TARGET_NAME=%~nxI"`,
|
||||
`for %I in ("%SOURCE%") do set "SOURCE_EXT=%~xI"`,
|
||||
`for /R "%EXTRACT_DIR%" %F in (*.exe) do (`,
|
||||
`set "SOURCE_EXE=%~fF"`,
|
||||
}
|
||||
for _, bad := range mustNotContain {
|
||||
if strings.Contains(script, bad) {
|
||||
t.Fatalf("windows update script contains invalid batch syntax: %s\nscript:\n%s", bad, script)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,9 @@ static void gonaviTuneWindowTranslucency(NSWindow *window) {
|
||||
[effectView release];
|
||||
}
|
||||
|
||||
[effectView setMaterial:NSVisualEffectMaterialHUDWindow];
|
||||
if (@available(macOS 10.14, *)) {
|
||||
[effectView setMaterial:NSVisualEffectMaterialHUDWindow];
|
||||
}
|
||||
[effectView setBlendingMode:NSVisualEffectBlendingModeBehindWindow];
|
||||
[effectView setState:NSVisualEffectStateActive];
|
||||
// 默认 alpha=0(不可见),由前端根据用户外观设置动态启用
|
||||
|
||||
9
internal/db/agent_process_stub.go
Normal file
9
internal/db/agent_process_stub.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package db
|
||||
|
||||
import "os/exec"
|
||||
|
||||
func configureAgentProcess(cmd *exec.Cmd) {
|
||||
_ = cmd
|
||||
}
|
||||
20
internal/db/agent_process_windows.go
Normal file
20
internal/db/agent_process_windows.go
Normal file
@@ -0,0 +1,20 @@
|
||||
//go:build windows
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
const windowsCreateNoWindow = 0x08000000
|
||||
|
||||
func configureAgentProcess(cmd *exec.Cmd) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
CreationFlags: windowsCreateNoWindow,
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build gonavi_full_drivers || gonavi_dameng_driver
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
|
||||
@@ -3,6 +3,7 @@ package db
|
||||
import (
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Database interface {
|
||||
@@ -25,42 +26,61 @@ type BatchApplier interface {
|
||||
ApplyChanges(tableName string, changes connection.ChangeSet) error
|
||||
}
|
||||
|
||||
// Factory
|
||||
func NewDatabase(dbType string) (Database, error) {
|
||||
switch dbType {
|
||||
case "mysql":
|
||||
return &MySQLDB{}, nil
|
||||
case "postgres":
|
||||
return &PostgresDB{}, nil
|
||||
case "sqlite":
|
||||
return &SQLiteDB{}, nil
|
||||
case "oracle":
|
||||
return &OracleDB{}, nil
|
||||
case "dameng":
|
||||
return &DamengDB{}, nil
|
||||
case "kingbase":
|
||||
return &KingbaseDB{}, nil
|
||||
case "mongodb":
|
||||
return &MongoDB{}, nil
|
||||
case "sqlserver":
|
||||
return &SqlServerDB{}, nil
|
||||
case "highgo":
|
||||
return &HighGoDB{}, nil
|
||||
case "mariadb":
|
||||
return &MariaDB{}, nil
|
||||
case "sphinx":
|
||||
return &SphinxDB{}, nil
|
||||
case "vastbase":
|
||||
return &VastbaseDB{}, nil
|
||||
case "tdengine":
|
||||
return &TDengineDB{}, nil
|
||||
case "custom":
|
||||
return &CustomDB{}, nil
|
||||
default:
|
||||
// Default to MySQL for backward compatibility if empty
|
||||
if dbType == "" {
|
||||
return &MySQLDB{}, nil
|
||||
type databaseFactory func() Database
|
||||
|
||||
var databaseFactories = map[string]databaseFactory{
|
||||
"mysql": func() Database {
|
||||
return &MySQLDB{}
|
||||
},
|
||||
"postgres": func() Database {
|
||||
return &PostgresDB{}
|
||||
},
|
||||
"oracle": func() Database {
|
||||
return &OracleDB{}
|
||||
},
|
||||
"custom": func() Database {
|
||||
return &CustomDB{}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
registerOptionalDatabaseFactories()
|
||||
}
|
||||
|
||||
func registerDatabaseFactory(factory databaseFactory, dbTypes ...string) {
|
||||
if factory == nil || len(dbTypes) == 0 {
|
||||
return
|
||||
}
|
||||
for _, dbType := range dbTypes {
|
||||
normalized := normalizeDatabaseType(dbType)
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported database type: %s", dbType)
|
||||
databaseFactories[normalized] = factory
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeDatabaseType(dbType string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(dbType))
|
||||
switch normalized {
|
||||
case "doris":
|
||||
return "diros"
|
||||
case "postgresql":
|
||||
return "postgres"
|
||||
default:
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
// Factory
|
||||
func NewDatabase(dbType string) (Database, error) {
|
||||
normalized := normalizeDatabaseType(dbType)
|
||||
if normalized == "" {
|
||||
normalized = "mysql"
|
||||
}
|
||||
factory, ok := databaseFactories[normalized]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported database type: %s", dbType)
|
||||
}
|
||||
return factory(), nil
|
||||
}
|
||||
|
||||
18
internal/db/database_optional_factories_full.go
Normal file
18
internal/db/database_optional_factories_full.go
Normal file
@@ -0,0 +1,18 @@
|
||||
//go:build gonavi_full_drivers
|
||||
|
||||
package db
|
||||
|
||||
func registerOptionalDatabaseFactories() {
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("mariadb"), "mariadb")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("diros"), "diros", "doris")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("sphinx"), "sphinx")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("sqlserver"), "sqlserver")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("sqlite"), "sqlite")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("duckdb"), "duckdb")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("dameng"), "dameng")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("kingbase"), "kingbase")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("highgo"), "highgo")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("vastbase"), "vastbase")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("mongodb"), "mongodb")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("tdengine"), "tdengine")
|
||||
}
|
||||
18
internal/db/database_optional_factories_lite.go
Normal file
18
internal/db/database_optional_factories_lite.go
Normal file
@@ -0,0 +1,18 @@
|
||||
//go:build !gonavi_full_drivers
|
||||
|
||||
package db
|
||||
|
||||
func registerOptionalDatabaseFactories() {
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("mariadb"), "mariadb")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("diros"), "diros", "doris")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("sphinx"), "sphinx")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("sqlserver"), "sqlserver")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("sqlite"), "sqlite")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("duckdb"), "duckdb")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("dameng"), "dameng")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("kingbase"), "kingbase")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("highgo"), "highgo")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("vastbase"), "vastbase")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("mongodb"), "mongodb")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("tdengine"), "tdengine")
|
||||
}
|
||||
220
internal/db/diros_impl.go
Normal file
220
internal/db/diros_impl.go
Normal file
@@ -0,0 +1,220 @@
|
||||
//go:build gonavi_full_drivers || gonavi_diros_driver
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
"GoNavi-Wails/internal/ssh"
|
||||
"GoNavi-Wails/internal/utils"
|
||||
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
const (
|
||||
dirosDriverName = "diros"
|
||||
defaultDirosPort = 9030
|
||||
)
|
||||
|
||||
// DirosDB 使用独立 driver 名称(diros)接入,底层协议兼容 MySQL。
|
||||
type DirosDB struct {
|
||||
MySQLDB
|
||||
}
|
||||
|
||||
func init() {
|
||||
for _, name := range sql.Drivers() {
|
||||
if name == dirosDriverName {
|
||||
return
|
||||
}
|
||||
}
|
||||
sql.Register(dirosDriverName, &mysqlDriver.MySQLDriver{})
|
||||
}
|
||||
|
||||
func applyDirosURI(config connection.ConnectionConfig) connection.ConnectionConfig {
|
||||
uriText := strings.TrimSpace(config.URI)
|
||||
if uriText == "" {
|
||||
return config
|
||||
}
|
||||
|
||||
lowerURI := strings.ToLower(uriText)
|
||||
if !strings.HasPrefix(lowerURI, "diros://") &&
|
||||
!strings.HasPrefix(lowerURI, "doris://") &&
|
||||
!strings.HasPrefix(lowerURI, "mysql://") {
|
||||
return config
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(uriText)
|
||||
if err != nil {
|
||||
return config
|
||||
}
|
||||
|
||||
if parsed.User != nil {
|
||||
if config.User == "" {
|
||||
config.User = parsed.User.Username()
|
||||
}
|
||||
if pass, ok := parsed.User.Password(); ok && config.Password == "" {
|
||||
config.Password = pass
|
||||
}
|
||||
}
|
||||
|
||||
if dbName := strings.TrimPrefix(parsed.Path, "/"); dbName != "" && config.Database == "" {
|
||||
config.Database = dbName
|
||||
}
|
||||
|
||||
defaultPort := config.Port
|
||||
if defaultPort <= 0 {
|
||||
defaultPort = defaultDirosPort
|
||||
}
|
||||
|
||||
hostsFromURI := make([]string, 0, 4)
|
||||
hostText := strings.TrimSpace(parsed.Host)
|
||||
if hostText != "" {
|
||||
for _, entry := range strings.Split(hostText, ",") {
|
||||
host, port, ok := parseHostPortWithDefault(entry, defaultPort)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
hostsFromURI = append(hostsFromURI, normalizeMySQLAddress(host, port))
|
||||
}
|
||||
}
|
||||
|
||||
if len(config.Hosts) == 0 && len(hostsFromURI) > 0 {
|
||||
config.Hosts = hostsFromURI
|
||||
}
|
||||
if strings.TrimSpace(config.Host) == "" && len(hostsFromURI) > 0 {
|
||||
host, port, ok := parseHostPortWithDefault(hostsFromURI[0], defaultPort)
|
||||
if ok {
|
||||
config.Host = host
|
||||
config.Port = port
|
||||
}
|
||||
}
|
||||
|
||||
if config.Topology == "" {
|
||||
topology := strings.TrimSpace(parsed.Query().Get("topology"))
|
||||
if topology != "" {
|
||||
config.Topology = strings.ToLower(topology)
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
func collectDirosAddresses(config connection.ConnectionConfig) []string {
|
||||
defaultPort := config.Port
|
||||
if defaultPort <= 0 {
|
||||
defaultPort = defaultDirosPort
|
||||
}
|
||||
|
||||
candidates := make([]string, 0, len(config.Hosts)+1)
|
||||
if len(config.Hosts) > 0 {
|
||||
candidates = append(candidates, config.Hosts...)
|
||||
} else {
|
||||
candidates = append(candidates, normalizeMySQLAddress(config.Host, defaultPort))
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(candidates))
|
||||
seen := make(map[string]struct{}, len(candidates))
|
||||
for _, entry := range candidates {
|
||||
host, port, ok := parseHostPortWithDefault(entry, defaultPort)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
normalized := normalizeMySQLAddress(host, port)
|
||||
if _, exists := seen[normalized]; exists {
|
||||
continue
|
||||
}
|
||||
seen[normalized] = struct{}{}
|
||||
result = append(result, normalized)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (d *DirosDB) getDSN(config connection.ConnectionConfig) string {
|
||||
database := config.Database
|
||||
protocol := "tcp"
|
||||
address := normalizeMySQLAddress(config.Host, config.Port)
|
||||
|
||||
if config.UseSSH {
|
||||
netName, err := ssh.RegisterSSHNetwork(config.SSH)
|
||||
if err == nil {
|
||||
protocol = netName
|
||||
address = normalizeMySQLAddress(config.Host, config.Port)
|
||||
} else {
|
||||
logger.Warnf("注册 Diros SSH 网络失败,将尝试直连:地址=%s:%d 用户=%s,原因:%v", config.Host, config.Port, config.User, err)
|
||||
}
|
||||
}
|
||||
|
||||
timeout := getConnectTimeoutSeconds(config)
|
||||
|
||||
return fmt.Sprintf("%s:%s@%s(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=%ds",
|
||||
config.User, config.Password, protocol, address, database, timeout)
|
||||
}
|
||||
|
||||
func resolveDirosCredential(config connection.ConnectionConfig, addressIndex int) (string, string) {
|
||||
primaryUser := strings.TrimSpace(config.User)
|
||||
primaryPassword := config.Password
|
||||
replicaUser := strings.TrimSpace(config.MySQLReplicaUser)
|
||||
replicaPassword := config.MySQLReplicaPassword
|
||||
|
||||
if addressIndex > 0 && replicaUser != "" {
|
||||
return replicaUser, replicaPassword
|
||||
}
|
||||
|
||||
if primaryUser == "" && replicaUser != "" {
|
||||
return replicaUser, replicaPassword
|
||||
}
|
||||
|
||||
return config.User, primaryPassword
|
||||
}
|
||||
|
||||
func (d *DirosDB) Connect(config connection.ConnectionConfig) error {
|
||||
runConfig := applyDirosURI(config)
|
||||
addresses := collectDirosAddresses(runConfig)
|
||||
if len(addresses) == 0 {
|
||||
return fmt.Errorf("连接建立后验证失败:未找到可用的 Diros 地址")
|
||||
}
|
||||
|
||||
var errorDetails []string
|
||||
for index, address := range addresses {
|
||||
candidateConfig := runConfig
|
||||
host, port, ok := parseHostPortWithDefault(address, defaultDirosPort)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
candidateConfig.Host = host
|
||||
candidateConfig.Port = port
|
||||
candidateConfig.User, candidateConfig.Password = resolveDirosCredential(runConfig, index)
|
||||
|
||||
dsn := d.getDSN(candidateConfig)
|
||||
db, err := sql.Open(dirosDriverName, dsn)
|
||||
if err != nil {
|
||||
errorDetails = append(errorDetails, fmt.Sprintf("%s 打开失败: %v", address, err))
|
||||
continue
|
||||
}
|
||||
|
||||
timeout := getConnectTimeout(candidateConfig)
|
||||
ctx, cancel := utils.ContextWithTimeout(timeout)
|
||||
pingErr := db.PingContext(ctx)
|
||||
cancel()
|
||||
if pingErr != nil {
|
||||
_ = db.Close()
|
||||
errorDetails = append(errorDetails, fmt.Sprintf("%s 验证失败: %v", address, pingErr))
|
||||
continue
|
||||
}
|
||||
|
||||
d.conn = db
|
||||
d.pingTimeout = timeout
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(errorDetails) == 0 {
|
||||
return fmt.Errorf("连接建立后验证失败:未找到可用的 Diros 地址")
|
||||
}
|
||||
return fmt.Errorf("连接建立后验证失败:%s", strings.Join(errorDetails, ";"))
|
||||
}
|
||||
222
internal/db/driver_support.go
Normal file
222
internal/db/driver_support.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var coreBuiltinDrivers = map[string]struct{}{
|
||||
"mysql": {},
|
||||
"redis": {},
|
||||
"oracle": {},
|
||||
"postgres": {},
|
||||
}
|
||||
|
||||
// optionalGoDrivers 表示需要用户“安装启用”后才能使用的纯 Go 驱动。
|
||||
// 注意:这是一种运行时门控(installed.json 标记),并不减少主二进制体积。
|
||||
var optionalGoDrivers = map[string]struct{}{
|
||||
"mariadb": {},
|
||||
"diros": {},
|
||||
"sphinx": {},
|
||||
"sqlserver": {},
|
||||
"sqlite": {},
|
||||
"duckdb": {},
|
||||
"dameng": {},
|
||||
"kingbase": {},
|
||||
"highgo": {},
|
||||
"vastbase": {},
|
||||
"mongodb": {},
|
||||
"tdengine": {},
|
||||
}
|
||||
|
||||
var (
|
||||
externalDriverDirMu sync.RWMutex
|
||||
externalDriverDir string
|
||||
)
|
||||
|
||||
func normalizeRuntimeDriverType(driverType string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(driverType))
|
||||
switch normalized {
|
||||
case "doris":
|
||||
return "diros"
|
||||
case "postgresql":
|
||||
return "postgres"
|
||||
default:
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
func driverDisplayName(driverType string) string {
|
||||
switch normalizeRuntimeDriverType(driverType) {
|
||||
case "mysql":
|
||||
return "MySQL"
|
||||
case "oracle":
|
||||
return "Oracle"
|
||||
case "redis":
|
||||
return "Redis"
|
||||
case "mariadb":
|
||||
return "MariaDB"
|
||||
case "diros":
|
||||
return "Diros"
|
||||
case "sphinx":
|
||||
return "Sphinx"
|
||||
case "postgres":
|
||||
return "PostgreSQL"
|
||||
case "sqlserver":
|
||||
return "SQL Server"
|
||||
case "sqlite":
|
||||
return "SQLite"
|
||||
case "duckdb":
|
||||
return "DuckDB"
|
||||
case "dameng":
|
||||
return "Dameng"
|
||||
case "kingbase":
|
||||
return "Kingbase"
|
||||
case "highgo":
|
||||
return "HighGo"
|
||||
case "vastbase":
|
||||
return "Vastbase"
|
||||
case "mongodb":
|
||||
return "MongoDB"
|
||||
case "tdengine":
|
||||
return "TDengine"
|
||||
default:
|
||||
return strings.ToUpper(strings.TrimSpace(driverType))
|
||||
}
|
||||
}
|
||||
|
||||
func IsOptionalGoDriver(driverType string) bool {
|
||||
_, ok := optionalGoDrivers[normalizeRuntimeDriverType(driverType)]
|
||||
return ok
|
||||
}
|
||||
|
||||
func IsOptionalGoDriverBuildIncluded(driverType string) bool {
|
||||
return optionalGoDriverBuildIncluded(normalizeRuntimeDriverType(driverType))
|
||||
}
|
||||
|
||||
func IsBuiltinDriver(driverType string) bool {
|
||||
_, ok := coreBuiltinDrivers[normalizeRuntimeDriverType(driverType)]
|
||||
return ok
|
||||
}
|
||||
|
||||
func defaultExternalDriverDownloadDirectory() string {
|
||||
if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" {
|
||||
return filepath.Join(home, ".gonavi", "drivers")
|
||||
}
|
||||
if wd, err := os.Getwd(); err == nil && strings.TrimSpace(wd) != "" {
|
||||
return filepath.Join(wd, ".gonavi-drivers")
|
||||
}
|
||||
return ".gonavi-drivers"
|
||||
}
|
||||
|
||||
func resolveExternalDriverRoot(downloadDir string) (string, error) {
|
||||
root := strings.TrimSpace(downloadDir)
|
||||
if root == "" {
|
||||
root = currentExternalDriverDownloadDirectory()
|
||||
}
|
||||
if root == "" {
|
||||
root = defaultExternalDriverDownloadDirectory()
|
||||
}
|
||||
if !filepath.IsAbs(root) {
|
||||
abs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
root = abs
|
||||
}
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
return "", fmt.Errorf("创建驱动目录失败:%w", err)
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func currentExternalDriverDownloadDirectory() string {
|
||||
externalDriverDirMu.RLock()
|
||||
current := strings.TrimSpace(externalDriverDir)
|
||||
externalDriverDirMu.RUnlock()
|
||||
if current != "" {
|
||||
return current
|
||||
}
|
||||
return defaultExternalDriverDownloadDirectory()
|
||||
}
|
||||
|
||||
func SetExternalDriverDownloadDirectory(downloadDir string) {
|
||||
root, err := resolveExternalDriverRoot(downloadDir)
|
||||
if err != nil {
|
||||
root = defaultExternalDriverDownloadDirectory()
|
||||
}
|
||||
externalDriverDirMu.Lock()
|
||||
externalDriverDir = root
|
||||
externalDriverDirMu.Unlock()
|
||||
}
|
||||
|
||||
func ResolveExternalDriverRoot(downloadDir string) (string, error) {
|
||||
return resolveExternalDriverRoot(downloadDir)
|
||||
}
|
||||
|
||||
func ResolveOptionalGoDriverMarkerPath(downloadDir string, driverType string) (string, error) {
|
||||
normalized := normalizeRuntimeDriverType(driverType)
|
||||
if !IsOptionalGoDriver(normalized) {
|
||||
return "", fmt.Errorf("%s 不是可选 Go 驱动", driverDisplayName(normalized))
|
||||
}
|
||||
root, err := resolveExternalDriverRoot(downloadDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(root, normalized, "installed.json"), nil
|
||||
}
|
||||
|
||||
func optionalGoDriverInstalled(driverType string) bool {
|
||||
markerPath, err := ResolveOptionalGoDriverMarkerPath("", driverType)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
info, statErr := os.Stat(markerPath)
|
||||
return statErr == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
func optionalGoDriverRuntimeReady(driverType string) (bool, string) {
|
||||
normalized := normalizeRuntimeDriverType(driverType)
|
||||
if !IsOptionalGoDriver(normalized) {
|
||||
return true, ""
|
||||
}
|
||||
executablePath, err := ResolveOptionalDriverAgentExecutablePath("", normalized)
|
||||
if err != nil {
|
||||
return false, fmt.Sprintf("%s 驱动代理路径解析失败,请在驱动管理中重新安装启用", driverDisplayName(normalized))
|
||||
}
|
||||
info, statErr := os.Stat(executablePath)
|
||||
if statErr != nil || info.IsDir() {
|
||||
return false, fmt.Sprintf("%s 驱动代理缺失,请在驱动管理中重新安装启用", driverDisplayName(normalized))
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// DriverRuntimeSupportStatus 返回当前构建下驱动是否可用(可直接用于连接)。
|
||||
func DriverRuntimeSupportStatus(driverType string) (bool, string) {
|
||||
normalized := normalizeRuntimeDriverType(driverType)
|
||||
if normalized == "" {
|
||||
return false, "未识别的数据源类型"
|
||||
}
|
||||
if normalized == "custom" {
|
||||
return true, ""
|
||||
}
|
||||
if IsBuiltinDriver(normalized) {
|
||||
return true, ""
|
||||
}
|
||||
if IsOptionalGoDriver(normalized) {
|
||||
if !IsOptionalGoDriverBuildIncluded(normalized) {
|
||||
return false, fmt.Sprintf("%s 当前发行包为精简构建,未内置该驱动;如需使用请安装 Full 版", driverDisplayName(normalized))
|
||||
}
|
||||
if optionalGoDriverInstalled(normalized) {
|
||||
if ready, reason := optionalGoDriverRuntimeReady(normalized); !ready {
|
||||
return false, reason
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
return false, fmt.Sprintf("%s 纯 Go 驱动未启用,请先在驱动管理中点击“安装启用”", driverDisplayName(normalized))
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
89
internal/db/driver_support_test.go
Normal file
89
internal/db/driver_support_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPostgresRuntimeSupportRequiresInstallMarker(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
SetExternalDriverDownloadDirectory(tmpDir)
|
||||
|
||||
supported, _ := DriverRuntimeSupportStatus("postgres")
|
||||
if !supported {
|
||||
t.Fatalf("postgres 属于免安装内置驱动,应可用")
|
||||
}
|
||||
supported, reason := DriverRuntimeSupportStatus("postgres")
|
||||
if !supported {
|
||||
t.Fatalf("postgres 应可用,reason=%s", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinLikeDriversRemainAvailable(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
SetExternalDriverDownloadDirectory(tmpDir)
|
||||
|
||||
supported, reason := DriverRuntimeSupportStatus("redis")
|
||||
if !supported {
|
||||
t.Fatalf("redis 应始终可用,reason=%s", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedDriverRequiresInstallMarker(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
SetExternalDriverDownloadDirectory(tmpDir)
|
||||
|
||||
supported, _ := DriverRuntimeSupportStatus("mariadb")
|
||||
if supported {
|
||||
t.Fatalf("mariadb 未安装时不应可用")
|
||||
}
|
||||
|
||||
if !IsOptionalGoDriverBuildIncluded("mariadb") {
|
||||
supported, reason := DriverRuntimeSupportStatus("mariadb")
|
||||
if supported {
|
||||
t.Fatalf("精简构建下 mariadb 不应可用")
|
||||
}
|
||||
if reason == "" {
|
||||
t.Fatalf("精简构建下 mariadb 应返回不可用原因")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
markerPath, err := ResolveOptionalGoDriverMarkerPath(tmpDir, "mariadb")
|
||||
if err != nil {
|
||||
t.Fatalf("解析 marker 路径失败: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(markerPath), 0o755); err != nil {
|
||||
t.Fatalf("创建 marker 目录失败: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(markerPath, []byte("{}"), 0o644); err != nil {
|
||||
t.Fatalf("写入 marker 失败: %v", err)
|
||||
}
|
||||
executablePath, err := ResolveOptionalDriverAgentExecutablePath(tmpDir, "mariadb")
|
||||
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)
|
||||
}
|
||||
|
||||
supported, reason := DriverRuntimeSupportStatus("mariadb")
|
||||
if !supported {
|
||||
t.Fatalf("mariadb 安装后应可用,reason=%s", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLBuiltinRuntimeSupportAvailable(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
SetExternalDriverDownloadDirectory(tmpDir)
|
||||
|
||||
supported, reason := DriverRuntimeSupportStatus("mysql")
|
||||
if !supported {
|
||||
t.Fatalf("mysql 属于免安装内置驱动,应可用,reason=%s", reason)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build gonavi_full_drivers
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
|
||||
5
internal/db/duckdb_driver_import.go
Normal file
5
internal/db/duckdb_driver_import.go
Normal file
@@ -0,0 +1,5 @@
|
||||
//go:build (gonavi_full_drivers || gonavi_duckdb_driver) && cgo && (duckdb_use_lib || duckdb_use_static_lib || (darwin && (amd64 || arm64)) || (linux && (amd64 || arm64)) || (windows && amd64))
|
||||
|
||||
package db
|
||||
|
||||
import _ "github.com/duckdb/duckdb-go/v2"
|
||||
466
internal/db/duckdb_impl.go
Normal file
466
internal/db/duckdb_impl.go
Normal file
@@ -0,0 +1,466 @@
|
||||
//go:build gonavi_full_drivers || gonavi_duckdb_driver
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/utils"
|
||||
)
|
||||
|
||||
type DuckDB struct {
|
||||
conn *sql.DB
|
||||
pingTimeout time.Duration
|
||||
}
|
||||
|
||||
func (d *DuckDB) Connect(config connection.ConnectionConfig) error {
|
||||
if supported, reason := duckDBBuildSupportStatus(); !supported {
|
||||
return fmt.Errorf("DuckDB 驱动不可用:%s", reason)
|
||||
}
|
||||
|
||||
dsn := strings.TrimSpace(config.Host)
|
||||
if dsn == "" {
|
||||
dsn = strings.TrimSpace(config.Database)
|
||||
}
|
||||
if dsn == "" {
|
||||
dsn = ":memory:"
|
||||
}
|
||||
|
||||
db, err := sql.Open("duckdb", dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开数据库连接失败:%w", err)
|
||||
}
|
||||
d.conn = db
|
||||
d.pingTimeout = getConnectTimeout(config)
|
||||
|
||||
if err := d.Ping(); err != nil {
|
||||
_ = db.Close()
|
||||
d.conn = nil
|
||||
return fmt.Errorf("连接建立后验证失败:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DuckDB) Close() error {
|
||||
if d.conn != nil {
|
||||
return d.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DuckDB) Ping() error {
|
||||
if d.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
}
|
||||
timeout := d.pingTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
ctx, cancel := utils.ContextWithTimeout(timeout)
|
||||
defer cancel()
|
||||
return d.conn.PingContext(ctx)
|
||||
}
|
||||
|
||||
func (d *DuckDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
if d.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
}
|
||||
rows, err := d.conn.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanRows(rows)
|
||||
}
|
||||
|
||||
func (d *DuckDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
if d.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
}
|
||||
rows, err := d.conn.Query(query)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanRows(rows)
|
||||
}
|
||||
|
||||
func (d *DuckDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
if d.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
}
|
||||
res, err := d.conn.ExecContext(ctx, query)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (d *DuckDB) Exec(query string) (int64, error) {
|
||||
if d.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
}
|
||||
res, err := d.conn.Exec(query)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (d *DuckDB) GetDatabases() ([]string, error) {
|
||||
data, _, err := d.Query("PRAGMA database_list")
|
||||
if err != nil {
|
||||
return []string{"main"}, nil
|
||||
}
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
var names []string
|
||||
for _, row := range data {
|
||||
name := strings.TrimSpace(duckDBRowString(row, "name", "database_name", "database"))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[name]; exists {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
names = append(names, name)
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return []string{"main"}, nil
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func (d *DuckDB) GetTables(dbName string) ([]string, error) {
|
||||
query := `
|
||||
SELECT table_schema, table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_type = 'BASE TABLE'
|
||||
AND table_schema NOT IN ('information_schema', 'pg_catalog')
|
||||
ORDER BY table_schema, table_name`
|
||||
|
||||
data, _, err := d.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
var tables []string
|
||||
for _, row := range data {
|
||||
schema := strings.TrimSpace(duckDBRowString(row, "table_schema"))
|
||||
name := strings.TrimSpace(duckDBRowString(row, "table_name"))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
qualified := name
|
||||
if schema != "" && !strings.EqualFold(schema, "main") {
|
||||
qualified = schema + "." + name
|
||||
}
|
||||
if _, exists := seen[qualified]; exists {
|
||||
continue
|
||||
}
|
||||
seen[qualified] = struct{}{}
|
||||
tables = append(tables, qualified)
|
||||
}
|
||||
return tables, nil
|
||||
}
|
||||
|
||||
func (d *DuckDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||
schema, pureTable := normalizeDuckDBSchemaAndTable(dbName, tableName)
|
||||
if pureTable == "" {
|
||||
return "", fmt.Errorf("table name required")
|
||||
}
|
||||
|
||||
escapedTable := escapeDuckDBLiteral(pureTable)
|
||||
escapedSchema := escapeDuckDBLiteral(schema)
|
||||
|
||||
queryCandidates := []string{
|
||||
fmt.Sprintf("SELECT sql FROM duckdb_tables() WHERE table_name = '%s' AND schema_name = '%s' LIMIT 1", escapedTable, escapedSchema),
|
||||
fmt.Sprintf("SELECT sql FROM duckdb_tables() WHERE table_name = '%s' LIMIT 1", escapedTable),
|
||||
fmt.Sprintf("SHOW CREATE TABLE %s", quoteDuckDBQualifiedTable(schema, pureTable)),
|
||||
}
|
||||
|
||||
for _, query := range queryCandidates {
|
||||
data, _, err := d.Query(query)
|
||||
if err != nil || len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
createSQL := strings.TrimSpace(duckDBRowString(data[0], "sql", "create_table", "Create Table", "create_statement"))
|
||||
if createSQL != "" {
|
||||
return createSQL, nil
|
||||
}
|
||||
for _, value := range data[0] {
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
if text != "" && text != "<nil>" {
|
||||
return text, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("create statement not found")
|
||||
}
|
||||
|
||||
func (d *DuckDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||
schema, pureTable := normalizeDuckDBSchemaAndTable(dbName, tableName)
|
||||
if pureTable == "" {
|
||||
return nil, fmt.Errorf("table name required")
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT column_name, data_type, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = '%s' AND table_schema = '%s'
|
||||
ORDER BY ordinal_position`, escapeDuckDBLiteral(pureTable), escapeDuckDBLiteral(schema))
|
||||
|
||||
data, _, err := d.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 && schema != "main" {
|
||||
fallbackQuery := fmt.Sprintf(`
|
||||
SELECT column_name, data_type, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = '%s'
|
||||
ORDER BY ordinal_position`, escapeDuckDBLiteral(pureTable))
|
||||
data, _, err = d.Query(fallbackQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var columns []connection.ColumnDefinition
|
||||
for _, row := range data {
|
||||
column := connection.ColumnDefinition{
|
||||
Name: duckDBRowString(row, "column_name"),
|
||||
Type: duckDBRowString(row, "data_type"),
|
||||
Nullable: strings.ToUpper(strings.TrimSpace(duckDBRowString(row, "is_nullable"))),
|
||||
Key: "",
|
||||
Extra: "",
|
||||
Comment: "",
|
||||
}
|
||||
if column.Nullable == "" {
|
||||
column.Nullable = "YES"
|
||||
}
|
||||
if defaultVal := strings.TrimSpace(duckDBRowString(row, "column_default")); defaultVal != "" && defaultVal != "<nil>" {
|
||||
def := defaultVal
|
||||
column.Default = &def
|
||||
}
|
||||
columns = append(columns, column)
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func (d *DuckDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||
query := `
|
||||
SELECT table_schema, table_name, column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema NOT IN ('information_schema', 'pg_catalog')
|
||||
ORDER BY table_schema, table_name, ordinal_position`
|
||||
|
||||
data, _, err := d.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
columns := make([]connection.ColumnDefinitionWithTable, 0, len(data))
|
||||
for _, row := range data {
|
||||
schema := strings.TrimSpace(duckDBRowString(row, "table_schema"))
|
||||
tableName := strings.TrimSpace(duckDBRowString(row, "table_name"))
|
||||
if tableName == "" {
|
||||
continue
|
||||
}
|
||||
if schema != "" && !strings.EqualFold(schema, "main") {
|
||||
tableName = schema + "." + tableName
|
||||
}
|
||||
|
||||
columns = append(columns, connection.ColumnDefinitionWithTable{
|
||||
TableName: tableName,
|
||||
Name: duckDBRowString(row, "column_name"),
|
||||
Type: duckDBRowString(row, "data_type"),
|
||||
})
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func (d *DuckDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||
return []connection.IndexDefinition{}, nil
|
||||
}
|
||||
|
||||
func (d *DuckDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||
return []connection.ForeignKeyDefinition{}, nil
|
||||
}
|
||||
|
||||
func (d *DuckDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||
return []connection.TriggerDefinition{}, nil
|
||||
}
|
||||
|
||||
func (d *DuckDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
if d.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
}
|
||||
|
||||
tx, err := d.conn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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])
|
||||
}
|
||||
|
||||
qualifiedTable := quoteIdent(table)
|
||||
if schema != "" {
|
||||
qualifiedTable = fmt.Sprintf("%s.%s", quoteIdent(schema), quoteIdent(table))
|
||||
}
|
||||
|
||||
for _, pk := range changes.Deletes {
|
||||
var wheres []string
|
||||
var args []interface{}
|
||||
for k, v := range pk {
|
||||
wheres = append(wheres, fmt.Sprintf("%s = ?", quoteIdent(k)))
|
||||
args = append(args, v)
|
||||
}
|
||||
if len(wheres) == 0 {
|
||||
continue
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
for _, update := range changes.Updates {
|
||||
var sets []string
|
||||
var args []interface{}
|
||||
for k, v := range update.Values {
|
||||
sets = append(sets, fmt.Sprintf("%s = ?", quoteIdent(k)))
|
||||
args = append(args, v)
|
||||
}
|
||||
if len(sets) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var wheres []string
|
||||
for k, v := range update.Keys {
|
||||
wheres = append(wheres, fmt.Sprintf("%s = ?", quoteIdent(k)))
|
||||
args = append(args, v)
|
||||
}
|
||||
if len(wheres) == 0 {
|
||||
return fmt.Errorf("update requires keys")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
for _, row := range changes.Inserts {
|
||||
var cols []string
|
||||
var placeholders []string
|
||||
var args []interface{}
|
||||
|
||||
for k, v := range row {
|
||||
cols = append(cols, quoteIdent(k))
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, v)
|
||||
}
|
||||
if len(cols) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
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 tx.Commit()
|
||||
}
|
||||
|
||||
func normalizeDuckDBSchemaAndTable(dbName string, tableName string) (string, string) {
|
||||
schema := strings.TrimSpace(dbName)
|
||||
table := strings.TrimSpace(tableName)
|
||||
if table == "" {
|
||||
if schema == "" {
|
||||
schema = "main"
|
||||
}
|
||||
return schema, table
|
||||
}
|
||||
|
||||
if parts := strings.SplitN(table, ".", 2); len(parts) == 2 {
|
||||
left := strings.TrimSpace(parts[0])
|
||||
right := strings.TrimSpace(parts[1])
|
||||
if left != "" && right != "" {
|
||||
return normalizeDuckDBIdentifier(left), normalizeDuckDBIdentifier(right)
|
||||
}
|
||||
}
|
||||
|
||||
if schema == "" {
|
||||
schema = "main"
|
||||
}
|
||||
return normalizeDuckDBIdentifier(schema), normalizeDuckDBIdentifier(table)
|
||||
}
|
||||
|
||||
func normalizeDuckDBIdentifier(raw string) string {
|
||||
text := strings.TrimSpace(raw)
|
||||
if len(text) >= 2 {
|
||||
first := text[0]
|
||||
last := text[len(text)-1]
|
||||
if (first == '"' && last == '"') || (first == '`' && last == '`') {
|
||||
text = strings.TrimSpace(text[1 : len(text)-1])
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func quoteDuckDBIdentifier(raw string) string {
|
||||
text := normalizeDuckDBIdentifier(raw)
|
||||
return `"` + strings.ReplaceAll(text, `"`, `""`) + `"`
|
||||
}
|
||||
|
||||
func quoteDuckDBQualifiedTable(schema string, table string) string {
|
||||
s := strings.TrimSpace(schema)
|
||||
t := strings.TrimSpace(table)
|
||||
if s == "" {
|
||||
return quoteDuckDBIdentifier(t)
|
||||
}
|
||||
return quoteDuckDBIdentifier(s) + "." + quoteDuckDBIdentifier(t)
|
||||
}
|
||||
|
||||
func duckDBRowString(row map[string]interface{}, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
for rowKey, value := range row {
|
||||
if !strings.EqualFold(rowKey, key) || value == nil {
|
||||
continue
|
||||
}
|
||||
return fmt.Sprintf("%v", value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func escapeDuckDBLiteral(raw string) string {
|
||||
return strings.ReplaceAll(raw, "'", "''")
|
||||
}
|
||||
7
internal/db/duckdb_platform_supported.go
Normal file
7
internal/db/duckdb_platform_supported.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build (gonavi_full_drivers || gonavi_duckdb_driver) && cgo && (duckdb_use_lib || duckdb_use_static_lib || (darwin && (amd64 || arm64)) || (linux && (amd64 || arm64)) || (windows && amd64))
|
||||
|
||||
package db
|
||||
|
||||
func duckDBBuildSupportStatus() (bool, string) {
|
||||
return true, ""
|
||||
}
|
||||
12
internal/db/duckdb_platform_unsupported.go
Normal file
12
internal/db/duckdb_platform_unsupported.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build (gonavi_full_drivers || gonavi_duckdb_driver) && !(cgo && (duckdb_use_lib || duckdb_use_static_lib || (darwin && (amd64 || arm64)) || (linux && (amd64 || arm64)) || (windows && amd64)))
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func duckDBBuildSupportStatus() (bool, string) {
|
||||
return false, fmt.Sprintf("当前构建不包含 DuckDB 驱动(平台=%s/%s)。需要启用 CGO,并使用受支持平台(darwin/linux amd64|arm64、windows/amd64)或通过 -tags duckdb_use_lib / duckdb_use_static_lib 提供自定义库", runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build gonavi_full_drivers || gonavi_highgo_driver
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build gonavi_full_drivers || gonavi_kingbase_driver
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build gonavi_full_drivers || gonavi_mariadb_driver
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build gonavi_full_drivers || gonavi_mongodb_driver
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
|
||||
435
internal/db/mysql_agent_impl.go
Normal file
435
internal/db/mysql_agent_impl.go
Normal file
@@ -0,0 +1,435 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
const (
|
||||
mysqlAgentMethodConnect = "connect"
|
||||
mysqlAgentMethodClose = "close"
|
||||
mysqlAgentMethodPing = "ping"
|
||||
mysqlAgentMethodQuery = "query"
|
||||
mysqlAgentMethodExec = "exec"
|
||||
mysqlAgentMethodGetDatabases = "getDatabases"
|
||||
mysqlAgentMethodGetTables = "getTables"
|
||||
mysqlAgentMethodGetCreateStmt = "getCreateStatement"
|
||||
mysqlAgentMethodGetColumns = "getColumns"
|
||||
mysqlAgentMethodGetAllColumns = "getAllColumns"
|
||||
mysqlAgentMethodGetIndexes = "getIndexes"
|
||||
mysqlAgentMethodGetForeignKeys = "getForeignKeys"
|
||||
mysqlAgentMethodGetTriggers = "getTriggers"
|
||||
mysqlAgentMethodApplyChanges = "applyChanges"
|
||||
mysqlAgentDefaultScannerMaxBytes = 8 << 20
|
||||
)
|
||||
|
||||
type mysqlAgentRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Config *connection.ConnectionConfig `json:"config,omitempty"`
|
||||
Query string `json:"query,omitempty"`
|
||||
DBName string `json:"dbName,omitempty"`
|
||||
TableName string `json:"tableName,omitempty"`
|
||||
Changes *connection.ChangeSet `json:"changes,omitempty"`
|
||||
}
|
||||
|
||||
type mysqlAgentResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
Fields []string `json:"fields,omitempty"`
|
||||
RowsAffected int64 `json:"rowsAffected,omitempty"`
|
||||
}
|
||||
|
||||
type mysqlAgentClient struct {
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
reader *bufio.Reader
|
||||
nextID int64
|
||||
mu sync.Mutex
|
||||
stderrMu sync.Mutex
|
||||
stderr strings.Builder
|
||||
}
|
||||
|
||||
func newMySQLAgentClient(executablePath string) (*mysqlAgentClient, error) {
|
||||
pathText := strings.TrimSpace(executablePath)
|
||||
if pathText == "" {
|
||||
return nil, fmt.Errorf("MySQL 驱动代理路径为空")
|
||||
}
|
||||
info, err := os.Stat(pathText)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("MySQL 驱动代理不存在:%s", pathText)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, fmt.Errorf("MySQL 驱动代理路径是目录:%s", pathText)
|
||||
}
|
||||
|
||||
cmd := exec.Command(pathText)
|
||||
configureAgentProcess(cmd)
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建 MySQL 驱动代理 stdin 失败:%w", err)
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建 MySQL 驱动代理 stdout 失败:%w", err)
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建 MySQL 驱动代理 stderr 失败:%w", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("启动 MySQL 驱动代理失败:%w", err)
|
||||
}
|
||||
|
||||
client := &mysqlAgentClient{
|
||||
cmd: cmd,
|
||||
stdin: stdin,
|
||||
reader: bufio.NewReader(stdout),
|
||||
}
|
||||
go client.captureStderr(stderr)
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *mysqlAgentClient) captureStderr(stderr io.Reader) {
|
||||
scanner := bufio.NewScanner(stderr)
|
||||
buffer := make([]byte, 0, 8<<10)
|
||||
scanner.Buffer(buffer, mysqlAgentDefaultScannerMaxBytes)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
c.stderrMu.Lock()
|
||||
if c.stderr.Len() > 0 {
|
||||
c.stderr.WriteString(" | ")
|
||||
}
|
||||
c.stderr.WriteString(line)
|
||||
c.stderrMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *mysqlAgentClient) stderrText() string {
|
||||
c.stderrMu.Lock()
|
||||
defer c.stderrMu.Unlock()
|
||||
return strings.TrimSpace(c.stderr.String())
|
||||
}
|
||||
|
||||
func (c *mysqlAgentClient) call(req mysqlAgentRequest, out interface{}, fields *[]string, rowsAffected *int64) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.nextID++
|
||||
req.ID = c.nextID
|
||||
|
||||
payload, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
if _, err := c.stdin.Write(payload); err != nil {
|
||||
stderrText := c.stderrText()
|
||||
if stderrText == "" {
|
||||
return fmt.Errorf("调用 MySQL 驱动代理失败:%w", err)
|
||||
}
|
||||
return fmt.Errorf("调用 MySQL 驱动代理失败:%w(stderr: %s)", err, stderrText)
|
||||
}
|
||||
|
||||
line, err := c.reader.ReadBytes('\n')
|
||||
if err != nil {
|
||||
stderrText := c.stderrText()
|
||||
if stderrText == "" {
|
||||
return fmt.Errorf("读取 MySQL 驱动代理响应失败:%w", err)
|
||||
}
|
||||
return fmt.Errorf("读取 MySQL 驱动代理响应失败:%w(stderr: %s)", err, stderrText)
|
||||
}
|
||||
|
||||
var resp mysqlAgentResponse
|
||||
if err := json.Unmarshal(line, &resp); err != nil {
|
||||
return fmt.Errorf("解析 MySQL 驱动代理响应失败:%w", err)
|
||||
}
|
||||
if !resp.Success {
|
||||
errText := strings.TrimSpace(resp.Error)
|
||||
if errText == "" {
|
||||
errText = "MySQL 驱动代理返回失败"
|
||||
}
|
||||
return errors.New(errText)
|
||||
}
|
||||
|
||||
if fields != nil {
|
||||
*fields = resp.Fields
|
||||
}
|
||||
if rowsAffected != nil {
|
||||
*rowsAffected = resp.RowsAffected
|
||||
}
|
||||
if out != nil && len(resp.Data) > 0 {
|
||||
if err := json.Unmarshal(resp.Data, out); err != nil {
|
||||
return fmt.Errorf("解析 MySQL 驱动代理数据失败:%w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *mysqlAgentClient) close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
var closeErr error
|
||||
if c.stdin != nil {
|
||||
_ = c.stdin.Close()
|
||||
}
|
||||
if c.cmd != nil && c.cmd.Process != nil {
|
||||
if err := c.cmd.Process.Kill(); err != nil {
|
||||
closeErr = err
|
||||
}
|
||||
}
|
||||
if c.cmd != nil {
|
||||
_ = c.cmd.Wait()
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
|
||||
type MySQLAgentDB struct {
|
||||
client *mysqlAgentClient
|
||||
}
|
||||
|
||||
func (m *MySQLAgentDB) Connect(config connection.ConnectionConfig) error {
|
||||
if m.client != nil {
|
||||
_ = m.client.close()
|
||||
m.client = nil
|
||||
}
|
||||
|
||||
executablePath, err := ResolveMySQLAgentExecutablePath("")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := newMySQLAgentClient(executablePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.call(mysqlAgentRequest{
|
||||
Method: mysqlAgentMethodConnect,
|
||||
Config: &config,
|
||||
}, nil, nil, nil); err != nil {
|
||||
_ = client.close()
|
||||
return err
|
||||
}
|
||||
m.client = client
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MySQLAgentDB) Close() error {
|
||||
if m.client == nil {
|
||||
return nil
|
||||
}
|
||||
_ = m.client.call(mysqlAgentRequest{Method: mysqlAgentMethodClose}, nil, nil, nil)
|
||||
err := m.client.close()
|
||||
m.client = nil
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *MySQLAgentDB) Ping() error {
|
||||
client, err := m.requireClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.call(mysqlAgentRequest{Method: mysqlAgentMethodPing}, nil, nil, nil)
|
||||
}
|
||||
|
||||
func (m *MySQLAgentDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return m.Query(query)
|
||||
}
|
||||
|
||||
func (m *MySQLAgentDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
client, err := m.requireClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var data []map[string]interface{}
|
||||
var fields []string
|
||||
if err := client.call(mysqlAgentRequest{
|
||||
Method: mysqlAgentMethodQuery,
|
||||
Query: query,
|
||||
}, &data, &fields, nil); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return data, fields, nil
|
||||
}
|
||||
|
||||
func (m *MySQLAgentDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return m.Exec(query)
|
||||
}
|
||||
|
||||
func (m *MySQLAgentDB) Exec(query string) (int64, error) {
|
||||
client, err := m.requireClient()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var affected int64
|
||||
if err := client.call(mysqlAgentRequest{
|
||||
Method: mysqlAgentMethodExec,
|
||||
Query: query,
|
||||
}, nil, nil, &affected); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
func (m *MySQLAgentDB) GetDatabases() ([]string, error) {
|
||||
client, err := m.requireClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var dbs []string
|
||||
if err := client.call(mysqlAgentRequest{
|
||||
Method: mysqlAgentMethodGetDatabases,
|
||||
}, &dbs, nil, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dbs, nil
|
||||
}
|
||||
|
||||
func (m *MySQLAgentDB) GetTables(dbName string) ([]string, error) {
|
||||
client, err := m.requireClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tables []string
|
||||
if err := client.call(mysqlAgentRequest{
|
||||
Method: mysqlAgentMethodGetTables,
|
||||
DBName: dbName,
|
||||
}, &tables, nil, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tables, nil
|
||||
}
|
||||
|
||||
func (m *MySQLAgentDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||
client, err := m.requireClient()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var sqlText string
|
||||
if err := client.call(mysqlAgentRequest{
|
||||
Method: mysqlAgentMethodGetCreateStmt,
|
||||
DBName: dbName,
|
||||
TableName: tableName,
|
||||
}, &sqlText, nil, nil); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return sqlText, nil
|
||||
}
|
||||
|
||||
func (m *MySQLAgentDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||
client, err := m.requireClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var columns []connection.ColumnDefinition
|
||||
if err := client.call(mysqlAgentRequest{
|
||||
Method: mysqlAgentMethodGetColumns,
|
||||
DBName: dbName,
|
||||
TableName: tableName,
|
||||
}, &columns, nil, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func (m *MySQLAgentDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||
client, err := m.requireClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var columns []connection.ColumnDefinitionWithTable
|
||||
if err := client.call(mysqlAgentRequest{
|
||||
Method: mysqlAgentMethodGetAllColumns,
|
||||
DBName: dbName,
|
||||
}, &columns, nil, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func (m *MySQLAgentDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||
client, err := m.requireClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var indexes []connection.IndexDefinition
|
||||
if err := client.call(mysqlAgentRequest{
|
||||
Method: mysqlAgentMethodGetIndexes,
|
||||
DBName: dbName,
|
||||
TableName: tableName,
|
||||
}, &indexes, nil, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return indexes, nil
|
||||
}
|
||||
|
||||
func (m *MySQLAgentDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||
client, err := m.requireClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var keys []connection.ForeignKeyDefinition
|
||||
if err := client.call(mysqlAgentRequest{
|
||||
Method: mysqlAgentMethodGetForeignKeys,
|
||||
DBName: dbName,
|
||||
TableName: tableName,
|
||||
}, &keys, nil, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func (m *MySQLAgentDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||
client, err := m.requireClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var triggers []connection.TriggerDefinition
|
||||
if err := client.call(mysqlAgentRequest{
|
||||
Method: mysqlAgentMethodGetTriggers,
|
||||
DBName: dbName,
|
||||
TableName: tableName,
|
||||
}, &triggers, nil, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return triggers, nil
|
||||
}
|
||||
|
||||
func (m *MySQLAgentDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
client, err := m.requireClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.call(mysqlAgentRequest{
|
||||
Method: mysqlAgentMethodApplyChanges,
|
||||
TableName: tableName,
|
||||
Changes: &changes,
|
||||
}, nil, nil, nil)
|
||||
}
|
||||
|
||||
func (m *MySQLAgentDB) requireClient() (*mysqlAgentClient, error) {
|
||||
if m.client == nil {
|
||||
return nil, fmt.Errorf("connection not open")
|
||||
}
|
||||
return m.client, nil
|
||||
}
|
||||
40
internal/db/mysql_agent_path.go
Normal file
40
internal/db/mysql_agent_path.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func mysqlAgentExecutableName() string {
|
||||
return optionalDriverAgentExecutableName("mysql")
|
||||
}
|
||||
|
||||
func optionalDriverAgentExecutableName(driverType string) string {
|
||||
normalized := normalizeRuntimeDriverType(driverType)
|
||||
if normalized == "" {
|
||||
normalized = "unknown"
|
||||
}
|
||||
name := fmt.Sprintf("%s-driver-agent", normalized)
|
||||
if runtime.GOOS == "windows" {
|
||||
return name + ".exe"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func ResolveOptionalDriverAgentExecutablePath(downloadDir string, driverType string) (string, error) {
|
||||
normalized := normalizeRuntimeDriverType(driverType)
|
||||
if strings.TrimSpace(normalized) == "" {
|
||||
return "", fmt.Errorf("驱动类型为空")
|
||||
}
|
||||
root, err := resolveExternalDriverRoot(downloadDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(root, normalized, optionalDriverAgentExecutableName(normalized)), nil
|
||||
}
|
||||
|
||||
func ResolveMySQLAgentExecutablePath(downloadDir string) (string, error) {
|
||||
return ResolveOptionalDriverAgentExecutablePath(downloadDir, "mysql")
|
||||
}
|
||||
@@ -77,7 +77,8 @@ func applyMySQLURI(config connection.ConnectionConfig) connection.ConnectionConf
|
||||
if uriText == "" {
|
||||
return config
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(uriText), "mysql://") {
|
||||
lowerURI := strings.ToLower(uriText)
|
||||
if !strings.HasPrefix(lowerURI, "mysql://") {
|
||||
return config
|
||||
}
|
||||
|
||||
@@ -500,6 +501,8 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
return fmt.Errorf("connection not open")
|
||||
}
|
||||
|
||||
columnTypeMap := m.loadColumnTypeMap(tableName)
|
||||
|
||||
tx, err := m.conn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -512,7 +515,7 @@ func (m *MySQLDB) 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, normalizeMySQLValueForWrite(k, v, columnTypeMap))
|
||||
}
|
||||
if len(wheres) == 0 {
|
||||
continue
|
||||
@@ -534,7 +537,7 @@ func (m *MySQLDB) 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, normalizeMySQLValueForWrite(k, v, columnTypeMap))
|
||||
}
|
||||
|
||||
if len(sets) == 0 {
|
||||
@@ -544,7 +547,7 @@ func (m *MySQLDB) 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, normalizeMySQLValueForWrite(k, v, columnTypeMap))
|
||||
}
|
||||
|
||||
if len(wheres) == 0 {
|
||||
@@ -568,12 +571,24 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
var args []interface{}
|
||||
|
||||
for k, v := range row {
|
||||
normalizedValue, omit := normalizeMySQLValueForInsert(k, v, columnTypeMap)
|
||||
if omit {
|
||||
continue
|
||||
}
|
||||
cols = append(cols, fmt.Sprintf("`%s`", k))
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, normalizeMySQLDateTimeValue(v))
|
||||
args = append(args, normalizedValue)
|
||||
}
|
||||
|
||||
if len(cols) == 0 {
|
||||
query := fmt.Sprintf("INSERT INTO `%s` () VALUES ()", tableName)
|
||||
res, err := tx.Exec(query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert error: %v", err)
|
||||
}
|
||||
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
|
||||
return fmt.Errorf("插入未生效:未影响任何行")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -628,6 +643,69 @@ func normalizeMySQLDateTimeValue(value interface{}) interface{} {
|
||||
return value
|
||||
}
|
||||
|
||||
func (m *MySQLDB) loadColumnTypeMap(tableName string) map[string]string {
|
||||
result := map[string]string{}
|
||||
table := strings.TrimSpace(tableName)
|
||||
if table == "" {
|
||||
return result
|
||||
}
|
||||
|
||||
columns, err := m.GetColumns("", table)
|
||||
if err != nil {
|
||||
logger.Warnf("加载列元数据失败(不影响提交):表=%s err=%v", table, err)
|
||||
return result
|
||||
}
|
||||
|
||||
for _, col := range columns {
|
||||
name := strings.ToLower(strings.TrimSpace(col.Name))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
result[name] = strings.TrimSpace(col.Type)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
text, ok := value.(string)
|
||||
if ok && strings.TrimSpace(text) == "" {
|
||||
// INSERT 空时间字段不写入,交给 DB 默认值处理(如 CURRENT_TIMESTAMP)。
|
||||
return nil, true
|
||||
}
|
||||
return normalizeMySQLDateTimeValue(value), false
|
||||
}
|
||||
|
||||
func normalizeMySQLValueForWrite(columnName string, value interface{}, columnTypeMap map[string]string) interface{} {
|
||||
columnType := strings.ToLower(strings.TrimSpace(columnTypeMap[strings.ToLower(strings.TrimSpace(columnName))]))
|
||||
if !isMySQLTemporalColumnType(columnType) {
|
||||
return value
|
||||
}
|
||||
text, ok := value.(string)
|
||||
if ok && strings.TrimSpace(text) == "" {
|
||||
return nil
|
||||
}
|
||||
return normalizeMySQLDateTimeValue(value)
|
||||
}
|
||||
|
||||
func isMySQLTemporalColumnType(columnType string) bool {
|
||||
raw := strings.ToLower(strings.TrimSpace(columnType))
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(raw, "datetime") || strings.Contains(raw, "timestamp") {
|
||||
return true
|
||||
}
|
||||
base := raw
|
||||
if idx := strings.IndexAny(base, "( "); idx >= 0 {
|
||||
base = base[:idx]
|
||||
}
|
||||
return base == "date" || base == "time" || base == "year"
|
||||
}
|
||||
|
||||
func hasTimezoneOffset(text string) bool {
|
||||
pos := strings.LastIndexAny(text, "+-")
|
||||
if pos < 0 || pos < 10 || pos+1 >= len(text) {
|
||||
|
||||
445
internal/db/optional_driver_agent_impl.go
Normal file
445
internal/db/optional_driver_agent_impl.go
Normal file
@@ -0,0 +1,445 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
const (
|
||||
optionalAgentMethodConnect = "connect"
|
||||
optionalAgentMethodClose = "close"
|
||||
optionalAgentMethodPing = "ping"
|
||||
optionalAgentMethodQuery = "query"
|
||||
optionalAgentMethodExec = "exec"
|
||||
optionalAgentMethodGetDatabases = "getDatabases"
|
||||
optionalAgentMethodGetTables = "getTables"
|
||||
optionalAgentMethodGetCreateStmt = "getCreateStatement"
|
||||
optionalAgentMethodGetColumns = "getColumns"
|
||||
optionalAgentMethodGetAllColumns = "getAllColumns"
|
||||
optionalAgentMethodGetIndexes = "getIndexes"
|
||||
optionalAgentMethodGetForeignKeys = "getForeignKeys"
|
||||
optionalAgentMethodGetTriggers = "getTriggers"
|
||||
optionalAgentMethodApplyChanges = "applyChanges"
|
||||
optionalAgentDefaultScannerMaxBytes = 8 << 20
|
||||
)
|
||||
|
||||
type optionalAgentRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Config *connection.ConnectionConfig `json:"config,omitempty"`
|
||||
Query string `json:"query,omitempty"`
|
||||
DBName string `json:"dbName,omitempty"`
|
||||
TableName string `json:"tableName,omitempty"`
|
||||
Changes *connection.ChangeSet `json:"changes,omitempty"`
|
||||
}
|
||||
|
||||
type optionalAgentResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
Fields []string `json:"fields,omitempty"`
|
||||
RowsAffected int64 `json:"rowsAffected,omitempty"`
|
||||
}
|
||||
|
||||
type optionalDriverAgentClient struct {
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
reader *bufio.Reader
|
||||
nextID int64
|
||||
mu sync.Mutex
|
||||
stderrMu sync.Mutex
|
||||
stderr strings.Builder
|
||||
driver string
|
||||
}
|
||||
|
||||
func newOptionalDriverAgentClient(driverType string, executablePath string) (*optionalDriverAgentClient, error) {
|
||||
pathText := strings.TrimSpace(executablePath)
|
||||
if pathText == "" {
|
||||
return nil, fmt.Errorf("%s 驱动代理路径为空", driverDisplayName(driverType))
|
||||
}
|
||||
info, err := os.Stat(pathText)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s 驱动代理不存在:%s", driverDisplayName(driverType), pathText)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, fmt.Errorf("%s 驱动代理路径是目录:%s", driverDisplayName(driverType), pathText)
|
||||
}
|
||||
|
||||
cmd := exec.Command(pathText)
|
||||
configureAgentProcess(cmd)
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建 %s 驱动代理 stdin 失败:%w", driverDisplayName(driverType), err)
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建 %s 驱动代理 stdout 失败:%w", driverDisplayName(driverType), err)
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建 %s 驱动代理 stderr 失败:%w", driverDisplayName(driverType), err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("启动 %s 驱动代理失败:%w", driverDisplayName(driverType), err)
|
||||
}
|
||||
|
||||
client := &optionalDriverAgentClient{
|
||||
cmd: cmd,
|
||||
stdin: stdin,
|
||||
reader: bufio.NewReader(stdout),
|
||||
driver: normalizeRuntimeDriverType(driverType),
|
||||
}
|
||||
go client.captureStderr(stderr)
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *optionalDriverAgentClient) captureStderr(stderr io.Reader) {
|
||||
scanner := bufio.NewScanner(stderr)
|
||||
buffer := make([]byte, 0, 8<<10)
|
||||
scanner.Buffer(buffer, optionalAgentDefaultScannerMaxBytes)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
c.stderrMu.Lock()
|
||||
if c.stderr.Len() > 0 {
|
||||
c.stderr.WriteString(" | ")
|
||||
}
|
||||
c.stderr.WriteString(line)
|
||||
c.stderrMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *optionalDriverAgentClient) stderrText() string {
|
||||
c.stderrMu.Lock()
|
||||
defer c.stderrMu.Unlock()
|
||||
return strings.TrimSpace(c.stderr.String())
|
||||
}
|
||||
|
||||
func (c *optionalDriverAgentClient) call(req optionalAgentRequest, out interface{}, fields *[]string, rowsAffected *int64) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.nextID++
|
||||
req.ID = c.nextID
|
||||
|
||||
payload, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
if _, err := c.stdin.Write(payload); err != nil {
|
||||
stderrText := c.stderrText()
|
||||
if stderrText == "" {
|
||||
return fmt.Errorf("调用 %s 驱动代理失败:%w", driverDisplayName(c.driver), err)
|
||||
}
|
||||
return fmt.Errorf("调用 %s 驱动代理失败:%w(stderr: %s)", driverDisplayName(c.driver), err, stderrText)
|
||||
}
|
||||
|
||||
line, err := c.reader.ReadBytes('\n')
|
||||
if err != nil {
|
||||
stderrText := c.stderrText()
|
||||
if stderrText == "" {
|
||||
return fmt.Errorf("读取 %s 驱动代理响应失败:%w", driverDisplayName(c.driver), err)
|
||||
}
|
||||
return fmt.Errorf("读取 %s 驱动代理响应失败:%w(stderr: %s)", driverDisplayName(c.driver), err, stderrText)
|
||||
}
|
||||
|
||||
var resp optionalAgentResponse
|
||||
if err := json.Unmarshal(line, &resp); err != nil {
|
||||
return fmt.Errorf("解析 %s 驱动代理响应失败:%w", driverDisplayName(c.driver), err)
|
||||
}
|
||||
if !resp.Success {
|
||||
errText := strings.TrimSpace(resp.Error)
|
||||
if errText == "" {
|
||||
errText = fmt.Sprintf("%s 驱动代理返回失败", driverDisplayName(c.driver))
|
||||
}
|
||||
return errors.New(errText)
|
||||
}
|
||||
|
||||
if fields != nil {
|
||||
*fields = resp.Fields
|
||||
}
|
||||
if rowsAffected != nil {
|
||||
*rowsAffected = resp.RowsAffected
|
||||
}
|
||||
if out != nil && len(resp.Data) > 0 {
|
||||
if err := json.Unmarshal(resp.Data, out); err != nil {
|
||||
return fmt.Errorf("解析 %s 驱动代理数据失败:%w", driverDisplayName(c.driver), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *optionalDriverAgentClient) close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
var closeErr error
|
||||
if c.stdin != nil {
|
||||
_ = c.stdin.Close()
|
||||
}
|
||||
if c.cmd != nil && c.cmd.Process != nil {
|
||||
if err := c.cmd.Process.Kill(); err != nil {
|
||||
closeErr = err
|
||||
}
|
||||
}
|
||||
if c.cmd != nil {
|
||||
_ = c.cmd.Wait()
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
|
||||
type OptionalDriverAgentDB struct {
|
||||
driverType string
|
||||
client *optionalDriverAgentClient
|
||||
}
|
||||
|
||||
func newOptionalDriverAgentDatabase(driverType string) databaseFactory {
|
||||
normalized := normalizeRuntimeDriverType(driverType)
|
||||
return func() Database {
|
||||
return &OptionalDriverAgentDB{driverType: normalized}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *OptionalDriverAgentDB) Connect(config connection.ConnectionConfig) error {
|
||||
if d.client != nil {
|
||||
_ = d.client.close()
|
||||
d.client = nil
|
||||
}
|
||||
|
||||
executablePath, err := ResolveOptionalDriverAgentExecutablePath("", d.driverType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := newOptionalDriverAgentClient(d.driverType, executablePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.call(optionalAgentRequest{
|
||||
Method: optionalAgentMethodConnect,
|
||||
Config: &config,
|
||||
}, nil, nil, nil); err != nil {
|
||||
_ = client.close()
|
||||
return err
|
||||
}
|
||||
d.client = client
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *OptionalDriverAgentDB) Close() error {
|
||||
if d.client == nil {
|
||||
return nil
|
||||
}
|
||||
_ = d.client.call(optionalAgentRequest{Method: optionalAgentMethodClose}, nil, nil, nil)
|
||||
err := d.client.close()
|
||||
d.client = nil
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *OptionalDriverAgentDB) Ping() error {
|
||||
client, err := d.requireClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.call(optionalAgentRequest{Method: optionalAgentMethodPing}, nil, nil, nil)
|
||||
}
|
||||
|
||||
func (d *OptionalDriverAgentDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return d.Query(query)
|
||||
}
|
||||
|
||||
func (d *OptionalDriverAgentDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
client, err := d.requireClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var data []map[string]interface{}
|
||||
var fields []string
|
||||
if err := client.call(optionalAgentRequest{
|
||||
Method: optionalAgentMethodQuery,
|
||||
Query: query,
|
||||
}, &data, &fields, nil); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return data, fields, nil
|
||||
}
|
||||
|
||||
func (d *OptionalDriverAgentDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return d.Exec(query)
|
||||
}
|
||||
|
||||
func (d *OptionalDriverAgentDB) Exec(query string) (int64, error) {
|
||||
client, err := d.requireClient()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var affected int64
|
||||
if err := client.call(optionalAgentRequest{
|
||||
Method: optionalAgentMethodExec,
|
||||
Query: query,
|
||||
}, nil, nil, &affected); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
func (d *OptionalDriverAgentDB) GetDatabases() ([]string, error) {
|
||||
client, err := d.requireClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var dbs []string
|
||||
if err := client.call(optionalAgentRequest{
|
||||
Method: optionalAgentMethodGetDatabases,
|
||||
}, &dbs, nil, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dbs, nil
|
||||
}
|
||||
|
||||
func (d *OptionalDriverAgentDB) GetTables(dbName string) ([]string, error) {
|
||||
client, err := d.requireClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tables []string
|
||||
if err := client.call(optionalAgentRequest{
|
||||
Method: optionalAgentMethodGetTables,
|
||||
DBName: dbName,
|
||||
}, &tables, nil, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tables, nil
|
||||
}
|
||||
|
||||
func (d *OptionalDriverAgentDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||
client, err := d.requireClient()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var sqlText string
|
||||
if err := client.call(optionalAgentRequest{
|
||||
Method: optionalAgentMethodGetCreateStmt,
|
||||
DBName: dbName,
|
||||
TableName: tableName,
|
||||
}, &sqlText, nil, nil); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return sqlText, nil
|
||||
}
|
||||
|
||||
func (d *OptionalDriverAgentDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||
client, err := d.requireClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var columns []connection.ColumnDefinition
|
||||
if err := client.call(optionalAgentRequest{
|
||||
Method: optionalAgentMethodGetColumns,
|
||||
DBName: dbName,
|
||||
TableName: tableName,
|
||||
}, &columns, nil, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func (d *OptionalDriverAgentDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||
client, err := d.requireClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var columns []connection.ColumnDefinitionWithTable
|
||||
if err := client.call(optionalAgentRequest{
|
||||
Method: optionalAgentMethodGetAllColumns,
|
||||
DBName: dbName,
|
||||
}, &columns, nil, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func (d *OptionalDriverAgentDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||
client, err := d.requireClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var indexes []connection.IndexDefinition
|
||||
if err := client.call(optionalAgentRequest{
|
||||
Method: optionalAgentMethodGetIndexes,
|
||||
DBName: dbName,
|
||||
TableName: tableName,
|
||||
}, &indexes, nil, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return indexes, nil
|
||||
}
|
||||
|
||||
func (d *OptionalDriverAgentDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||
client, err := d.requireClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var keys []connection.ForeignKeyDefinition
|
||||
if err := client.call(optionalAgentRequest{
|
||||
Method: optionalAgentMethodGetForeignKeys,
|
||||
DBName: dbName,
|
||||
TableName: tableName,
|
||||
}, &keys, nil, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func (d *OptionalDriverAgentDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||
client, err := d.requireClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var triggers []connection.TriggerDefinition
|
||||
if err := client.call(optionalAgentRequest{
|
||||
Method: optionalAgentMethodGetTriggers,
|
||||
DBName: dbName,
|
||||
TableName: tableName,
|
||||
}, &triggers, nil, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return triggers, nil
|
||||
}
|
||||
|
||||
func (d *OptionalDriverAgentDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
client, err := d.requireClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.call(optionalAgentRequest{
|
||||
Method: optionalAgentMethodApplyChanges,
|
||||
TableName: tableName,
|
||||
Changes: &changes,
|
||||
}, nil, nil, nil)
|
||||
}
|
||||
|
||||
func (d *OptionalDriverAgentDB) requireClient() (*optionalDriverAgentClient, error) {
|
||||
if d.client == nil {
|
||||
return nil, fmt.Errorf("connection not open")
|
||||
}
|
||||
return d.client, nil
|
||||
}
|
||||
9
internal/db/optional_driver_build_full.go
Normal file
9
internal/db/optional_driver_build_full.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build gonavi_full_drivers
|
||||
|
||||
package db
|
||||
|
||||
func optionalGoDriverBuildIncluded(driverType string) bool {
|
||||
_, ok := optionalGoDrivers[normalizeRuntimeDriverType(driverType)]
|
||||
return ok
|
||||
}
|
||||
|
||||
8
internal/db/optional_driver_build_lite.go
Normal file
8
internal/db/optional_driver_build_lite.go
Normal file
@@ -0,0 +1,8 @@
|
||||
//go:build !gonavi_full_drivers
|
||||
|
||||
package db
|
||||
|
||||
func optionalGoDriverBuildIncluded(driverType string) bool {
|
||||
_, ok := optionalGoDrivers[normalizeRuntimeDriverType(driverType)]
|
||||
return ok
|
||||
}
|
||||
@@ -18,14 +18,12 @@ import (
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
|
||||
type PostgresDB struct {
|
||||
conn *sql.DB
|
||||
pingTimeout time.Duration
|
||||
forwarder *ssh.LocalForwarder // Store SSH tunnel forwarder
|
||||
}
|
||||
|
||||
|
||||
func (p *PostgresDB) getDSN(config connection.ConnectionConfig) string {
|
||||
// postgres://user:password@host:port/dbname?sslmode=disable
|
||||
dbname := config.Database
|
||||
@@ -48,6 +46,13 @@ func (p *PostgresDB) getDSN(config connection.ConnectionConfig) string {
|
||||
}
|
||||
|
||||
func (p *PostgresDB) Connect(config connection.ConnectionConfig) error {
|
||||
if supported, reason := DriverRuntimeSupportStatus("postgres"); !supported {
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
reason = "PostgreSQL 纯 Go 驱动未启用,请先在驱动管理中安装启用"
|
||||
}
|
||||
return fmt.Errorf("%s", reason)
|
||||
}
|
||||
|
||||
var dsn string
|
||||
var err error
|
||||
|
||||
@@ -98,7 +103,6 @@ func (p *PostgresDB) Connect(config connection.ConnectionConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
func (p *PostgresDB) Close() error {
|
||||
// Close SSH forwarder first if exists
|
||||
if p.forwarder != nil {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build gonavi_full_drivers || gonavi_sphinx_driver
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
@@ -5,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
)
|
||||
|
||||
const sphinxDefaultDatabaseName = "default"
|
||||
@@ -108,7 +111,67 @@ func (s *SphinxDB) GetCreateStatement(dbName, tableName string) (string, error)
|
||||
}
|
||||
|
||||
func (s *SphinxDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||
return s.MySQLDB.GetColumns(s.resolveDatabaseName(dbName), tableName)
|
||||
// Sphinx 使用 DESCRIBE 语法获取索引结构
|
||||
query := fmt.Sprintf("DESCRIBE %s", tableName)
|
||||
data, _, err := s.MySQLDB.Query(query)
|
||||
if err != nil {
|
||||
// 如果 DESCRIBE 失败,尝试使用 MySQL 的方式作为降级
|
||||
return s.MySQLDB.GetColumns(s.resolveDatabaseName(dbName), tableName)
|
||||
}
|
||||
|
||||
var columns []connection.ColumnDefinition
|
||||
for _, row := range data {
|
||||
// Sphinx DESCRIBE 返回的字段:Field, Type, Properties
|
||||
fieldName := ""
|
||||
if val, ok := row["Field"]; ok {
|
||||
fieldName = fmt.Sprintf("%v", val)
|
||||
} else if val, ok := row["field"]; ok {
|
||||
fieldName = fmt.Sprintf("%v", val)
|
||||
}
|
||||
|
||||
fieldType := ""
|
||||
if val, ok := row["Type"]; ok {
|
||||
fieldType = fmt.Sprintf("%v", val)
|
||||
} else if val, ok := row["type"]; ok {
|
||||
fieldType = fmt.Sprintf("%v", val)
|
||||
}
|
||||
|
||||
properties := ""
|
||||
if val, ok := row["Properties"]; ok {
|
||||
properties = fmt.Sprintf("%v", val)
|
||||
} else if val, ok := row["properties"]; ok {
|
||||
properties = fmt.Sprintf("%v", val)
|
||||
}
|
||||
|
||||
if fieldName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
col := connection.ColumnDefinition{
|
||||
Name: fieldName,
|
||||
Type: fieldType,
|
||||
Nullable: "YES", // Sphinx 默认字段可为空
|
||||
Key: "", // Sphinx 没有主键概念
|
||||
Default: nil, // Sphinx DESCRIBE 不返回默认值
|
||||
Extra: properties,
|
||||
Comment: "",
|
||||
}
|
||||
|
||||
// 根据 properties 判断是否为索引字段
|
||||
if strings.Contains(strings.ToLower(properties), "indexed") {
|
||||
col.Key = "MUL"
|
||||
}
|
||||
|
||||
columns = append(columns, col)
|
||||
}
|
||||
|
||||
// 如果没有获取到任何列,尝试使用 MySQL 方式
|
||||
if len(columns) == 0 {
|
||||
logger.Warnf("Sphinx DESCRIBE 未返回任何列,尝试使用 MySQL 方式获取:表=%s", tableName)
|
||||
return s.MySQLDB.GetColumns(s.resolveDatabaseName(dbName), tableName)
|
||||
}
|
||||
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func (s *SphinxDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
//go:build gonavi_full_drivers || gonavi_sqlite_driver
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -19,7 +24,14 @@ type SQLiteDB struct {
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) Connect(config connection.ConnectionConfig) error {
|
||||
dsn := config.Host
|
||||
dsn, err := resolveSQLiteDSN(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureSQLiteParentDir(dsn); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开数据库连接失败:%w", err)
|
||||
@@ -29,11 +41,140 @@ func (s *SQLiteDB) Connect(config connection.ConnectionConfig) error {
|
||||
|
||||
// Force verification
|
||||
if err := s.Ping(); err != nil {
|
||||
_ = db.Close()
|
||||
s.conn = nil
|
||||
return fmt.Errorf("连接建立后验证失败:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveSQLiteDSN(config connection.ConnectionConfig) (string, error) {
|
||||
dsn := strings.TrimSpace(config.Host)
|
||||
if dsn == "" {
|
||||
dsn = strings.TrimSpace(config.Database)
|
||||
}
|
||||
dsn = normalizeSQLitePath(dsn)
|
||||
if dsn == "" {
|
||||
return "", fmt.Errorf("SQLite 需要本地数据库文件路径(例如 /path/to/demo.sqlite)")
|
||||
}
|
||||
if strings.EqualFold(dsn, ":memory:") {
|
||||
return dsn, nil
|
||||
}
|
||||
if looksLikeHostPort(dsn) {
|
||||
return "", fmt.Errorf("SQLite 需要本地数据库文件路径,当前输入看起来是主机地址:%s", dsn)
|
||||
}
|
||||
return dsn, nil
|
||||
}
|
||||
|
||||
func normalizeSQLitePath(raw string) string {
|
||||
text := strings.TrimSpace(raw)
|
||||
if strings.HasPrefix(text, "/") && len(text) > 3 && isWindowsDrivePath(text[1:]) {
|
||||
text = text[1:]
|
||||
}
|
||||
if isWindowsDrivePath(text) {
|
||||
text = trimLegacyPortSuffix(text)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func isWindowsDrivePath(path string) bool {
|
||||
if len(path) < 3 {
|
||||
return false
|
||||
}
|
||||
drive := path[0]
|
||||
if !((drive >= 'a' && drive <= 'z') || (drive >= 'A' && drive <= 'Z')) {
|
||||
return false
|
||||
}
|
||||
if path[1] != ':' {
|
||||
return false
|
||||
}
|
||||
sep := path[2]
|
||||
return sep == '\\' || sep == '/'
|
||||
}
|
||||
|
||||
func trimLegacyPortSuffix(path string) string {
|
||||
normalized := path
|
||||
for {
|
||||
idx := strings.LastIndex(normalized, ":")
|
||||
if idx <= 1 || idx+1 >= len(normalized) {
|
||||
return normalized
|
||||
}
|
||||
suffix := normalized[idx+1:]
|
||||
validDigits := true
|
||||
for _, ch := range suffix {
|
||||
if ch < '0' || ch > '9' {
|
||||
validDigits = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !validDigits {
|
||||
return normalized
|
||||
}
|
||||
normalized = normalized[:idx]
|
||||
}
|
||||
}
|
||||
|
||||
func looksLikeHostPort(raw string) bool {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
if strings.ContainsAny(text, `/\`) {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(text), "file:") {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(text, "[") {
|
||||
closing := strings.LastIndex(text, "]")
|
||||
if closing <= 0 || closing+1 >= len(text) {
|
||||
return false
|
||||
}
|
||||
portText := strings.TrimSpace(strings.TrimPrefix(text[closing+1:], ":"))
|
||||
return isValidPortText(portText)
|
||||
}
|
||||
if strings.Count(text, ":") != 1 {
|
||||
return false
|
||||
}
|
||||
split := strings.LastIndex(text, ":")
|
||||
if split <= 0 || split+1 >= len(text) {
|
||||
return false
|
||||
}
|
||||
return isValidPortText(strings.TrimSpace(text[split+1:]))
|
||||
}
|
||||
|
||||
func isValidPortText(text string) bool {
|
||||
port, err := strconv.Atoi(text)
|
||||
return err == nil && port > 0 && port <= 65535
|
||||
}
|
||||
|
||||
func ensureSQLiteParentDir(dsn string) error {
|
||||
text := strings.TrimSpace(dsn)
|
||||
if text == "" || strings.EqualFold(text, ":memory:") {
|
||||
return nil
|
||||
}
|
||||
// file: URI 由驱动处理,避免在这里误判路径格式。
|
||||
if strings.HasPrefix(strings.ToLower(text), "file:") {
|
||||
return nil
|
||||
}
|
||||
path := text
|
||||
if idx := strings.Index(path, "?"); idx >= 0 {
|
||||
path = path[:idx]
|
||||
}
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if dir == "." || dir == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("创建 SQLite 数据文件目录失败:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) Close() error {
|
||||
if s.conn != nil {
|
||||
return s.conn.Close()
|
||||
|
||||
79
internal/db/sqlite_impl_test.go
Normal file
79
internal/db/sqlite_impl_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
//go:build gonavi_full_drivers || gonavi_sqlite_driver
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
func TestResolveSQLiteDSNRejectsHostPort(t *testing.T) {
|
||||
_, err := resolveSQLiteDSN(connection.ConnectionConfig{Type: "sqlite", Host: "localhost:3306"})
|
||||
if err == nil {
|
||||
t.Fatalf("期望拦截 host:port 输入")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "本地数据库文件路径") {
|
||||
t.Fatalf("错误提示不符合预期: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSQLiteDSNFallbackDatabase(t *testing.T) {
|
||||
dsn, err := resolveSQLiteDSN(connection.ConnectionConfig{Type: "sqlite", Database: "/tmp/demo.sqlite"})
|
||||
if err != nil {
|
||||
t.Fatalf("解析 DSN 失败: %v", err)
|
||||
}
|
||||
if dsn != "/tmp/demo.sqlite" {
|
||||
t.Fatalf("期望使用 database 作为 DSN,实际=%s", dsn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSQLiteDSNNormalizesWindowsLegacyPath(t *testing.T) {
|
||||
dsn, err := resolveSQLiteDSN(connection.ConnectionConfig{Type: "sqlite", Host: `F:\py\py\history.db:3306:3306`})
|
||||
if err != nil {
|
||||
t.Fatalf("解析 DSN 失败: %v", err)
|
||||
}
|
||||
if dsn != `F:\py\py\history.db` {
|
||||
t.Fatalf("期望清理历史端口污染,实际=%s", dsn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSQLiteDSNNormalizesWindowsPathWithLeadingSlash(t *testing.T) {
|
||||
dsn, err := resolveSQLiteDSN(connection.ConnectionConfig{Type: "sqlite", Host: `/F:\py\py\history.db:3306`})
|
||||
if err != nil {
|
||||
t.Fatalf("解析 DSN 失败: %v", err)
|
||||
}
|
||||
if dsn != `F:\py\py\history.db` {
|
||||
t.Fatalf("期望清理前导斜杠与端口污染,实际=%s", dsn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSQLiteParentDirCreatesNestedDir(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
target := filepath.Join(base, "nested", "child", "demo.sqlite")
|
||||
if err := ensureSQLiteParentDir(target); err != nil {
|
||||
t.Fatalf("创建目录失败: %v", err)
|
||||
}
|
||||
info, err := os.Stat(filepath.Dir(target))
|
||||
if err != nil {
|
||||
t.Fatalf("目录不存在: %v", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
t.Fatalf("目标不是目录: %s", filepath.Dir(target))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeHostPort(t *testing.T) {
|
||||
if !looksLikeHostPort("localhost:3306") {
|
||||
t.Fatalf("localhost:3306 应识别为 host:port")
|
||||
}
|
||||
if looksLikeHostPort("/tmp/demo.sqlite") {
|
||||
t.Fatalf("/tmp/demo.sqlite 不应识别为 host:port")
|
||||
}
|
||||
if looksLikeHostPort(`C:\sqlite\demo.db`) {
|
||||
t.Fatalf("Windows 路径不应识别为 host:port")
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build gonavi_full_drivers || gonavi_sqlserver_driver
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build gonavi_full_drivers || gonavi_tdengine_driver
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build gonavi_full_drivers || gonavi_vastbase_driver
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
|
||||
@@ -22,7 +22,7 @@ func quoteIdentByType(dbType string, ident string) string {
|
||||
}
|
||||
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "sphinx":
|
||||
case "mysql", "mariadb", "diros", "sphinx":
|
||||
return "`" + strings.ReplaceAll(ident, "`", "``") + "`"
|
||||
case "sqlserver":
|
||||
escaped := strings.ReplaceAll(ident, "]", "]]")
|
||||
@@ -100,7 +100,7 @@ func qualifiedNameForQuery(dbType string, schema string, table string, original
|
||||
return raw
|
||||
}
|
||||
return s + "." + table
|
||||
case "mysql", "mariadb", "sphinx":
|
||||
case "mysql", "mariadb", "diros", "sphinx":
|
||||
s := strings.TrimSpace(schema)
|
||||
if s == "" || table == "" {
|
||||
return table
|
||||
|
||||
Reference in New Issue
Block a user