Compare commits

...

38 Commits

Author SHA1 Message Date
Syngnat
bec5013a44 🐛 fix(update-windows): 修复自动更新脚本变量转义导致TARGET语法错误
- 将 buildWindowsScript 改为模板占位符替换,避免 fmt.Sprintf 吞掉批处理百分号
- 修正 for %%I/%%F 语法,消除“此时不应有 TARGET~nxI”报错
- 保留原有更新重试与日志流程,不改变下载与安装主链路
- refs #112
2026-02-26 14:23:36 +08:00
Syngnat
66a3113fa8 🐛 fix(datagrid-mysql): 修复MySQL行编辑时datetime空值提交失败
- 前端按列类型归一化 temporal 字段,INSERT 空值跳过字段、UPDATE 空值转 NULL
- 后端 ApplyChanges 增加 temporal 字段兜底,避免空字符串写入 datetime/timestamp
- 新增全默认值插入路径,兼容 CURRENT_TIMESTAMP 等默认值场景
- refs #113
2026-02-26 14:13:27 +08:00
Syngnat
a435d62d3b feat(connection-modal): 新增SSH私钥文件浏览选择能力
- 新增私钥文件选择入口,减少手动输入路径错误
- 复用系统文件对话框并自动回填私钥路径
- 保留手动输入作为兜底方式
- refs #119
2026-02-26 13:57:50 +08:00
Syngnat
50d92d3184 🐛 fix(backup-export): 修复批量备份未区分视图与表导致导出失败
- 批量操作弹窗按“表/视图”分组展示并支持混合勾选
- 批量导出改为对象集合传参,统一结构/数据导出入口
- SQL 导出链路新增视图识别与排序,避免将视图当表处理
- 增加多方言视图 DDL 查询与回退逻辑,规避 create statement not found
- 视图数据导出阶段自动跳过并追加说明注释
- refs #117
2026-02-26 13:45:17 +08:00
Syngnat
91658848c9 🔧 fix(frontend): 修复表设计能力门禁并优化构建分包策略
- 修复触发器分组进入设计页时误设只读,恢复索引/外键页增删改按钮显示
  - 重构 TableDesigner 数据源方言识别,移除 MySQL 与固定方言白名单硬限制
  - 按能力控制索引/外键/表备注编辑入口,并补充多方言 DDL 生成与通用兜底
  - 收敛已知不支持场景:sqlite/duckdb/tdengine 禁用外键编辑,sqlite 禁用表备注编辑
  - Monaco 改为按需 worker(editor/json)并补齐 vite 类型声明,避免构建类型报错
  - 细化 Vite manualChunks(antd/monaco 子模块拆分),消除 >500k chunk 告警
  - refs #115
2026-02-26 12:08:07 +08:00
Syngnat
fda30539b6 🐛 fix(highgo): 修复海量数据源复制表结构仅返回注释
- 识别 HighGo 占位建表语句
- 通过 GetColumns 生成包含字段与主键的建表SQL
- 避免右键复制表结构出现空字段
- refs #99
2026-02-14 15:45:02 +08:00
Syngnat
1ba68fcbfe 🐛 fix(release): 修复 Debian 13 Linux 产物 WebKitGTK 依赖不兼容
- Linux Release 增加 WebKitGTK 4.1 变体(-WebKit41),保留 4.0 产物
- CI 按 WebKit 版本安装依赖,并为 Wails 注入 webkit2_41 构建标签
- 完善驱动代理可执行文件路径校验错误提示(区分不存在/目录)
- README 补充 Linux 依赖排障与产物选择说明
- refs #98
2026-02-14 15:17:03 +08:00
Syngnat
f0e1c7e72c 🔧 fix(driver-agent): 修复 Windows 启动驱动代理弹出终端窗口
- 为 Windows 新增 agent 进程启动参数(HideWindow + CREATE_NO_WINDOW)
- optional driver agent 启动路径统一应用进程隐藏配置
- MySQL agent 启动路径同步应用进程隐藏配置
2026-02-14 15:01:29 +08:00
Syngnat
663717d738 ♻️ refactor(driver-delivery): 重构可选驱动分发为总包+索引模式
- 工作流统一收敛驱动产物并打包单一压缩包
- 新增驱动总包索引读取与缓存合并逻辑
- 保留原单文件直链兼容并增加总包提取回退
2026-02-14 11:45:51 +08:00
Syngnat
5329f212f7 feat(schema-editor): 表设计器新增索引/外键管理能力并支持表备注修改
- 支持新增/修改/删除索引与外键(MySQL)
- 表备注弹窗编辑并同步刷新 DDL/元数据
- 索引类型补齐 UNIQUE/PRIMARY/FULLTEXT/SPATIAL 等
- refs #108
2026-02-14 11:25:13 +08:00
Syngnat
d6e967a0d0 feat(table-designer): 支持字段注释弹框编辑并恢复DDL常显
- 注释列新增双击与按钮触发的弹框编辑能力
- 增加长文本注释编辑弹窗并支持直接回写字段定义
- 非新建表场景统一拉取并展示 DDL 标签页
- 优化注释只读态展示,补充悬浮完整内容
- refs #105
2026-02-14 10:36:54 +08:00
Syngnat
7ca2d20c17 feat(datagrid): 增强列头字段信息展示并优化排序与右键菜单交互
- 新增列头类型/备注常驻显示与悬浮详情展示
- 新增字段信息开关并持久化 showColumnComment/showColumnType 配置
- 排序改为仅箭头区域可触发,排序提示仅显示在排序图标上
- 修复可编辑表中右键菜单重复弹出与透明重影问题
- refs #106
2026-02-14 10:30:01 +08:00
Syngnat
9307ca5e16 feat(table-designer): 支持勾选字段并一键复制到新表
- 设计表字段列表增加多选能力,支持按行勾选字段
- 工具栏新增“复制选中到新表”按钮与交互
- 新增目标表配置弹窗,支持表名、字符集、排序规则设置
- 复用建表 SQL 生成逻辑并直接执行创建新表
- refs #107
2026-02-14 09:57:47 +08:00
Syngnat
60a42e3c34 🔧 fix(connection-modal): 修复 SQLite 连接配置回填导致路径变形问题
- ConnectionModal 中 sqlite 使用独立路径规则,不再参与 host:port 解析
- 修复编辑连接时的回填逻辑,阻断 F:\... 被追加 :3306
- 统一 URI 解析与生成行为,确保保存后再次编辑不变形
- 保留并强化驱动安装态判断与现有交互
2026-02-14 09:51:17 +08:00
Syngnat
26a7aacfec feat(drivers): 支持按需启动数据源并通过外置驱动代理减少发行包体积
- MySQL/Redis/Oracle/PostgreSQL 内置可用,其余数据源改为“安装启用”后可用
- 新建连接对未安装驱动做弹窗内拦截提示,并支持一键跳转驱动管理安装
- 驱动管理展示安装包真实大小(从 Release 资产元数据读取)并优化加载性能
- Release 工作流发布各平台驱动代理资产,主程序构建启用 -s -w 精简
2026-02-13 17:23:38 +08:00
Syngnat
8df9ea717c 🔧 fix(ci-release-duckdb): 修复 DuckDB 导致的多平台打包失败并统一发布命名与更新匹配
- DuckDB 驱动迁移至官方 duckdb-go/v2 并按平台条件编译
- 修复 Windows/arm64 与 macOS/arm64 的构建失败链路
- 修复 macOS 10.13 下窗口材质可用性告警导致的打包问题
- 统一发布包命名规则(去掉版本前缀 v,架构统一 Amd64/Arm64)
- Windows 同时产出 exe/zip,在线更新优先匹配 exe 并保留 zip 兼容
2026-02-12 10:37:00 +08:00
Syngnat
31f2a47d26 🐛 fix(updater-macos): 修复更新状态误判并调整Mac下载目录
- CheckForUpdates 增加本地已下载包探测并回填 downloaded/downloadPath
- DownloadUpdate 复用同版本已下载包,避免重复下载
- macOS 更新包默认落盘到 ~/Desktop/GoNavi-<version>/
- 关于页更新状态改为按已下载/未下载准确展示
2026-02-11 17:41:42 +08:00
Syngnat
e01ecfc387 feat(datasource): 新增 DuckDB 与 Diros 数据源并补齐 DuckDB 函数管理
- 新增 DuckDB 与 Diros 后端驱动实现并接入数据库工厂
- 前端连接配置补充 DuckDB/Diros 入口及方言映射
- 侧边栏支持 DuckDB Macro 函数列表加载与对象分组展示
- 定义查看器支持 DuckDB 函数定义查询与 DDL 还原
- 后端补充 DuckDB 函数删除分支并限制存储过程操作
2026-02-11 17:25:38 +08:00
Syngnat
69d9a0b11e Merge pull request #100 from xuanyanwow/main
Support Sphinx DESCRIBE in GetColumns
2026-02-11 15:41:00 +08:00
宣言就是Siam
33f4208f39 Support Sphinx DESCRIBE in GetColumns
Update SphinxDB.GetColumns to use Sphinx's DESCRIBE output to build column definitions instead of delegating unconditionally to MySQL. The code issues `DESCRIBE <table>` and parses Field/Type/Properties (with case-insensitive lookup), sets sensible defaults (Nullable="YES", no primary key, Extra from Properties) and marks indexed fields as MUL. If DESCRIBE fails or returns no rows the implementation falls back to s.MySQLDB.GetColumns. Also add a logger import and a warning when DESCRIBE returns no columns.
2026-02-11 15:23:46 +08:00
Syngnat
0eeda1d137 Merge pull request #97 from Syngnat/release/0.4.2
Release/0.4.2
2026-02-11 11:18:45 +08:00
Syngnat
17d174bc5b ♻️ refactor(sphinx-compat): 优化Sphinx表列表查询兼容实现
- 保留MySQL复用路径并增加Sphinx语法不兼容回退分支
- 统一回退查询结果的字段提取逻辑
- 提升Sphinx索引列表加载健壮性与容错能力
2026-02-11 11:14:39 +08:00
Syngnat
9320f524a2 🐛 fix(connection-modal): 修复URI解析提示显示在弹窗外的问题
- 将生成/解析/复制URI反馈改为弹窗内联Alert展示
- 统一URI操作提示状态管理,避免全局message层级错位
- 在弹窗打开及URI/type变更时清理旧提示
2026-02-11 10:54:32 +08:00
Syngnat
e31dc4e7f1 feat(redis-stream): 支持 Redis Stream 类型查看与消息增删
- 后端扩展 RedisClient 接口,新增 StreamEntry 与 Stream 操作定义
- Redis 实现新增 XADD/XDEL/XRANGE 封装并接入 RedisGetValue 的 stream 分支
- App 层新增 RedisStreamAdd 与 RedisStreamDelete 方法并返回操作结果
- 前端新增 stream 类型视图,支持消息新增、删除与字段复制
- refs #92
2026-02-11 10:41:22 +08:00
Syngnat
ab92e94bf8 ♻️ refactor(tab-lifecycle): 统一连接与数据库关闭时的标签回收逻辑
- 下沉批量关页逻辑到 store,减少组件重复过滤代码
- Sidebar 仅负责触发动作,状态回收由 store 原子处理
- 优化标签生命周期一致性与可维护性
2026-02-11 10:23:54 +08:00
Syngnat
da5708b5bc 🔧 fix(frontend-data-grid): 修复小屏布局截断并根治MySQL排序内存溢出 2026-02-11 10:12:03 +08:00
Syngnat
189a2a1871 Merge pull request #96 from Syngnat/release/0.4.1
Release/0.4.1
2026-02-10 21:55:42 +08:00
杨国锋
ecf47da81b ♻️ refactor(connection-modal): 重构连接测试反馈交互并优化弹窗布局
- 将测试反馈统一收敛到底部状态区展示
- 失败原因改为独立弹窗查看,避免超长文案挤压主界面
- 调整 modal content/body/footer 弹性结构以适配高度变化
2026-02-10 21:51:50 +08:00
杨国锋
21c8b9a102 🔧 fix(table-designer): 对齐设计表字段拖拽与数据表格的交互与样式
- 字段列宽拖拽改为“虚线预览 + 鼠标释放后提交宽度”
- 新增列宽拖拽 Ghost Line,统一与数据表格的视觉反馈
- 拖拽期间统一全局 col-resize 光标与禁选文本,结束后完整清理监听与状态
2026-02-10 21:02:31 +08:00
杨国锋
a07b418b8f ♻️ refactor(log-panel): 优化SQL日志面板高度边界与滚动区域样式
- 重构最小高度约束逻辑,最小态聚焦单条日志
- 增加日志区域局部滚动条样式,避免影响全局滚动条
- 调整日志表格背景透明度以统一界面表现
2026-02-10 20:54:40 +08:00
杨国锋
4bf10e5612 🔧 fix(connection-uri): 修复URI解析成功后异常配置落盘导致应用崩溃
- 收紧 ConnectionModal 的 URI 解析校验(长度、主机数量、主机格式、端口范围、超时上限)
- 为 URI 回填增加异常兜底,避免解析阶段触发前端崩溃
- 在 store persist 的 migrate/merge 增加连接配置净化,启动时自动隔离坏数据
- 补充 ConnectionConfig 的 driver/dsn/timeout 类型并同步需求追踪文档
2026-02-10 20:40:22 +08:00
杨国锋
e6fe6eb026 feat(sphinx): 新增Sphinx数据源并补齐对象能力兼容链路
- 新增 SphinxDB 驱动注册并复用 MySQL 协议连接
- 前端新增 sphinx 连接类型与默认端口 9306
- 函数/视图/触发器改为多语句回退查询与版本探测提示
- 后端对不支持能力返回稳定降级结果
2026-02-10 20:12:25 +08:00
杨国锋
b4f80f39df 🔧 fix(app-window): 修复 Linux Mint 窗口仅左上角可缩放问题
- 增加 Linux 运行时识别并启用专用缩放命中层
- 补齐四边四角 app-region: drag 热区
- Linux 下禁用外层 clipPath 裁切以避免边缘命中异常
2026-02-10 19:32:03 +08:00
杨国锋
4d32dd2cb5 🔧 fix(data-viewer): 修复筛选后提交事务导致记录顺序漂移
- 抽取统一 ORDER BY 生成逻辑,避免无序重载
- 无显式排序时回退按主键升序,保证结果稳定
- 同步更新 DataGrid 当前页查询导出排序规则
2026-02-10 18:41:25 +08:00
Syngnat
de8fb60a30 feat(highgo-sm3): 增加瀚高SM3专用驱动并解耦PostgreSQL连接链路
- 引入 third_party/highgo-pq 作为 HighGo 专用驱动实现
- 调整驱动注册与连接入口,避免覆盖 postgres 驱动
- 保持 PG 数据源行为不变并补充接入文档
2026-02-10 17:42:28 +08:00
Syngnat
b3b77f490d Merge pull request #95 from Syngnat/release/0.4.0
🔧 fix(data-grid/sidebar/import): 修复时间格式异常并完善schema分层分组
2026-02-10 17:00:48 +08:00
Syngnat
52abed83e6 🔧 fix(data-grid/sidebar/import): 修复时间格式异常并完善schema分层分组
- 导入按列类型标准化 datetime/date/time,避免 +0800 CST 导致 1292 错误
- 导出文件统一时间格式为 yyyy-MM-dd HH:mm:ss
- JSON 视图时间字符串统一规范化显示
- 侧边栏改为 schema -> 对象类型 -> 对象 的分层分组展示
- refs #89
2026-02-10 16:58:13 +08:00
Syngnat
80dc863455 feat(data-grid-import): 新增结果多视图与导入预览进度能力
- DataGrid 新增表格/JSON/文本视图切换,支持 JSON 与文本模式编辑回写
- 修复展开 SQL 日志后横向滚动条异常及末行被遮挡问题
- 新增导入预览与进度导入接口,支持 CSV/JSON/Excel 文件
- 补充 Wails 绑定与 excelize 依赖更新
2026-02-10 16:08:10 +08:00
165 changed files with 29584 additions and 1232 deletions

View File

@@ -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 需要 libsoup34.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

View File

@@ -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 运行环境。
---
## 🤝 贡献指南

View File

@@ -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'

View 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
}

View 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
}

View 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{}
}
}

View 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{}
}
}

View 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{}
}
}

View 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{}
}
}

View 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{}
}
}

View 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{}
}
}

View 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{}
}
}

View 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{}
}
}

View 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{}
}
}

View 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{}
}
}

View 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{}
}
}

View 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{}
}
}

View 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{}
}
}

View File

@@ -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 驱动并验证基本功能正常后再进行。

View File

@@ -1,179 +0,0 @@
# HighGo SM3 国密驱动集成指南
## 一、背景说明
HighGo瀚高数据库需要使用支持 SM3 国密认证的 PostgreSQL 驱动。瀚高官方提供了基于 `lib/pq` 的安全增强版本。
## 二、集成步骤
### 步骤 1下载瀚高 pq 驱动
1. 访问百度网盘链接:
```
https://pan.baidu.com/s/1xuz6uJz0utRgKWecXhpOiA?pwd=o0tj
```
2. 下载驱动源码压缩包
### 步骤 2放置驱动源码
1. 在项目根目录创建 vendor 目录(如果不存在):
```bash
mkdir -p vendor/highgo-pq
```
2. 解压下载的驱动源码到 `vendor/highgo-pq/` 目录
3. 确保目录结构如下:
```
GoNavi/
├── vendor/
│ └── highgo-pq/
│ ├── go.mod
│ ├── conn.go
│ ├── ... (其他 pq 驱动源文件)
```
### 步骤 3修改 go.mod
在 `go.mod` 文件末尾添加 replace 指令:
```go
replace github.com/lib/pq => ./vendor/highgo-pq
```
完整示例:
```go
module GoNavi-Wails
go 1.24.3
require (
// ... 现有依赖
github.com/lib/pq v1.11.1
// ... 其他依赖
)
// 在文件末尾添加
replace github.com/lib/pq => ./vendor/highgo-pq
```
### 步骤 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 连接
- 测试连接是否成功
## 三、重要说明
### ⚠️ 影响范围
使用 `go.mod replace` 会**全局替换** `github.com/lib/pq` 驱动,这意味着:
1. **PostgreSQL 连接也会使用瀚高驱动**
2. **需要验证瀚高驱动对标准 PostgreSQL 的兼容性**
### 兼容性验证
集成后,请务必测试:
1. ✅ HighGo 数据库连接SM3 认证)
2. ✅ 标准 PostgreSQL 连接(确保仍然可用)
如果标准 PostgreSQL 连接失败,说明瀚高驱动不完全兼容,需要考虑其他方案。
### 回滚方案
如果集成后出现问题,可以快速回滚:
1. 删除 `go.mod` 中的 replace 指令
2. 删除 `vendor/highgo-pq/` 目录
3. 运行 `go mod tidy`
4. 重新编译
## 四、瀚高驱动特性
根据官方文档:
- **包路径**`github.com/lib/pq`(与标准版相同)
- **驱动名**`postgres`(与标准版相同)
- **SM3 支持**:自动启用国密认证
- **默认端口**5866
- **默认数据库**`highgo`
- **默认用户**`sysdba`
- **sslmode 默认**`require`
## 五、故障排查
### 问题 1编译失败
**现象**`go build` 报错找不到 `github.com/lib/pq`
**解决**
1. 检查 `vendor/highgo-pq/` 目录是否存在
2. 检查 `go.mod` 中 replace 路径是否正确
3. 运行 `go mod download`
### 问题 2HighGo 连接失败
**现象**:连接 HighGo 时报认证错误
**解决**
1. 确认瀚高驱动已正确替换(检查 `go.mod`
2. 确认 HighGo 服务器支持 SM3 认证
3. 检查用户名、密码、端口是否正确
### 问题 3PostgreSQL 连接失败
**现象**:集成后标准 PostgreSQL 无法连接
**解决**
1. 这说明瀚高驱动不完全兼容标准 PostgreSQL
2. 需要考虑条件编译或其他隔离方案
3. 临时回滚:删除 replace 指令
## 六、后续优化建议
如果发现瀚高驱动与标准 PostgreSQL 不兼容,可以考虑:
1. **条件编译**:使用 Go build tags 分别编译两个版本
2. **动态驱动注册**:如果瀚高驱动支持自定义驱动名
3. **联系瀚高技术支持**:咨询官方兼容性方案
## 七、参考资料
- 瀚高官方文档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/pqhttps://github.com/lib/pq

83
docs/driver-manifest.json Normal file
View 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"
}
}
}

View File

@@ -72,6 +72,21 @@ body[data-theme='dark'] {
overflow: hidden !important;
}
.connection-modal-wrap .ant-modal-content {
max-height: calc(100vh - 72px);
display: flex;
flex-direction: column;
}
.connection-modal-wrap .ant-modal-body {
flex: 1 1 auto;
min-height: 0;
}
.connection-modal-wrap .ant-modal-footer {
flex-shrink: 0;
}
/* Custom Title Bar Close Button Hover */
.titlebar-close-btn:hover {
background-color: #ff4d4f !important;

View File

@@ -2,11 +2,12 @@ import React, { useState, useEffect } from 'react';
import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Progress } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined } from '@ant-design/icons';
import { EventsOn } from '../wailsjs/runtime/runtime';
import { Environment, EventsOn } from '../wailsjs/runtime/runtime';
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);
@@ -29,6 +31,7 @@ function App() {
const effectiveBlur = normalizeBlurForPlatform(appearance.blur);
const blurFilter = blurToFilter(effectiveBlur);
const windowCornerRadius = 14;
const [isLinuxRuntime, setIsLinuxRuntime] = useState(false);
// 同步 macOS 窗口透明度opacity=1.0 且 blur=0 时关闭 NSVisualEffectView
// 避免 GPU 持续计算窗口背后的模糊合成
@@ -36,6 +39,23 @@ function App() {
SetWindowTranslucency(appearance.opacity, appearance.blur).catch(() => {});
}, [appearance.opacity, appearance.blur]);
useEffect(() => {
let cancelled = false;
Environment()
.then((env) => {
if (cancelled) return;
setIsLinuxRuntime((env?.platform || '').toLowerCase() === 'linux');
})
.catch(() => {
if (cancelled) return;
const platform = typeof navigator !== 'undefined' ? navigator.platform : '';
setIsLinuxRuntime(/linux/i.test(platform));
});
return () => {
cancelled = true;
};
}, []);
// Background Helper
const getBg = (darkHex: string, lightHex: string) => {
if (!darkMode) return `rgba(255, 255, 255, ${effectiveOpacity})`; // Light mode usually white
@@ -97,6 +117,8 @@ function App() {
assetUrl?: string;
assetSize?: number;
sha256?: string;
downloaded?: boolean;
downloadPath?: string;
};
type UpdateDownloadProgressEvent = {
@@ -229,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;
@@ -340,6 +380,12 @@ function App() {
label: '数据同步',
icon: <UploadOutlined rotate={90} />,
onClick: () => setIsSyncModalOpen(true)
},
{
key: 'drivers',
label: '驱动管理',
icon: <SettingOutlined />,
onClick: () => setIsDriverModalOpen(true)
}
];
@@ -368,8 +414,13 @@ function App() {
const [isAppearanceModalOpen, setIsAppearanceModalOpen] = useState(false);
// Log Panel
const [logPanelHeight, setLogPanelHeight] = useState(200);
// Log Panel: 最小高度按“工具栏 + 1 条日志行(微增)”限制
const LOG_PANEL_TOOLBAR_HEIGHT = 32;
const LOG_PANEL_SINGLE_ROW_HEIGHT = 39;
const LOG_PANEL_MIN_VISIBLE_ROWS = 1;
const LOG_PANEL_MIN_HEIGHT = LOG_PANEL_TOOLBAR_HEIGHT + (LOG_PANEL_SINGLE_ROW_HEIGHT * LOG_PANEL_MIN_VISIBLE_ROWS);
const LOG_PANEL_MAX_HEIGHT = 800;
const [logPanelHeight, setLogPanelHeight] = useState(Math.max(200, LOG_PANEL_MIN_HEIGHT));
const [isLogPanelOpen, setIsLogPanelOpen] = useState(false);
const logResizeRef = React.useRef<{ startY: number, startHeight: number } | null>(null);
const logGhostRef = React.useRef<HTMLDivElement>(null);
@@ -398,7 +449,10 @@ function App() {
const handleLogResizeUp = (e: MouseEvent) => {
if (logResizeRef.current) {
const delta = logResizeRef.current.startY - e.clientY;
const newHeight = Math.max(100, Math.min(800, logResizeRef.current.startHeight + delta));
const newHeight = Math.max(
LOG_PANEL_MIN_HEIGHT,
Math.min(LOG_PANEL_MAX_HEIGHT, logResizeRef.current.startHeight + delta)
);
setLogPanelHeight(newHeight);
}
@@ -421,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"]')) {
@@ -550,6 +610,17 @@ function App() {
};
}, []);
const linuxResizeHandleStyleBase = {
position: 'fixed',
zIndex: 12000,
background: 'transparent',
WebkitAppRegion: 'drag',
'--wails-draggable': 'drag',
userSelect: 'none'
} as any;
const showLinuxResizeHandles = isLinuxRuntime;
return (
<ConfigProvider
locale={zhCN}
@@ -593,8 +664,8 @@ function App() {
display: 'flex',
flexDirection: 'column',
background: 'transparent',
borderRadius: windowCornerRadius,
clipPath: `inset(0 round ${windowCornerRadius}px)`,
borderRadius: showLinuxResizeHandles ? 0 : windowCornerRadius,
clipPath: showLinuxResizeHandles ? 'none' : `inset(0 round ${windowCornerRadius}px)`,
backdropFilter: blurFilter,
WebkitBackdropFilter: blurFilter,
}}>
@@ -668,7 +739,7 @@ function App() {
</Dropdown>
<Button type="text" icon={<InfoCircleOutlined />} title="关于" onClick={() => setIsAboutOpen(true)}></Button>
</div>
<Layout style={{ flex: 1, minHeight: 0 }}>
<Layout style={{ flex: 1, minHeight: 0, minWidth: 0 }}>
<Sider
width={sidebarWidth}
style={{
@@ -719,8 +790,8 @@ function App() {
title="拖动调整宽度"
/>
</Sider>
<Content style={{ background: 'transparent', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', background: bgContent }}>
<Content style={{ background: 'transparent', overflow: 'hidden', display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<div style={{ flex: 1, minHeight: 0, minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', background: bgContent }}>
<TabManager />
</div>
{isLogPanelOpen && (
@@ -736,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}
@@ -891,6 +967,21 @@ function App() {
) : null}
</div>
</Modal>
{showLinuxResizeHandles && (
<>
{/* Linux Mint 下 frameless 仅局部可缩放:补四边四角命中层 */}
<div style={{ ...linuxResizeHandleStyleBase, top: 0, left: 14, right: 14, height: 6, cursor: 'ns-resize' }} />
<div style={{ ...linuxResizeHandleStyleBase, bottom: 0, left: 14, right: 14, height: 6, cursor: 'ns-resize' }} />
<div style={{ ...linuxResizeHandleStyleBase, top: 14, bottom: 14, left: 0, width: 6, cursor: 'ew-resize' }} />
<div style={{ ...linuxResizeHandleStyleBase, top: 14, bottom: 14, right: 0, width: 6, cursor: 'ew-resize' }} />
<div style={{ ...linuxResizeHandleStyleBase, top: 0, left: 0, width: 14, height: 14, cursor: 'nwse-resize' }} />
<div style={{ ...linuxResizeHandleStyleBase, top: 0, right: 0, width: 14, height: 14, cursor: 'nesw-resize' }} />
<div style={{ ...linuxResizeHandleStyleBase, bottom: 0, left: 0, width: 14, height: 14, cursor: 'nesw-resize' }} />
<div style={{ ...linuxResizeHandleStyleBase, bottom: 0, right: 0, width: 14, height: 14, cursor: 'nwse-resize' }} />
</>
)}
{/* Ghost Resize Line for Sidebar */}
<div

View File

@@ -1,16 +1,21 @@
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 } from '@ant-design/icons';
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;
const { Text } = Typography;
const MAX_URI_LENGTH = 4096;
const MAX_URI_HOSTS = 32;
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;
case 'tdengine': return 6041;
@@ -22,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);
@@ -34,10 +62,16 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
const [step, setStep] = useState(1); // 1: Select Type, 2: Configure
const [activeGroup, setActiveGroup] = useState(0); // Active category index in step 1
const [testResult, setTestResult] = useState<{ type: 'success' | 'error', message: string } | null>(null);
const [testErrorLogOpen, setTestErrorLogOpen] = useState(false);
const [dbList, setDbList] = useState<string[]>([]);
const [redisDbList, setRedisDbList] = useState<number[]>([]); // Redis databases 0-15
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);
@@ -46,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) {
@@ -59,7 +157,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
const parsedPort = Number(portText);
return {
host: host || 'localhost',
port: Number.isFinite(parsedPort) && parsedPort > 0 ? parsedPort : defaultPort,
port: Number.isFinite(parsedPort) && parsedPort > 0 && parsedPort <= 65535 ? parsedPort : defaultPort,
};
}
}
@@ -72,7 +170,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
const parsedPort = Number(portText);
return {
host: host || 'localhost',
port: Number.isFinite(parsedPort) && parsedPort > 0 ? parsedPort : defaultPort,
port: Number.isFinite(parsedPort) && parsedPort > 0 && parsedPort <= 65535 ? parsedPort : defaultPort,
};
}
@@ -104,6 +202,15 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
return result;
};
const isValidUriHostEntry = (entry: string): boolean => {
const text = String(entry || '').trim();
if (!text) return false;
if (text.length > 255) return false;
// 拒绝明显的 DSN 片段或路径/空白,避免把非 URI 主机段误判为合法地址。
if (/[()\\/\s]/.test(text)) return false;
return true;
};
const normalizeMongoSrvHostList = (rawList: unknown, defaultPort: number): string[] => {
const list = Array.isArray(rawList) ? rawList : [];
const seen = new Set<string>();
@@ -131,12 +238,33 @@ 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)) {
return null;
}
let rest = uriText.slice(prefix.length);
const hashIndex = rest.indexOf('#');
if (hashIndex >= 0) {
rest = rest.slice(0, hashIndex);
}
let queryText = '';
const queryIndex = rest.indexOf('?');
if (queryIndex >= 0) {
@@ -186,36 +314,74 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
if (!trimmedUri) {
return null;
}
if (trimmedUri.length > MAX_URI_LENGTH) {
return null;
}
if (type === 'mysql' || type === 'mariadb') {
const parsed = parseMultiHostUri(trimmedUri, 'mysql');
if (type === 'mysql' || type === 'mariadb' || type === 'diros' || type === 'sphinx') {
const mysqlDefaultPort = getDefaultPortByType(type);
const parsed = parseMultiHostUri(trimmedUri, 'mysql')
|| parseMultiHostUri(trimmedUri, 'diros')
|| parseMultiHostUri(trimmedUri, 'doris');
if (!parsed) {
return null;
}
const hostList = normalizeAddressList(parsed.hosts, 3306);
const primary = parseHostPort(hostList[0] || 'localhost:3306', 3306);
if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) {
return null;
}
if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) {
return null;
}
const hostList = normalizeAddressList(parsed.hosts, mysqlDefaultPort);
if (!hostList.length) {
return null;
}
const primary = parseHostPort(hostList[0] || `localhost:${mysqlDefaultPort}`, mysqlDefaultPort);
const timeoutValue = Number(parsed.params.get('timeout'));
const topology = String(parsed.params.get('topology') || '').toLowerCase();
return {
host: primary?.host || 'localhost',
port: primary?.port || 3306,
port: primary?.port || mysqlDefaultPort,
user: parsed.username,
password: parsed.password,
database: parsed.database || '',
mysqlTopology: hostList.length > 1 || parsed.params.get('topology') === 'replica' ? 'replica' : 'single',
mysqlTopology: hostList.length > 1 || topology === 'replica' ? 'replica' : 'single',
mysqlReplicaHosts: hostList.slice(1),
timeout: Number.isFinite(timeoutValue) && timeoutValue > 0 ? timeoutValue : undefined,
timeout: Number.isFinite(timeoutValue) && timeoutValue > 0
? Math.min(3600, Math.trunc(timeoutValue))
: undefined,
};
}
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) {
return null;
}
if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) {
return null;
}
if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) {
return null;
}
const isSrv = trimmedUri.toLowerCase().startsWith('mongodb+srv://');
const hostList = isSrv
? normalizeMongoSrvHostList(parsed.hosts, 27017)
: normalizeAddressList(parsed.hosts, 27017);
if (!hostList.length) {
return null;
}
const primary = isSrv
? { host: hostList[0] || 'localhost', port: 27017 }
: parseHostPort(hostList[0] || 'localhost:27017', 27017);
@@ -233,7 +399,9 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
mongoAuthSource: parsed.params.get('authSource') || '',
mongoReadPreference: parsed.params.get('readPreference') || 'primary',
mongoAuthMechanism: parsed.params.get('authMechanism') || '',
timeout: Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.ceil(timeoutMs / 1000) : undefined,
timeout: Number.isFinite(timeoutMs) && timeoutMs > 0
? Math.min(MAX_TIMEOUT_SECONDS, Math.ceil(timeoutMs / 1000))
: undefined,
savePassword: true,
};
}
@@ -259,8 +427,15 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
});
const getUriPlaceholder = () => {
if (dbType === 'mysql' || dbType === 'mariadb') {
return 'mysql://user:pass@127.0.0.1:3306,127.0.0.2:3306/db_name?topology=replica';
if (dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') {
const defaultPort = getDefaultPortByType(dbType);
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';
@@ -281,12 +456,12 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
? `${encodeURIComponent(user)}${password ? `:${encodeURIComponent(password)}` : ''}@`
: '';
if (type === 'mysql' || type === 'mariadb') {
const primary = toAddress(host, port, 3306);
if (type === 'mysql' || type === 'mariadb' || type === 'diros' || type === 'sphinx') {
const primary = toAddress(host, port, defaultPort);
const replicas = values.mysqlTopology === 'replica'
? normalizeAddressList(values.mysqlReplicaHosts, 3306)
? normalizeAddressList(values.mysqlReplicaHosts, defaultPort)
: [];
const hosts = normalizeAddressList([primary, ...replicas], 3306);
const hosts = normalizeAddressList([primary, ...replicas], defaultPort);
const params = new URLSearchParams();
if (hosts.length > 1 || values.mysqlTopology === 'replica') {
params.set('topology', 'replica');
@@ -296,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') {
@@ -347,29 +531,33 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
const values = form.getFieldsValue(true);
const uri = buildUriFromValues(values);
form.setFieldValue('uri', uri);
message.success('URI 已生成');
setUriFeedback({ type: 'success', message: 'URI 已生成' });
} catch {
message.error('生成 URI 失败');
setUriFeedback({ type: 'error', message: '生成 URI 失败' });
}
};
const handleParseURI = () => {
const uriText = String(form.getFieldValue('uri') || '').trim();
const type = String(form.getFieldValue('type') || dbType).trim().toLowerCase();
if (!uriText) {
message.warning('请先输入 URI');
return;
try {
const uriText = String(form.getFieldValue('uri') || '').trim();
const type = String(form.getFieldValue('type') || dbType).trim().toLowerCase();
if (!uriText) {
setUriFeedback({ type: 'warning', message: '请先输入 URI' });
return;
}
const parsedValues = parseUriToValues(uriText, type);
if (!parsedValues) {
setUriFeedback({ type: 'error', message: '当前 URI 与数据源类型不匹配,或 URI 格式不支持' });
return;
}
form.setFieldsValue({ ...parsedValues, uri: uriText });
if (testResult) {
setTestResult(null);
}
setUriFeedback({ type: 'success', message: '已根据 URI 回填连接参数' });
} catch {
setUriFeedback({ type: 'error', message: 'URI 解析失败,请检查格式后重试' });
}
const parsedValues = parseUriToValues(uriText, type);
if (!parsedValues) {
message.error('当前 URI 与数据源类型不匹配,或 URI 格式不支持');
return;
}
form.setFieldsValue({ ...parsedValues, uri: uriText });
if (testResult) {
setTestResult(null);
}
message.success('已根据 URI 回填连接参数');
};
const handleCopyURI = async () => {
@@ -380,37 +568,73 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
form.setFieldValue('uri', uriText);
}
if (!uriText) {
message.warning('没有可复制的 URI');
setUriFeedback({ type: 'warning', message: '没有可复制的 URI' });
return;
}
try {
await navigator.clipboard.writeText(uriText);
message.success('URI 已复制');
setUriFeedback({ type: 'success', message: 'URI 已复制' });
} catch {
message.error('复制失败');
setUriFeedback({ type: 'error', message: '复制失败' });
}
};
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
setTestErrorLogOpen(false);
setDbList([]);
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') ? 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;
@@ -478,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);
@@ -486,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
@@ -520,11 +750,24 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
}, 0);
};
const buildTestFailureMessage = (reason: unknown, fallback: string) => {
const text = String(reason ?? '').trim();
const normalized = text && text !== 'undefined' && text !== 'null' ? text : fallback;
return `测试失败: ${normalized}`;
};
const handleTest = async () => {
if (testInFlightRef.current) return;
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);
@@ -549,10 +792,23 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
}
}
} else {
setTestResult({ type: 'error', message: "测试失败: " + res.message });
const failMessage = buildTestFailureMessage(
res?.message,
'连接被拒绝或参数无效,请检查后重试'
);
setTestResult({ type: 'error', message: failMessage });
}
} catch (e) {
// ignore
} catch (e: unknown) {
if (e && typeof e === 'object' && 'errorFields' in e) {
const failMessage = '测试失败: 请先完善必填项后再测试连接';
setTestResult({ type: 'error', message: failMessage });
return;
}
const reason = e instanceof Error
? e.message
: (typeof e === 'string' ? e : '未知异常');
const failMessage = buildTestFailureMessage(reason, '未知异常');
setTestResult({ type: 'error', message: failMessage });
} finally {
testInFlightRef.current = false;
setLoading(false);
@@ -616,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;
@@ -638,7 +904,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
? mergedValues.savePassword !== false
: true;
if (type === 'mysql' || type === 'mariadb') {
if (type === 'mysql' || type === 'mariadb' || type === 'diros' || type === 'sphinx') {
const replicas = mergedValues.mysqlTopology === 'replica'
? normalizeAddressList(mergedValues.mysqlReplicaHosts, defaultPort)
: [];
@@ -716,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',
@@ -745,17 +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: [
@@ -779,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 }}>
@@ -809,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%' } }}
>
@@ -821,6 +1153,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
</Row>
</div>
</div>
</div>
);
const renderStep2 = () => (
@@ -850,7 +1183,13 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
mongoReplicaPassword: '',
}}
onValuesChange={(changed) => {
if (testResult) setTestResult(null); // Clear result on change
if (testResult) {
setTestResult(null); // Clear result on change
setTestErrorLogOpen(false);
}
if (changed.uri !== undefined || changed.type !== undefined) {
setUriFeedback(null);
}
if (changed.useSSH !== undefined) setUseSSH(changed.useSSH);
// Type change handled by step 1, but keep sync if select changes (hidden now)
if (changed.type !== undefined) setDbType(changed.type);
@@ -884,6 +1223,32 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
<Button onClick={handleParseURI}> URI </Button>
<Button onClick={handleCopyURI}> URI</Button>
</Space>
{uriFeedback && (
<Alert
showIcon
closable
type={uriFeedback.type}
message={uriFeedback.message}
onClose={() => setUriFeedback(null)}
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 ? (
<>
@@ -899,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)"
@@ -920,7 +1285,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
)}
</div>
{(dbType === 'mysql' || dbType === 'mariadb') && (
{(dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') && (
<>
<Form.Item name="mysqlTopology" label="连接模式">
<Select
@@ -1085,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"
@@ -1120,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>)}
@@ -1128,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 }}>
@@ -1153,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>
)}
@@ -1183,14 +1555,6 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
</>
)}
{testResult && (
<Alert
message={testResult.message}
type={testResult.type}
showIcon
style={{ marginTop: 16 }}
/>
)}
</Form>
);
@@ -1200,12 +1564,60 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
<Button key="cancel" onClick={onClose}></Button>
];
}
return [
!initialValues && <Button key="back" onClick={() => setStep(1)} style={{ float: 'left' }}></Button>,
<Button key="test" loading={loading} onClick={requestTest}></Button>,
<Button key="cancel" onClick={onClose}></Button>,
<Button key="submit" type="primary" loading={loading} onClick={handleOk}></Button>
];
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 }}>
{!initialValues && <Button key="back" onClick={() => setStep(1)}></Button>}
{testResult ? (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 24,
padding: '0 10px',
borderRadius: 999,
border: isTestSuccess ? '1px solid rgba(82, 196, 26, 0.35)' : '1px solid rgba(255, 77, 79, 0.35)',
background: isTestSuccess ? 'rgba(82, 196, 26, 0.10)' : 'rgba(255, 77, 79, 0.10)',
color: isTestSuccess ? '#389e0d' : '#cf1322',
fontSize: 12,
lineHeight: '22px',
whiteSpace: 'nowrap',
boxSizing: 'border-box',
}}
>
{isTestSuccess ? <CheckCircleFilled /> : <CloseCircleFilled />}
<span>{isTestSuccess ? '连接成功' : '连接失败'}</span>
</span>
) : null}
{hasTestError && (
<Button
size="small"
icon={<FileTextOutlined />}
style={{
height: 24,
borderRadius: 999,
padding: '0 10px',
borderColor: '#ffccc7',
background: '#fff2f0',
color: '#cf1322',
}}
onClick={() => setTestErrorLogOpen(true)}
>
</Button>
)}
</div>
<Space size={8} style={{ flexShrink: 0 }}>
<Button key="test" loading={loading} disabled={operationBlocked} onClick={requestTest}></Button>
<Button key="cancel" onClick={onClose}></Button>
<Button key="submit" type="primary" loading={loading} disabled={operationBlocked} onClick={handleOk}></Button>
</Space>
</div>
);
};
const getTitle = () => {
@@ -1218,26 +1630,59 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
? { padding: '16px 24px', overflow: 'hidden' as const }
: {
padding: '16px 24px',
maxHeight: 'calc(100vh - 220px)',
overflowY: 'auto' as const,
overflowX: 'hidden' as const,
};
return (
<Modal
title={getTitle()}
open={open}
onCancel={onClose}
footer={getFooter()}
wrapClassName="connection-modal-wrap"
width={step === 1 ? 650 : 600}
zIndex={10001}
destroyOnHidden
maskClosable={false}
styles={{ body: modalBodyStyle }}
>
{step === 1 ? renderStep1() : renderStep2()}
</Modal>
<>
<Modal
title={getTitle()}
open={open}
onCancel={onClose}
footer={getFooter()}
centered
wrapClassName="connection-modal-wrap"
width={step === 1 ? 650 : 600}
zIndex={10001}
destroyOnHidden
maskClosable={false}
styles={{ body: modalBodyStyle }}
>
{step === 1 ? renderStep1() : renderStep2()}
</Modal>
<Modal
title="测试连接失败原因"
open={testErrorLogOpen}
onCancel={() => setTestErrorLogOpen(false)}
centered
width={760}
zIndex={10002}
destroyOnHidden
footer={[
<Button key="close" onClick={() => setTestErrorLogOpen(false)}></Button>,
]}
>
<pre
style={{
margin: 0,
maxHeight: '50vh',
overflowY: 'auto',
padding: 12,
borderRadius: 6,
background: '#fff2f0',
border: '1px solid #ffccc7',
color: '#a8071a',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
lineHeight: '20px',
fontSize: 13,
}}
>
{String(testResult?.message || '暂无失败日志')}
</pre>
</Modal>
</>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ import { TabData, ColumnDefinition } from '../types';
import { useStore } from '../store';
import { DBQuery, DBGetColumns } from '../../wailsjs/go/app/App';
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { buildWhereSQL, quoteIdentPart, quoteQualifiedIdent, type FilterCondition } from '../utils/sql';
import { buildOrderBySQL, buildWhereSQL, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const [data, setData] = useState<any[]>([]);
@@ -60,6 +60,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
};
const dbType = config.type || '';
const dbTypeLower = String(dbType || '').trim().toLowerCase();
const isMySQLFamily = dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros';
const dbName = tab.dbName || '';
const tableName = tab.tableName || '';
@@ -69,31 +71,51 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const countSql = `SELECT COUNT(*) as total FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
let sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
if (sortInfo && sortInfo.order) {
sql += ` ORDER BY ${quoteIdentPart(dbType, sortInfo.columnKey)} ${sortInfo.order === 'ascend' ? 'ASC' : 'DESC'}`;
}
sql += buildOrderBySQL(dbType, sortInfo, pkColumns);
const offset = (page - 1) * size;
// 大表性能:打开表不阻塞在 COUNT(*),先通过多取 1 条判断是否还有下一页;总数在后台统计并异步回填。
sql += ` LIMIT ${size + 1} OFFSET ${offset}`;
const startTime = Date.now();
const requestStartTime = Date.now();
let executedSql = sql;
try {
const pData = DBQuery(config as any, dbName, sql);
const executeDataQuery = async (querySql: string, attemptLabel: string) => {
const startTime = Date.now();
const result = await DBQuery(config as any, dbName, querySql);
addSqlLog({
id: `log-${Date.now()}-data`,
timestamp: Date.now(),
sql: querySql,
status: result.success ? 'success' : 'error',
duration: Date.now() - startTime,
message: result.success ? '' : `${attemptLabel}: ${result.message}`,
affectedRows: Array.isArray(result.data) ? result.data.length : undefined,
dbName
});
return result;
};
const resData = await pData;
const duration = Date.now() - startTime;
// Log Execution
addSqlLog({
id: `log-${Date.now()}-data`,
timestamp: Date.now(),
sql: sql,
status: resData.success ? 'success' : 'error',
duration: duration,
message: resData.success ? '' : resData.message,
affectedRows: Array.isArray(resData.data) ? resData.data.length : undefined,
dbName
});
const hasSort = !!sortInfo?.columnKey && (sortInfo?.order === 'ascend' || sortInfo?.order === 'descend');
const isSortMemoryErr = (msg: string) => /error\s*1038|out of sort memory/i.test(String(msg || ''));
let resData = await executeDataQuery(sql, '主查询');
if (!resData.success && isMySQLFamily && hasSort && isSortMemoryErr(resData.message)) {
const retrySql32MB = withSortBufferTuningSQL(dbType, sql, 32 * 1024 * 1024);
if (retrySql32MB !== sql) {
executedSql = retrySql32MB;
resData = await executeDataQuery(retrySql32MB, '重试(32MB sort_buffer)');
}
if (!resData.success && isSortMemoryErr(resData.message)) {
const retrySql128MB = withSortBufferTuningSQL(dbType, sql, 128 * 1024 * 1024);
if (retrySql128MB !== executedSql) {
executedSql = retrySql128MB;
resData = await executeDataQuery(retrySql128MB, '重试(128MB sort_buffer)');
}
}
if (resData.success) {
message.warning('已自动提升排序缓冲并重试成功。');
}
}
if (pkColumns.length === 0) {
const pkKey = `${tab.connectionId}|${dbName}|${tableName}`;
@@ -189,7 +211,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
}
}
} else {
message.error(resData.message);
message.error(String(resData.message || '查询失败'));
}
} catch (e: any) {
if (fetchSeqRef.current !== seq) return;
@@ -197,27 +219,31 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
addSqlLog({
id: `log-${Date.now()}-error`,
timestamp: Date.now(),
sql: sql,
sql: executedSql,
status: 'error',
duration: Date.now() - startTime,
duration: Date.now() - requestStartTime,
message: e.message,
dbName
});
}
if (fetchSeqRef.current === seq) setLoading(false);
}, [connections, tab, sortInfo, filterConditions, pkColumns.length]);
// Depend on pkColumns.length to avoid loop? No, pkColumns is updated inside.
// Actually, 'pkColumns' state shouldn't trigger re-fetch.
// The 'if (pkColumns.length === 0)' check is inside.
// So adding pkColumns to dependency is safer but might trigger double fetch if not careful?
// Only if pkColumns changes. It changes once from [] to [...].
// So it's fine.
}, [connections, tab, sortInfo, filterConditions, pkColumns]);
// 依赖 pkColumns:在无手动排序时可回退到主键稳定排序。
// 主键信息只会在首次加载后更新一次,避免循环查询。
// Handlers memoized
const handleReload = useCallback(() => {
fetchData(pagination.current, pagination.pageSize);
}, [fetchData, pagination.current, pagination.pageSize]);
const handleSort = useCallback((field: string, order: string) => setSortInfo({ columnKey: field, order }), []);
const handleSort = useCallback((field: string, order: string) => {
const normalizedOrder = order === 'ascend' || order === 'descend' ? order : '';
const normalizedField = String(field || '').trim();
if (!normalizedField || !normalizedOrder) {
setSortInfo(null);
return;
}
setSortInfo({ columnKey: normalizedField, order: normalizedOrder });
}, []);
const handlePageChange = useCallback((page: number, size: number) => fetchData(page, size), [fetchData]);
const handleToggleFilter = useCallback(() => setShowFilter(prev => !prev), []);
const handleApplyFilter = useCallback((conditions: FilterCondition[]) => setFilterConditions(conditions), []);
@@ -227,7 +253,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
}, [tab, sortInfo, filterConditions]); // Initial load and re-load on sort/filter
return (
<div style={{ flex: '1 1 auto', minHeight: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: '1 1 auto', minHeight: 0, minWidth: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<DataGrid
data={data}
columnNames={columnNames}
@@ -244,6 +270,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
onToggleFilter={handleToggleFilter}
onApplyFilter={handleApplyFilter}
readOnly={forceReadOnly}
sortInfoExternal={sortInfo}
/>
</div>
);

View File

@@ -23,13 +23,23 @@ 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') return 'mysql';
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
};
const isSphinxConnection = (conn: any): boolean => {
const type = String(conn?.config?.type || '').trim().toLowerCase();
if (type === 'sphinx') return true;
if (type !== 'custom') return false;
const driver = String(conn?.config?.driver || '').trim().toLowerCase();
return driver === 'sphinx' || driver === 'sphinxql';
};
const parseSchemaAndName = (fullName: string): { schema: string; name: string } => {
const raw = String(fullName || '').trim();
const idx = raw.lastIndexOf('.');
@@ -39,40 +49,99 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
return { schema: '', name: raw };
};
const buildShowViewQuery = (dialect: string, viewName: string, dbName: string): string => {
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);
const safeDbName = escapeSQLLiteral(dbName);
switch (dialect) {
case 'mysql':
return `SHOW CREATE VIEW \`${name.replace(/`/g, '``')}\``;
return [
`SHOW CREATE VIEW \`${name.replace(/`/g, '``')}\``,
safeDbName
? `SELECT VIEW_DEFINITION AS view_definition FROM information_schema.views WHERE table_schema = '${safeDbName}' AND table_name = '${safeName}' LIMIT 1`
: '',
`SHOW CREATE TABLE \`${name.replace(/`/g, '``')}\``,
].filter(Boolean);
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase': {
const schemaRef = schema || 'public';
return `SELECT pg_get_viewdef('${escapeSQLLiteral(schemaRef)}.${safeName}'::regclass, true) AS view_definition`;
return [`SELECT pg_get_viewdef('${escapeSQLLiteral(schemaRef)}.${safeName}'::regclass, true) AS view_definition`];
}
case 'sqlserver':
return `SELECT OBJECT_DEFINITION(OBJECT_ID('${escapeSQLLiteral(viewName)}')) AS view_definition`;
return [`SELECT OBJECT_DEFINITION(OBJECT_ID('${escapeSQLLiteral(viewName)}')) AS view_definition`];
case 'oracle':
case 'dm':
if (schema) {
return `SELECT TEXT AS view_definition FROM ALL_VIEWS WHERE OWNER = '${escapeSQLLiteral(schema).toUpperCase()}' AND VIEW_NAME = '${safeName.toUpperCase()}'`;
return [`SELECT TEXT AS view_definition FROM ALL_VIEWS WHERE OWNER = '${escapeSQLLiteral(schema).toUpperCase()}' AND VIEW_NAME = '${safeName.toUpperCase()}'`];
}
if (safeDbName) {
return `SELECT TEXT AS view_definition FROM ALL_VIEWS WHERE OWNER = '${safeDbName.toUpperCase()}' AND VIEW_NAME = '${safeName.toUpperCase()}'`;
return [`SELECT TEXT AS view_definition FROM ALL_VIEWS WHERE OWNER = '${safeDbName.toUpperCase()}' AND VIEW_NAME = '${safeName.toUpperCase()}'`];
}
return `SELECT TEXT AS view_definition FROM USER_VIEWS WHERE VIEW_NAME = '${safeName.toUpperCase()}'`;
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}'`;
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 `-- 暂不支持该数据库类型的视图定义查看`;
return [`-- 暂不支持该数据库类型的视图定义查看`];
}
};
const buildShowRoutineQuery = (dialect: string, routineName: string, routineType: string, dbName: string): string => {
const buildShowRoutineQueries = (dialect: string, routineName: string, routineType: string, dbName: string): string[] => {
const { schema, name } = parseSchemaAndName(routineName);
const safeName = escapeSQLLiteral(name);
const safeDbName = escapeSQLLiteral(dbName);
@@ -80,31 +149,105 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
switch (dialect) {
case 'mysql':
return `SHOW CREATE ${upperType} \`${name.replace(/`/g, '``')}\``;
return [
`SHOW CREATE ${upperType} \`${name.replace(/`/g, '``')}\``,
safeDbName
? `SELECT ROUTINE_DEFINITION AS routine_definition, ROUTINE_TYPE AS routine_type FROM information_schema.routines WHERE routine_schema = '${safeDbName}' AND routine_name = '${safeName}' LIMIT 1`
: '',
upperType === 'PROCEDURE'
? `SHOW PROCEDURE STATUS LIKE '${safeName}'`
: `SHOW FUNCTION STATUS LIKE '${safeName}'`,
].filter(Boolean);
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase': {
const schemaRef = schema || 'public';
return `SELECT pg_get_functiondef(p.oid) AS routine_definition FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname = '${escapeSQLLiteral(schemaRef)}' AND p.proname = '${safeName}' LIMIT 1`;
return [`SELECT pg_get_functiondef(p.oid) AS routine_definition FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname = '${escapeSQLLiteral(schemaRef)}' AND p.proname = '${safeName}' LIMIT 1`];
}
case 'sqlserver':
return `SELECT OBJECT_DEFINITION(OBJECT_ID('${escapeSQLLiteral(routineName)}')) AS routine_definition`;
return [`SELECT OBJECT_DEFINITION(OBJECT_ID('${escapeSQLLiteral(routineName)}')) AS routine_definition`];
case 'oracle':
case 'dm': {
const owner = schema ? escapeSQLLiteral(schema).toUpperCase() : (safeDbName ? safeDbName.toUpperCase() : '');
if (owner) {
return `SELECT TEXT FROM ALL_SOURCE WHERE OWNER = '${owner}' AND NAME = '${safeName.toUpperCase()}' AND TYPE = '${upperType}' ORDER BY LINE`;
return [`SELECT TEXT FROM ALL_SOURCE WHERE OWNER = '${owner}' AND NAME = '${safeName.toUpperCase()}' AND TYPE = '${upperType}' ORDER BY LINE`];
}
return `SELECT TEXT FROM USER_SOURCE WHERE NAME = '${safeName.toUpperCase()}' AND TYPE = '${upperType}' ORDER BY LINE`;
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 `-- 暂不支持该数据库类型的函数/存储过程定义查看`;
return [`-- 暂不支持该数据库类型的函数/存储过程定义查看`];
}
};
const runQueryCandidates = async (
config: Record<string, any>,
dbName: string,
queries: string[]
): Promise<{ success: boolean; data: any[]; message?: string }> => {
let lastMessage = '';
let hasSuccessfulQuery = false;
for (const query of queries) {
const sql = String(query || '').trim();
if (!sql) continue;
try {
const result = await DBQuery(config as any, dbName, sql);
if (!result.success || !Array.isArray(result.data)) {
lastMessage = result.message || lastMessage;
continue;
}
hasSuccessfulQuery = true;
if (result.data.length > 0) {
return { success: true, data: result.data };
}
} catch (error: any) {
lastMessage = error?.message || String(error);
}
}
if (hasSuccessfulQuery) {
return { success: true, data: [] };
}
return { success: false, data: [], message: lastMessage };
};
const getVersionHint = async (config: Record<string, any>, dbName: string): Promise<string> => {
const candidates = [
`SELECT VERSION() AS version`,
`SHOW VARIABLES LIKE 'version'`,
];
for (const query of candidates) {
try {
const result = await DBQuery(config as any, dbName, query);
if (!result.success || !Array.isArray(result.data) || result.data.length === 0) {
continue;
}
const row = result.data[0] as Record<string, any>;
const version =
row.version
|| row.VERSION
|| row.Value
|| row.value
|| Object.values(row)[1]
|| Object.values(row)[0];
const text = String(version || '').trim();
if (text) return text;
} catch {
// ignore
}
}
return '';
};
const extractViewDefinition = (dialect: string, data: any[]): string => {
if (!data || data.length === 0) return '-- 未找到视图定义';
const row = data[0];
@@ -112,11 +255,15 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
switch (dialect) {
case 'mysql': {
const keys = Object.keys(row);
const textDefinition = row.view_definition || row.VIEW_DEFINITION;
if (textDefinition) return String(textDefinition);
const sqlKey = keys.find(k => k.toLowerCase().includes('create view') || k.toLowerCase() === 'create view');
if (sqlKey) return row[sqlKey];
const tableSqlKey = keys.find(k => k.toLowerCase().includes('create table'));
if (tableSqlKey) return row[tableSqlKey];
for (const key of keys) {
const val = String(row[key] || '');
if (val.toUpperCase().includes('CREATE') && val.toUpperCase().includes('VIEW')) {
if (val.toUpperCase().includes('CREATE') && (val.toUpperCase().includes('VIEW') || val.toUpperCase().includes('TABLE'))) {
return val;
}
}
@@ -137,6 +284,9 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
case 'mysql': {
const row = data[0];
const keys = Object.keys(row);
if (row.routine_definition || row.ROUTINE_DEFINITION) {
return String(row.routine_definition || row.ROUTINE_DEFINITION);
}
const sqlKey = keys.find(k => k.toLowerCase().includes('create function') || k.toLowerCase().includes('create procedure'));
if (sqlKey) return row[sqlKey];
for (const key of keys) {
@@ -145,6 +295,11 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
return val;
}
}
const routineName = String(row.Name || row.name || '').trim();
if (routineName) {
const routineType = String(row.Type || row.type || row.ROUTINE_TYPE || row.routine_type || 'FUNCTION').trim().toUpperCase();
return `-- 当前数据源未返回可执行定义文本,已返回元数据\n-- 名称: ${routineName}\n-- 类型: ${routineType}\n${JSON.stringify(row, null, 2)}`;
}
return JSON.stringify(row, null, 2);
}
case 'oracle':
@@ -152,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] || '';
@@ -173,9 +343,11 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
const dbName = tab.dbName || '';
const dialect = getMetadataDialect(conn);
const sphinxLike = isSphinxConnection(conn) && dialect === 'mysql';
let query: string;
let queries: string[];
let extractFn: (dialect: string, data: any[]) => string;
let objectLabel: string;
if (tab.type === 'view-def') {
const viewName = tab.viewName || '';
@@ -184,8 +356,9 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
setLoading(false);
return;
}
query = buildShowViewQuery(dialect, viewName, dbName);
queries = buildShowViewQueries(dialect, viewName, dbName);
extractFn = extractViewDefinition;
objectLabel = '视图';
} else {
const routineName = tab.routineName || '';
const routineType = tab.routineType || 'FUNCTION';
@@ -194,12 +367,13 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
setLoading(false);
return;
}
query = buildShowRoutineQuery(dialect, routineName, routineType, dbName);
queries = buildShowRoutineQueries(dialect, routineName, routineType, dbName);
extractFn = extractRoutineDefinition;
objectLabel = '函数/存储过程';
}
if (query.startsWith('--')) {
setDefinition(query);
if (!queries.length || String(queries[0] || '').startsWith('--')) {
setDefinition(String(queries[0] || '-- 暂不支持该对象定义查看'));
setLoading(false);
return;
}
@@ -214,11 +388,26 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
ssh: conn.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }
};
const result = await DBQuery(config as any, dbName, query);
const result = await runQueryCandidates(config, dbName, queries);
if (result.success && Array.isArray(result.data)) {
if (result.success && Array.isArray(result.data) && result.data.length > 0) {
const def = extractFn(dialect, result.data);
setDefinition(def);
return;
}
if (result.success) {
if (sphinxLike) {
const version = await getVersionHint(config, dbName);
const versionText = version ? `(版本: ${version}` : '';
setDefinition(`-- 当前 Sphinx 实例${versionText}未返回${objectLabel}定义。\n-- 已执行多套兼容查询,可能是版本能力限制或对象类型不支持。`);
return;
}
setDefinition(`-- 未找到${objectLabel}定义`);
} else if (sphinxLike) {
const version = await getVersionHint(config, dbName);
const versionText = version ? `(版本: ${version}` : '';
setDefinition(`-- 当前 Sphinx 实例${versionText}不支持${objectLabel}定义查询。\n-- 已自动尝试兼容语句,返回失败信息: ${result.message || 'unknown error'}`);
} else {
setError(result.message || '查询定义失败');
}

View 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;

View File

@@ -0,0 +1,250 @@
import React, { useState, useEffect } from 'react';
import { Modal, Table, Alert, Progress, Button, Space } from 'antd';
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import { PreviewImportFile, ImportDataWithProgress } from '../../wailsjs/go/app/App';
import { EventsOn, EventsOff } from '../../wailsjs/runtime/runtime';
import { useStore } from '../store';
interface ImportPreviewModalProps {
visible: boolean;
filePath: string;
connectionId: string;
dbName: string;
tableName: string;
onClose: () => void;
onSuccess: () => void;
}
interface PreviewData {
columns: string[];
totalRows: number;
previewRows: any[];
}
interface ImportProgress {
current: number;
total: number;
success: number;
errors: number;
}
const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
visible,
filePath,
connectionId,
dbName,
tableName,
onClose,
onSuccess
}) => {
const connections = useStore(state => state.connections);
const [loading, setLoading] = useState(true);
const [previewData, setPreviewData] = useState<PreviewData | null>(null);
const [error, setError] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
const [progress, setProgress] = useState<ImportProgress | null>(null);
const [importResult, setImportResult] = useState<any>(null);
useEffect(() => {
if (visible && filePath) {
loadPreview();
}
}, [visible, filePath]);
useEffect(() => {
if (importing) {
const unsubscribe = EventsOn('import:progress', (data: ImportProgress) => {
setProgress(data);
});
return () => {
EventsOff('import:progress');
};
}
}, [importing]);
const loadPreview = async () => {
setLoading(true);
setError(null);
try {
const res = await PreviewImportFile(filePath);
if (res.success && res.data) {
setPreviewData({
columns: res.data.columns || [],
totalRows: res.data.totalRows || 0,
previewRows: res.data.previewRows || []
});
} else {
setError(res.message || '预览失败');
}
} catch (e: any) {
setError('预览失败: ' + e.message);
} finally {
setLoading(false);
}
};
const handleImport = async () => {
if (!previewData) return;
setImporting(true);
setProgress({ current: 0, total: previewData.totalRows, success: 0, errors: 0 });
setImportResult(null);
try {
const conn = connections.find(c => c.id === connectionId);
if (!conn) {
setError('连接配置未找到');
setImporting(false);
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 res = await ImportDataWithProgress(config as any, dbName, tableName, filePath);
if (res.success && res.data) {
setImportResult(res.data);
if (res.data.failed === 0) {
onSuccess();
}
} else {
setError(res.message || '导入失败');
}
} catch (e: any) {
setError('导入失败: ' + e.message);
} finally {
setImporting(false);
}
};
const columns = previewData?.columns.map(col => ({
title: col,
dataIndex: col,
key: col,
ellipsis: true,
width: 150
})) || [];
const progressPercent = progress ? Math.round((progress.current / progress.total) * 100) : 0;
return (
<Modal
title="导入数据预览"
open={visible}
onCancel={onClose}
width={900}
footer={
importResult ? (
<Space>
<Button onClick={onClose}></Button>
</Space>
) : importing ? null : (
<Space>
<Button onClick={onClose}></Button>
<Button
type="primary"
onClick={handleImport}
disabled={!previewData || loading}
>
</Button>
</Space>
)
}
>
{error && <Alert type="error" message={error} style={{ marginBottom: 16 }} showIcon />}
{loading && <div style={{ textAlign: 'center', padding: 40 }}>...</div>}
{!loading && previewData && !importing && !importResult && (
<>
<Alert
type="info"
message={`${previewData.totalRows} 行数据,${previewData.columns.length} 个字段`}
description='以下是前 5 行预览数据,确认无误后点击“开始导入”'
style={{ marginBottom: 16 }}
showIcon
/>
<div style={{ marginBottom: 8, fontWeight: 600 }}></div>
<div style={{ marginBottom: 16, padding: 8, background: '#f5f5f5', borderRadius: 4 }}>
{previewData.columns.join(', ')}
</div>
<div style={{ marginBottom: 8, fontWeight: 600 }}> 5 </div>
<Table
dataSource={previewData.previewRows}
columns={columns}
pagination={false}
scroll={{ x: 'max-content' }}
size="small"
bordered
/>
</>
)}
{importing && progress && (
<div style={{ padding: '40px 20px' }}>
<div style={{ marginBottom: 16, fontSize: 16, fontWeight: 600, textAlign: 'center' }}>
...
</div>
<Progress percent={progressPercent} status="active" />
<div style={{ marginTop: 16, textAlign: 'center', color: '#666' }}>
{progress.current} / {progress.total}
<span style={{ marginLeft: 16, color: '#52c41a' }}>
<CheckCircleOutlined /> {progress.success}
</span>
{progress.errors > 0 && (
<span style={{ marginLeft: 16, color: '#ff4d4f' }}>
<CloseCircleOutlined /> {progress.errors}
</span>
)}
</div>
</div>
)}
{importResult && (
<div style={{ padding: 20 }}>
<Alert
type={importResult.failed === 0 ? 'success' : 'warning'}
message="导入完成"
description={
<div>
<div> {importResult.success} </div>
{importResult.failed > 0 && <div> {importResult.failed} </div>}
</div>
}
showIcon
style={{ marginBottom: 16 }}
/>
{importResult.errorLogs && importResult.errorLogs.length > 0 && (
<>
<div style={{ marginBottom: 8, fontWeight: 600, color: '#ff4d4f' }}></div>
<div style={{
maxHeight: 300,
overflow: 'auto',
background: '#fff1f0',
border: '1px solid #ffccc7',
borderRadius: 4,
padding: 12,
fontSize: 12,
fontFamily: 'monospace'
}}>
{importResult.errorLogs.map((log: string, idx: number) => (
<div key={idx} style={{ marginBottom: 4 }}>{log}</div>
))}
</div>
</>
)}
</div>
)}
</Modal>
);
};
export default ImportPreviewModal;

View File

@@ -29,6 +29,8 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
};
const bgMain = getBg('#1f1f1f');
const bgToolbar = getBg('#2a2a2a');
const logScrollbarThumb = darkMode ? 'rgba(255, 255, 255, 0.34)' : 'rgba(0, 0, 0, 0.26)';
const logScrollbarThumbHover = darkMode ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.36)';
const columns = [
{
@@ -113,8 +115,9 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
</div>
{/* List */}
<div style={{ flex: 1, overflow: 'auto' }}>
<div className="log-panel-scroll" style={{ flex: 1, overflow: 'auto' }}>
<Table
className="log-panel-table"
dataSource={sqlLogs}
columns={columns}
size="small"
@@ -124,6 +127,35 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
// scroll={{ y: height - 32 }} // Let flex handle it
/>
</div>
<style>{`
.log-panel-scroll {
scrollbar-width: thin;
scrollbar-color: ${logScrollbarThumb} transparent;
}
.log-panel-scroll::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.log-panel-scroll::-webkit-scrollbar-track,
.log-panel-scroll::-webkit-scrollbar-corner {
background: transparent;
}
.log-panel-scroll::-webkit-scrollbar-thumb {
background: ${logScrollbarThumb};
border-radius: 8px;
border: 2px solid transparent;
background-clip: padding-box;
}
.log-panel-scroll::-webkit-scrollbar-thumb:hover {
background: ${logScrollbarThumbHover};
background-clip: padding-box;
}
.log-panel-table .ant-table,
.log-panel-table .ant-table-container,
.log-panel-table .ant-table-tbody > tr > td {
background: transparent !important;
}
`}</style>
</div>
);
};

View File

@@ -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 === '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 };

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { Table, Input, Button, Space, Tag, Tree, Spin, message, Modal, Form, InputNumber, Popconfirm, Tooltip, Radio } from 'antd';
import { ReloadOutlined, DeleteOutlined, PlusOutlined, EditOutlined, SearchOutlined, ClockCircleOutlined, CopyOutlined, FolderOpenOutlined, KeyOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { RedisKeyInfo, RedisValue } from '../types';
import { RedisKeyInfo, RedisValue, StreamEntry } from '../types';
import Editor from '@monaco-editor/react';
import type { DataNode } from 'antd/es/tree';
@@ -625,6 +625,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
case 'list': return 'orange';
case 'set': return 'purple';
case 'zset': return 'magenta';
case 'stream': return 'cyan';
default: return 'default';
}
};
@@ -1468,6 +1469,212 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
);
};
const renderStreamValue = () => {
const processValue = (value: string) => {
if (viewMode === 'hex') {
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
} else if (viewMode === 'text') {
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
} else if (viewMode === 'utf8') {
try {
const bytes = new Uint8Array(value.length);
for (let i = 0; i < value.length; i++) {
bytes[i] = value.charCodeAt(i) & 0xFF;
}
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' };
} catch (e) {
return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' };
}
} else {
return formatStringValue(value);
}
};
const data = (keyValue.value as StreamEntry[]).map((item, index) => {
const rawFieldsText = JSON.stringify(item.fields ?? {}, null, 2);
const { displayValue, isBinary, isJson, encoding } = processValue(rawFieldsText);
return {
index,
id: item.id,
rawFieldsText,
displayFields: displayValue,
isBinary,
isJson,
encoding,
};
});
const handleAddStreamEntry = async (fieldsText: string, id: string) => {
const config = getConfig();
if (!config) return;
let parsed: unknown;
try {
parsed = JSON.parse(fieldsText);
} catch (e) {
message.error('字段 JSON 格式不正确');
return;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
message.error('字段必须是 JSON 对象');
return;
}
const fieldMap: Record<string, string> = {};
Object.entries(parsed as Record<string, unknown>).forEach(([field, value]) => {
fieldMap[field] = value == null ? '' : String(value);
});
if (Object.keys(fieldMap).length === 0) {
message.error('至少提供一个字段');
return;
}
try {
const res = await (window as any).go.app.App.RedisStreamAdd(config, selectedKey, fieldMap, id || '*');
if (res.success) {
const newID = res.data?.id ? ` (${res.data.id})` : '';
message.success(`添加成功${newID}`);
loadKeyValue(selectedKey);
} else {
message.error('添加失败: ' + res.message);
}
} catch (e: any) {
message.error('添加失败: ' + (e?.message || String(e)));
}
};
const handleDeleteStreamEntry = async (id: string) => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisStreamDelete(config, selectedKey, [id]);
if (res.success) {
const deleted = Number(res.data?.deleted ?? 0);
if (deleted > 0) {
message.success('删除成功');
} else {
message.warning('未删除任何消息,可能已不存在');
}
loadKeyValue(selectedKey);
} else {
message.error('删除失败: ' + res.message);
}
} catch (e: any) {
message.error('删除失败: ' + (e?.message || String(e)));
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Button size="small" icon={<PlusOutlined />} onClick={() => {
Modal.confirm({
title: '添加 Stream 消息',
width: 680,
content: (
<div>
<div style={{ marginBottom: 8 }}>
<label>ID *</label>
<Input id="new-stream-id" placeholder="例如: * 或 1723110000000-0" />
</div>
<div>
<label> JSON</label>
<Input.TextArea id="new-stream-fields" rows={8} defaultValue={'{\n "field": "value"\n}'} />
</div>
</div>
),
onOk: async () => {
const id = (document.getElementById('new-stream-id') as HTMLInputElement)?.value?.trim() || '*';
const fieldsText = (document.getElementById('new-stream-fields') as HTMLTextAreaElement)?.value || '{}';
await handleAddStreamEntry(fieldsText, id);
}
});
}}></Button>
<Radio.Group size="small" value={viewMode} onChange={(e) => setViewMode(e.target.value)}>
<Radio.Button value="auto"></Radio.Button>
<Radio.Button value="text"></Radio.Button>
<Radio.Button value="utf8">UTF-8</Radio.Button>
<Radio.Button value="hex"></Radio.Button>
</Radio.Group>
</div>
<Table
dataSource={data}
columns={[
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 240,
ellipsis: true,
},
{
title: '字段',
dataIndex: 'displayFields',
key: 'fields',
ellipsis: true,
render: (text: string, record: any) => {
const tooltipContent = record.encoding && record.encoding !== 'UTF-8'
? `[${record.encoding}]\n${text}`
: text;
return (
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{tooltipContent}</pre>} styles={{ root: { maxWidth: 720 } }}>
<span style={{
color: record.isBinary ? '#d46b08' : (record.isJson ? '#1890ff' : undefined),
fontFamily: record.isBinary ? 'monospace' : undefined,
fontSize: record.isBinary ? 11 : undefined
}}>
{text}
</span>
</Tooltip>
);
}
},
{
title: '操作',
key: 'action',
width: 140,
render: (_: any, record: any) => (
<Space size="small">
<Tooltip title="复制 ID">
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => {
navigator.clipboard.writeText(record.id).then(() => {
message.success('已复制');
}).catch(() => {
message.error('复制失败');
});
}} />
</Tooltip>
<Tooltip title="复制字段 JSON">
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => {
navigator.clipboard.writeText(record.rawFieldsText).then(() => {
message.success('已复制');
}).catch(() => {
message.error('复制失败');
});
}} />
</Tooltip>
<Popconfirm title="确定删除此消息?" onConfirm={() => handleDeleteStreamEntry(record.id)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
)
}
]}
rowKey="id"
size="small"
pagination={{ pageSize: 50 }}
scroll={{ y: 'calc(100vh - 350px)' }}
style={{ flex: 1 }}
/>
</div>
);
};
return (
<div style={{ padding: 12, height: '100%', display: 'flex', flexDirection: 'column' }}>
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0 }}>
@@ -1511,6 +1718,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
{keyValue.type === 'list' && renderListValue()}
{keyValue.type === 'set' && renderSetValue()}
{keyValue.type === 'zset' && renderZSetValue()}
{keyValue.type === 'stream' && renderStreamValue()}
</div>
</div>
);

File diff suppressed because it is too large Load Diff

View File

@@ -116,6 +116,7 @@ const TabManager: React.FC = () => {
height: 100%;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
@@ -126,6 +127,7 @@ const TabManager: React.FC = () => {
.main-tabs .ant-tabs-content-holder {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow: hidden;
display: flex;
flex-direction: column;
@@ -133,12 +135,14 @@ const TabManager: React.FC = () => {
.main-tabs .ant-tabs-content {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
}
.main-tabs .ant-tabs-tabpane {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
@@ -146,6 +150,7 @@ const TabManager: React.FC = () => {
.main-tabs .ant-tabs-tabpane > div {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
}
.main-tabs .ant-tabs-tabpane-hidden {
display: none !important;

File diff suppressed because it is too large Load Diff

View File

@@ -50,49 +50,127 @@ 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') return 'mysql';
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
};
const buildShowTriggerQuery = (dialect: string, triggerName: string, dbName: string): string => {
const isSphinxConnection = (conn: any): boolean => {
const type = String(conn?.config?.type || '').trim().toLowerCase();
if (type === 'sphinx') return true;
if (type !== 'custom') return false;
const driver = String(conn?.config?.driver || '').trim().toLowerCase();
return driver === 'sphinx' || driver === 'sphinxql';
};
const buildShowTriggerQueries = (dialect: string, triggerName: string, dbName: string): string[] => {
const safeTriggerName = escapeSQLLiteral(triggerName);
const safeDbName = escapeSQLLiteral(dbName);
switch (dialect) {
case 'mysql':
return `SHOW CREATE TRIGGER \`${triggerName.replace(/`/g, '``')}\``;
return [
`SHOW CREATE TRIGGER \`${triggerName.replace(/`/g, '``')}\``,
safeDbName
? `SELECT ACTION_STATEMENT AS trigger_definition FROM information_schema.triggers WHERE trigger_schema = '${safeDbName}' AND trigger_name = '${safeTriggerName}' LIMIT 1`
: '',
safeDbName
? `SHOW TRIGGERS FROM \`${dbName.replace(/`/g, '``')}\` LIKE '${safeTriggerName}'`
: `SHOW TRIGGERS LIKE '${safeTriggerName}'`,
].filter(Boolean);
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase':
return `SELECT pg_get_triggerdef(t.oid, true) AS trigger_definition
return [`SELECT pg_get_triggerdef(t.oid, true) AS trigger_definition
FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
WHERE t.tgname = '${safeTriggerName}'
AND NOT t.tgisinternal
LIMIT 1`;
LIMIT 1`];
case 'sqlserver': {
return `SELECT OBJECT_DEFINITION(OBJECT_ID('${safeTriggerName.replace(/'/g, "''")}')) AS trigger_definition`;
return [`SELECT OBJECT_DEFINITION(OBJECT_ID('${safeTriggerName.replace(/'/g, "''")}')) AS trigger_definition`];
}
case 'oracle':
case 'dm':
if (!safeDbName) {
return `SELECT TRIGGER_BODY FROM USER_TRIGGERS WHERE TRIGGER_NAME = '${safeTriggerName.toUpperCase()}'`;
return [`SELECT TRIGGER_BODY FROM USER_TRIGGERS WHERE TRIGGER_NAME = '${safeTriggerName.toUpperCase()}'`];
}
return `SELECT TRIGGER_BODY FROM ALL_TRIGGERS WHERE OWNER = '${safeDbName.toUpperCase()}' AND TRIGGER_NAME = '${safeTriggerName.toUpperCase()}'`;
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}'`;
return [`SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = '${safeTriggerName}'`];
case 'duckdb':
return [`-- DuckDB 不支持触发器`];
case 'tdengine':
return `-- TDengine 不支持触发器`;
return [`-- TDengine 不支持触发器`];
case 'mongodb':
return `-- MongoDB 不支持触发器`;
return [`-- MongoDB 不支持触发器`];
default:
return `-- 暂不支持该数据库类型的触发器定义查看`;
return [`-- 暂不支持该数据库类型的触发器定义查看`];
}
};
const runQueryCandidates = async (
config: Record<string, any>,
dbName: string,
queries: string[]
): Promise<{ success: boolean; data: any[]; message?: string }> => {
let lastMessage = '';
let hasSuccessfulQuery = false;
for (const query of queries) {
const sql = String(query || '').trim();
if (!sql) continue;
try {
const result = await DBQuery(config as any, dbName, sql);
if (!result.success || !Array.isArray(result.data)) {
lastMessage = result.message || lastMessage;
continue;
}
hasSuccessfulQuery = true;
if (result.data.length > 0) {
return { success: true, data: result.data };
}
} catch (error: any) {
lastMessage = error?.message || String(error);
}
}
if (hasSuccessfulQuery) {
return { success: true, data: [] };
}
return { success: false, data: [], message: lastMessage };
};
const getVersionHint = async (config: Record<string, any>, dbName: string): Promise<string> => {
const candidates = [
`SELECT VERSION() AS version`,
`SHOW VARIABLES LIKE 'version'`,
];
for (const query of candidates) {
try {
const result = await DBQuery(config as any, dbName, query);
if (!result.success || !Array.isArray(result.data) || result.data.length === 0) {
continue;
}
const row = result.data[0] as Record<string, any>;
const version =
row.version
|| row.VERSION
|| row.Value
|| row.value
|| Object.values(row)[1]
|| Object.values(row)[0];
const text = String(version || '').trim();
if (text) return text;
} catch {
// ignore
}
}
return '';
};
const extractTriggerDefinition = (dialect: string, data: any[]): string => {
if (!data || data.length === 0) {
return '-- 未找到触发器定义';
@@ -104,6 +182,12 @@ LIMIT 1`;
case 'mysql': {
// MySQL SHOW CREATE TRIGGER returns: Trigger, sql_mode, SQL Original Statement, ...
const keys = Object.keys(row);
if (row.trigger_definition || row.TRIGGER_DEFINITION) {
return String(row.trigger_definition || row.TRIGGER_DEFINITION);
}
if (row.ACTION_STATEMENT || row.action_statement) {
return String(row.ACTION_STATEMENT || row.action_statement);
}
const sqlKey = keys.find(k => k.toLowerCase().includes('statement') || k.toLowerCase() === 'sql original statement');
if (sqlKey) return row[sqlKey];
// Fallback: try to find any key containing CREATE TRIGGER
@@ -158,10 +242,11 @@ LIMIT 1`;
}
const dialect = getMetadataDialect(conn);
const query = buildShowTriggerQuery(dialect, triggerName, dbName);
const queries = buildShowTriggerQueries(dialect, triggerName, dbName);
const sphinxLike = isSphinxConnection(conn) && dialect === 'mysql';
if (query.startsWith('--')) {
setTriggerDefinition(query);
if (!queries.length || String(queries[0] || '').startsWith('--')) {
setTriggerDefinition(String(queries[0] || '-- 暂不支持该数据库类型的触发器定义查看'));
setLoading(false);
return;
}
@@ -176,11 +261,26 @@ LIMIT 1`;
ssh: conn.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }
};
const result = await DBQuery(config as any, dbName, query);
const result = await runQueryCandidates(config, dbName, queries);
if (result.success && Array.isArray(result.data)) {
if (result.success && Array.isArray(result.data) && result.data.length > 0) {
const definition = extractTriggerDefinition(dialect, result.data);
setTriggerDefinition(definition);
return;
}
if (result.success) {
if (sphinxLike) {
const version = await getVersionHint(config, dbName);
const versionText = version ? `(版本: ${version}` : '';
setTriggerDefinition(`-- 当前 Sphinx 实例${versionText}未返回触发器定义。\n-- 已执行多套兼容查询,可能是版本能力限制或对象类型不支持。`);
return;
}
setTriggerDefinition('-- 未找到触发器定义');
} else if (sphinxLike) {
const version = await getVersionHint(config, dbName);
const versionText = version ? `(版本: ${version}` : '';
setTriggerDefinition(`-- 当前 Sphinx 实例${versionText}不支持触发器定义查询。\n-- 已自动尝试兼容语句,返回失败信息: ${result.message || 'unknown error'}`);
} else {
setError(result.message || '查询触发器定义失败');
}

View File

@@ -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 中重复定义

View File

@@ -1,10 +1,236 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { SavedConnection, TabData, SavedQuery } from './types';
import { ConnectionConfig, SavedConnection, TabData, SavedQuery } from './types';
const DEFAULT_APPEARANCE = { opacity: 1.0, blur: 0 };
const LEGACY_DEFAULT_OPACITY = 0.95;
const OPACITY_EPSILON = 1e-6;
const MAX_URI_LENGTH = 4096;
const MAX_HOST_ENTRY_LENGTH = 512;
const MAX_HOST_ENTRIES = 64;
const DEFAULT_TIMEOUT_SECONDS = 30;
const MAX_TIMEOUT_SECONDS = 3600;
const DEFAULT_CONNECTION_TYPE = 'mysql';
const SUPPORTED_CONNECTION_TYPES = new Set([
'mysql',
'mariadb',
'diros',
'sphinx',
'postgres',
'redis',
'tdengine',
'oracle',
'dameng',
'kingbase',
'sqlserver',
'mongodb',
'highgo',
'vastbase',
'sqlite',
'duckdb',
'custom',
]);
const getDefaultPortByType = (type: string): number => {
switch (type) {
case 'mysql':
case 'mariadb':
return 3306;
case 'diros':
return 9030;
case 'duckdb':
return 0;
case 'sphinx':
return 9306;
case 'postgres':
case 'vastbase':
return 5432;
case 'redis':
return 6379;
case 'tdengine':
return 6041;
case 'oracle':
return 1521;
case 'dameng':
return 5236;
case 'kingbase':
return 54321;
case 'sqlserver':
return 1433;
case 'mongodb':
return 27017;
case 'highgo':
return 5866;
default:
return 3306;
}
};
const toTrimmedString = (value: unknown, fallback = ''): string => {
if (typeof value === 'string') {
return value.trim();
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value).trim();
}
return fallback;
};
const normalizePort = (value: unknown, fallbackPort: number): number => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallbackPort;
const port = Math.trunc(parsed);
if (port <= 0 || port > 65535) return fallbackPort;
return port;
};
const normalizeIntegerInRange = (value: unknown, fallbackValue: number, min: number, max: number): number => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallbackValue;
const normalized = Math.trunc(parsed);
if (normalized < min || normalized > max) return fallbackValue;
return normalized;
};
const isValidHostEntry = (entry: string): boolean => {
if (!entry) return false;
if (entry.length > MAX_HOST_ENTRY_LENGTH) return false;
if (/[()\\/\s]/.test(entry)) return false;
return true;
};
const sanitizeStringArray = (value: unknown, maxLength = 256): string[] => {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const result: string[] = [];
value.forEach((entry) => {
const normalized = toTrimmedString(entry);
if (!normalized || normalized.length > maxLength) return;
if (seen.has(normalized)) return;
seen.add(normalized);
result.push(normalized);
});
return result;
};
const sanitizeNumberArray = (value: unknown, min: number, max: number): number[] => {
if (!Array.isArray(value)) return [];
const seen = new Set<number>();
const result: number[] = [];
value.forEach((entry) => {
const parsed = Number(entry);
if (!Number.isFinite(parsed)) return;
const num = Math.trunc(parsed);
if (num < min || num > max) return;
if (seen.has(num)) return;
seen.add(num);
result.push(num);
});
return result;
};
const sanitizeAddressList = (value: unknown): string[] => {
const all = sanitizeStringArray(value, MAX_HOST_ENTRY_LENGTH)
.filter((entry) => isValidHostEntry(entry));
return all.slice(0, MAX_HOST_ENTRIES);
};
const normalizeConnectionType = (value: unknown): string => {
const type = toTrimmedString(value).toLowerCase();
return SUPPORTED_CONNECTION_TYPES.has(type) ? type : DEFAULT_CONNECTION_TYPE;
};
const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
const type = normalizeConnectionType(raw.type);
const defaultPort = getDefaultPortByType(type);
const savePassword = typeof raw.savePassword === 'boolean' ? raw.savePassword : true;
const mongoSrv = !!raw.mongoSrv;
const sshRaw = (raw.ssh && typeof raw.ssh === 'object') ? raw.ssh as Record<string, unknown> : {};
const ssh = {
host: toTrimmedString(sshRaw.host),
port: normalizePort(sshRaw.port, 22),
user: toTrimmedString(sshRaw.user),
password: toTrimmedString(sshRaw.password),
keyPath: toTrimmedString(sshRaw.keyPath),
};
const safeConfig: ConnectionConfig & Record<string, unknown> = {
...raw,
type,
host: toTrimmedString(raw.host, 'localhost') || 'localhost',
port: normalizePort(raw.port, defaultPort),
user: toTrimmedString(raw.user),
password: savePassword ? toTrimmedString(raw.password) : '',
savePassword,
database: toTrimmedString(raw.database),
useSSH: !!raw.useSSH,
ssh,
uri: toTrimmedString(raw.uri).slice(0, MAX_URI_LENGTH),
hosts: sanitizeAddressList(raw.hosts),
topology: raw.topology === 'replica' ? 'replica' : 'single',
mysqlReplicaUser: toTrimmedString(raw.mysqlReplicaUser),
mysqlReplicaPassword: savePassword ? toTrimmedString(raw.mysqlReplicaPassword) : '',
replicaSet: toTrimmedString(raw.replicaSet),
authSource: toTrimmedString(raw.authSource),
readPreference: toTrimmedString(raw.readPreference),
mongoSrv,
mongoAuthMechanism: toTrimmedString(raw.mongoAuthMechanism),
mongoReplicaUser: toTrimmedString(raw.mongoReplicaUser),
mongoReplicaPassword: savePassword ? toTrimmedString(raw.mongoReplicaPassword) : '',
timeout: normalizeIntegerInRange(raw.timeout, DEFAULT_TIMEOUT_SECONDS, 1, MAX_TIMEOUT_SECONDS),
};
if (type === 'redis') {
safeConfig.redisDB = normalizeIntegerInRange(raw.redisDB, 0, 0, 15);
}
if (type === 'custom') {
safeConfig.driver = toTrimmedString(raw.driver);
safeConfig.dsn = toTrimmedString(raw.dsn).slice(0, MAX_URI_LENGTH);
}
return safeConfig;
};
const sanitizeSavedConnection = (value: unknown, index: number): SavedConnection | null => {
if (!value || typeof value !== 'object') return null;
const raw = value as Record<string, unknown>;
const config = sanitizeConnectionConfig(raw.config);
const id = toTrimmedString(raw.id, `conn-${index + 1}`) || `conn-${index + 1}`;
const fallbackName = config.host ? `${config.type}-${config.host}` : `连接-${index + 1}`;
const name = toTrimmedString(raw.name, fallbackName) || fallbackName;
const includeDatabases = sanitizeStringArray(raw.includeDatabases, 256);
const includeRedisDatabases = sanitizeNumberArray(raw.includeRedisDatabases, 0, 15);
return {
id,
name,
config,
includeDatabases: includeDatabases.length > 0 ? includeDatabases : undefined,
includeRedisDatabases: includeRedisDatabases.length > 0 ? includeRedisDatabases : undefined,
};
};
const sanitizeConnections = (value: unknown): SavedConnection[] => {
if (!Array.isArray(value)) return [];
const result: SavedConnection[] = [];
const idSet = new Set<string>();
value.forEach((entry, index) => {
const conn = sanitizeSavedConnection(entry, index);
if (!conn) return;
let nextId = conn.id;
if (idSet.has(nextId)) {
nextId = `${nextId}-${index + 1}`;
}
idSet.add(nextId);
result.push({ ...conn, id: nextId });
});
return result;
};
const isLegacyDefaultAppearance = (appearance: Partial<{ opacity: number; blur: number }> | undefined): boolean => {
if (!appearance) {
@@ -26,6 +252,12 @@ export interface SqlLog {
affectedRows?: number;
}
export interface QueryOptions {
maxRows: number;
showColumnComment: boolean;
showColumnType: boolean;
}
interface AppState {
connections: SavedConnection[];
tabs: TabData[];
@@ -35,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'>;
@@ -49,6 +281,8 @@ interface AppState {
closeOtherTabs: (id: string) => void;
closeTabsToLeft: (id: string) => void;
closeTabsToRight: (id: string) => void;
closeTabsByConnection: (connectionId: string) => void;
closeTabsByDatabase: (connectionId: string, dbName: string) => void;
closeAllTabs: () => void;
setActiveTab: (id: string) => void;
setActiveContext: (context: { connectionId: string; dbName: string } | null) => void;
@@ -59,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;
@@ -68,6 +302,84 @@ interface AppState {
setTableSortPreference: (connectionId: string, dbName: string, sortBy: 'name' | 'frequency') => void;
}
const sanitizeSavedQueries = (value: unknown): SavedQuery[] => {
if (!Array.isArray(value)) return [];
const result: SavedQuery[] = [];
value.forEach((entry, index) => {
if (!entry || typeof entry !== 'object') return;
const raw = entry as Record<string, unknown>;
const id = toTrimmedString(raw.id, `query-${index + 1}`) || `query-${index + 1}`;
const sql = toTrimmedString(raw.sql);
const connectionId = toTrimmedString(raw.connectionId);
const dbName = toTrimmedString(raw.dbName);
if (!sql || !connectionId || !dbName) return;
result.push({
id,
name: toTrimmedString(raw.name, `查询-${index + 1}`) || `查询-${index + 1}`,
sql,
connectionId,
dbName,
createdAt: Number.isFinite(Number(raw.createdAt)) ? Number(raw.createdAt) : Date.now(),
});
});
return result;
};
const sanitizeTheme = (value: unknown): 'light' | 'dark' => (value === 'dark' ? 'dark' : 'light');
const sanitizeSqlFormatOptions = (value: unknown): { keywordCase: 'upper' | 'lower' } => {
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
return { keywordCase: raw.keywordCase === 'lower' ? 'lower' : 'upper' };
};
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, showColumnComment, showColumnType };
}
return { maxRows: Math.min(50000, Math.trunc(maxRows)), showColumnComment, showColumnType };
};
const sanitizeTableAccessCount = (value: unknown): Record<string, number> => {
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
const result: Record<string, number> = {};
Object.entries(raw).forEach(([key, count]) => {
const parsed = Number(count);
if (!Number.isFinite(parsed) || parsed < 0) return;
result[key] = Math.trunc(parsed);
});
return result;
};
const sanitizeTableSortPreference = (value: unknown): Record<string, 'name' | 'frequency'> => {
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
const result: Record<string, 'name' | 'frequency'> = {};
Object.entries(raw).forEach(([key, preference]) => {
result[key] = preference === 'frequency' ? 'frequency' : 'name';
});
return result;
};
const sanitizeAppearance = (
appearance: Partial<{ opacity: number; blur: number }> | undefined,
version: number
): { opacity: number; blur: number } => {
if (!appearance || typeof appearance !== 'object') {
return { ...DEFAULT_APPEARANCE };
}
const nextAppearance = {
opacity: typeof appearance.opacity === 'number' ? appearance.opacity : DEFAULT_APPEARANCE.opacity,
blur: typeof appearance.blur === 'number' ? appearance.blur : DEFAULT_APPEARANCE.blur,
};
if (version < 2 && isLegacyDefaultAppearance(appearance)) {
return { ...DEFAULT_APPEARANCE };
}
return nextAppearance;
};
export const useStore = create<AppState>()(
persist(
(set) => ({
@@ -79,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: {},
@@ -132,6 +444,45 @@ export const useStore = create<AppState>()(
return { tabs: newTabs, activeTabId: activeStillExists ? state.activeTabId : id };
}),
closeTabsByConnection: (connectionId) => set((state) => {
const targetConnectionId = String(connectionId || '').trim();
if (!targetConnectionId) return state;
const newTabs = state.tabs.filter(t => String(t.connectionId || '').trim() !== targetConnectionId);
const activeStillExists = state.activeTabId ? newTabs.some(t => t.id === state.activeTabId) : false;
const nextActiveTabId = activeStillExists
? state.activeTabId
: (newTabs.length > 0 ? newTabs[newTabs.length - 1].id : null);
const nextActiveContext = state.activeContext?.connectionId === targetConnectionId ? null : state.activeContext;
return {
tabs: newTabs,
activeTabId: nextActiveTabId,
activeContext: nextActiveContext,
};
}),
closeTabsByDatabase: (connectionId, dbName) => set((state) => {
const targetConnectionId = String(connectionId || '').trim();
const targetDbName = String(dbName || '').trim();
if (!targetConnectionId || !targetDbName) return state;
const newTabs = state.tabs.filter((tab) => {
const sameConnection = String(tab.connectionId || '').trim() === targetConnectionId;
const sameDb = String(tab.dbName || '').trim() === targetDbName;
return !(sameConnection && sameDb);
});
const activeStillExists = state.activeTabId ? newTabs.some(t => t.id === state.activeTabId) : false;
const nextActiveTabId = activeStillExists
? state.activeTabId
: (newTabs.length > 0 ? newTabs[newTabs.length - 1].id : null);
const sameActiveContext = state.activeContext
&& state.activeContext.connectionId === targetConnectionId
&& state.activeContext.dbName === targetDbName;
return {
tabs: newTabs,
activeTabId: nextActiveTabId,
activeContext: sameActiveContext ? null : state.activeContext,
};
}),
closeAllTabs: () => set(() => ({ tabs: [], activeTabId: null })),
setActiveTab: (id) => set({ activeTabId: id }),
@@ -179,33 +530,40 @@ export const useStore = create<AppState>()(
}),
{
name: 'lite-db-storage', // name of the item in the storage (must be unique)
version: 2,
version: 3,
migrate: (persistedState: unknown, version: number) => {
if (!persistedState || typeof persistedState !== 'object') {
return persistedState as AppState;
}
const state = persistedState as Partial<AppState>;
const nextState: Partial<AppState> = { ...state };
const appearance = state.appearance;
if (!appearance || typeof appearance !== 'object') {
nextState.appearance = { ...DEFAULT_APPEARANCE };
return nextState as AppState;
}
const nextAppearance = {
opacity: typeof appearance.opacity === 'number' ? appearance.opacity : DEFAULT_APPEARANCE.opacity,
blur: typeof appearance.blur === 'number' ? appearance.blur : DEFAULT_APPEARANCE.blur,
};
if (version < 2 && isLegacyDefaultAppearance(appearance)) {
nextState.appearance = { ...DEFAULT_APPEARANCE };
} else {
nextState.appearance = nextAppearance;
}
nextState.connections = sanitizeConnections(state.connections);
nextState.savedQueries = sanitizeSavedQueries(state.savedQueries);
nextState.theme = sanitizeTheme(state.theme);
nextState.appearance = sanitizeAppearance(state.appearance, version);
nextState.sqlFormatOptions = sanitizeSqlFormatOptions(state.sqlFormatOptions);
nextState.queryOptions = sanitizeQueryOptions(state.queryOptions);
nextState.tableAccessCount = sanitizeTableAccessCount(state.tableAccessCount);
nextState.tableSortPreference = sanitizeTableSortPreference(state.tableSortPreference);
return nextState as AppState;
},
merge: (persistedState, currentState) => {
const state = (persistedState && typeof persistedState === 'object')
? persistedState as Partial<AppState>
: {};
return {
...currentState,
...state,
connections: sanitizeConnections(state.connections),
savedQueries: sanitizeSavedQueries(state.savedQueries),
theme: sanitizeTheme(state.theme),
appearance: sanitizeAppearance(state.appearance, 3),
sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions),
queryOptions: sanitizeQueryOptions(state.queryOptions),
tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount),
tableSortPreference: sanitizeTableSortPreference(state.tableSortPreference),
};
},
partialize: (state) => ({
connections: state.connections,
savedQueries: state.savedQueries,

View File

@@ -16,6 +16,9 @@ export interface ConnectionConfig {
database?: string;
useSSH?: boolean;
ssh?: SSHConfig;
driver?: string;
dsn?: string;
timeout?: number;
redisDB?: number; // Redis database index (0-15)
uri?: string; // Connection URI for copy/paste
hosts?: string[]; // Multi-host addresses: host:port
@@ -128,7 +131,7 @@ export interface RedisScanResult {
}
export interface RedisValue {
type: 'string' | 'hash' | 'list' | 'set' | 'zset';
type: 'string' | 'hash' | 'list' | 'set' | 'zset' | 'stream';
ttl: number;
value: any;
length: number;
@@ -143,3 +146,8 @@ export interface ZSetMember {
member: string;
score: number;
}
export interface StreamEntry {
id: string;
fields: Record<string, string>;
}

View File

@@ -36,7 +36,7 @@ export const quoteIdentPart = (dbType: string, ident: string) => {
if (!raw) return raw;
const dbTypeLower = (dbType || '').toLowerCase();
if (dbTypeLower === 'mysql' || dbTypeLower === 'tdengine') {
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros' || dbTypeLower === 'sphinx' || dbTypeLower === 'tdengine') {
return `\`${raw.replace(/`/g, '``')}\``;
}
@@ -63,6 +63,76 @@ export const quoteQualifiedIdent = (dbType: string, ident: string) => {
export const escapeLiteral = (val: string) => (val || '').replace(/'/g, "''");
type SortInfo = {
columnKey?: string;
order?: string;
} | null | undefined;
// 为排序查询按库类型注入 sort_buffer 提升参数(仅影响当前语句)。
// MySQL: 使用 Optimizer Hint `SET_VAR`。
// MariaDB: 使用 `SET STATEMENT ... FOR` 包装当前查询。
export const withSortBufferTuningSQL = (
dbType: string,
sql: string,
sortBufferBytes: number,
) => {
const rawSql = String(sql || '');
const trimmed = rawSql.trim();
if (!trimmed) return rawSql;
if (!/^select\b/i.test(trimmed)) return rawSql;
const normalizedType = String(dbType || '').trim().toLowerCase();
const bytes = Math.max(256 * 1024, Math.floor(Number(sortBufferBytes) || 0));
if (normalizedType === 'mysql') {
return rawSql.replace(
/^\s*select\b/i,
(matched) => `${matched} /*+ SET_VAR(sort_buffer_size=${bytes}) */`,
);
}
if (normalizedType === 'mariadb') {
return `SET STATEMENT sort_buffer_size=${bytes} FOR ${rawSql}`;
}
return rawSql;
};
export const buildOrderBySQL = (
dbType: string,
sortInfo: SortInfo,
fallbackColumns: string[] = [],
) => {
const dbTypeLower = String(dbType || '').trim().toLowerCase();
const sortColumn = normalizeIdentPart(String(sortInfo?.columnKey || ''));
const sortOrder = String(sortInfo?.order || '');
const direction = sortOrder === 'ascend' ? 'ASC' : sortOrder === 'descend' ? 'DESC' : '';
if (sortColumn && direction) {
return ` ORDER BY ${quoteIdentPart(dbType, sortColumn)} ${direction}`;
}
// MySQL/MariaDB 大表在无显式排序需求时强制 ORDER BY即使按主键可能触发 filesort
// 导致 `Error 1038 (HY001): Out of sort memory`。
// 因此仅在用户主动点击排序时下发 ORDER BY默认分页查询不加兜底排序。
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros') {
return '';
}
const seen = new Set<string>();
const stableColumns = (fallbackColumns || [])
.map((col) => normalizeIdentPart(String(col || '')))
.filter((col) => {
if (!col) return false;
const key = col.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
if (stableColumns.length > 0) {
const parts = stableColumns.map((col) => `${quoteIdentPart(dbType, col)} ASC`);
return ` ORDER BY ${parts.join(', ')}`;
}
return '';
};
export const parseListValues = (val: string) => {
const raw = (val || '').trim();
if (!raw) return [];

2
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />

View File

@@ -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')
},
},
},
},
})

View File

@@ -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,10 +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>;
@@ -80,6 +90,8 @@ export function MySQLShowCreateTable(arg1:connection.ConnectionConfig,arg2:strin
export function OpenSQLFile():Promise<connection.QueryResult>;
export function PreviewImportFile(arg1:string):Promise<connection.QueryResult>;
export function RedisConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function RedisDeleteHashField(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
@@ -116,18 +128,36 @@ export function RedisSetString(arg1:connection.ConnectionConfig,arg2:string,arg3
export function RedisSetTTL(arg1:connection.ConnectionConfig,arg2:string,arg3:number):Promise<connection.QueryResult>;
export function RedisStreamAdd(arg1:connection.ConnectionConfig,arg2:string,arg3:Record<string, string>,arg4:string):Promise<connection.QueryResult>;
export function RedisStreamDelete(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
export function RedisTestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function RedisZSetAdd(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<redis.ZSetMember>):Promise<connection.QueryResult>;
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>;

View File

@@ -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']();
}
@@ -122,6 +134,14 @@ export function ImportData(arg1, arg2, arg3) {
return window['go']['app']['App']['ImportData'](arg1, arg2, arg3);
}
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']();
}
@@ -154,6 +174,10 @@ export function OpenSQLFile() {
return window['go']['app']['App']['OpenSQLFile']();
}
export function PreviewImportFile(arg1) {
return window['go']['app']['App']['PreviewImportFile'](arg1);
}
export function RedisConnect(arg1) {
return window['go']['app']['App']['RedisConnect'](arg1);
}
@@ -226,6 +250,14 @@ export function RedisSetTTL(arg1, arg2, arg3) {
return window['go']['app']['App']['RedisSetTTL'](arg1, arg2, arg3);
}
export function RedisStreamAdd(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['RedisStreamAdd'](arg1, arg2, arg3, arg4);
}
export function RedisStreamDelete(arg1, arg2, arg3) {
return window['go']['app']['App']['RedisStreamDelete'](arg1, arg2, arg3);
}
export function RedisTestConnection(arg1) {
return window['go']['app']['App']['RedisTestConnection'](arg1);
}
@@ -238,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);
}
@@ -250,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);
}

0
frontend/wailsjs/runtime/package.json Executable file → Normal file
View File

0
frontend/wailsjs/runtime/runtime.d.ts vendored Executable file → Normal file
View File

0
frontend/wailsjs/runtime/runtime.js Executable file → Normal file
View File

37
go.mod
View File

@@ -5,13 +5,16 @@ 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
github.com/microsoft/go-mssqldb v1.9.6
github.com/redis/go-redis/v9 v9.17.3
github.com/sijms/go-ora/v2 v2.9.0
github.com/taosdata/driver-go/v3 v3.7.8
github.com/wailsapp/wails/v2 v2.11.0
github.com/xuri/excelize/v2 v2.10.0
go.mongodb.org/mongo-driver/v2 v2.5.0
golang.org/x/crypto v0.47.0
golang.org/x/text v0.33.0
@@ -20,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
@@ -43,15 +57,19 @@ 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
github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.4 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/tiendc/go-deepcopy v1.7.1 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
@@ -60,12 +78,21 @@ require (
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
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
)
replace github.com/highgo/pq-sm3 => ./third_party/highgo-pq

101
go.sum
View File

@@ -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,22 +125,35 @@ 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=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -121,16 +165,19 @@ 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=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/taosdata/driver-go/v3 v3.7.8 h1:N2H6HLLZH2ve2ipcoFgG9BJS+yW0XksqNYwEdSmHaJk=
github.com/taosdata/driver-go/v3 v3.7.8/go.mod h1:gSxBEPOueMg0rTmMO1Ug6aeD7AwGdDGvUtLrsDTTpYc=
github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4=
github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -149,26 +196,38 @@ github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4=
github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
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=
@@ -185,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=
@@ -200,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=

View File

@@ -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()

View File

@@ -14,7 +14,7 @@ func normalizeRunConfig(config connection.ConnectionConfig, dbName string) conne
}
switch strings.ToLower(strings.TrimSpace(config.Type)) {
case "mysql", "mariadb", "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":

View File

@@ -88,8 +88,10 @@ 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 暂不支持创建数据库"}
}
_, err = dbInst.Exec(query)
@@ -114,6 +116,10 @@ func resolveDDLDBType(config connection.ConnectionConfig) string {
return "dameng"
case "sqlite3":
return "sqlite"
case "sphinxql":
return "sphinx"
case "diros", "doris":
return "diros"
default:
return driver
}
@@ -156,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", "postgres", "kingbase", "vastbase", "dameng":
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "vastbase", "dameng":
if strings.TrimSpace(dbName) != "" {
runConfig.Database = strings.TrimSpace(dbName)
}
@@ -177,8 +183,8 @@ func (a *App) RenameDatabase(config connection.ConnectionConfig, oldName string,
dbType := resolveDDLDBType(config)
switch dbType {
case "mysql", "mariadb":
return connection.QueryResult{Success: false, Message: "MySQL/MariaDB 不支持直接重命名数据库,请新建库后迁移数据"}
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: "当前连接正在使用目标数据库,请先连接到其他数据库后再重命名"}
@@ -213,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))
@@ -255,7 +261,7 @@ func (a *App) RenameTable(config connection.ConnectionConfig, dbName string, old
dbType := resolveDDLDBType(config)
switch dbType {
case "mysql", "mariadb", "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)}
}
@@ -269,7 +275,7 @@ func (a *App) RenameTable(config connection.ConnectionConfig, dbName string, old
var sql string
switch dbType {
case "mysql", "mariadb":
case "mysql", "mariadb", "diros", "sphinx":
newQualifiedTable := quoteTableIdentByType(dbType, schemaName, newTableName)
sql = fmt.Sprintf("RENAME TABLE %s TO %s", oldQualifiedTable, newQualifiedTable)
case "sqlserver":
@@ -301,7 +307,7 @@ func (a *App) DropTable(config connection.ConnectionConfig, dbName string, table
dbType := resolveDDLDBType(config)
switch dbType {
case "mysql", "mariadb", "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)}
}
@@ -459,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 {
@@ -472,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)
@@ -552,7 +669,7 @@ func (a *App) DropView(config connection.ConnectionConfig, dbName string, viewNa
dbType := resolveDDLDBType(config)
switch dbType {
case "mysql", "mariadb", "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)}
}
@@ -587,10 +704,13 @@ func (a *App) DropFunction(config connection.ConnectionConfig, dbName string, ro
dbType := resolveDDLDBType(config)
switch dbType {
case "mysql", "mariadb", "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 == "" {
@@ -638,7 +758,7 @@ func (a *App) RenameView(config connection.ConnectionConfig, dbName string, oldN
var sql string
switch dbType {
case "mysql", "mariadb":
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":

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -450,6 +450,40 @@ func (a *App) RedisZSetRemove(config connection.ConnectionConfig, key string, me
return connection.QueryResult{Success: true, Message: "删除成功"}
}
// RedisStreamAdd adds an entry to a stream
func (a *App) RedisStreamAdd(config connection.ConnectionConfig, key string, fields map[string]string, id string) connection.QueryResult {
config.Type = "redis"
client, err := a.getRedisClient(config)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
newID, err := client.StreamAdd(key, fields, id)
if err != nil {
logger.Error(err, "RedisStreamAdd 添加失败key=%s id=%s", key, id)
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Message: "添加成功", Data: map[string]string{"id": newID}}
}
// RedisStreamDelete deletes stream entries by IDs
func (a *App) RedisStreamDelete(config connection.ConnectionConfig, key string, ids []string) connection.QueryResult {
config.Type = "redis"
client, err := a.getRedisClient(config)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
deleted, err := client.StreamDelete(key, ids...)
if err != nil {
logger.Error(err, "RedisStreamDelete 删除失败key=%s ids=%v", key, ids)
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Message: "删除成功", Data: map[string]int64{"deleted": deleted}}
}
// RedisFlushDB flushes the current database
func (a *App) RedisFlushDB(config connection.ConnectionConfig) connection.QueryResult {
config.Type = "redis"

View File

@@ -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 {

View 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)
}
}
}

View File

@@ -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不可见由前端根据用户外观设置动态启用

View File

@@ -0,0 +1,9 @@
//go:build !windows
package db
import "os/exec"
func configureAgentProcess(cmd *exec.Cmd) {
_ = cmd
}

View 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,
}
}

View File

@@ -1,3 +1,5 @@
//go:build gonavi_full_drivers || gonavi_dameng_driver
package db
import (

View File

@@ -3,6 +3,7 @@ package db
import (
"GoNavi-Wails/internal/connection"
"fmt"
"strings"
)
type Database interface {
@@ -25,40 +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 "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
}

View 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")
}

View 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
View 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, ""))
}

View 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, ""
}

View 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)
}
}

View File

@@ -1,3 +1,5 @@
//go:build gonavi_full_drivers
package db
import (

View 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
View 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, "'", "''")
}

View 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, ""
}

View 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)
}

View File

@@ -1,3 +1,5 @@
//go:build gonavi_full_drivers || gonavi_highgo_driver
package db
import (
@@ -15,7 +17,7 @@ import (
"GoNavi-Wails/internal/ssh"
"GoNavi-Wails/internal/utils"
_ "github.com/lib/pq" // HighGo is PostgreSQL compatible
_ "github.com/highgo/pq-sm3" // HighGo uses dedicated SM3-capable driver
)
// HighGoDB implements Database interface for HighGo (瀚高) database
@@ -80,7 +82,7 @@ func (h *HighGoDB) Connect(config connection.ConnectionConfig) error {
dsn = h.getDSN(config)
}
db, err := sql.Open("postgres", dsn)
db, err := sql.Open("highgo", dsn)
if err != nil {
return fmt.Errorf("打开数据库连接失败:%w", err)
}

View File

@@ -1,3 +1,5 @@
//go:build gonavi_full_drivers || gonavi_kingbase_driver
package db
import (

View File

@@ -1,3 +1,5 @@
//go:build gonavi_full_drivers || gonavi_mariadb_driver
package db
import (

View File

@@ -1,3 +1,5 @@
//go:build gonavi_full_drivers || gonavi_mongodb_driver
package db
import (

View 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 驱动代理失败:%wstderr: %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 驱动代理响应失败:%wstderr: %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
}

View 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")
}

View File

@@ -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) {

View 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 驱动代理失败:%wstderr: %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 驱动代理响应失败:%wstderr: %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
}

View File

@@ -0,0 +1,9 @@
//go:build gonavi_full_drivers
package db
func optionalGoDriverBuildIncluded(driverType string) bool {
_, ok := optionalGoDrivers[normalizeRuntimeDriverType(driverType)]
return ok
}

View File

@@ -0,0 +1,8 @@
//go:build !gonavi_full_drivers
package db
func optionalGoDriverBuildIncluded(driverType string) bool {
_, ok := optionalGoDrivers[normalizeRuntimeDriverType(driverType)]
return ok
}

View File

@@ -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 {

199
internal/db/sphinx_impl.go Normal file
View File

@@ -0,0 +1,199 @@
//go:build gonavi_full_drivers || gonavi_sphinx_driver
package db
import (
"fmt"
"strings"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/logger"
)
const sphinxDefaultDatabaseName = "default"
// SphinxDB 复用 MySQL 协议实现,并在数据库列表不可用时提供兜底。
type SphinxDB struct {
MySQLDB
fallbackDatabase string
}
func isSphinxUnsupportedFeatureError(err error) bool {
if err == nil {
return false
}
text := strings.ToLower(strings.TrimSpace(err.Error()))
if text == "" {
return false
}
keywords := []string{
"not supported",
"unsupported",
"syntax error",
"unknown table",
"unknown column",
"doesn't exist",
}
for _, keyword := range keywords {
if strings.Contains(text, keyword) {
return true
}
}
return false
}
func (s *SphinxDB) Connect(config connection.ConnectionConfig) error {
runConfig := applyMySQLURI(config)
s.fallbackDatabase = strings.TrimSpace(runConfig.Database)
return s.MySQLDB.Connect(config)
}
func (s *SphinxDB) resolveDatabaseName(dbName string) string {
name := strings.TrimSpace(dbName)
if name == "" {
return s.fallbackDatabase
}
if strings.EqualFold(name, sphinxDefaultDatabaseName) && s.fallbackDatabase == "" {
return ""
}
return name
}
func (s *SphinxDB) GetDatabases() ([]string, error) {
dbs, err := s.MySQLDB.GetDatabases()
if err == nil && len(dbs) > 0 {
return dbs, nil
}
if s.fallbackDatabase != "" {
return []string{s.fallbackDatabase}, nil
}
return []string{sphinxDefaultDatabaseName}, nil
}
func (s *SphinxDB) GetTables(dbName string) ([]string, error) {
tables, err := s.MySQLDB.GetTables(s.resolveDatabaseName(dbName))
if err == nil {
return tables, nil
}
if !isSphinxUnsupportedFeatureError(err) {
return nil, err
}
// Sphinx/Manticore 常见返回列名为 `Index`,并且不支持 `SHOW TABLES FROM <db>` 语法。
data, fields, fallbackErr := s.MySQLDB.Query("SHOW TABLES")
if fallbackErr != nil {
return nil, fallbackErr
}
fallbackTables := make([]string, 0, len(data))
for _, row := range data {
if val, ok := row["Index"]; ok {
fallbackTables = append(fallbackTables, fmt.Sprintf("%v", val))
continue
}
if val, ok := row["index"]; ok {
fallbackTables = append(fallbackTables, fmt.Sprintf("%v", val))
continue
}
for _, field := range fields {
if val, ok := row[field]; ok {
fallbackTables = append(fallbackTables, fmt.Sprintf("%v", val))
break
}
}
}
return fallbackTables, nil
}
func (s *SphinxDB) GetCreateStatement(dbName, tableName string) (string, error) {
return s.MySQLDB.GetCreateStatement(s.resolveDatabaseName(dbName), tableName)
}
func (s *SphinxDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
// 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) {
return s.MySQLDB.GetAllColumns(s.resolveDatabaseName(dbName))
}
func (s *SphinxDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
return s.MySQLDB.GetIndexes(s.resolveDatabaseName(dbName), tableName)
}
func (s *SphinxDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
fks, err := s.MySQLDB.GetForeignKeys(s.resolveDatabaseName(dbName), tableName)
if err != nil && isSphinxUnsupportedFeatureError(err) {
return []connection.ForeignKeyDefinition{}, nil
}
return fks, err
}
func (s *SphinxDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
triggers, err := s.MySQLDB.GetTriggers(s.resolveDatabaseName(dbName), tableName)
if err != nil && isSphinxUnsupportedFeatureError(err) {
return []connection.TriggerDefinition{}, nil
}
return triggers, err
}

View File

@@ -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()

View 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")
}
}

View File

@@ -1,3 +1,5 @@
//go:build gonavi_full_drivers || gonavi_sqlserver_driver
package db
import (

View File

@@ -1,3 +1,5 @@
//go:build gonavi_full_drivers || gonavi_tdengine_driver
package db
import (

View File

@@ -1,3 +1,5 @@
//go:build gonavi_full_drivers || gonavi_vastbase_driver
package db
import (

View File

@@ -4,7 +4,7 @@ import "GoNavi-Wails/internal/connection"
// RedisValue represents a Redis value with its type and metadata
type RedisValue struct {
Type string `json:"type"` // string, hash, list, set, zset
Type string `json:"type"` // string, hash, list, set, zset, stream
TTL int64 `json:"ttl"` // TTL in seconds, -1 means no expiry, -2 means key doesn't exist
Value interface{} `json:"value"` // The actual value
Length int64 `json:"length"` // Length/size of the value
@@ -72,6 +72,11 @@ type RedisClient interface {
ZSetAdd(key string, members ...ZSetMember) error
ZSetRemove(key string, members ...string) error
// Stream operations
GetStream(key, start, stop string, count int64) ([]StreamEntry, error)
StreamAdd(key string, fields map[string]string, id string) (string, error)
StreamDelete(key string, ids ...string) (int64, error)
// Command execution
ExecuteCommand(args []string) (interface{}, error)
@@ -88,3 +93,9 @@ type ZSetMember struct {
Member string `json:"member"`
Score float64 `json:"score"`
}
// StreamEntry represents a single stream message
type StreamEntry struct {
ID string `json:"id"`
Fields map[string]string `json:"fields"`
}

View File

@@ -334,6 +334,26 @@ func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) {
result.Value = members
result.Length = length
case "stream":
length, err := r.client.XLen(ctx, key).Result()
if err != nil {
return nil, err
}
result.Length = length
if length == 0 {
result.Value = []StreamEntry{}
break
}
limit := int64(1000)
if length < limit {
limit = length
}
val, err := r.client.XRangeN(ctx, key, "-", "+", limit).Result()
if err != nil {
return nil, err
}
result.Value = toStreamEntries(val)
default:
return nil, fmt.Errorf("不支持的 Redis 数据类型: %s", keyType)
}
@@ -523,6 +543,91 @@ func (r *RedisClientImpl) ZSetRemove(key string, members ...string) error {
return r.client.ZRem(ctx, key, args...).Err()
}
// GetStream gets stream entries in a range
func (r *RedisClientImpl) GetStream(key, start, stop string, count int64) ([]StreamEntry, error) {
if r.client == nil {
return nil, fmt.Errorf("Redis 客户端未连接")
}
if start == "" {
start = "-"
}
if stop == "" {
stop = "+"
}
if count <= 0 {
count = 1000
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
val, err := r.client.XRangeN(ctx, key, start, stop, count).Result()
if err != nil {
return nil, err
}
return toStreamEntries(val), nil
}
// StreamAdd adds an entry to a stream
func (r *RedisClientImpl) StreamAdd(key string, fields map[string]string, id string) (string, error) {
if r.client == nil {
return "", fmt.Errorf("Redis 客户端未连接")
}
if len(fields) == 0 {
return "", fmt.Errorf("Stream 字段不能为空")
}
if id == "" {
id = "*"
}
values := make(map[string]interface{}, len(fields))
for field, value := range fields {
values[field] = value
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
newID, err := r.client.XAdd(ctx, &redis.XAddArgs{
Stream: key,
ID: id,
Values: values,
}).Result()
if err != nil {
return "", err
}
return newID, nil
}
// StreamDelete deletes entries from a stream by IDs
func (r *RedisClientImpl) StreamDelete(key string, ids ...string) (int64, error) {
if r.client == nil {
return 0, fmt.Errorf("Redis 客户端未连接")
}
if len(ids) == 0 {
return 0, fmt.Errorf("Stream ID 不能为空")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return r.client.XDel(ctx, key, ids...).Result()
}
func toStreamEntries(messages []redis.XMessage) []StreamEntry {
entries := make([]StreamEntry, 0, len(messages))
for _, msg := range messages {
fields := make(map[string]string, len(msg.Values))
for field, value := range msg.Values {
fields[field] = fmt.Sprint(value)
}
entries = append(entries, StreamEntry{
ID: msg.ID,
Fields: fields,
})
}
return entries
}
// ExecuteCommand executes a raw Redis command
func (r *RedisClientImpl) ExecuteCommand(args []string) (interface{}, error) {
if r.client == nil {

View File

@@ -22,7 +22,7 @@ func quoteIdentByType(dbType string, ident string) string {
}
switch dbType {
case "mysql", "mariadb":
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":
case "mysql", "mariadb", "diros", "sphinx":
s := strings.TrimSpace(schema)
if s == "" || table == "" {
return table

6
third_party/highgo-pq/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.db
*.test
*~
*.swp
.idea
.vscode

8
third_party/highgo-pq/LICENSE.md vendored Normal file
View File

@@ -0,0 +1,8 @@
Copyright (c) 2011-2013, 'pq' Contributors
Portions Copyright (C) 2011 Blake Mizerany
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

36
third_party/highgo-pq/README.md vendored Normal file
View File

@@ -0,0 +1,36 @@
# pq - A pure Go postgres driver for Go's database/sql package
[![GoDoc](https://godoc.org/github.com/lib/pq?status.svg)](https://pkg.go.dev/github.com/lib/pq?tab=doc)
## Install
go get github.com/lib/pq
## Features
* SSL
* Handles bad connections for `database/sql`
* Scan `time.Time` correctly (i.e. `timestamp[tz]`, `time[tz]`, `date`)
* Scan binary blobs correctly (i.e. `bytea`)
* Package for `hstore` support
* COPY FROM support
* pq.ParseURL for converting urls to connection strings for sql.Open.
* Many libpq compatible environment variables
* Unix socket support
* Notifications: `LISTEN`/`NOTIFY`
* pgpass support
* GSS (Kerberos) auth
## Tests
`go test` is used for testing. See [TESTS.md](TESTS.md) for more details.
## Status
This package is currently in maintenance mode, which means:
1. It generally does not accept new features.
2. It does accept bug fixes and version compatability changes provided by the community.
3. Maintainers usually do not resolve reported issues.
4. Community members are encouraged to help each other with reported issues.
For users that require new features or reliable resolution of reported bugs, we recommend using [pgx](https://github.com/jackc/pgx) which is under active development.

33
third_party/highgo-pq/TESTS.md vendored Normal file
View File

@@ -0,0 +1,33 @@
# Tests
## Running Tests
`go test` is used for testing. A running PostgreSQL
server is required, with the ability to log in. The
database to connect to test with is "pqgotest," on
"localhost" but these can be overridden using [environment
variables](https://www.postgresql.org/docs/9.3/static/libpq-envars.html).
Example:
PGHOST=/run/postgresql go test
## Benchmarks
A benchmark suite can be run as part of the tests:
go test -bench .
## Example setup (Docker)
Run a postgres container:
```
docker run --expose 5432:5432 postgres
```
Run tests:
```
PGHOST=localhost PGPORT=5432 PGUSER=postgres PGSSLMODE=disable PGDATABASE=postgres go test
```

895
third_party/highgo-pq/array.go vendored Normal file
View File

@@ -0,0 +1,895 @@
package pq
import (
"bytes"
"database/sql"
"database/sql/driver"
"encoding/hex"
"fmt"
"reflect"
"strconv"
"strings"
)
var typeByteSlice = reflect.TypeOf([]byte{})
var typeDriverValuer = reflect.TypeOf((*driver.Valuer)(nil)).Elem()
var typeSQLScanner = reflect.TypeOf((*sql.Scanner)(nil)).Elem()
// Array returns the optimal driver.Valuer and sql.Scanner for an array or
// slice of any dimension.
//
// For example:
// db.Query(`SELECT * FROM t WHERE id = ANY($1)`, pq.Array([]int{235, 401}))
//
// var x []sql.NullInt64
// db.QueryRow(`SELECT ARRAY[235, 401]`).Scan(pq.Array(&x))
//
// Scanning multi-dimensional arrays is not supported. Arrays where the lower
// bound is not one (such as `[0:0]={1}') are not supported.
func Array(a interface{}) interface {
driver.Valuer
sql.Scanner
} {
switch a := a.(type) {
case []bool:
return (*BoolArray)(&a)
case []float64:
return (*Float64Array)(&a)
case []float32:
return (*Float32Array)(&a)
case []int64:
return (*Int64Array)(&a)
case []int32:
return (*Int32Array)(&a)
case []string:
return (*StringArray)(&a)
case [][]byte:
return (*ByteaArray)(&a)
case *[]bool:
return (*BoolArray)(a)
case *[]float64:
return (*Float64Array)(a)
case *[]float32:
return (*Float32Array)(a)
case *[]int64:
return (*Int64Array)(a)
case *[]int32:
return (*Int32Array)(a)
case *[]string:
return (*StringArray)(a)
case *[][]byte:
return (*ByteaArray)(a)
}
return GenericArray{a}
}
// ArrayDelimiter may be optionally implemented by driver.Valuer or sql.Scanner
// to override the array delimiter used by GenericArray.
type ArrayDelimiter interface {
// ArrayDelimiter returns the delimiter character(s) for this element's type.
ArrayDelimiter() string
}
// BoolArray represents a one-dimensional array of the PostgreSQL boolean type.
type BoolArray []bool
// Scan implements the sql.Scanner interface.
func (a *BoolArray) Scan(src interface{}) error {
switch src := src.(type) {
case []byte:
return a.scanBytes(src)
case string:
return a.scanBytes([]byte(src))
case nil:
*a = nil
return nil
}
return fmt.Errorf("pq: cannot convert %T to BoolArray", src)
}
func (a *BoolArray) scanBytes(src []byte) error {
elems, err := scanLinearArray(src, []byte{','}, "BoolArray")
if err != nil {
return err
}
if *a != nil && len(elems) == 0 {
*a = (*a)[:0]
} else {
b := make(BoolArray, len(elems))
for i, v := range elems {
if len(v) != 1 {
return fmt.Errorf("pq: could not parse boolean array index %d: invalid boolean %q", i, v)
}
switch v[0] {
case 't':
b[i] = true
case 'f':
b[i] = false
default:
return fmt.Errorf("pq: could not parse boolean array index %d: invalid boolean %q", i, v)
}
}
*a = b
}
return nil
}
// Value implements the driver.Valuer interface.
func (a BoolArray) Value() (driver.Value, error) {
if a == nil {
return nil, nil
}
if n := len(a); n > 0 {
// There will be exactly two curly brackets, N bytes of values,
// and N-1 bytes of delimiters.
b := make([]byte, 1+2*n)
for i := 0; i < n; i++ {
b[2*i] = ','
if a[i] {
b[1+2*i] = 't'
} else {
b[1+2*i] = 'f'
}
}
b[0] = '{'
b[2*n] = '}'
return string(b), nil
}
return "{}", nil
}
// ByteaArray represents a one-dimensional array of the PostgreSQL bytea type.
type ByteaArray [][]byte
// Scan implements the sql.Scanner interface.
func (a *ByteaArray) Scan(src interface{}) error {
switch src := src.(type) {
case []byte:
return a.scanBytes(src)
case string:
return a.scanBytes([]byte(src))
case nil:
*a = nil
return nil
}
return fmt.Errorf("pq: cannot convert %T to ByteaArray", src)
}
func (a *ByteaArray) scanBytes(src []byte) error {
elems, err := scanLinearArray(src, []byte{','}, "ByteaArray")
if err != nil {
return err
}
if *a != nil && len(elems) == 0 {
*a = (*a)[:0]
} else {
b := make(ByteaArray, len(elems))
for i, v := range elems {
b[i], err = parseBytea(v)
if err != nil {
return fmt.Errorf("could not parse bytea array index %d: %s", i, err.Error())
}
}
*a = b
}
return nil
}
// Value implements the driver.Valuer interface. It uses the "hex" format which
// is only supported on PostgreSQL 9.0 or newer.
func (a ByteaArray) Value() (driver.Value, error) {
if a == nil {
return nil, nil
}
if n := len(a); n > 0 {
// There will be at least two curly brackets, 2*N bytes of quotes,
// 3*N bytes of hex formatting, and N-1 bytes of delimiters.
size := 1 + 6*n
for _, x := range a {
size += hex.EncodedLen(len(x))
}
b := make([]byte, size)
for i, s := 0, b; i < n; i++ {
o := copy(s, `,"\\x`)
o += hex.Encode(s[o:], a[i])
s[o] = '"'
s = s[o+1:]
}
b[0] = '{'
b[size-1] = '}'
return string(b), nil
}
return "{}", nil
}
// Float64Array represents a one-dimensional array of the PostgreSQL double
// precision type.
type Float64Array []float64
// Scan implements the sql.Scanner interface.
func (a *Float64Array) Scan(src interface{}) error {
switch src := src.(type) {
case []byte:
return a.scanBytes(src)
case string:
return a.scanBytes([]byte(src))
case nil:
*a = nil
return nil
}
return fmt.Errorf("pq: cannot convert %T to Float64Array", src)
}
func (a *Float64Array) scanBytes(src []byte) error {
elems, err := scanLinearArray(src, []byte{','}, "Float64Array")
if err != nil {
return err
}
if *a != nil && len(elems) == 0 {
*a = (*a)[:0]
} else {
b := make(Float64Array, len(elems))
for i, v := range elems {
if b[i], err = strconv.ParseFloat(string(v), 64); err != nil {
return fmt.Errorf("pq: parsing array element index %d: %v", i, err)
}
}
*a = b
}
return nil
}
// Value implements the driver.Valuer interface.
func (a Float64Array) Value() (driver.Value, error) {
if a == nil {
return nil, nil
}
if n := len(a); n > 0 {
// There will be at least two curly brackets, N bytes of values,
// and N-1 bytes of delimiters.
b := make([]byte, 1, 1+2*n)
b[0] = '{'
b = strconv.AppendFloat(b, a[0], 'f', -1, 64)
for i := 1; i < n; i++ {
b = append(b, ',')
b = strconv.AppendFloat(b, a[i], 'f', -1, 64)
}
return string(append(b, '}')), nil
}
return "{}", nil
}
// Float32Array represents a one-dimensional array of the PostgreSQL double
// precision type.
type Float32Array []float32
// Scan implements the sql.Scanner interface.
func (a *Float32Array) Scan(src interface{}) error {
switch src := src.(type) {
case []byte:
return a.scanBytes(src)
case string:
return a.scanBytes([]byte(src))
case nil:
*a = nil
return nil
}
return fmt.Errorf("pq: cannot convert %T to Float32Array", src)
}
func (a *Float32Array) scanBytes(src []byte) error {
elems, err := scanLinearArray(src, []byte{','}, "Float32Array")
if err != nil {
return err
}
if *a != nil && len(elems) == 0 {
*a = (*a)[:0]
} else {
b := make(Float32Array, len(elems))
for i, v := range elems {
var x float64
if x, err = strconv.ParseFloat(string(v), 32); err != nil {
return fmt.Errorf("pq: parsing array element index %d: %v", i, err)
}
b[i] = float32(x)
}
*a = b
}
return nil
}
// Value implements the driver.Valuer interface.
func (a Float32Array) Value() (driver.Value, error) {
if a == nil {
return nil, nil
}
if n := len(a); n > 0 {
// There will be at least two curly brackets, N bytes of values,
// and N-1 bytes of delimiters.
b := make([]byte, 1, 1+2*n)
b[0] = '{'
b = strconv.AppendFloat(b, float64(a[0]), 'f', -1, 32)
for i := 1; i < n; i++ {
b = append(b, ',')
b = strconv.AppendFloat(b, float64(a[i]), 'f', -1, 32)
}
return string(append(b, '}')), nil
}
return "{}", nil
}
// GenericArray implements the driver.Valuer and sql.Scanner interfaces for
// an array or slice of any dimension.
type GenericArray struct{ A interface{} }
func (GenericArray) evaluateDestination(rt reflect.Type) (reflect.Type, func([]byte, reflect.Value) error, string) {
var assign func([]byte, reflect.Value) error
var del = ","
// TODO calculate the assign function for other types
// TODO repeat this section on the element type of arrays or slices (multidimensional)
{
if reflect.PtrTo(rt).Implements(typeSQLScanner) {
// dest is always addressable because it is an element of a slice.
assign = func(src []byte, dest reflect.Value) (err error) {
ss := dest.Addr().Interface().(sql.Scanner)
if src == nil {
err = ss.Scan(nil)
} else {
err = ss.Scan(src)
}
return
}
goto FoundType
}
assign = func([]byte, reflect.Value) error {
return fmt.Errorf("pq: scanning to %s is not implemented; only sql.Scanner", rt)
}
}
FoundType:
if ad, ok := reflect.Zero(rt).Interface().(ArrayDelimiter); ok {
del = ad.ArrayDelimiter()
}
return rt, assign, del
}
// Scan implements the sql.Scanner interface.
func (a GenericArray) Scan(src interface{}) error {
dpv := reflect.ValueOf(a.A)
switch {
case dpv.Kind() != reflect.Ptr:
return fmt.Errorf("pq: destination %T is not a pointer to array or slice", a.A)
case dpv.IsNil():
return fmt.Errorf("pq: destination %T is nil", a.A)
}
dv := dpv.Elem()
switch dv.Kind() {
case reflect.Slice:
case reflect.Array:
default:
return fmt.Errorf("pq: destination %T is not a pointer to array or slice", a.A)
}
switch src := src.(type) {
case []byte:
return a.scanBytes(src, dv)
case string:
return a.scanBytes([]byte(src), dv)
case nil:
if dv.Kind() == reflect.Slice {
dv.Set(reflect.Zero(dv.Type()))
return nil
}
}
return fmt.Errorf("pq: cannot convert %T to %s", src, dv.Type())
}
func (a GenericArray) scanBytes(src []byte, dv reflect.Value) error {
dtype, assign, del := a.evaluateDestination(dv.Type().Elem())
dims, elems, err := parseArray(src, []byte(del))
if err != nil {
return err
}
// TODO allow multidimensional
if len(dims) > 1 {
return fmt.Errorf("pq: scanning from multidimensional ARRAY%s is not implemented",
strings.Replace(fmt.Sprint(dims), " ", "][", -1))
}
// Treat a zero-dimensional array like an array with a single dimension of zero.
if len(dims) == 0 {
dims = append(dims, 0)
}
for i, rt := 0, dv.Type(); i < len(dims); i, rt = i+1, rt.Elem() {
switch rt.Kind() {
case reflect.Slice:
case reflect.Array:
if rt.Len() != dims[i] {
return fmt.Errorf("pq: cannot convert ARRAY%s to %s",
strings.Replace(fmt.Sprint(dims), " ", "][", -1), dv.Type())
}
default:
// TODO handle multidimensional
}
}
values := reflect.MakeSlice(reflect.SliceOf(dtype), len(elems), len(elems))
for i, e := range elems {
if err := assign(e, values.Index(i)); err != nil {
return fmt.Errorf("pq: parsing array element index %d: %v", i, err)
}
}
// TODO handle multidimensional
switch dv.Kind() {
case reflect.Slice:
dv.Set(values.Slice(0, dims[0]))
case reflect.Array:
for i := 0; i < dims[0]; i++ {
dv.Index(i).Set(values.Index(i))
}
}
return nil
}
// Value implements the driver.Valuer interface.
func (a GenericArray) Value() (driver.Value, error) {
if a.A == nil {
return nil, nil
}
rv := reflect.ValueOf(a.A)
switch rv.Kind() {
case reflect.Slice:
if rv.IsNil() {
return nil, nil
}
case reflect.Array:
default:
return nil, fmt.Errorf("pq: Unable to convert %T to array", a.A)
}
if n := rv.Len(); n > 0 {
// There will be at least two curly brackets, N bytes of values,
// and N-1 bytes of delimiters.
b := make([]byte, 0, 1+2*n)
b, _, err := appendArray(b, rv, n)
return string(b), err
}
return "{}", nil
}
// Int64Array represents a one-dimensional array of the PostgreSQL integer types.
type Int64Array []int64
// Scan implements the sql.Scanner interface.
func (a *Int64Array) Scan(src interface{}) error {
switch src := src.(type) {
case []byte:
return a.scanBytes(src)
case string:
return a.scanBytes([]byte(src))
case nil:
*a = nil
return nil
}
return fmt.Errorf("pq: cannot convert %T to Int64Array", src)
}
func (a *Int64Array) scanBytes(src []byte) error {
elems, err := scanLinearArray(src, []byte{','}, "Int64Array")
if err != nil {
return err
}
if *a != nil && len(elems) == 0 {
*a = (*a)[:0]
} else {
b := make(Int64Array, len(elems))
for i, v := range elems {
if b[i], err = strconv.ParseInt(string(v), 10, 64); err != nil {
return fmt.Errorf("pq: parsing array element index %d: %v", i, err)
}
}
*a = b
}
return nil
}
// Value implements the driver.Valuer interface.
func (a Int64Array) Value() (driver.Value, error) {
if a == nil {
return nil, nil
}
if n := len(a); n > 0 {
// There will be at least two curly brackets, N bytes of values,
// and N-1 bytes of delimiters.
b := make([]byte, 1, 1+2*n)
b[0] = '{'
b = strconv.AppendInt(b, a[0], 10)
for i := 1; i < n; i++ {
b = append(b, ',')
b = strconv.AppendInt(b, a[i], 10)
}
return string(append(b, '}')), nil
}
return "{}", nil
}
// Int32Array represents a one-dimensional array of the PostgreSQL integer types.
type Int32Array []int32
// Scan implements the sql.Scanner interface.
func (a *Int32Array) Scan(src interface{}) error {
switch src := src.(type) {
case []byte:
return a.scanBytes(src)
case string:
return a.scanBytes([]byte(src))
case nil:
*a = nil
return nil
}
return fmt.Errorf("pq: cannot convert %T to Int32Array", src)
}
func (a *Int32Array) scanBytes(src []byte) error {
elems, err := scanLinearArray(src, []byte{','}, "Int32Array")
if err != nil {
return err
}
if *a != nil && len(elems) == 0 {
*a = (*a)[:0]
} else {
b := make(Int32Array, len(elems))
for i, v := range elems {
x, err := strconv.ParseInt(string(v), 10, 32)
if err != nil {
return fmt.Errorf("pq: parsing array element index %d: %v", i, err)
}
b[i] = int32(x)
}
*a = b
}
return nil
}
// Value implements the driver.Valuer interface.
func (a Int32Array) Value() (driver.Value, error) {
if a == nil {
return nil, nil
}
if n := len(a); n > 0 {
// There will be at least two curly brackets, N bytes of values,
// and N-1 bytes of delimiters.
b := make([]byte, 1, 1+2*n)
b[0] = '{'
b = strconv.AppendInt(b, int64(a[0]), 10)
for i := 1; i < n; i++ {
b = append(b, ',')
b = strconv.AppendInt(b, int64(a[i]), 10)
}
return string(append(b, '}')), nil
}
return "{}", nil
}
// StringArray represents a one-dimensional array of the PostgreSQL character types.
type StringArray []string
// Scan implements the sql.Scanner interface.
func (a *StringArray) Scan(src interface{}) error {
switch src := src.(type) {
case []byte:
return a.scanBytes(src)
case string:
return a.scanBytes([]byte(src))
case nil:
*a = nil
return nil
}
return fmt.Errorf("pq: cannot convert %T to StringArray", src)
}
func (a *StringArray) scanBytes(src []byte) error {
elems, err := scanLinearArray(src, []byte{','}, "StringArray")
if err != nil {
return err
}
if *a != nil && len(elems) == 0 {
*a = (*a)[:0]
} else {
b := make(StringArray, len(elems))
for i, v := range elems {
if b[i] = string(v); v == nil {
return fmt.Errorf("pq: parsing array element index %d: cannot convert nil to string", i)
}
}
*a = b
}
return nil
}
// Value implements the driver.Valuer interface.
func (a StringArray) Value() (driver.Value, error) {
if a == nil {
return nil, nil
}
if n := len(a); n > 0 {
// There will be at least two curly brackets, 2*N bytes of quotes,
// and N-1 bytes of delimiters.
b := make([]byte, 1, 1+3*n)
b[0] = '{'
b = appendArrayQuotedBytes(b, []byte(a[0]))
for i := 1; i < n; i++ {
b = append(b, ',')
b = appendArrayQuotedBytes(b, []byte(a[i]))
}
return string(append(b, '}')), nil
}
return "{}", nil
}
// appendArray appends rv to the buffer, returning the extended buffer and
// the delimiter used between elements.
//
// It panics when n <= 0 or rv's Kind is not reflect.Array nor reflect.Slice.
func appendArray(b []byte, rv reflect.Value, n int) ([]byte, string, error) {
var del string
var err error
b = append(b, '{')
if b, del, err = appendArrayElement(b, rv.Index(0)); err != nil {
return b, del, err
}
for i := 1; i < n; i++ {
b = append(b, del...)
if b, del, err = appendArrayElement(b, rv.Index(i)); err != nil {
return b, del, err
}
}
return append(b, '}'), del, nil
}
// appendArrayElement appends rv to the buffer, returning the extended buffer
// and the delimiter to use before the next element.
//
// When rv's Kind is neither reflect.Array nor reflect.Slice, it is converted
// using driver.DefaultParameterConverter and the resulting []byte or string
// is double-quoted.
//
// See http://www.postgresql.org/docs/current/static/arrays.html#ARRAYS-IO
func appendArrayElement(b []byte, rv reflect.Value) ([]byte, string, error) {
if k := rv.Kind(); k == reflect.Array || k == reflect.Slice {
if t := rv.Type(); t != typeByteSlice && !t.Implements(typeDriverValuer) {
if n := rv.Len(); n > 0 {
return appendArray(b, rv, n)
}
return b, "", nil
}
}
var del = ","
var err error
var iv interface{} = rv.Interface()
if ad, ok := iv.(ArrayDelimiter); ok {
del = ad.ArrayDelimiter()
}
if iv, err = driver.DefaultParameterConverter.ConvertValue(iv); err != nil {
return b, del, err
}
switch v := iv.(type) {
case nil:
return append(b, "NULL"...), del, nil
case []byte:
return appendArrayQuotedBytes(b, v), del, nil
case string:
return appendArrayQuotedBytes(b, []byte(v)), del, nil
}
b, err = appendValue(b, iv)
return b, del, err
}
func appendArrayQuotedBytes(b, v []byte) []byte {
b = append(b, '"')
for {
i := bytes.IndexAny(v, `"\`)
if i < 0 {
b = append(b, v...)
break
}
if i > 0 {
b = append(b, v[:i]...)
}
b = append(b, '\\', v[i])
v = v[i+1:]
}
return append(b, '"')
}
func appendValue(b []byte, v driver.Value) ([]byte, error) {
return append(b, encode(nil, v, 0)...), nil
}
// parseArray extracts the dimensions and elements of an array represented in
// text format. Only representations emitted by the backend are supported.
// Notably, whitespace around brackets and delimiters is significant, and NULL
// is case-sensitive.
//
// See http://www.postgresql.org/docs/current/static/arrays.html#ARRAYS-IO
func parseArray(src, del []byte) (dims []int, elems [][]byte, err error) {
var depth, i int
if len(src) < 1 || src[0] != '{' {
return nil, nil, fmt.Errorf("pq: unable to parse array; expected %q at offset %d", '{', 0)
}
Open:
for i < len(src) {
switch src[i] {
case '{':
depth++
i++
case '}':
elems = make([][]byte, 0)
goto Close
default:
break Open
}
}
dims = make([]int, i)
Element:
for i < len(src) {
switch src[i] {
case '{':
if depth == len(dims) {
break Element
}
depth++
dims[depth-1] = 0
i++
case '"':
var elem = []byte{}
var escape bool
for i++; i < len(src); i++ {
if escape {
elem = append(elem, src[i])
escape = false
} else {
switch src[i] {
default:
elem = append(elem, src[i])
case '\\':
escape = true
case '"':
elems = append(elems, elem)
i++
break Element
}
}
}
default:
for start := i; i < len(src); i++ {
if bytes.HasPrefix(src[i:], del) || src[i] == '}' {
elem := src[start:i]
if len(elem) == 0 {
return nil, nil, fmt.Errorf("pq: unable to parse array; unexpected %q at offset %d", src[i], i)
}
if bytes.Equal(elem, []byte("NULL")) {
elem = nil
}
elems = append(elems, elem)
break Element
}
}
}
}
for i < len(src) {
if bytes.HasPrefix(src[i:], del) && depth > 0 {
dims[depth-1]++
i += len(del)
goto Element
} else if src[i] == '}' && depth > 0 {
dims[depth-1]++
depth--
i++
} else {
return nil, nil, fmt.Errorf("pq: unable to parse array; unexpected %q at offset %d", src[i], i)
}
}
Close:
for i < len(src) {
if src[i] == '}' && depth > 0 {
depth--
i++
} else {
return nil, nil, fmt.Errorf("pq: unable to parse array; unexpected %q at offset %d", src[i], i)
}
}
if depth > 0 {
err = fmt.Errorf("pq: unable to parse array; expected %q at offset %d", '}', i)
}
if err == nil {
for _, d := range dims {
if (len(elems) % d) != 0 {
err = fmt.Errorf("pq: multidimensional arrays must have elements with matching dimensions")
}
}
}
return
}
func scanLinearArray(src, del []byte, typ string) (elems [][]byte, err error) {
dims, elems, err := parseArray(src, del)
if err != nil {
return nil, err
}
if len(dims) > 1 {
return nil, fmt.Errorf("pq: cannot convert ARRAY%s to %s", strings.Replace(fmt.Sprint(dims), " ", "][", -1), typ)
}
return elems, err
}

1652
third_party/highgo-pq/array_test.go vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
module github.com/lib/pq/auth/kerberos
go 1.13
require (
github.com/alexbrainman/sspi v0.0.0-20180613141037-e580b900e9f5
github.com/jcmturner/gokrb5/v8 v8.2.0
)

View File

@@ -0,0 +1,40 @@
github.com/alexbrainman/sspi v0.0.0-20180613141037-e580b900e9f5 h1:P5U+E4x5OkVEKQDklVPmzs71WM56RTTRqV4OrDC//Y4=
github.com/alexbrainman/sspi v0.0.0-20180613141037-e580b900e9f5/go.mod h1:976q2ETgjT2snVCf2ZaBnyBbVoPERGjUz+0sofzEfro=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYbQ=
github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.0.0 h1:J7uCkflzTEhUZ64xqKnkDxq3kzc96ajM1Gli5ktUem8=
github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.2.0 h1:lzPl/30ZLkTveYsYZPKMcgXc8MbnE6RsTd4F9KgiLtk=
github.com/jcmturner/gokrb5/v8 v8.2.0/go.mod h1:T1hnNppQsBtxW0tCHMHTkAt8n/sABdzZgZdoFrZaZNM=
github.com/jcmturner/rpc/v2 v2.0.2 h1:gMB4IwRXYsWw4Bc6o/az2HJgFUA1ffSh90i26ZJ6Xl0=
github.com/jcmturner/rpc/v2 v2.0.2/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200117160349-530e935923ad h1:Jh8cai0fqIK+f6nG0UgPW5wFk8wmiMhM3AyciDBdtQg=
golang.org/x/crypto v0.0.0-20200117160349-530e935923ad/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa h1:F+8P+gmewFQYRk6JoLQLwjBCTu3mcIURZfNkVweuRKA=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

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