Compare commits

..

69 Commits

Author SHA1 Message Date
Syngnat
9685102229 feat(sidebar-batch-table): 批量操作表新增对象筛选与作用范围控制
- 批量操作表弹窗新增关键字筛选(忽略大小写包含匹配)
- 新增类型筛选(全部对象/仅表/仅视图)
- 新增勾选作用范围切换(当前筛选结果/全部对象)
- 全选、取消全选、反选逻辑按作用范围执行
- 筛选区域展示命中计数与无匹配空态提示
- refs #130
2026-02-27 14:21:14 +08:00
Syngnat
3505b4428a Merge remote-tracking branch 'origin/fix/windows-issue-20260226-ygf' into fix/windows-issue-20260226-ygf 2026-02-27 13:57:04 +08:00
Syngnat
9ebdf7f053 feat(appearance): 新增启动时全屏开关并支持启动窗口状态自动应用
- 在外观设置中提供用户可控的启动全屏偏好项
- 持久化保存用户偏好,重启后自动恢复
- 启动阶段按偏好自动执行全屏,失败时回退最大化
- 保持现有标题栏窗口操作行为不变
- refs #129
2026-02-27 13:56:36 +08:00
Syngnat
9ad852c10b 🐛 fix(redis-viewer): 修复大数据量场景 Key 加载不完整问题
- 后端 ScanKeys 改为按目标数量多轮聚合扫描,不再只依赖单轮返回结果
- 新增扫描目标数/步长/轮次上限,避免扫描过少或无限循环
- 前端首屏加载、搜索、刷新统一按较大批次请求,避免回退到几百条
- 加载更多改为按固定批次继续拉取并保持去重合并
- refs #129
2026-02-27 13:56:36 +08:00
Syngnat
2a8fff4d93 feat(driver-manager): 增强驱动管理网络诊断与本地导入能力
- 新增 CheckDriverNetworkStatus,探测 GitHub API/Release/Go Proxy 可达性并返回代理环境信息。
- 驱动管理弹窗新增网络检测结果、驱动目录复用说明、本地导入入口与日志查看。
- 操作日志支持同签名进度覆盖更新,下载百分比动态刷新,不再重复新增日志行。
- 修正弹窗滚动行为与表格滚动体验。
- refs #128
2026-02-27 13:56:36 +08:00
Syngnat
eca560b4e5 🐛 fix(data-grid): 修复单元格编辑器拖拽越界不自动滚动
- 在 DataGrid 拖拽选区流程新增边缘自动滚动能力(横向+纵向)
- 拖拽过程中增加鼠标位置跟踪并通过 RAF 循环驱动滚动
- 通过 elementFromPoint 兜底命中单元格,保证越界拖拽时选区持续更新
- 在 mouseup、模式切换和退出编辑器时统一清理 RAF 与拖拽状态
- refs #127
2026-02-27 13:56:36 +08:00
Syngnat
2f475dddc0 🐛 fix(windows-upgrade): 修复Windows升级后连接列表丢失问题
- 启动参数新增固定 WebviewUserDataPath 到 %APPDATA%/GoNavi/WebView2
- 首次启动自动迁移历史 WebView 数据目录
- 保留现有存储键,避免破坏已落盘配置
- 前端持久化读取增加历史结构兼容
- refs #125
2026-02-27 13:56:35 +08:00
Syngnat
ad9d8a12be feat(appearance): 新增启动时全屏开关并支持启动窗口状态自动应用
- 在外观设置中提供用户可控的启动全屏偏好项
- 持久化保存用户偏好,重启后自动恢复
- 启动阶段按偏好自动执行全屏,失败时回退最大化
- 保持现有标题栏窗口操作行为不变
- refs #129
2026-02-27 13:55:37 +08:00
Syngnat
095b22951e 🐛 fix(redis-viewer): 修复大数据量场景 Key 加载不完整问题
- 后端 ScanKeys 改为按目标数量多轮聚合扫描,不再只依赖单轮返回结果
- 新增扫描目标数/步长/轮次上限,避免扫描过少或无限循环
- 前端首屏加载、搜索、刷新统一按较大批次请求,避免回退到几百条
- 加载更多改为按固定批次继续拉取并保持去重合并
- refs #129
2026-02-27 13:26:28 +08:00
Syngnat
7350a011e3 feat(driver-manager): 增强驱动管理网络诊断与本地导入能力
- 新增 CheckDriverNetworkStatus,探测 GitHub API/Release/Go Proxy 可达性并返回代理环境信息。
- 驱动管理弹窗新增网络检测结果、驱动目录复用说明、本地导入入口与日志查看。
- 操作日志支持同签名进度覆盖更新,下载百分比动态刷新,不再重复新增日志行。
- 修正弹窗滚动行为与表格滚动体验。
- refs #128
2026-02-27 12:29:54 +08:00
Syngnat
53b5802add 🐛 fix(data-grid): 修复单元格编辑器拖拽越界不自动滚动
- 在 DataGrid 拖拽选区流程新增边缘自动滚动能力(横向+纵向)
- 拖拽过程中增加鼠标位置跟踪并通过 RAF 循环驱动滚动
- 通过 elementFromPoint 兜底命中单元格,保证越界拖拽时选区持续更新
- 在 mouseup、模式切换和退出编辑器时统一清理 RAF 与拖拽状态
- refs #127
2026-02-27 10:57:05 +08:00
Syngnat
54e7077317 🐛 fix(windows-upgrade): 修复Windows升级后连接列表丢失问题
- 启动参数新增固定 WebviewUserDataPath 到 %APPDATA%/GoNavi/WebView2
- 首次启动自动迁移历史 WebView 数据目录
- 保留现有存储键,避免破坏已落盘配置
- 前端持久化读取增加历史结构兼容
2026-02-27 10:45:57 +08:00
Syngnat
96de46cf1e 🐛 fix(postgres-connection): 修复无postgres库时连接失败并支持默认连接库配置
- PostgreSQL 空 database 时按 postgres、template1、用户名同名库回退连接
- 移除后端对 database=postgres 的硬编码写死逻辑
- 连接弹窗新增 PostgreSQL 默认连接数据库(可选)配置项
- refs #120
2026-02-27 09:49:47 +08:00
Syngnat
7d5592d8d9 feat(db): 数据库连接新增 SOCKS5/HTTP 代理能力并兼容 SRV/SSH 场景
- 后端 ConnectionConfig 增加代理配置并完成规范化处理
- 普通 TCP 数据源通过本地转发接入代理
- MongoDB 使用 Dialer 支持代理连接(含 SRV)
- 前端连接配置新增代理 UI、字段清洗与数据回填
- refs #122
2026-02-27 09:31:24 +08:00
Syngnat
d0ba8822f3 feat(driver-manager): 完善驱动多版本安装与版本级包大小动态展示
- 新增驱动版本列表能力,支持按版本选择安装
- 新增按版本查询安装包大小接口,前端切换版本后动态刷新
- 增加版本大小查询回退策略(tag 未命中时回退 latest)
- 优化版本下拉加载链路并增加后台预热,降低首次展开等待
2026-02-27 08:37:35 +08:00
Syngnat
140db73ef4 🐛 fix(startup-release): 修复 Win/mac 发布包白屏与无响应问题
- 移除 v0.4.7 引入的高风险 chunk 拆分配置
- 恢复 main.tsx 的 Monaco 稳定初始化方式
- 调整 release workflow 的 macOS codesign 参数避免双击无反应
2026-02-26 15:21:36 +08:00
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
Syngnat
1a3b55ce19 Merge pull request #94 from Syngnat/release/0.3.9
🔧fix(mongodb): 修复MongoDB查询仅返回一条数据的问题
2026-02-10 12:27:53 +08:00
Syngnat
fa318a9f0e 🔧fix(mongodb): 修复MongoDB查询仅返回一条数据的问题
- queryWithContext 中 find/count 命令改用原生 Collection.Find()和 CountDocuments() API,替代RunCommand 的 firstBatch 模式
- 新增 convertBsonValue 将 ObjectID/bson.M/bson.D/bson.A 转为JSON 友好类型,_id 列自动置首
- DBQuery 增加 MongoDB JSON 命令识别,避免 find 命令误走 Exec 分支

️perf(macos): 动态控制 NSVisualEffectView 降低 MacOS GPU 持续消耗,Windows不受影响

- NSVisualEffectView 启动默认 alpha 由 0.72 改为 0,窗口默认 opaque
- 新增 gonaviSetEffectViewAlpha ObjC 函数支持运行时动态切换
- 新增 SetWindowTranslucency Wails 绑定方法供前端调用
- 启动重试次数由 24 次缩减至 8 次
- opacity=1.0 且 blur=0 时窗口标记 opaque,GPU 不再持续计算模糊合成
- App.tsx 仅保留最外层 Layout 的 backdropFilter,移除 TitleBar/MenuBar/Content/DataGrid/LogPanel 冗余嵌套
- App.css 移除暗色模式全局 text-shadow 减少 compositing 开销
2026-02-10 12:25:34 +08:00
Syngnat
8dafad7ce3 Merge pull request #93 from Syngnat/release/0.3.8
Release/0.3.8
2026-02-09 21:56:38 +08:00
杨国锋
78e35a5be8 ️ perf(data-grid): 重构批量编辑链路并优化表格渲染性能
- 重构批量改单元格的状态流,减少高频交互时的无效重渲染
- 优化大数据量场景下的表格交互流畅度与响应延迟
- 调整单元格编辑细节,增强与 Navicat 编辑习惯的一致性

🔧 fix(sidebar-connection): 修复多数据源切换后旧连接节点无响应问题

- 修复新建并连接新数据源后,旧数据源点击无响应的问题

 feat(tab-manager): 表与设计标签支持环境前缀显示

- 基于连接名识别 DEV/UAT/PROD/SIT/STG/TEST 环境标记
- 仅对 table/design 标签添加环境前缀,查询等标签保持原样
- 无法识别标准环境时回退显示连接名,提升多环境可辨识性

 feat(connection-config): 新增连接URI复制解析并支持MySQL/Mongo主从配置

- 连接弹窗新增 URI 生成、解析、复制能力,支持参数回填
- MySQL 支持多地址主从拓扑、从库地址列表与从库独立凭据
- Mongo 支持多节点配置、replicaSet、authSource、readPreference
- 扩展前后端连接配置模型并同步 Wails 生成类型文件
- 后端接入主从凭据回退策略,保持旧配置兼容

 feat(mongodb-replica): 对齐Navicat主从配置并补齐成员发现能力

- 新增 mongoSrv、mongoAuthMechanism、savePassword 配置项
- 支持 mongodb+srv URI 构建与解析,并透传 authMechanism
- 新增 MongoDiscoverMembers 接口,返回成员与状态信息
- 驱动侧实现 replSetGetStatus -> hello/isMaster 回退发现链路
- 前端弹窗新增 SRV 开关、验证方式、成员发现按钮与状态表
- 增加 SRV+SSH 冲突提示与后端保护,避免无效连接路径

🔧 fix(app-error-text): 修复连接测试错误信息乱码并完善日志提示

- 新增错误文本编码纠正能力,处理混合编码导致的中文乱码
- 连接错误提示统一走 normalizeErrorMessage 输出
- 增加 GB18030 纠正相关单元测试覆盖 PostgreSQL 认证失败场景
- go.mod 显式引入 golang.org/x/text 依赖

 feat(filter-panel): 筛选条件支持启用停用与批量开关

- 筛选条件新增 enabled 状态,支持按条件勾选启用/停用
- 筛选面板新增“全启用”“全停用”快捷操作
- SQL 组装时自动跳过已停用条件,保留条件内容便于复用
- 同步 DataViewer 与 SQL 工具层类型,确保筛选链路一致性

🔧 fix(connection-modal-scroll): 修复连接弹窗滚动行为并去除外层滚动条

- 连接配置步骤设置弹窗 body 最大高度与内部滚动
- 为连接弹窗增加专用 wrapClassName 并禁用外层滚动
- 修复出现双滚动条的问题,确保仅保留弹窗内部滚动条
2026-02-09 21:54:11 +08:00
Syngnat
35ed555857 ️ perf(data-grid): 重构批量编辑实现并优化渲染性能
- 架构优化:移除 CellEditModeContext,避免 Context 变化触发全表重渲染
  - 事件委托:在容器级别处理鼠标事件,减少事件监听器数量从 O(n*m) 到 O(1)
  - DOM查询优化:使用 data-row-key/data-col-name 属性直接定位单元格
  - RAF节流:拖拽选择使用 requestAnimationFrame 节流,保证 60fps 流畅度
  - CSS类控制:批量编辑模式样式通过 CSS 类切换,而非内联 style
2026-02-09 17:37:59 +08:00
Syngnat
954a5d77d3 Merge pull request #90 from Syngnat/release/0.3.7
Release/0.3.7
2026-02-09 16:01:12 +08:00
Syngnat
f3130ff517 🔧fix(data-grid): 修复无效日期时间值导致应用崩溃问题
- normalizeDateTimeString 函数添加无效日期时间检测(0000-00-00)
- 无效日期时间保持原样显示,不尝试转换
- 根本原因:MySQL 等数据库的 0000-00-00 00:00:00 值导致渲染崩溃
2026-02-09 15:58:04 +08:00
Syngnat
012c99be9e feat(sidebar): 新增侧栏表自定义排序功能
- 支持按名称排序(字母顺序,默认)
- 支持按使用频率排序(打开次数降序)
- 右键表分组节点选择排序方式
- 排序偏好和访问统计持久化保存
- 每个数据库可独立设置排序方式
2026-02-09 15:53:35 +08:00
Syngnat
c8575c315b 🔧fix(data-grid): 修复查询含SQL语句字段时应用崩溃问题
- formatCellValue 函数添加 try-catch 保护
- JSON.stringify 异常时降级显示 [Object]
- 新增 DataGridErrorBoundary 错误边界组件
- 渲染错误时显示友好提示并提供重试按钮
2026-02-09 15:43:25 +08:00
Syngnat
601d69faeb feat(data-grid): 新增表格批量编辑功能
- 批量填充相同值:右键菜单新增"填充到选中行"选项,可将当前单元格值批量填充到所有选中行
- 拖拽填充柄:单元格悬停时右下角显示蓝色填充柄,支持向下拖拽自动填充
- 智能自增算法:数字类型+1,字符串末尾数字+1并保持前导零位数(如 item_001 → item_002)
- 性能优化:使用 ref 缓存 DOM 查询结果,避免拖拽过程中触发 React 重渲染
- 选区指示器使用 fixed 定位渲染到 Portal,确保位置准确
2026-02-09 15:31:18 +08:00
Syngnat
fdb7781a9b feat(db-sidebar): 新增数据库对象分组展示及触发器管理功能
- 侧栏数据库节点按对象类型分组展示(表/视图/触发器)
 - 新增视图节点支持双击打开数据浏览
 - 新增触发器节点支持双击查看触发器定义(TriggerViewer组件)
 - 表级触发器管理:支持查看语句、新增、修改、删除操作
 - 对象分组内按名称字母排序
 - DDL查看及触发器编辑器适配透明模式背景
 - 多数据库类型的视图/触发器元数据查询SQL适配
 - refs #89
2026-02-09 14:50:13 +08:00
Syngnat
087578693e feat(db-sidebar): 新增TDengine支持并优化跨数据源表名展示体验
- 引入 TDengine 数据源能力并补齐运行时配置与标识符处理
- 侧栏对 schema.table 数据源统一展示短表名
- 表节点悬停显示完整 schema.table,降低重名识别成本
- 更新文档与验证用例,保证改动可追踪可回归
2026-02-09 12:12:35 +08:00
Syngnat
aceabb63f5 🔧 fix(redis-viewer): 修复 Redis Key 列表窄窗口遮挡并支持 TTL 响应式隐藏
- 将 Key 行改为弹性布局,避免 type 标签覆盖名称
- 基于左侧面板宽度阈值自动隐藏 TTL,优先保证名称可读性
- refs #88
2026-02-09 11:20:03 +08:00
杨国锋
8587f72f81 🔧 fix(update): 修复更新下载时文件被占用导致失败的问题 2026-02-08 14:19:13 +08:00
Syngnat
1b5a71d478 Merge pull request #87 from Syngnat/release/0.3.6
 feat(datasource): 新增 MariaDB、Vastbase、HighGo、MongoDB、SQL Server 五种数据源支持

- MariaDB:MySQL驱动占位,默认端口3306,归类关系型数据库
- Vastbase(海量):PG驱动占位,默认端口5432,归类国产数据库
- HighGo(瀚高):PG驱动,支持SM3认证扩展,归类国产数据库
- MongoDB:官方驱动实现,归类NoSQL
- SQL Server:微软官方驱动实现,归类关系型数据库
- ConnectionModal 新增数据源选项卡与默认端口配置
- database.go 新增5种类型的实例化分支
- 同步更新 db_context、methods_db、sql_sanitize、methods_file、sql_helpers 类型判断

🔧 fix(appearance): 优化 Windows 透明效果体验并调整透明度滑块灵敏度

- Windows 平台限制:隐藏模糊滑块,显示系统 Acrylic 效果说明
- 透明度因子调整:Windows 从 0.20 调整为 0.70,变化更平滑
- 透明度因子调整:macOS 从 0.20 调整为 0.60,变化更平滑
- 用户体验:修复滑块从 100% 拉到 95% 时透明度变化过于剧烈的问题
2026-02-08 14:02:55 +08:00
176 changed files with 37978 additions and 1303 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)
@@ -105,9 +203,12 @@ jobs:
APP_NAME=$(basename "$APP_PATH")
echo "🔏 正在进行 Ad-hoc 签名..."
codesign --force --options runtime --deep --sign - "$APP_NAME"
# 注意Ad-hoc + hardened runtime--options runtime在未配置 entitlements 时,
# 可能导致部分 macOS 机型上应用双击无响应。这里保持 Ad-hoc 深签名但禁用 runtime hardened。
codesign --force --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 +222,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 +273,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 +325,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 +343,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 +365,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

@@ -37,6 +37,7 @@
- **Oracle**:基础数据访问与编辑支持。
- **Dameng达梦**:基础数据访问与编辑支持。
- **Kingbase人大金仓**:基础数据访问与编辑支持。
- **TDengine**:时序数据库连接、库表浏览与 SQL 查询支持。
- **Redis**Key/Value 浏览、命令执行、视图与编码切换。
- **自定义驱动**:支持配置 Driver/DSN 接入更多数据源。
- **SSH 隧道**:内置 SSH 隧道支持,安全连接内网数据库。
@@ -128,6 +129,7 @@ wails build -clean
支持构建:
* macOS (AMD64 / ARM64)
* Windows (AMD64)
* Linux (AMD64提供 WebKitGTK 4.0 与 4.1 变体产物)
---
@@ -145,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": "1.9.3",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/mariadb"
},
"diros": {
"engine": "go",
"version": "1.9.3",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/diros"
},
"sphinx": {
"engine": "go",
"version": "1.9.3",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/sphinx"
},
"sqlserver": {
"engine": "go",
"version": "1.9.6",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/sqlserver"
},
"sqlite": {
"engine": "go",
"version": "1.44.3",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/sqlite"
},
"duckdb": {
"engine": "go",
"version": "2.5.5",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/duckdb"
},
"dameng": {
"engine": "go",
"version": "1.8.22",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/dameng"
},
"kingbase": {
"engine": "go",
"version": "0.0.0-20201021123113-29bd62a876c3",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/kingbase"
},
"highgo": {
"engine": "go",
"version": "0.0.0-local",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/highgo"
},
"vastbase": {
"engine": "go",
"version": "1.11.1",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/vastbase"
},
"mongodb": {
"engine": "go",
"version": "2.5.0",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/mongodb"
},
"tdengine": {
"engine": "go",
"version": "3.7.8",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/tdengine"
},
"postgres": {
"engine": "go",
"version": "1.11.1",
"checksumPolicy": "off",
"downloadUrl": "builtin://activate/postgres"
}
}
}

1
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.ace-tool/

View File

@@ -1 +1 @@
d0f9366af59a6367ad3c7e2d4185ead4
5b8157374dae5f9340e31b2d0bd2c00e

View File

@@ -63,8 +63,28 @@ body {
}
body[data-theme='dark'] {
/* Improve contrast on transparent backgrounds */
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
/* 移除全局 text-shadow对每个文本元素增加 GPU compositing 成本,
在透明窗口环境下会显著加剧 GPU 负载 */
}
/* 连接配置弹窗:滚动仅在弹窗 body 内部,不使用外层 wrap 滚动条 */
.connection-modal-wrap {
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 */

View File

@@ -1,16 +1,18 @@
import React, { useState, useEffect } from 'react';
import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Progress } from 'antd';
import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Progress, Switch } 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, WindowFullscreen, WindowIsFullscreen, WindowIsMaximised, WindowMaximise } 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';
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform } from './utils/appearance';
import { SetWindowTranslucency } from '../wailsjs/go/app/App';
import './App.css';
const { Sider, Content } = Layout;
@@ -18,16 +20,121 @@ 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);
const appearance = useStore(state => state.appearance);
const setAppearance = useStore(state => state.setAppearance);
const startupFullscreen = useStore(state => state.startupFullscreen);
const setStartupFullscreen = useStore(state => state.setStartupFullscreen);
const darkMode = themeMode === 'dark';
const effectiveOpacity = normalizeOpacityForPlatform(appearance.opacity);
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 持续计算窗口背后的模糊合成
useEffect(() => {
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;
};
}, []);
useEffect(() => {
let cancelled = false;
let startupWindowTimer: number | null = null;
const maxApplyAttempts = 6;
const applyRetryDelayMs = 400;
const settleDelayMs = 160;
const checkStartupPreferenceApplied = async (): Promise<boolean> => {
try {
if (await WindowIsFullscreen()) {
return true;
}
} catch (_) {
// ignore
}
try {
if (await WindowIsMaximised()) {
return true;
}
} catch (_) {
// ignore
}
return false;
};
const applyStartupWindowPreference = (attempt: number) => {
if (startupWindowTimer !== null) {
window.clearTimeout(startupWindowTimer);
}
startupWindowTimer = window.setTimeout(() => {
if (cancelled) {
return;
}
if (!useStore.getState().startupFullscreen) {
return;
}
Promise.resolve()
.then(async () => {
if (await checkStartupPreferenceApplied()) {
return;
}
// 优先尝试全屏,若当前平台/时机不生效,后续走最大化兜底。
WindowFullscreen();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
if (await checkStartupPreferenceApplied()) {
return;
}
WindowMaximise();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
if (await checkStartupPreferenceApplied()) {
return;
}
if (attempt < maxApplyAttempts) {
applyStartupWindowPreference(attempt + 1);
}
});
}, 300);
};
if (useStore.persist.hasHydrated()) {
applyStartupWindowPreference(1);
}
const unsubscribeHydration = useStore.persist.onFinishHydration(() => {
if (cancelled) {
return;
}
applyStartupWindowPreference(1);
});
return () => {
cancelled = true;
if (startupWindowTimer !== null) {
window.clearTimeout(startupWindowTimer);
}
unsubscribeHydration();
};
}, []);
// Background Helper
const getBg = (darkHex: string, lightHex: string) => {
@@ -90,6 +197,8 @@ function App() {
assetUrl?: string;
assetSize?: number;
sha256?: string;
downloaded?: boolean;
downloadPath?: string;
};
type UpdateDownloadProgressEvent = {
@@ -222,12 +331,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;
@@ -333,6 +460,12 @@ function App() {
label: '数据同步',
icon: <UploadOutlined rotate={90} />,
onClick: () => setIsSyncModalOpen(true)
},
{
key: 'drivers',
label: '驱动管理',
icon: <SettingOutlined />,
onClick: () => setIsDriverModalOpen(true)
}
];
@@ -361,8 +494,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);
@@ -391,7 +529,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);
}
@@ -414,6 +555,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"]')) {
@@ -543,6 +690,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}
@@ -586,8 +744,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,
}}>
@@ -601,8 +759,6 @@ function App() {
alignItems: 'center',
justifyContent: 'space-between',
background: bgMain,
backdropFilter: blurFilter,
WebkitBackdropFilter: blurFilter,
borderBottom: 'none',
userSelect: 'none',
WebkitAppRegion: 'drag', // Wails drag region
@@ -653,8 +809,6 @@ function App() {
padding: '0 8px',
borderBottom: 'none',
background: bgMain,
backdropFilter: blurFilter,
WebkitBackdropFilter: blurFilter,
}}
>
<Dropdown menu={{ items: toolsMenu }} placement="bottomLeft">
@@ -665,7 +819,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={{
@@ -716,8 +870,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, backdropFilter: blurFilter, WebkitBackdropFilter: blurFilter }}>
<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 && (
@@ -733,11 +887,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}
@@ -836,6 +995,16 @@ function App() {
</>
)}
</div>
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}></div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<span></span>
<Switch checked={startupFullscreen} onChange={(checked) => setStartupFullscreen(checked)} />
</div>
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
*
</div>
</div>
</div>
</Modal>
@@ -888,6 +1057,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

File diff suppressed because it is too large Load Diff

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 } 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[]>([]);
@@ -29,7 +29,9 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null);
const [showFilter, setShowFilter] = useState(false);
const [filterConditions, setFilterConditions] = useState<any[]>([]);
const [filterConditions, setFilterConditions] = useState<FilterCondition[]>([]);
const currentConnType = (connections.find(c => c.id === tab.connectionId)?.config?.type || '').toLowerCase();
const forceReadOnly = currentConnType === 'tdengine';
useEffect(() => {
setPkColumns([]);
@@ -58,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 || '';
@@ -67,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}`;
@@ -187,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;
@@ -195,37 +219,41 @@ 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: any[]) => setFilterConditions(conditions), []);
const handleApplyFilter = useCallback((conditions: FilterCondition[]) => setFilterConditions(conditions), []);
useEffect(() => {
fetchData(1, pagination.pageSize);
}, [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}
@@ -241,6 +269,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
showFilter={showFilter}
onToggleFilter={handleToggleFilter}
onApplyFilter={handleApplyFilter}
readOnly={forceReadOnly}
sortInfoExternal={sortInfo}
/>
</div>
);

View File

@@ -0,0 +1,471 @@
import React, { useState, useEffect } from 'react';
import Editor from '@monaco-editor/react';
import { Spin, Alert } from 'antd';
import { TabData } from '../types';
import { useStore } from '../store';
import { DBQuery } from '../../wailsjs/go/app/App';
interface DefinitionViewerProps {
tab: TabData;
}
const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [definition, setDefinition] = useState<string>('');
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const darkMode = theme === 'dark';
const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''");
const getMetadataDialect = (conn: any): string => {
const type = String(conn?.config?.type || '').trim().toLowerCase();
if (type === 'custom') {
const driver = String(conn?.config?.driver || '').trim().toLowerCase();
if (driver === 'diros' || driver === 'doris') return 'mysql';
return driver;
}
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('.');
if (idx > 0 && idx < raw.length - 1) {
return { schema: raw.substring(0, idx), name: raw.substring(idx + 1) };
}
return { schema: '', name: raw };
};
const getCaseInsensitiveRawValue = (row: Record<string, any>, candidateKeys: string[]): any => {
const keyMap = new Map<string, any>();
Object.keys(row || {}).forEach((key) => keyMap.set(key.toLowerCase(), row[key]));
for (const key of candidateKeys) {
const value = keyMap.get(key.toLowerCase());
if (value !== undefined && value !== null) {
return value;
}
}
return undefined;
};
const parseDuckDBParameterNames = (raw: any): string[] => {
if (Array.isArray(raw)) {
return raw
.map((item) => String(item ?? '').trim())
.filter((item) => item !== '' && item.toLowerCase() !== '<nil>');
}
const text = String(raw ?? '').trim();
if (!text) return [];
const normalized = text.startsWith('[') && text.endsWith(']')
? text.slice(1, -1)
: text;
return normalized
.split(',')
.map((part) => part.trim())
.filter((part) => part !== '' && part.toLowerCase() !== '<nil>');
};
const buildDuckDBMacroDDL = (
schemaName: string,
functionName: string,
parametersRaw: any,
macroDefinitionRaw: any
): string => {
const schema = String(schemaName || '').trim();
const name = String(functionName || '').trim();
const macroDefinition = String(macroDefinitionRaw || '').trim();
if (!name || !macroDefinition) return '';
const parameters = parseDuckDBParameterNames(parametersRaw).join(', ');
const qualifiedName = schema ? `${schema}.${name}` : name;
const isTableMacro = !macroDefinition.startsWith('(');
if (isTableMacro) {
return `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS TABLE ${macroDefinition};`;
}
return `CREATE OR REPLACE MACRO ${qualifiedName}(${parameters}) AS ${macroDefinition};`;
};
const buildShowViewQueries = (dialect: string, viewName: string, dbName: string): string[] => {
const { schema, name } = parseSchemaAndName(viewName);
const safeName = escapeSQLLiteral(name);
const safeDbName = escapeSQLLiteral(dbName);
switch (dialect) {
case 'mysql':
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`];
}
case 'sqlserver':
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()}'`];
}
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 USER_VIEWS WHERE VIEW_NAME = '${safeName.toUpperCase()}'`];
case 'sqlite':
return [`SELECT sql AS view_definition FROM sqlite_master WHERE type='view' AND name='${safeName}'`];
case 'duckdb': {
const schemaRef = schema || 'main';
return [`SELECT view_definition FROM information_schema.views WHERE table_schema = '${escapeSQLLiteral(schemaRef)}' AND table_name = '${safeName}' LIMIT 1`];
}
default:
return [`-- 暂不支持该数据库类型的视图定义查看`];
}
};
const buildShowRoutineQueries = (dialect: string, routineName: string, routineType: string, dbName: string): string[] => {
const { schema, name } = parseSchemaAndName(routineName);
const safeName = escapeSQLLiteral(name);
const safeDbName = escapeSQLLiteral(dbName);
const upperType = (routineType || 'FUNCTION').toUpperCase();
switch (dialect) {
case 'mysql':
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`];
}
case 'sqlserver':
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 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 不支持函数/存储过程定义管理`];
default:
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];
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') || val.toUpperCase().includes('TABLE'))) {
return val;
}
}
return JSON.stringify(row, null, 2);
}
case 'oracle':
case 'dm':
return row.view_definition || row.VIEW_DEFINITION || row.text || row.TEXT || Object.values(row)[0] || '';
default:
return row.view_definition || row.VIEW_DEFINITION || row.sql || row.SQL || Object.values(row)[0] || '';
}
};
const extractRoutineDefinition = (dialect: string, data: any[]): string => {
if (!data || data.length === 0) return '-- 未找到函数/存储过程定义';
switch (dialect) {
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) {
const val = String(row[key] || '');
if (val.toUpperCase().includes('CREATE') && (val.toUpperCase().includes('FUNCTION') || val.toUpperCase().includes('PROCEDURE'))) {
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':
case 'dm': {
// 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] || '';
}
}
};
useEffect(() => {
const loadDefinition = async () => {
setLoading(true);
setError(null);
const conn = connections.find(c => c.id === tab.connectionId);
if (!conn) {
setError('未找到数据库连接');
setLoading(false);
return;
}
const dbName = tab.dbName || '';
const dialect = getMetadataDialect(conn);
const sphinxLike = isSphinxConnection(conn) && dialect === 'mysql';
let queries: string[];
let extractFn: (dialect: string, data: any[]) => string;
let objectLabel: string;
if (tab.type === 'view-def') {
const viewName = tab.viewName || '';
if (!viewName) {
setError('视图名称为空');
setLoading(false);
return;
}
queries = buildShowViewQueries(dialect, viewName, dbName);
extractFn = extractViewDefinition;
objectLabel = '视图';
} else {
const routineName = tab.routineName || '';
const routineType = tab.routineType || 'FUNCTION';
if (!routineName) {
setError('函数/存储过程名称为空');
setLoading(false);
return;
}
queries = buildShowRoutineQueries(dialect, routineName, routineType, dbName);
extractFn = extractRoutineDefinition;
objectLabel = '函数/存储过程';
}
if (!queries.length || String(queries[0] || '').startsWith('--')) {
setDefinition(String(queries[0] || '-- 暂不支持该对象定义查看'));
setLoading(false);
return;
}
try {
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 result = await runQueryCandidates(config, dbName, queries);
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 || '查询定义失败');
}
} catch (e: any) {
setError('查询定义失败: ' + (e?.message || String(e)));
} finally {
setLoading(false);
}
};
loadDefinition();
}, [tab.connectionId, tab.dbName, tab.viewName, tab.routineName, tab.routineType, tab.type, connections]);
const objectLabel = tab.type === 'view-def' ? '视图' : '函数/存储过程';
const objectName = tab.type === 'view-def' ? tab.viewName : tab.routineName;
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Spin tip={`加载${objectLabel}定义...`} />
</div>
);
}
if (error) {
return (
<div style={{ padding: 16 }}>
<Alert type="error" message="加载失败" description={error} showIcon />
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ padding: '8px 16px', borderBottom: darkMode ? '1px solid #303030' : '1px solid #f0f0f0' }}>
<strong>{objectLabel}: </strong>{objectName}
{tab.dbName && <span style={{ marginLeft: 16, color: '#888' }}>: {tab.dbName}</span>}
{tab.routineType && <span style={{ marginLeft: 16, color: '#888' }}>: {tab.routineType}</span>}
</div>
<div style={{ flex: 1, minHeight: 0 }}>
<Editor
height="100%"
language="sql"
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={definition}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
scrollBeyondLastLine: false,
wordWrap: 'on',
automaticLayout: true,
}}
/>
</div>
</div>
);
};
export default DefinitionViewer;

View File

@@ -0,0 +1,929 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, Button, Collapse, Modal, Progress, Select, Space, Table, Tag, Typography, message } from 'antd';
import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, ReloadOutlined } from '@ant-design/icons';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import {
CheckDriverNetworkStatus,
DownloadDriverPackage,
GetDriverVersionList,
GetDriverVersionPackageSize,
GetDriverStatusList,
InstallLocalDriverPackage,
RemoveDriverPackage,
SelectDriverPackageFile,
} from '../../wailsjs/go/app/App';
const { Paragraph, Text } = Typography;
type DriverStatusRow = {
type: string;
name: string;
builtIn: boolean;
pinnedVersion?: string;
installedVersion?: string;
packageSizeText?: string;
runtimeAvailable: boolean;
packageInstalled: boolean;
connectable: boolean;
defaultDownloadUrl?: string;
installDir?: string;
packagePath?: string;
executablePath?: string;
downloadedAt?: 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;
};
type DriverLogEntry = {
time: string;
text: string;
signature: string;
};
type DriverNetworkProbe = {
name: string;
url: string;
reachable: boolean;
httpStatus?: number;
latencyMs?: number;
error?: string;
};
type DriverNetworkStatus = {
reachable: boolean;
summary: string;
recommendedProxy: boolean;
proxyConfigured: boolean;
proxyEnv?: Record<string, string>;
checks: DriverNetworkProbe[];
checkedAt?: string;
logPath?: string;
};
type DriverVersionOption = {
version: string;
downloadUrl: string;
packageSizeText?: string;
recommended?: boolean;
source?: string;
year?: string;
displayLabel?: string;
};
const buildVersionOptionKey = (option: DriverVersionOption) => `${option.version}@@${option.downloadUrl}`;
const buildVersionSizeLoadingKey = (driverType: string, optionKey: string) => `${driverType}@@${optionKey}`;
const buildVersionSelectOptions = (options: DriverVersionOption[]) => {
type SelectOption = { value: string; label: string };
type SelectGroup = { label: string; options: SelectOption[] };
if (options.length === 0) {
return [] as Array<SelectOption | SelectGroup>;
}
const yearGroups = new Map<string, SelectOption[]>();
const others: SelectOption[] = [];
options.forEach((option) => {
const selectOption: SelectOption = {
value: buildVersionOptionKey(option),
label: option.displayLabel || option.version || '默认版本',
};
const year = String(option.year || '').trim();
if (!year) {
others.push(selectOption);
return;
}
const group = yearGroups.get(year) || [];
group.push(selectOption);
yearGroups.set(year, group);
});
const sortedYears = Array.from(yearGroups.keys()).sort((a, b) => {
const left = Number.parseInt(a, 10);
const right = Number.parseInt(b, 10);
const leftValid = Number.isFinite(left);
const rightValid = Number.isFinite(right);
if (leftValid && rightValid) {
return right - left;
}
return b.localeCompare(a);
});
const grouped: SelectGroup[] = sortedYears.map((year) => ({
label: `${year}`,
options: yearGroups.get(year) || [],
}));
if (others.length > 0) {
grouped.push({ label: '其他', options: others });
}
return grouped;
};
const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
const [loading, setLoading] = useState(false);
const [downloadDir, setDownloadDir] = useState('');
const [networkChecking, setNetworkChecking] = useState(false);
const [networkStatus, setNetworkStatus] = useState<DriverNetworkStatus | null>(null);
const [rows, setRows] = useState<DriverStatusRow[]>([]);
const [actionDriver, setActionDriver] = useState('');
const [progressMap, setProgressMap] = useState<Record<string, ProgressState>>({});
const [operationLogMap, setOperationLogMap] = useState<Record<string, DriverLogEntry[]>>({});
const [logDriverType, setLogDriverType] = useState('');
const [logModalOpen, setLogModalOpen] = useState(false);
const [versionMap, setVersionMap] = useState<Record<string, DriverVersionOption[]>>({});
const [selectedVersionMap, setSelectedVersionMap] = useState<Record<string, string>>({});
const [versionLoadingMap, setVersionLoadingMap] = useState<Record<string, boolean>>({});
const [versionSizeLoadingMap, setVersionSizeLoadingMap] = useState<Record<string, boolean>>({});
const appendOperationLog = useCallback((
driverType: string,
text: string,
signature?: string,
mode: 'append' | 'update-last' = 'append',
) => {
const normalized = String(driverType || '').trim().toLowerCase();
const content = String(text || '').trim();
if (!normalized || !content) {
return;
}
const sign = String(signature || content).trim() || content;
const now = new Date().toLocaleTimeString();
setOperationLogMap((prev) => {
const history = prev[normalized] || [];
if (history.length > 0) {
const last = history[history.length - 1];
if (last.signature === sign) {
if (mode === 'update-last') {
if (last.text === content) {
return prev;
}
const nextHistory = [...history];
nextHistory[nextHistory.length - 1] = {
...last,
text: content,
time: now,
};
return { ...prev, [normalized]: nextHistory };
}
return prev;
}
}
const nextHistory = [
...history,
{
time: now,
text: content,
signature: sign,
},
];
const sliced = nextHistory.length > 200 ? nextHistory.slice(nextHistory.length - 200) : nextHistory;
return { ...prev, [normalized]: sliced };
});
}, []);
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,
pinnedVersion: String(item.pinnedVersion || '').trim() || undefined,
installedVersion: String(item.installedVersion || '').trim() || undefined,
packageSizeText: String(item.packageSizeText || '').trim() || undefined,
runtimeAvailable: !!item.runtimeAvailable,
packageInstalled: !!item.packageInstalled,
connectable: !!item.connectable,
defaultDownloadUrl: String(item.defaultDownloadUrl || '').trim() || undefined,
installDir: String(item.installDir || '').trim() || undefined,
packagePath: String(item.packagePath || '').trim() || undefined,
executablePath: String(item.executablePath || '').trim() || undefined,
downloadedAt: String(item.downloadedAt || '').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]);
const checkNetworkStatus = useCallback(async (toastOnError = false) => {
setNetworkChecking(true);
try {
const res = await CheckDriverNetworkStatus();
if (!res?.success) {
if (toastOnError) {
message.error(res?.message || '驱动网络检测失败');
}
return;
}
const data = (res?.data || {}) as any;
const checks = Array.isArray(data.checks) ? data.checks : [];
const normalizedChecks: DriverNetworkProbe[] = checks.map((item: any) => ({
name: String(item.name || '').trim(),
url: String(item.url || '').trim(),
reachable: !!item.reachable,
httpStatus: Number(item.httpStatus || 0) || undefined,
latencyMs: Number(item.latencyMs || 0) || undefined,
error: String(item.error || '').trim() || undefined,
}));
setNetworkStatus({
reachable: !!data.reachable,
summary: String(data.summary || '').trim() || '驱动网络检测已完成',
recommendedProxy: !!data.recommendedProxy,
proxyConfigured: !!data.proxyConfigured,
proxyEnv: (data.proxyEnv || {}) as Record<string, string>,
checkedAt: String(data.checkedAt || '').trim() || undefined,
checks: normalizedChecks,
logPath: String(data.logPath || '').trim() || undefined,
});
} catch (err: any) {
if (toastOnError) {
message.error(`驱动网络检测失败:${err?.message || String(err)}`);
}
} finally {
setNetworkChecking(false);
}
}, []);
const loadVersionOptions = useCallback(async (row: DriverStatusRow, toastOnError = false) => {
if (row.builtIn) {
return [] as DriverVersionOption[];
}
const driverType = String(row.type || '').trim();
if (!driverType) {
return [] as DriverVersionOption[];
}
setVersionLoadingMap((prev) => ({ ...prev, [driverType]: true }));
try {
const res = await GetDriverVersionList(driverType, '');
if (!res?.success) {
if (toastOnError) {
message.error(res?.message || `${row.name} 版本列表加载失败`);
}
return [] as DriverVersionOption[];
}
const data = (res?.data || {}) as any;
const rawVersions = Array.isArray(data.versions) ? data.versions : [];
const options: DriverVersionOption[] = rawVersions
.map((item: any) => {
const version = String(item.version || '').trim();
const downloadUrl = String(item.downloadUrl || '').trim();
if (!version && !downloadUrl) {
return null;
}
return {
version,
downloadUrl,
packageSizeText: String(item.packageSizeText || '').trim() || undefined,
recommended: !!item.recommended,
source: String(item.source || '').trim() || undefined,
year: String(item.year || '').trim() || undefined,
displayLabel: String(item.displayLabel || '').trim() || undefined,
} as DriverVersionOption;
})
.filter((item: DriverVersionOption | null): item is DriverVersionOption => !!item);
if (options.length === 0) {
const fallbackVersion = String(row.pinnedVersion || '').trim();
const fallbackURL = String(row.defaultDownloadUrl || '').trim();
if (fallbackVersion || fallbackURL) {
options.push({
version: fallbackVersion,
downloadUrl: fallbackURL,
recommended: true,
source: 'fallback',
displayLabel: fallbackVersion || '默认版本',
});
}
}
setVersionMap((prev) => ({ ...prev, [driverType]: options }));
setSelectedVersionMap((prev) => {
const currentKey = prev[driverType];
if (currentKey && options.some((option) => buildVersionOptionKey(option) === currentKey)) {
return prev;
}
const preferred =
options.find((option) => option.version === row.installedVersion) ||
options.find((option) => option.version === row.pinnedVersion) ||
options.find((option) => option.recommended) ||
options[0];
if (!preferred) {
return prev;
}
return { ...prev, [driverType]: buildVersionOptionKey(preferred) };
});
return options;
} catch (err: any) {
if (toastOnError) {
message.error(`加载 ${row.name} 版本列表失败:${err?.message || String(err)}`);
}
return [] as DriverVersionOption[];
} finally {
setVersionLoadingMap((prev) => ({ ...prev, [driverType]: false }));
}
}, []);
const loadVersionPackageSize = useCallback(async (row: DriverStatusRow, optionKey: string) => {
if (row.builtIn) {
return;
}
const driverType = String(row.type || '').trim();
if (!driverType || !optionKey) {
return;
}
const options = versionMap[driverType] || [];
const selectedOption = options.find((item) => buildVersionOptionKey(item) === optionKey);
if (!selectedOption) {
return;
}
if (String(selectedOption.packageSizeText || '').trim()) {
return;
}
const versionText = String(selectedOption.version || '').trim();
if (!versionText) {
return;
}
const loadingKey = buildVersionSizeLoadingKey(driverType, optionKey);
if (versionSizeLoadingMap[loadingKey]) {
return;
}
setVersionSizeLoadingMap((prev) => ({ ...prev, [loadingKey]: true }));
try {
const res = await GetDriverVersionPackageSize(driverType, versionText);
if (!res?.success) {
return;
}
const data = (res?.data || {}) as any;
const sizeText = String(data.packageSizeText || '').trim();
if (!sizeText) {
return;
}
setVersionMap((prev) => {
const current = prev[driverType] || [];
let changed = false;
const next = current.map((item) => {
if (buildVersionOptionKey(item) !== optionKey) {
return item;
}
if (String(item.packageSizeText || '').trim() === sizeText) {
return item;
}
changed = true;
return { ...item, packageSizeText: sizeText };
});
if (!changed) {
return prev;
}
return { ...prev, [driverType]: next };
});
} finally {
setVersionSizeLoadingMap((prev) => {
if (!prev[loadingKey]) {
return prev;
}
const next = { ...prev };
delete next[loadingKey];
return next;
});
}
}, [versionMap, versionSizeLoadingMap]);
useEffect(() => {
if (!open) {
return;
}
refreshStatus(false);
checkNetworkStatus(false);
}, [checkNetworkStatus, 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,
},
}));
const progressText = `${Math.round(percent)}%`;
const statusText = String(status || '').toUpperCase();
const lineText = `[${statusText}] ${messageText || '-'} (${progressText})`;
const lineSignature = `${statusText}|${messageText || '-'}`;
appendOperationLog(driverType, lineText, lineSignature, 'update-last');
});
return () => {
off();
};
}, [appendOperationLog, open]);
const installDriver = useCallback(async (row: DriverStatusRow) => {
setActionDriver(row.type);
setProgressMap((prev) => ({
...prev,
[row.type]: {
status: 'start',
message: '开始安装',
percent: 0,
},
}));
appendOperationLog(row.type, '[START] 开始自动安装');
try {
let options = versionMap[row.type] || [];
if (options.length === 0) {
options = await loadVersionOptions(row, true);
}
const selectedKey = selectedVersionMap[row.type];
const selectedOption =
options.find((item) => buildVersionOptionKey(item) === selectedKey) ||
options.find((item) => item.recommended) ||
options[0];
const selectedVersion = selectedOption?.version || row.pinnedVersion || '';
const selectedDownloadURL = selectedOption?.downloadUrl || row.defaultDownloadUrl || '';
const result = await DownloadDriverPackage(row.type, selectedVersion, selectedDownloadURL, downloadDir);
if (!result?.success) {
const errText = result?.message || `安装 ${row.name} 失败`;
appendOperationLog(row.type, `[ERROR] ${errText}`);
message.error(errText);
return;
}
const versionTip = selectedVersion ? `${selectedVersion}` : '';
appendOperationLog(row.type, `[DONE] 自动安装完成 ${versionTip}`);
message.success(`${row.name}${versionTip} 已安装启用`);
refreshStatus(false);
} finally {
setActionDriver('');
}
}, [appendOperationLog, downloadDir, loadVersionOptions, refreshStatus, selectedVersionMap, versionMap]);
const installDriverFromLocalFile = useCallback(async (row: DriverStatusRow) => {
const fileRes = await SelectDriverPackageFile(downloadDir);
if (!fileRes?.success) {
if (String(fileRes?.message || '') !== 'Cancelled') {
message.error(fileRes?.message || '选择本地驱动包失败');
}
return;
}
const filePath = String((fileRes?.data as any)?.path || '').trim();
if (!filePath) {
message.error('未选择有效的驱动包文件');
return;
}
setActionDriver(row.type);
setProgressMap((prev) => ({
...prev,
[row.type]: {
status: 'start',
message: '开始导入本地驱动包',
percent: 0,
},
}));
appendOperationLog(row.type, `[START] 开始本地导入:${filePath}`);
try {
const result = await InstallLocalDriverPackage(row.type, filePath, downloadDir);
if (!result?.success) {
const errText = result?.message || `导入 ${row.name} 本地驱动包失败`;
appendOperationLog(row.type, `[ERROR] ${errText}`);
message.error(errText);
return;
}
appendOperationLog(row.type, '[DONE] 本地导入安装完成');
message.success(`${row.name} 本地驱动包已安装启用`);
refreshStatus(false);
} finally {
setActionDriver('');
}
}, [appendOperationLog, downloadDir, refreshStatus]);
const openDriverLog = useCallback((driverType: string) => {
const normalized = String(driverType || '').trim().toLowerCase();
if (!normalized) {
return;
}
setLogDriverType(normalized);
setLogModalOpen(true);
}, []);
const removeDriver = useCallback(async (row: DriverStatusRow) => {
setActionDriver(row.type);
appendOperationLog(row.type, '[START] 开始移除驱动');
try {
const result = await RemoveDriverPackage(row.type, downloadDir);
if (!result?.success) {
const errText = result?.message || `移除 ${row.name} 失败`;
appendOperationLog(row.type, `[ERROR] ${errText}`);
message.error(errText);
return;
}
appendOperationLog(row.type, '[DONE] 驱动移除完成');
message.success(`${row.name} 已移除`);
setProgressMap((prev) => {
const next = { ...prev };
delete next[row.type];
return next;
});
refreshStatus(false);
} finally {
setActionDriver('');
}
}, [appendOperationLog, downloadDir, refreshStatus]);
const columns = useMemo(() => {
return [
{
title: '数据源',
dataIndex: 'name',
key: 'name',
width: 150,
},
{
title: '安装位置',
key: 'installPath',
width: 260,
render: (_: string, row: DriverStatusRow) => {
if (row.builtIn) {
return <Text type="secondary"></Text>;
}
const installPath = row.executablePath || row.installDir || '-';
if (installPath === '-') {
return <Text type="secondary">-</Text>;
}
return (
<Text copyable={{ text: installPath }} style={{ fontSize: 12 }}>
{installPath}
</Text>
);
},
},
{
title: '安装包大小',
dataIndex: 'packageSizeText',
key: 'packageSizeText',
width: 120,
render: (_: string | undefined, row: DriverStatusRow) => {
if (row.builtIn) {
return row.packageSizeText || '-';
}
const options = versionMap[row.type] || [];
const selectedKey = selectedVersionMap[row.type];
const loadingKey = buildVersionSizeLoadingKey(row.type, selectedKey || '');
const selectedOption =
options.find((item) => buildVersionOptionKey(item) === selectedKey) ||
options.find((item) => item.recommended) ||
options[0];
const anyKnownSize = options.find((item) => String(item.packageSizeText || '').trim())?.packageSizeText;
if (selectedKey && versionSizeLoadingMap[loadingKey]) {
return '计算中...';
}
return selectedOption?.packageSizeText || anyKnownSize || 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: 'driverVersion',
width: 230,
render: (_: string, row: DriverStatusRow) => {
if (row.builtIn) {
return <Text type="secondary">-</Text>;
}
const options = versionMap[row.type] || [];
const selectedKey = selectedVersionMap[row.type];
const selectOptions = buildVersionSelectOptions(options);
return (
<Select
size="small"
style={{ width: '100%' }}
loading={!!versionLoadingMap[row.type]}
disabled={actionDriver === row.type}
placeholder={options.length > 0 ? '选择驱动版本' : '点击展开加载版本'}
value={selectedKey}
options={selectOptions as any}
onOpenChange={(open) => {
if (open && options.length === 0 && !versionLoadingMap[row.type]) {
void loadVersionOptions(row, true);
return;
}
if (open && selectedKey) {
void loadVersionPackageSize(row, selectedKey);
}
}}
onChange={(value) => {
setSelectedVersionMap((prev) => ({ ...prev, [row.type]: value }));
void loadVersionPackageSize(row, value);
}}
/>
);
},
},
{
title: '操作',
key: 'actions',
width: 320,
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>;
}
const logs = operationLogMap[row.type] || [];
const hasLogs = logs.length > 0;
const mainAction = row.connectable ? (
<Button
danger
icon={<DeleteOutlined />}
loading={loadingAction}
onClick={() => removeDriver(row)}
>
</Button>
) : (
<Button
type="primary"
icon={<DownloadOutlined />}
loading={loadingAction}
onClick={() => installDriver(row)}
>
</Button>
);
return (
<Space size={8} wrap>
{mainAction}
<Button
icon={<FileSearchOutlined />}
loading={loadingAction}
onClick={() => installDriverFromLocalFile(row)}
>
</Button>
<Button
type={hasLogs ? 'default' : 'text'}
disabled={!hasLogs}
onClick={() => openDriverLog(row.type)}
>
</Button>
</Space>
);
},
},
];
}, [actionDriver, installDriver, installDriverFromLocalFile, loadVersionOptions, loadVersionPackageSize, openDriverLog, operationLogMap, progressMap, removeDriver, selectedVersionMap, versionLoadingMap, versionMap, versionSizeLoadingMap]);
const activeLogRow = useMemo(() => {
if (!logDriverType) {
return undefined;
}
return rows.find((item) => item.type === logDriverType);
}, [logDriverType, rows]);
const activeDriverLogs = operationLogMap[logDriverType] || [];
const activeDriverLogLines = activeDriverLogs.map((item) => `[${item.time}] ${item.text}`);
const proxyEnvEntries = Object.entries(networkStatus?.proxyEnv || {});
return (
<Modal
title="驱动管理"
open={open}
onCancel={onClose}
width={980}
style={{ top: 24 }}
styles={{
body: {
maxHeight: 'calc(100vh - 220px)',
overflowY: 'auto',
overflowX: 'hidden',
paddingRight: 18,
},
}}
destroyOnClose
footer={[
<Button key="refresh" icon={<ReloadOutlined />} onClick={() => refreshStatus(true)} loading={loading}>
</Button>,
<Button key="network" onClick={() => checkNetworkStatus(true)} loading={networkChecking}>
</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>
{networkStatus ? (
<Alert
type={networkStatus.reachable ? 'success' : 'warning'}
showIcon
message={networkStatus.summary}
description={(
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text type="secondary">
GitHub Go HTTP/HTTPS/SOCKS5
</Text>
<Collapse
size="small"
items={[
{
key: 'checks',
label: '查看网络检测明细',
children: (
<Space direction="vertical" size={4} style={{ width: '100%' }}>
{networkStatus.checks.map((item) => (
<Text key={`${item.name}-${item.url}`} type={item.reachable ? 'secondary' : 'danger'}>
{item.name}{item.reachable ? '可达' : '不可达'}{item.httpStatus ? `HTTP ${item.httpStatus}` : ''}{item.latencyMs ? `${item.latencyMs}ms` : ''}{item.error ? `${item.error}` : ''}
</Text>
))}
{proxyEnvEntries.length > 0 ? (
<Text type="secondary">
{proxyEnvEntries.map(([key]) => key).join('、')}
</Text>
) : (
<Text type="secondary"></Text>
)}
</Space>
),
},
]}
/>
</Space>
)}
/>
) : (
<Alert type="info" showIcon message={networkChecking ? '正在检测驱动下载网络...' : '尚未完成网络检测'} />
)}
<Alert
type="info"
showIcon
message="驱动目录与复用说明"
description={(
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text type="secondary"></Text>
<Text type="secondary"> `mariadb-driver-agent` / `mariadb-driver-agent.exe` `GoNavi-DriverAgents.zip`</Text>
<Paragraph copyable={{ text: downloadDir || '-' }} style={{ marginBottom: 0 }}>
{downloadDir || '-'}
</Paragraph>
{networkStatus?.logPath ? (
<Paragraph copyable={{ text: networkStatus.logPath }} style={{ marginBottom: 0 }}>
{networkStatus.logPath}
</Paragraph>
) : null}
</Space>
)}
/>
<Table
rowKey="type"
loading={loading}
columns={columns as any}
dataSource={rows}
pagination={false}
size="middle"
scroll={{ x: 1450 }}
/>
</Space>
<Modal
title={`驱动日志 - ${activeLogRow?.name || logDriverType}`}
open={logModalOpen}
onCancel={() => setLogModalOpen(false)}
footer={[
<Button key="close-log" type="primary" onClick={() => setLogModalOpen(false)}>
</Button>,
]}
width={780}
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{activeLogRow?.installDir ? (
<Paragraph copyable={{ text: activeLogRow.installDir }} style={{ marginBottom: 0 }}>
{activeLogRow.installDir}
</Paragraph>
) : null}
{activeLogRow?.executablePath ? (
<Paragraph copyable={{ text: activeLogRow.executablePath }} style={{ marginBottom: 0 }}>
{activeLogRow.executablePath}
</Paragraph>
) : null}
{activeDriverLogLines.length > 0 ? (
<pre style={{ margin: 0, maxHeight: 360, overflow: 'auto', padding: 12, background: '#fafafa', borderRadius: 8, border: '1px solid #f0f0f0', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{activeDriverLogLines.join('\n')}
</pre>
) : (
<Text type="secondary"></Text>
)}
</Space>
</Modal>
</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

@@ -2,7 +2,7 @@ import React, { useRef, useEffect } from 'react';
import { Table, Tag, Button, Tooltip } from 'antd';
import { ClearOutlined, CloseOutlined, CaretRightOutlined, BugOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform } from '../utils/appearance';
import { normalizeOpacityForPlatform } from '../utils/appearance';
interface LogPanelProps {
height: number;
@@ -17,7 +17,6 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
const appearance = useStore(state => state.appearance);
const darkMode = theme === 'dark';
const opacity = normalizeOpacityForPlatform(appearance.opacity);
const blur = normalizeBlurForPlatform(appearance.blur);
// Background Helper
const getBg = (darkHex: string) => {
@@ -30,7 +29,8 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
};
const bgMain = getBg('#1f1f1f');
const bgToolbar = getBg('#2a2a2a');
const blurFilter = blurToFilter(blur);
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 = [
{
@@ -73,8 +73,6 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
height,
borderTop: 'none',
background: bgMain,
backdropFilter: blurFilter,
WebkitBackdropFilter: blurFilter,
display: 'flex',
flexDirection: 'column',
position: 'relative',
@@ -117,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"
@@ -128,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

@@ -196,6 +196,9 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
editorRef.current = editor;
monacoRef.current = monaco;
// 应用透明主题(主题已在 main.tsx 全局注册)
monaco.editor.setTheme(darkMode ? 'transparent-dark' : 'transparent-light');
monaco.languages.registerCompletionItemProvider('sql', {
triggerCharacters: ['.'],
provideCompletionItems: async (model: any, position: any) => {
@@ -919,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 === '';
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 };
@@ -997,6 +1000,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const nextResultSets: ResultSet[] = [];
const maxRows = Number(queryOptions?.maxRows) || 0;
const dbType = String((config as any).type || 'mysql');
const normalizedDbType = dbType.toLowerCase();
const forceReadOnlyResult = normalizedDbType === 'tdengine';
const wantsLimitProbe = Number.isFinite(maxRows) && maxRows > 0;
const probeLimit = wantsLimitProbe ? (maxRows + 1) : 0;
let anyTruncated = false;
@@ -1053,7 +1058,9 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const tableMatch = rawStatement.match(/^\s*SELECT\s+\*\s+FROM\s+[`"]?(\w+)[`"]?\s*(?:WHERE.*)?(?:ORDER BY.*)?(?:LIMIT.*)?$/i);
if (tableMatch) {
simpleTableName = tableMatch[1];
pendingPk.push({ resultKey: `result-${idx + 1}`, tableName: simpleTableName });
if (!forceReadOnlyResult) {
pendingPk.push({ resultKey: `result-${idx + 1}`, tableName: simpleTableName });
}
}
nextResultSets.push({
@@ -1207,7 +1214,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
transition: none !important;
}
`}</style>
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: '8px', flexShrink: 0, alignItems: 'center' }}>
<div style={{ padding: '8px', display: 'flex', gap: '8px', flexShrink: 0, alignItems: 'center' }}>
<Select
style={{ width: 150 }}
placeholder="选择连接"
@@ -1261,11 +1268,11 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
</Button.Group>
</div>
<div style={{ height: editorHeight, minHeight: '100px', borderBottom: '1px solid #eee' }}>
<div style={{ height: editorHeight, minHeight: '100px' }}>
<Editor
height="100%"
defaultLanguage="sql"
theme={darkMode ? "vs-dark" : "light"}
theme={darkMode ? "transparent-dark" : "transparent-light"}
value={query}
onChange={(val) => setQuery(val || '')}
onMount={handleEditorDidMount}
@@ -1283,7 +1290,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
style={{
height: '5px',
cursor: 'row-resize',
background: darkMode ? '#333' : '#f0f0f0',
background: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)',
flexShrink: 0,
zIndex: 10
}}

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';
@@ -10,6 +10,12 @@ const { Search } = Input;
const KEY_GROUP_DELIMITER = ':';
const EMPTY_SEGMENT_LABEL = '(empty)';
const REDIS_TREE_KEY_TYPE_WIDTH = 92;
const REDIS_TREE_KEY_TYPE_WIDTH_NARROW = 84;
const REDIS_TREE_KEY_TTL_WIDTH = 92;
const REDIS_TREE_HIDE_TTL_THRESHOLD = 460;
const REDIS_KEY_INITIAL_LOAD_COUNT = 2000;
const REDIS_KEY_LOAD_MORE_COUNT = 2000;
interface RedisViewerProps {
connectionId: string;
@@ -263,7 +269,8 @@ const countGroupLeafNodes = (group: RedisKeyTreeGroup): number => {
const buildRedisKeyTree = (
keys: RedisKeyInfo[],
formatTTL: (ttl: number) => string,
getTypeColor: (type: string) => string
getTypeColor: (type: string) => string,
showTTL: boolean
): RedisKeyTreeResult => {
const root = createTreeGroup('__root__', '__root__');
@@ -330,48 +337,66 @@ const buildRedisKeyTree = (
title: (
<div
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) 92px 92px',
columnGap: 8,
display: 'flex',
alignItems: 'center',
gap: 8,
minWidth: 0,
width: '100%',
overflow: 'hidden',
}}
>
<Space size={6} style={{ minWidth: 0 }}>
<KeyOutlined style={{ color: '#1677ff' }} />
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
minWidth: 0,
flex: 1,
overflow: 'hidden',
}}
>
<KeyOutlined style={{ color: '#1677ff', flexShrink: 0 }} />
<Tooltip title={leaf.keyInfo.key}>
<span
style={{
maxWidth: '100%',
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'inline-block',
verticalAlign: 'bottom',
display: 'block',
}}
>
{leaf.label}
</span>
</Tooltip>
</Space>
</div>
<Tag
color={getTypeColor(leaf.keyInfo.type)}
style={{ marginInlineEnd: 0, width: '100%', textAlign: 'center' }}
style={{
marginInlineEnd: 0,
width: showTTL ? REDIS_TREE_KEY_TYPE_WIDTH : REDIS_TREE_KEY_TYPE_WIDTH_NARROW,
textAlign: 'center',
flexShrink: 0
}}
>
{leaf.keyInfo.type}
</Tag>
<span
style={{
width: '100%',
fontSize: 12,
color: '#999',
textAlign: 'left',
whiteSpace: 'nowrap',
}}
>
{formatTTL(leaf.keyInfo.ttl)}
</span>
{showTTL && (
<span
style={{
width: REDIS_TREE_KEY_TTL_WIDTH,
fontSize: 12,
color: '#999',
textAlign: 'left',
whiteSpace: 'nowrap',
flexShrink: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{formatTTL(leaf.keyInfo.ttl)}
</span>
)}
</div>
),
};
@@ -424,6 +449,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
// 面板宽度状态和 ref - 默认占据 50% 宽度
const [leftPanelWidth, setLeftPanelWidth] = useState<number | string>('50%');
const leftPanelRef = useRef<HTMLDivElement>(null);
const [showTreeKeyTTL, setShowTreeKeyTTL] = useState(true);
const [expandedGroupKeys, setExpandedGroupKeys] = useState<string[]>([]);
const getConfig = useCallback(() => {
@@ -438,27 +464,34 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
};
}, [connection, redisDB]);
const loadKeys = useCallback(async (pattern: string = '*', fromCursor: number = 0, append: boolean = false) => {
const loadKeys = useCallback(async (
pattern: string = '*',
fromCursor: number = 0,
append: boolean = false,
targetCount: number = REDIS_KEY_INITIAL_LOAD_COUNT
) => {
const config = getConfig();
if (!config) return;
setLoading(true);
try {
const res = await (window as any).go.app.App.RedisScanKeys(config, pattern, fromCursor, 100);
const res = await (window as any).go.app.App.RedisScanKeys(config, pattern, fromCursor, targetCount);
if (res.success) {
const result = res.data;
const scannedKeys = Array.isArray(result?.keys) ? result.keys : [];
const nextCursor = Number(result?.cursor || 0);
if (append) {
setKeys(prev => {
const keyMap = new Map<string, RedisKeyInfo>();
prev.forEach(item => keyMap.set(item.key, item));
result.keys.forEach((item: RedisKeyInfo) => keyMap.set(item.key, item));
scannedKeys.forEach((item: RedisKeyInfo) => keyMap.set(item.key, item));
return Array.from(keyMap.values());
});
} else {
setKeys(result.keys);
setKeys(scannedKeys);
}
setCursor(result.cursor);
setHasMore(result.cursor !== 0);
setCursor(nextCursor);
setHasMore(nextCursor !== 0);
} else {
message.error('加载 Key 失败: ' + res.message);
}
@@ -470,23 +503,26 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
}, [getConfig]);
useEffect(() => {
loadKeys(searchPattern, 0, false);
loadKeys(searchPattern, 0, false, REDIS_KEY_INITIAL_LOAD_COUNT);
}, [redisDB]);
const handleSearch = (value: string) => {
const pattern = value.trim() || '*';
setSearchPattern(pattern);
setCursor(0);
loadKeys(pattern, 0, false);
loadKeys(pattern, 0, false, REDIS_KEY_INITIAL_LOAD_COUNT);
};
const handleLoadMore = () => {
loadKeys(searchPattern, cursor, true);
if (!hasMore || loading) {
return;
}
loadKeys(searchPattern, cursor, true, REDIS_KEY_LOAD_MORE_COUNT);
};
const handleRefresh = () => {
setCursor(0);
loadKeys(searchPattern, 0, false);
loadKeys(searchPattern, 0, false, REDIS_KEY_INITIAL_LOAD_COUNT);
};
const loadKeyValue = async (key: string) => {
@@ -601,6 +637,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';
}
};
@@ -614,9 +651,36 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
return `${Math.floor(ttl / 86400)}${Math.floor((ttl % 86400) / 3600)}`;
};
useEffect(() => {
const target = leftPanelRef.current;
if (!target) return;
const updateTTLVisibility = (width: number) => {
const nextShowTTL = width > REDIS_TREE_HIDE_TTL_THRESHOLD;
setShowTreeKeyTTL((prev) => (prev === nextShowTTL ? prev : nextShowTTL));
};
updateTTLVisibility(Math.round(target.getBoundingClientRect().width));
if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver((entries) => {
const width = Math.round(entries[0]?.contentRect.width || target.getBoundingClientRect().width);
updateTTLVisibility(width);
});
observer.observe(target);
return () => observer.disconnect();
}
const handleWindowResize = () => {
updateTTLVisibility(Math.round(target.getBoundingClientRect().width));
};
window.addEventListener('resize', handleWindowResize);
return () => window.removeEventListener('resize', handleWindowResize);
}, []);
const keyTree = useMemo(() => {
return buildRedisKeyTree(keys, formatTTL, getTypeColor);
}, [keys]);
return buildRedisKeyTree(keys, formatTTL, getTypeColor, showTreeKeyTTL);
}, [keys, showTreeKeyTTL]);
const selectedTreeNodeKeys = useMemo(() => {
if (!selectedKey) {
@@ -1417,6 +1481,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 }}>
@@ -1460,6 +1730,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>
);
@@ -1518,7 +1789,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
</Spin>
{hasMore && (
<div style={{ padding: 8, textAlign: 'center' }}>
<Button onClick={handleLoadMore} loading={loading}></Button>
<Button onClick={handleLoadMore} loading={loading} disabled={!hasMore || loading}></Button>
</div>
)}
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -7,9 +7,31 @@ import QueryEditor from './QueryEditor';
import TableDesigner from './TableDesigner';
import RedisViewer from './RedisViewer';
import RedisCommandEditor from './RedisCommandEditor';
import TriggerViewer from './TriggerViewer';
import DefinitionViewer from './DefinitionViewer';
import type { TabData } from '../types';
const detectConnectionEnvLabel = (connectionName: string): string | null => {
const tokens = connectionName.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
if (tokens.includes('prod') || tokens.includes('production')) return 'PROD';
if (tokens.includes('uat')) return 'UAT';
if (tokens.includes('dev') || tokens.includes('development')) return 'DEV';
if (tokens.includes('sit')) return 'SIT';
if (tokens.includes('stg') || tokens.includes('stage') || tokens.includes('staging') || tokens.includes('pre')) return 'STG';
if (tokens.includes('test') || tokens.includes('qa')) return 'TEST';
return null;
};
const buildTabDisplayTitle = (tab: TabData, connectionName: string | undefined): string => {
if (tab.type !== 'table' && tab.type !== 'design') return tab.title;
if (!connectionName) return tab.title;
const prefix = detectConnectionEnvLabel(connectionName) || connectionName;
return `[${prefix}] ${tab.title}`;
};
const TabManager: React.FC = () => {
const tabs = useStore(state => state.tabs);
const connections = useStore(state => state.connections);
const activeTabId = useStore(state => state.activeTabId);
const setActiveTab = useStore(state => state.setActiveTab);
const closeTab = useStore(state => state.closeTab);
@@ -29,6 +51,8 @@ const TabManager: React.FC = () => {
};
const items = useMemo(() => tabs.map((tab, index) => {
const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name;
const displayTitle = buildTabDisplayTitle(tab, connectionName);
let content;
if (tab.type === 'query') {
content = <QueryEditor tab={tab} />;
@@ -40,6 +64,10 @@ const TabManager: React.FC = () => {
content = <RedisViewer connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
} else if (tab.type === 'redis-command') {
content = <RedisCommandEditor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
} else if (tab.type === 'trigger') {
content = <TriggerViewer tab={tab} />;
} else if (tab.type === 'view-def' || tab.type === 'routine-def') {
content = <DefinitionViewer tab={tab} />;
}
const menuItems: MenuProps['items'] = [
@@ -73,13 +101,13 @@ const TabManager: React.FC = () => {
return {
label: (
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
<span onContextMenu={(e) => e.preventDefault()}>{tab.title}</span>
<span onContextMenu={(e) => e.preventDefault()}>{displayTitle}</span>
</Dropdown>
),
key: tab.id,
children: content,
};
}), [tabs, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
}), [tabs, connections, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
return (
<>
@@ -88,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;
@@ -98,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;
@@ -105,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;
@@ -118,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

@@ -0,0 +1,340 @@
import React, { useState, useEffect } from 'react';
import Editor, { loader } from '@monaco-editor/react';
import { Spin, Alert } from 'antd';
import { TabData } from '../types';
import { useStore } from '../store';
import { DBQuery } from '../../wailsjs/go/app/App';
interface TriggerViewerProps {
tab: TabData;
}
const TriggerViewer: React.FC<TriggerViewerProps> = ({ tab }) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [triggerDefinition, setTriggerDefinition] = useState<string>('');
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const darkMode = theme === 'dark';
// 初始化透明 Monaco Editor 主题
useEffect(() => {
loader.init().then(monaco => {
monaco.editor.defineTheme('transparent-dark', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: {
'editor.background': '#00000000',
'editor.lineHighlightBackground': '#ffffff10',
'editorGutter.background': '#00000000',
}
});
monaco.editor.defineTheme('transparent-light', {
base: 'vs',
inherit: true,
rules: [],
colors: {
'editor.background': '#00000000',
'editor.lineHighlightBackground': '#00000010',
'editorGutter.background': '#00000000',
}
});
});
}, []);
const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''");
const quoteSqlServerIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`;
const getMetadataDialect = (conn: any): string => {
const type = String(conn?.config?.type || '').trim().toLowerCase();
if (type === 'custom') {
const driver = String(conn?.config?.driver || '').trim().toLowerCase();
if (driver === 'diros' || driver === 'doris') return 'mysql';
return driver;
}
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 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, '``')}\``,
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
FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
WHERE t.tgname = '${safeTriggerName}'
AND NOT t.tgisinternal
LIMIT 1`];
case 'sqlserver': {
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 ALL_TRIGGERS WHERE OWNER = '${safeDbName.toUpperCase()}' AND TRIGGER_NAME = '${safeTriggerName.toUpperCase()}'`];
case 'sqlite':
return [`SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = '${safeTriggerName}'`];
case 'duckdb':
return [`-- DuckDB 不支持触发器`];
case 'tdengine':
return [`-- TDengine 不支持触发器`];
case 'mongodb':
return [`-- MongoDB 不支持触发器`];
default:
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 '-- 未找到触发器定义';
}
const row = data[0];
switch (dialect) {
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
for (const key of keys) {
const val = String(row[key] || '');
if (val.toUpperCase().includes('CREATE TRIGGER')) {
return val;
}
}
return JSON.stringify(row, null, 2);
}
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase': {
return row.trigger_definition || row.TRIGGER_DEFINITION || Object.values(row)[0] || '';
}
case 'sqlserver': {
return row.trigger_definition || row.TRIGGER_DEFINITION || Object.values(row)[0] || '';
}
case 'oracle':
case 'dm': {
return row.trigger_body || row.TRIGGER_BODY || Object.values(row)[0] || '';
}
case 'sqlite': {
return row.sql || row.SQL || Object.values(row)[0] || '';
}
default:
return JSON.stringify(row, null, 2);
}
};
useEffect(() => {
const loadTriggerDefinition = async () => {
setLoading(true);
setError(null);
const conn = connections.find(c => c.id === tab.connectionId);
if (!conn) {
setError('未找到数据库连接');
setLoading(false);
return;
}
const triggerName = tab.triggerName || '';
const dbName = tab.dbName || '';
if (!triggerName) {
setError('触发器名称为空');
setLoading(false);
return;
}
const dialect = getMetadataDialect(conn);
const queries = buildShowTriggerQueries(dialect, triggerName, dbName);
const sphinxLike = isSphinxConnection(conn) && dialect === 'mysql';
if (!queries.length || String(queries[0] || '').startsWith('--')) {
setTriggerDefinition(String(queries[0] || '-- 暂不支持该数据库类型的触发器定义查看'));
setLoading(false);
return;
}
try {
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 result = await runQueryCandidates(config, dbName, queries);
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 || '查询触发器定义失败');
}
} catch (e: any) {
setError('查询触发器定义失败: ' + (e?.message || String(e)));
} finally {
setLoading(false);
}
};
loadTriggerDefinition();
}, [tab.connectionId, tab.dbName, tab.triggerName, connections]);
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Spin tip="加载触发器定义..." />
</div>
);
}
if (error) {
return (
<div style={{ padding: 16 }}>
<Alert type="error" message="加载失败" description={error} showIcon />
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ padding: '8px 16px', borderBottom: darkMode ? '1px solid #303030' : '1px solid #f0f0f0' }}>
<strong>: </strong>{tab.triggerName}
{tab.dbName && <span style={{ marginLeft: 16, color: '#888' }}>: {tab.dbName}</span>}
</div>
<div style={{ flex: 1, minHeight: 0 }}>
<Editor
height="100%"
language="sql"
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={triggerDefinition}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
scrollBeyondLastLine: false,
wordWrap: 'on',
automaticLayout: true,
}}
/>
</div>
</div>
);
};
export default TriggerViewer;

View File

@@ -3,6 +3,22 @@ import ReactDOM from 'react-dom/client'
import App from './App'
// import './index.css' // Optional global styles
// 全局配置 Monaco Editor 使用本地打包的文件,避免从 CDN (jsdelivr) 加载。
// Windows WebView2 环境下访问外部 CDN 可能失败,导致编辑器一直显示 Loading。
import { loader } from '@monaco-editor/react'
import * as monaco from 'monaco-editor'
loader.config({ monaco })
// 全局注册透明主题,避免每个 Editor 组件 beforeMount 中重复定义
monaco.editor.defineTheme('transparent-dark', {
base: 'vs-dark', inherit: true, rules: [],
colors: { 'editor.background': '#00000000', 'editor.lineHighlightBackground': '#ffffff10', 'editorGutter.background': '#00000000' }
})
monaco.editor.defineTheme('transparent-light', {
base: 'vs', inherit: true, rules: [],
colors: { 'editor.background': '#00000000', 'editor.lineHighlightBackground': '#00000010', 'editorGutter.background': '#00000000' }
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />

View File

@@ -1,10 +1,266 @@
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 DEFAULT_STARTUP_FULLSCREEN = false;
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 proxyRaw = (raw.proxy && typeof raw.proxy === 'object') ? raw.proxy as Record<string, unknown> : {};
const proxyTypeRaw = toTrimmedString(proxyRaw.type, 'socks5').toLowerCase();
const proxyType: 'socks5' | 'http' = proxyTypeRaw === 'http' ? 'http' : 'socks5';
const proxy = {
type: proxyType,
host: toTrimmedString(proxyRaw.host),
port: normalizePort(proxyRaw.port, proxyTypeRaw === 'http' ? 8080 : 1080),
user: toTrimmedString(proxyRaw.user),
password: toTrimmedString(proxyRaw.password),
};
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,
useProxy: !!raw.useProxy,
proxy,
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 resolveConnectionConfigPayload = (raw: Record<string, unknown>): unknown => {
if (raw.config && typeof raw.config === 'object') {
return raw.config;
}
// 兼容历史/导入场景:连接对象可能是扁平结构(无 config 包装)。
const hasLegacyFlatConfig =
raw.type !== undefined ||
raw.host !== undefined ||
raw.port !== undefined ||
raw.user !== undefined ||
raw.database !== undefined;
if (hasLegacyFlatConfig) {
return raw;
}
return undefined;
};
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(resolveConnectionConfigPayload(raw));
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 +282,12 @@ export interface SqlLog {
affectedRows?: number;
}
export interface QueryOptions {
maxRows: number;
showColumnComment: boolean;
showColumnType: boolean;
}
interface AppState {
connections: SavedConnection[];
tabs: TabData[];
@@ -34,19 +296,24 @@ interface AppState {
savedQueries: SavedQuery[];
theme: 'light' | 'dark';
appearance: { opacity: number; blur: number };
startupFullscreen: boolean;
sqlFormatOptions: { keywordCase: 'upper' | 'lower' };
queryOptions: { maxRows: number };
queryOptions: QueryOptions;
sqlLogs: SqlLog[];
tableAccessCount: Record<string, number>;
tableSortPreference: Record<string, 'name' | 'frequency'>;
addConnection: (conn: SavedConnection) => void;
updateConnection: (conn: SavedConnection) => void;
removeConnection: (id: string) => void;
addTab: (tab: TabData) => void;
closeTab: (id: string) => void;
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;
@@ -56,13 +323,110 @@ interface AppState {
setTheme: (theme: 'light' | 'dark') => void;
setAppearance: (appearance: Partial<{ opacity: number; blur: number }>) => void;
setStartupFullscreen: (enabled: boolean) => void;
setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void;
setQueryOptions: (options: Partial<{ maxRows: number }>) => void;
setQueryOptions: (options: Partial<QueryOptions>) => void;
addSqlLog: (log: SqlLog) => void;
clearSqlLogs: () => void;
recordTableAccess: (connectionId: string, dbName: string, tableName: string) => void;
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;
};
const sanitizeStartupFullscreen = (value: unknown): boolean => {
return value === true;
};
const unwrapPersistedAppState = (persistedState: unknown): Record<string, unknown> => {
if (!persistedState || typeof persistedState !== 'object') {
return {};
}
const raw = persistedState as Record<string, unknown>;
if (raw.state && typeof raw.state === 'object') {
return raw.state as Record<string, unknown>;
}
return raw;
};
export const useStore = create<AppState>()(
persist(
(set) => ({
@@ -73,13 +437,16 @@ export const useStore = create<AppState>()(
savedQueries: [],
theme: 'light',
appearance: { ...DEFAULT_APPEARANCE },
startupFullscreen: DEFAULT_STARTUP_FULLSCREEN,
sqlFormatOptions: { keywordCase: 'upper' },
queryOptions: { maxRows: 5000 },
queryOptions: { maxRows: 5000, showColumnComment: true, showColumnType: true },
sqlLogs: [],
tableAccessCount: {},
tableSortPreference: {},
addConnection: (conn) => set((state) => ({ connections: [...state.connections, conn] })),
updateConnection: (conn) => set((state) => ({
connections: state.connections.map(c => c.id === conn.id ? conn : c)
updateConnection: (conn) => set((state) => ({
connections: state.connections.map(c => c.id === conn.id ? conn : c)
})),
removeConnection: (id) => set((state) => ({ connections: state.connections.filter(c => c.id !== id) })),
@@ -125,6 +492,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 }),
@@ -143,42 +549,78 @@ export const useStore = create<AppState>()(
setTheme: (theme) => set({ theme }),
setAppearance: (appearance) => set((state) => ({ appearance: { ...state.appearance, ...appearance } })),
setStartupFullscreen: (enabled) => set({ startupFullscreen: !!enabled }),
setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }),
setQueryOptions: (options) => set((state) => ({ queryOptions: { ...state.queryOptions, ...options } })),
addSqlLog: (log) => set((state) => ({ sqlLogs: [log, ...state.sqlLogs].slice(0, 1000) })), // Keep last 1000 logs
clearSqlLogs: () => set({ sqlLogs: [] }),
recordTableAccess: (connectionId, dbName, tableName) => set((state) => {
const key = `${connectionId}-${dbName}-${tableName}`;
const currentCount = state.tableAccessCount[key] || 0;
return {
tableAccessCount: {
...state.tableAccessCount,
[key]: currentCount + 1
}
};
}),
setTableSortPreference: (connectionId, dbName, sortBy) => set((state) => {
const key = `${connectionId}-${dbName}`;
return {
tableSortPreference: {
...state.tableSortPreference,
[key]: sortBy
}
};
}),
}),
{
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 state = unwrapPersistedAppState(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.startupFullscreen = sanitizeStartupFullscreen(state.startupFullscreen);
nextState.sqlFormatOptions = sanitizeSqlFormatOptions(state.sqlFormatOptions);
nextState.queryOptions = sanitizeQueryOptions(state.queryOptions);
nextState.tableAccessCount = sanitizeTableAccessCount(state.tableAccessCount);
nextState.tableSortPreference = sanitizeTableSortPreference(state.tableSortPreference);
return nextState as AppState;
},
partialize: (state) => ({ connections: state.connections, savedQueries: state.savedQueries, theme: state.theme, appearance: state.appearance, sqlFormatOptions: state.sqlFormatOptions, queryOptions: state.queryOptions }), // Don't persist logs
merge: (persistedState, currentState) => {
const state = unwrapPersistedAppState(persistedState) as Partial<AppState>;
return {
...currentState,
...state,
connections: sanitizeConnections(state.connections),
savedQueries: sanitizeSavedQueries(state.savedQueries),
theme: sanitizeTheme(state.theme),
appearance: sanitizeAppearance(state.appearance, 3),
startupFullscreen: sanitizeStartupFullscreen(state.startupFullscreen),
sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions),
queryOptions: sanitizeQueryOptions(state.queryOptions),
tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount),
tableSortPreference: sanitizeTableSortPreference(state.tableSortPreference),
};
},
partialize: (state) => ({
connections: state.connections,
savedQueries: state.savedQueries,
theme: state.theme,
appearance: state.appearance,
startupFullscreen: state.startupFullscreen,
sqlFormatOptions: state.sqlFormatOptions,
queryOptions: state.queryOptions,
tableAccessCount: state.tableAccessCount,
tableSortPreference: state.tableSortPreference
}), // Don't persist logs
}
)
);

View File

@@ -6,16 +6,51 @@ export interface SSHConfig {
keyPath?: string;
}
export interface ProxyConfig {
type: 'socks5' | 'http';
host: string;
port: number;
user?: string;
password?: string;
}
export interface ConnectionConfig {
type: string;
host: string;
port: number;
user: string;
password?: string;
savePassword?: boolean;
database?: string;
useSSH?: boolean;
ssh?: SSHConfig;
useProxy?: boolean;
proxy?: ProxyConfig;
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
topology?: 'single' | 'replica';
mysqlReplicaUser?: string;
mysqlReplicaPassword?: string;
replicaSet?: string;
authSource?: string;
readPreference?: string;
mongoSrv?: boolean;
mongoAuthMechanism?: string;
mongoReplicaUser?: string;
mongoReplicaPassword?: string;
}
export interface MongoMemberInfo {
host: string;
role: string;
state: string;
stateCode?: number;
healthy: boolean;
isSelf?: boolean;
}
export interface SavedConnection {
@@ -62,7 +97,7 @@ export interface TriggerDefinition {
export interface TabData {
id: string;
title: string;
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command';
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'trigger' | 'view-def' | 'routine-def';
connectionId: string;
dbName?: string;
tableName?: string;
@@ -70,6 +105,10 @@ export interface TabData {
initialTab?: string;
readOnly?: boolean;
redisDB?: number; // Redis database index for redis tabs
triggerName?: string; // Trigger name for trigger tabs
viewName?: string; // View name for view definition tabs
routineName?: string; // Routine name for function/procedure definition tabs
routineType?: string; // 'FUNCTION' or 'PROCEDURE'
}
export interface DatabaseNode {
@@ -102,7 +141,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;
@@ -117,3 +156,8 @@ export interface ZSetMember {
member: string;
score: number;
}
export interface StreamEntry {
id: string;
fields: Record<string, string>;
}

View File

@@ -1,5 +1,6 @@
export type FilterCondition = {
id?: number;
enabled?: boolean;
column?: string;
op?: string;
value?: string;
@@ -35,7 +36,7 @@ export const quoteIdentPart = (dbType: string, ident: string) => {
if (!raw) return raw;
const dbTypeLower = (dbType || '').toLowerCase();
if (dbTypeLower === 'mysql') {
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros' || dbTypeLower === 'sphinx' || dbTypeLower === 'tdengine') {
return `\`${raw.replace(/`/g, '``')}\``;
}
@@ -62,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 [];
@@ -75,6 +146,8 @@ export const buildWhereSQL = (dbType: string, conditions: FilterCondition[]) =>
const whereParts: string[] = [];
(conditions || []).forEach((cond) => {
if (cond?.enabled === false) return;
const op = (cond?.op || '').trim();
const column = (cond?.column || '').trim();
const value = (cond?.value ?? '').toString();
@@ -197,4 +270,3 @@ export const buildWhereSQL = (dbType: string, conditions: FilterCondition[]) =>
return whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '';
};

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

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

View File

@@ -12,4 +12,4 @@ export default defineConfig({
outDir: 'dist', // Standard Wails output directory
emptyOutDir: true,
}
})
})

View File

@@ -6,8 +6,12 @@ import {redis} from '../models';
export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise<connection.QueryResult>;
export function CheckDriverNetworkStatus():Promise<connection.QueryResult>;
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,12 +40,18 @@ 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,arg4:string):Promise<connection.QueryResult>;
export function DownloadUpdate():Promise<connection.QueryResult>;
export function DropDatabase(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
export function DropFunction(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function DropTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function DropView(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function ExportData(arg1:Array<Record<string, any>>,arg2:Array<string>,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function ExportDatabaseSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:boolean):Promise<connection.QueryResult>;
@@ -56,12 +66,24 @@ 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 GetDriverVersionList(arg1:string,arg2:string):Promise<connection.QueryResult>;
export function GetDriverVersionPackageSize(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>;
export function MySQLConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function MySQLGetDatabases(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
@@ -74,6 +96,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>;
@@ -110,14 +134,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

@@ -6,10 +6,18 @@ export function ApplyChanges(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ApplyChanges'](arg1, arg2, arg3, arg4);
}
export function CheckDriverNetworkStatus() {
return window['go']['app']['App']['CheckDriverNetworkStatus']();
}
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 +74,10 @@ export function DataSyncPreview(arg1, arg2, arg3) {
return window['go']['app']['App']['DataSyncPreview'](arg1, arg2, arg3);
}
export function DownloadDriverPackage(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['DownloadDriverPackage'](arg1, arg2, arg3, arg4);
}
export function DownloadUpdate() {
return window['go']['app']['App']['DownloadUpdate']();
}
@@ -74,10 +86,18 @@ export function DropDatabase(arg1, arg2) {
return window['go']['app']['App']['DropDatabase'](arg1, arg2);
}
export function DropFunction(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['DropFunction'](arg1, arg2, arg3, arg4);
}
export function DropTable(arg1, arg2, arg3) {
return window['go']['app']['App']['DropTable'](arg1, arg2, arg3);
}
export function DropView(arg1, arg2, arg3) {
return window['go']['app']['App']['DropView'](arg1, arg2, arg3);
}
export function ExportData(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ExportData'](arg1, arg2, arg3, arg4);
}
@@ -106,6 +126,18 @@ export function GetAppInfo() {
return window['go']['app']['App']['GetAppInfo']();
}
export function GetDriverStatusList(arg1, arg2) {
return window['go']['app']['App']['GetDriverStatusList'](arg1, arg2);
}
export function GetDriverVersionList(arg1, arg2) {
return window['go']['app']['App']['GetDriverVersionList'](arg1, arg2);
}
export function GetDriverVersionPackageSize(arg1, arg2) {
return window['go']['app']['App']['GetDriverVersionPackageSize'](arg1, arg2);
}
export function ImportConfigFile() {
return window['go']['app']['App']['ImportConfigFile']();
}
@@ -114,10 +146,22 @@ 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']();
}
export function MongoDiscoverMembers(arg1) {
return window['go']['app']['App']['MongoDiscoverMembers'](arg1);
}
export function MySQLConnect(arg1) {
return window['go']['app']['App']['MySQLConnect'](arg1);
}
@@ -142,6 +186,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);
}
@@ -214,6 +262,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);
}
@@ -226,6 +282,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);
}
@@ -234,6 +294,38 @@ export function RenameTable(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['RenameTable'](arg1, arg2, arg3, arg4);
}
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);
}
export function TestConnection(arg1) {
return window['go']['app']['App']['TestConnection'](arg1);
}

View File

@@ -48,6 +48,26 @@ export namespace connection {
return a;
}
}
export class ProxyConfig {
type: string;
host: string;
port: number;
user?: string;
password?: string;
static createFrom(source: any = {}) {
return new ProxyConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.type = source["type"];
this.host = source["host"];
this.port = source["port"];
this.user = source["user"];
this.password = source["password"];
}
}
export class SSHConfig {
host: string;
port: number;
@@ -74,13 +94,28 @@ export namespace connection {
port: number;
user: string;
password: string;
savePassword?: boolean;
database: string;
useSSH: boolean;
ssh: SSHConfig;
useProxy?: boolean;
proxy?: ProxyConfig;
driver?: string;
dsn?: string;
timeout?: number;
redisDB?: number;
uri?: string;
hosts?: string[];
topology?: string;
mysqlReplicaUser?: string;
mysqlReplicaPassword?: string;
replicaSet?: string;
authSource?: string;
readPreference?: string;
mongoSrv?: boolean;
mongoAuthMechanism?: string;
mongoReplicaUser?: string;
mongoReplicaPassword?: string;
static createFrom(source: any = {}) {
return new ConnectionConfig(source);
@@ -93,13 +128,28 @@ export namespace connection {
this.port = source["port"];
this.user = source["user"];
this.password = source["password"];
this.savePassword = source["savePassword"];
this.database = source["database"];
this.useSSH = source["useSSH"];
this.ssh = this.convertValues(source["ssh"], SSHConfig);
this.useProxy = source["useProxy"];
this.proxy = this.convertValues(source["proxy"], ProxyConfig);
this.driver = source["driver"];
this.dsn = source["dsn"];
this.timeout = source["timeout"];
this.redisDB = source["redisDB"];
this.uri = source["uri"];
this.hosts = source["hosts"];
this.topology = source["topology"];
this.mysqlReplicaUser = source["mysqlReplicaUser"];
this.mysqlReplicaPassword = source["mysqlReplicaPassword"];
this.replicaSet = source["replicaSet"];
this.authSource = source["authSource"];
this.readPreference = source["readPreference"];
this.mongoSrv = source["mongoSrv"];
this.mongoAuthMechanism = source["mongoAuthMechanism"];
this.mongoReplicaUser = source["mongoReplicaUser"];
this.mongoReplicaPassword = source["mongoReplicaPassword"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -120,6 +170,7 @@ export namespace connection {
return a;
}
}
export class QueryResult {
success: boolean;
message: string;

42
go.mod
View File

@@ -5,32 +5,52 @@ 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/mod v0.32.0
golang.org/x/net v0.49.0
golang.org/x/text v0.33.0
modernc.org/sqlite v1.44.3
)
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/klauspost/compress v1.17.6 // indirect
github.com/json-iterator/go v1.1.12 // 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
@@ -39,13 +59,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-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
@@ -54,13 +80,19 @@ 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/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.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

120
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=
@@ -24,16 +30,36 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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=
@@ -43,22 +69,34 @@ 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=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
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/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/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=
@@ -87,18 +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/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=
@@ -108,8 +163,21 @@ github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
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/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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/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.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=
@@ -128,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=
@@ -164,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=
@@ -179,9 +261,15 @@ 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=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=

View File

@@ -15,6 +15,7 @@ import (
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
"GoNavi-Wails/internal/logger"
proxytunnel "GoNavi-Wails/internal/proxy"
)
const dbCachePingInterval = 30 * time.Second
@@ -49,6 +50,13 @@ func (a *App) Startup(ctx context.Context) {
logger.Infof("应用启动完成")
}
// SetWindowTranslucency 动态调整 macOS 窗口透明度。
// 前端在加载用户外观设置后、以及用户修改外观时调用此方法。
// opacity=1.0 且 blur=0 时窗口标记为 opaqueGPU 不再持续计算窗口背后的模糊合成。
func (a *App) SetWindowTranslucency(opacity float64, blur float64) {
setMacWindowTranslucency(opacity, blur)
}
// Shutdown is called when the app terminates
func (a *App) Shutdown(ctx context.Context) {
logger.Infof("应用开始关闭,准备释放资源")
@@ -59,6 +67,7 @@ func (a *App) Shutdown(ctx context.Context) {
logger.Error(err, "关闭数据库连接失败")
}
}
proxytunnel.CloseAllForwarders()
// Close all Redis connections
CloseAllRedisClients()
logger.Infof("资源释放完成,应用已关闭")
@@ -70,9 +79,8 @@ func getCacheKey(config connection.ConnectionConfig) string {
if !config.UseSSH {
config.SSH = connection.SSHConfig{}
}
// 保持与驱动默认一致,避免同一连接被重复缓存
if config.Type == "postgres" && config.Database == "" {
config.Database = "postgres"
if !config.UseProxy {
config.Proxy = connection.ProxyConfig{}
}
b, _ := json.Marshal(config)
@@ -103,10 +111,11 @@ type withLogHint struct {
}
func (e withLogHint) Error() string {
message := normalizeErrorMessage(e.err)
if strings.TrimSpace(e.logPath) == "" {
return e.err.Error()
return message
}
return fmt.Sprintf("%s详细日志%s", e.err.Error(), e.logPath)
return fmt.Sprintf("%s详细日志%s", message, e.logPath)
}
func (e withLogHint) Unwrap() error {
@@ -125,12 +134,54 @@ 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)))
}
if strings.TrimSpace(config.Topology) != "" {
b.WriteString(fmt.Sprintf(" 拓扑=%s", strings.TrimSpace(config.Topology)))
}
if strings.TrimSpace(config.URI) != "" {
b.WriteString(fmt.Sprintf(" URI=已配置(长度=%d)", len(config.URI)))
}
if strings.TrimSpace(config.MySQLReplicaUser) != "" {
b.WriteString(" MySQL从库凭据=已配置")
}
if strings.EqualFold(strings.TrimSpace(config.Type), "mongodb") {
if strings.TrimSpace(config.MongoReplicaUser) != "" {
b.WriteString(" Mongo从库凭据=已配置")
}
if strings.TrimSpace(config.ReplicaSet) != "" {
b.WriteString(fmt.Sprintf(" 副本集=%s", strings.TrimSpace(config.ReplicaSet)))
}
if strings.TrimSpace(config.ReadPreference) != "" {
b.WriteString(fmt.Sprintf(" 读偏好=%s", strings.TrimSpace(config.ReadPreference)))
}
if strings.TrimSpace(config.AuthSource) != "" {
b.WriteString(fmt.Sprintf(" 认证库=%s", strings.TrimSpace(config.AuthSource)))
}
}
if config.UseSSH {
b.WriteString(fmt.Sprintf(" SSH=%s:%d 用户=%s", config.SSH.Host, config.SSH.Port, config.SSH.User))
}
if config.UseProxy {
b.WriteString(fmt.Sprintf(" 代理=%s://%s:%d", strings.ToLower(strings.TrimSpace(config.Proxy.Type)), config.Proxy.Host, config.Proxy.Port))
if strings.TrimSpace(config.Proxy.User) != "" {
b.WriteString(" 代理认证=已配置")
}
}
if config.Type == "custom" {
driver := strings.TrimSpace(config.Driver)
@@ -163,6 +214,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()
@@ -211,7 +276,14 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
return nil, err
}
if err := dbInst.Connect(config); err != nil {
connectConfig, proxyErr := resolveDialConfigWithProxy(config)
if proxyErr != nil {
wrapped := wrapConnectError(config, proxyErr)
logger.Error(wrapped, "连接代理准备失败:%s 缓存Key=%s", formatConnSummary(config), shortKey)
return nil, wrapped
}
if err := dbInst.Connect(connectConfig); err != nil {
wrapped := wrapConnectError(config, err)
logger.Error(wrapped, "建立数据库连接失败:%s 缓存Key=%s", formatConnSummary(config), shortKey)
return nil, wrapped

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":
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "sqlserver", "mongodb", "tdengine":
// 这些类型的 dbName 表示"数据库",需要写入连接配置以选择目标库。
runConfig.Database = name
case "dameng":
@@ -56,4 +56,3 @@ func normalizeSchemaAndTable(config connection.ConnectionConfig, dbName string,
return rawDB, rawTable
}
}

202
internal/app/db_proxy.go Normal file
View File

@@ -0,0 +1,202 @@
package app
import (
"fmt"
"net"
"strconv"
"strings"
"GoNavi-Wails/internal/connection"
proxytunnel "GoNavi-Wails/internal/proxy"
)
func resolveDialConfigWithProxy(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
config := raw
if !config.UseProxy {
config.Proxy = connection.ProxyConfig{}
return config, nil
}
normalizedProxy, err := proxytunnel.NormalizeConfig(config.Proxy)
if err != nil {
return connection.ConnectionConfig{}, err
}
config.Proxy = normalizedProxy
if config.UseSSH {
sshPort := config.SSH.Port
if sshPort <= 0 {
sshPort = 22
}
forwardedSSH, err := buildProxyForwardAddress(normalizedProxy, strings.TrimSpace(config.SSH.Host), sshPort)
if err != nil {
return connection.ConnectionConfig{}, fmt.Errorf("代理连接 SSH 网关失败:%w", err)
}
config.SSH.Host = forwardedSSH.host
config.SSH.Port = forwardedSSH.port
config.UseProxy = false
config.Proxy = connection.ProxyConfig{}
return config, nil
}
normalizedType := strings.ToLower(strings.TrimSpace(config.Type))
if normalizedType == "sqlite" || normalizedType == "duckdb" || normalizedType == "custom" {
// 文件型/自定义 DSN 类型不走标准 host:port不在此层改写。
return config, nil
}
if normalizedType == "mongodb" && config.MongoSRV {
// Mongo SRV 由驱动侧 Dialer 处理代理,避免破坏 DNS SRV 拓扑发现。
return config, nil
}
targetPort := config.Port
if targetPort <= 0 {
targetPort = defaultPortByType(normalizedType)
}
forwardedPrimary, err := buildProxyForwardAddress(normalizedProxy, strings.TrimSpace(config.Host), targetPort)
if err != nil {
return connection.ConnectionConfig{}, err
}
config.Host = forwardedPrimary.host
config.Port = forwardedPrimary.port
if len(config.Hosts) > 0 {
rewritten := make([]string, 0, len(config.Hosts))
seen := make(map[string]struct{}, len(config.Hosts))
for _, rawEntry := range config.Hosts {
targetHost, targetPort, ok := parseAddressWithDefaultPort(rawEntry, defaultPortByType(normalizedType))
if !ok {
continue
}
forwarded, forwardErr := buildProxyForwardAddress(normalizedProxy, targetHost, targetPort)
if forwardErr != nil {
return connection.ConnectionConfig{}, forwardErr
}
rewrittenAddress := formatHostPort(forwarded.host, forwarded.port)
if _, exists := seen[rewrittenAddress]; exists {
continue
}
seen[rewrittenAddress] = struct{}{}
rewritten = append(rewritten, rewrittenAddress)
}
config.Hosts = rewritten
}
config.UseProxy = false
config.Proxy = connection.ProxyConfig{}
return config, nil
}
type hostPort struct {
host string
port int
}
func buildProxyForwardAddress(proxyConfig connection.ProxyConfig, targetHost string, targetPort int) (hostPort, error) {
host := strings.TrimSpace(targetHost)
if host == "" {
host = "localhost"
}
port := targetPort
if port <= 0 {
return hostPort{}, fmt.Errorf("目标端口无效:%d", targetPort)
}
forwarder, err := proxytunnel.GetOrCreateLocalForwarder(proxyConfig, host, port)
if err != nil {
return hostPort{}, err
}
localHost, localPort, splitOK := parseAddressWithDefaultPort(forwarder.LocalAddr, 0)
if !splitOK || localPort <= 0 {
return hostPort{}, fmt.Errorf("解析代理本地转发地址失败:%s", forwarder.LocalAddr)
}
return hostPort{host: localHost, port: localPort}, nil
}
func parseAddressWithDefaultPort(raw string, defaultPort int) (string, int, bool) {
text := strings.TrimSpace(raw)
if text == "" {
return "", 0, false
}
if strings.HasPrefix(text, "[") {
if host, portText, err := net.SplitHostPort(text); err == nil {
if port, convErr := strconv.Atoi(portText); convErr == nil && port > 0 && port <= 65535 {
return strings.TrimSpace(host), port, true
}
return "", 0, false
}
trimmed := strings.Trim(strings.TrimPrefix(text, "["), "]")
if trimmed != "" && defaultPort > 0 {
return trimmed, defaultPort, true
}
return "", 0, false
}
if strings.Count(text, ":") == 0 {
if defaultPort <= 0 {
return "", 0, false
}
return text, defaultPort, true
}
if strings.Count(text, ":") == 1 {
host, portText, err := net.SplitHostPort(text)
if err == nil {
port, convErr := strconv.Atoi(portText)
if convErr == nil && port > 0 && port <= 65535 {
return strings.TrimSpace(host), port, true
}
return "", 0, false
}
if defaultPort > 0 {
return strings.TrimSpace(text), defaultPort, true
}
return "", 0, false
}
// IPv6 地址未带端口,使用默认端口。
if defaultPort > 0 {
return text, defaultPort, true
}
return "", 0, false
}
func formatHostPort(host string, port int) string {
h := strings.TrimSpace(host)
if strings.Contains(h, ":") && !strings.HasPrefix(h, "[") {
return fmt.Sprintf("[%s]:%d", h, port)
}
return fmt.Sprintf("%s:%d", h, port)
}
func defaultPortByType(driverType string) int {
switch strings.ToLower(strings.TrimSpace(driverType)) {
case "mysql", "mariadb":
return 3306
case "diros":
return 9030
case "sphinx":
return 9306
case "postgres", "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 0
}
}

100
internal/app/error_text.go Normal file
View File

@@ -0,0 +1,100 @@
package app
import (
"strings"
"unicode"
"unicode/utf8"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
)
func normalizeErrorMessage(err error) string {
if err == nil {
return ""
}
return normalizeMixedEncodingText(err.Error())
}
func normalizeMixedEncodingText(text string) string {
if text == "" {
return text
}
raw := []byte(text)
output := make([]byte, 0, len(raw)+16)
suspect := make([]byte, 0, 16)
flushSuspect := func() {
if len(suspect) == 0 {
return
}
fallback := strings.ToValidUTF8(string(suspect), "<22>")
decoded, _, err := transform.Bytes(simplifiedchinese.GB18030.NewDecoder(), suspect)
if err == nil && utf8.Valid(decoded) {
candidate := string(decoded)
if scoreDecodedText(candidate) > scoreDecodedText(fallback) {
output = append(output, []byte(candidate)...)
} else {
output = append(output, []byte(fallback)...)
}
} else {
output = append(output, []byte(fallback)...)
}
suspect = suspect[:0]
}
for len(raw) > 0 {
r, size := utf8.DecodeRune(raw)
if r == utf8.RuneError && size == 1 {
suspect = append(suspect, raw[0])
raw = raw[1:]
continue
}
if isLikelyMojibakeRune(r) {
suspect = append(suspect, raw[:size]...)
} else {
flushSuspect()
output = append(output, raw[:size]...)
}
raw = raw[size:]
}
flushSuspect()
return string(output)
}
func isLikelyMojibakeRune(r rune) bool {
if r == utf8.RuneError {
return true
}
if r >= 0x00C0 && r <= 0x02FF {
return true
}
if unicode.In(r, unicode.Hebrew, unicode.Arabic, unicode.Cyrillic, unicode.Greek) {
return true
}
return false
}
func scoreDecodedText(text string) int {
score := 0
for _, r := range text {
switch {
case r == '<27>':
score -= 6
case unicode.Is(unicode.Han, r):
score += 4
case isLikelyMojibakeRune(r):
score -= 3
case unicode.IsPrint(r):
score += 1
default:
score -= 2
}
}
return score
}

View File

@@ -0,0 +1,25 @@
package app
import "testing"
func TestNormalizeMixedEncodingText_GBKErrorMessage(t *testing.T) {
raw := []byte("pq: ")
raw = append(raw, 0xD3, 0xC3, 0xBB, 0xA7) // 用户
raw = append(raw, []byte(` "root" Password `)...)
raw = append(raw, 0xC8, 0xCF, 0xD6, 0xA4, 0xCA, 0xA7, 0xB0, 0xDC) // 认证失败
raw = append(raw, []byte(" (28P01)")...)
got := normalizeMixedEncodingText(string(raw))
want := `pq: 用户 "root" Password 认证失败 (28P01)`
if got != want {
t.Fatalf("normalizeMixedEncodingText() mismatch\nwant: %q\ngot: %q", want, got)
}
}
func TestNormalizeMixedEncodingText_KeepUTF8(t *testing.T) {
input := `连接建立后验证失败pq: password authentication failed for user "root"`
got := normalizeMixedEncodingText(input)
if got != input {
t.Fatalf("expected unchanged utf8 text, got: %q", got)
}
}

View File

@@ -36,6 +36,41 @@ func (a *App) TestConnection(config connection.ConnectionConfig) connection.Quer
return connection.QueryResult{Success: true, Message: "连接成功"}
}
func (a *App) MongoDiscoverMembers(config connection.ConnectionConfig) connection.QueryResult {
config.Type = "mongodb"
dbInst, err := a.getDatabaseForcePing(config)
if err != nil {
logger.Error(err, "MongoDiscoverMembers 获取连接失败:%s", formatConnSummary(config))
return connection.QueryResult{Success: false, Message: err.Error()}
}
discoverable, ok := dbInst.(interface {
DiscoverMembers() (string, []connection.MongoMemberInfo, error)
})
if !ok {
return connection.QueryResult{Success: false, Message: "当前 MongoDB 驱动不支持成员发现"}
}
replicaSet, members, err := discoverable.DiscoverMembers()
if err != nil {
logger.Error(err, "MongoDiscoverMembers 执行失败:%s", formatConnSummary(config))
return connection.QueryResult{Success: false, Message: err.Error()}
}
data := map[string]interface{}{
"replicaSet": replicaSet,
"members": members,
}
logger.Infof("MongoDiscoverMembers 成功:%s 成员数=%d 副本集=%s", formatConnSummary(config), len(members), replicaSet)
return connection.QueryResult{
Success: true,
Message: fmt.Sprintf("发现 %d 个成员", len(members)),
Data: data,
}
}
func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string) connection.QueryResult {
runConfig := config
runConfig.Database = ""
@@ -51,8 +86,12 @@ func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string)
if dbType == "postgres" || dbType == "kingbase" || dbType == "highgo" || dbType == "vastbase" {
escapedDbName = strings.ReplaceAll(dbName, `"`, `""`)
query = fmt.Sprintf("CREATE DATABASE \"%s\"", escapedDbName)
} else if dbType == "mariadb" {
} else if dbType == "tdengine" {
query = fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", quoteIdentByType(dbType, dbName))
} 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)
@@ -77,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
}
@@ -119,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)
}
@@ -140,16 +183,13 @@ 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: "当前连接正在使用目标数据库,请先连接到其他数据库后再重命名"}
}
runConfig := config
if strings.TrimSpace(runConfig.Database) == "" {
runConfig.Database = "postgres"
}
dbInst, err := a.getDatabase(runConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
@@ -176,7 +216,7 @@ func (a *App) DropDatabase(config connection.ConnectionConfig, dbName string) co
sql string
)
switch dbType {
case "mysql", "mariadb":
case "mysql", "mariadb", "diros", "tdengine":
runConfig = config
runConfig.Database = ""
sql = fmt.Sprintf("DROP DATABASE %s", quoteIdentByType(dbType, dbName))
@@ -185,9 +225,6 @@ func (a *App) DropDatabase(config connection.ConnectionConfig, dbName string) co
return connection.QueryResult{Success: false, Message: "当前连接正在使用目标数据库,请先连接到其他数据库后再删除"}
}
runConfig = config
if strings.TrimSpace(runConfig.Database) == "" {
runConfig.Database = "postgres"
}
sql = fmt.Sprintf("DROP DATABASE %s", quoteIdentByType(dbType, dbName))
default:
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除数据库", dbType)}
@@ -218,7 +255,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)}
}
@@ -232,7 +269,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":
@@ -264,7 +301,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":
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)}
}
@@ -330,7 +367,12 @@ func (a *App) DBQuery(config connection.ConnectionConfig, dbName string, query s
defer cancel()
lowerQuery := strings.TrimSpace(strings.ToLower(query))
if strings.HasPrefix(lowerQuery, "select") || strings.HasPrefix(lowerQuery, "show") || strings.HasPrefix(lowerQuery, "describe") || strings.HasPrefix(lowerQuery, "explain") {
isReadQuery := strings.HasPrefix(lowerQuery, "select") || strings.HasPrefix(lowerQuery, "show") || strings.HasPrefix(lowerQuery, "describe") || strings.HasPrefix(lowerQuery, "explain")
// MongoDB JSON 命令中的 find/count/aggregate 也属于读查询
if !isReadQuery && strings.ToLower(strings.TrimSpace(runConfig.Type)) == "mongodb" && strings.HasPrefix(strings.TrimSpace(query), "{") {
isReadQuery = true
}
if isReadQuery {
var data []map[string]interface{}
var columns []string
if q, ok := dbInst.(interface {
@@ -417,6 +459,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 {
@@ -430,10 +473,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)
@@ -502,6 +655,128 @@ func (a *App) DBGetTriggers(config connection.ConnectionConfig, dbName string, t
return connection.QueryResult{Success: true, Data: triggers}
}
func (a *App) DropView(config connection.ConnectionConfig, dbName string, viewName string) connection.QueryResult {
viewName = strings.TrimSpace(viewName)
if viewName == "" {
return connection.QueryResult{Success: false, Message: "视图名称不能为空"}
}
dbType := resolveDDLDBType(config)
switch dbType {
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)}
}
schemaName, pureViewName := normalizeSchemaAndTableByType(dbType, dbName, viewName)
if pureViewName == "" {
return connection.QueryResult{Success: false, Message: "视图名称不能为空"}
}
qualifiedView := quoteTableIdentByType(dbType, schemaName, pureViewName)
sql := fmt.Sprintf("DROP VIEW %s", qualifiedView)
runConfig := buildRunConfigForDDL(config, dbType, dbName)
dbInst, err := a.getDatabase(runConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if _, err := dbInst.Exec(sql); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Message: "视图删除成功"}
}
func (a *App) DropFunction(config connection.ConnectionConfig, dbName string, routineName string, routineType string) connection.QueryResult {
routineName = strings.TrimSpace(routineName)
routineType = strings.TrimSpace(strings.ToUpper(routineType))
if routineName == "" {
return connection.QueryResult{Success: false, Message: "函数/存储过程名称不能为空"}
}
if routineType != "FUNCTION" && routineType != "PROCEDURE" {
routineType = "FUNCTION"
}
dbType := resolveDDLDBType(config)
switch dbType {
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 == "" {
return connection.QueryResult{Success: false, Message: "函数/存储过程名称不能为空"}
}
qualifiedName := quoteTableIdentByType(dbType, schemaName, pureName)
sql := fmt.Sprintf("DROP %s %s", routineType, qualifiedName)
runConfig := buildRunConfigForDDL(config, dbType, dbName)
dbInst, err := a.getDatabase(runConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if _, err := dbInst.Exec(sql); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
label := "函数"
if routineType == "PROCEDURE" {
label = "存储过程"
}
return connection.QueryResult{Success: true, Message: fmt.Sprintf("%s删除成功", label)}
}
func (a *App) RenameView(config connection.ConnectionConfig, dbName string, oldName string, newName string) connection.QueryResult {
oldName = strings.TrimSpace(oldName)
newName = strings.TrimSpace(newName)
if oldName == "" || newName == "" {
return connection.QueryResult{Success: false, Message: "视图名称不能为空"}
}
if strings.EqualFold(oldName, newName) {
return connection.QueryResult{Success: false, Message: "新旧视图名称不能相同"}
}
if strings.Contains(newName, ".") {
return connection.QueryResult{Success: false, Message: "新视图名不能包含 schema 或数据库前缀"}
}
dbType := resolveDDLDBType(config)
schemaName, pureOldName := normalizeSchemaAndTableByType(dbType, dbName, oldName)
if pureOldName == "" {
return connection.QueryResult{Success: false, Message: "旧视图名不能为空"}
}
oldQualified := quoteTableIdentByType(dbType, schemaName, pureOldName)
newQuoted := quoteIdentByType(dbType, newName)
var sql string
switch dbType {
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":
sql = fmt.Sprintf("ALTER VIEW %s RENAME TO %s", oldQualified, newQuoted)
case "sqlserver":
oldFullName := schemaName + "." + pureOldName
escapedOld := strings.ReplaceAll(oldFullName, "'", "''")
escapedNew := strings.ReplaceAll(newName, "'", "''")
sql = fmt.Sprintf("EXEC sp_rename '%s', '%s'", escapedOld, escapedNew)
default:
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持重命名视图", dbType)}
}
runConfig := buildRunConfigForDDL(config, dbType, dbName)
dbInst, err := a.getDatabase(runConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if _, err := dbInst.Exec(sql); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Message: "视图重命名成功"}
}
func (a *App) DBGetAllColumns(config connection.ConnectionConfig, dbName string) connection.QueryResult {
runConfig := normalizeRunConfig(config, dbName)

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: "无法确定当前应用目录,无法下载更新"}
@@ -221,14 +245,30 @@ func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult {
return connection.QueryResult{Success: false, Message: errMsg}
}
stagedDir, err := os.MkdirTemp(workspaceDir, ".gonavi-update-work-")
if err != nil {
errMsg := fmt.Sprintf("无法在应用目录创建更新工作目录:%s", workspaceDir)
// 使用版本号命名的工作目录,便于识别和调试
stagedDir := filepath.Join(workspaceDir, fmt.Sprintf(".gonavi-update-%s-%s", stdRuntime.GOOS, info.LatestVersion))
// 清理可能残留的旧目录(上次下载失败后未清理)
// Windows 上文件可能被杀毒软件/索引服务占用,需要重试
for retry := 0; retry < 5; retry++ {
err := os.RemoveAll(stagedDir)
if err == nil {
break
}
if retry < 4 {
time.Sleep(time.Duration(retry+1) * 500 * time.Millisecond)
} else {
// 最后一次仍然失败,换一个带时间戳的目录名避免冲突
stagedDir = filepath.Join(workspaceDir, fmt.Sprintf(".gonavi-update-%s-%s-%d", stdRuntime.GOOS, info.LatestVersion, time.Now().UnixNano()))
}
}
if err := os.MkdirAll(stagedDir, 0o755); err != nil {
errMsg := fmt.Sprintf("无法在应用目录创建更新工作目录:%s", stagedDir)
a.emitUpdateDownloadProgress("error", 0, info.AssetSize, errMsg)
return connection.QueryResult{Success: false, Message: errMsg}
}
assetPath := filepath.Join(workspaceDir, 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 {
@@ -263,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()
@@ -283,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
}
@@ -353,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)
@@ -486,11 +539,21 @@ func downloadFileWithHash(url, filePath string, onProgress func(downloaded, tota
return "", fmt.Errorf("下载更新包失败HTTP %d", resp.StatusCode)
}
out, err := os.Create(filePath)
if err != nil {
return "", err
// Windows 上旧文件可能被杀毒软件/索引服务占用,先尝试删除并重试
_ = os.Remove(filePath)
var out *os.File
for retry := 0; retry < 5; retry++ {
out, err = os.Create(filePath)
if err == nil {
break
}
if retry < 4 {
time.Sleep(time.Duration(retry+1) * 500 * time.Millisecond)
}
}
if err != nil {
return "", fmt.Errorf("更新下载失败,文件被占用:%w", err)
}
defer out.Close()
hasher := sha256.New()
total := resp.ContentLength
@@ -504,12 +567,22 @@ func downloadFileWithHash(url, filePath string, onProgress func(downloaded, tota
onProgress(0, total)
}
if _, err := io.Copy(io.MultiWriter(writers...), resp.Body); err != nil {
out.Close()
return "", err
}
if onProgress != nil {
onProgress(progressWriter.written, total)
}
// 显式 Sync + Close确保数据落盘且文件句柄释放
if err := out.Sync(); err != nil {
out.Close()
return "", err
}
if err := out.Close(); err != nil {
return "", err
}
return hex.EncodeToString(hasher.Sum(nil)), nil
}
@@ -539,19 +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 {
exePath, err := os.Executable()
if err != nil {
return ""
func sanitizeVersionForPath(version string) string {
trimmed := strings.TrimSpace(version)
if trimmed == "" {
return "latest"
}
exePath, _ = filepath.EvalSymlinks(exePath)
if stdRuntime.GOOS == "darwin" {
appPath := detectMacAppPath(exePath)
if appPath != "" {
return filepath.Dir(appPath)
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
}
}
return filepath.Dir(exePath)
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 {
@@ -658,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
)
@@ -682,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 (
@@ -698,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 {
@@ -853,7 +1092,12 @@ func detectMacAppPath(exePath string) string {
parts := strings.Split(exePath, string(filepath.Separator))
for i := len(parts) - 1; i >= 0; i-- {
if strings.HasSuffix(parts[i], ".app") {
return filepath.Join(parts[:i+1]...)
appPath := filepath.Join(parts[:i+1]...)
// 确保返回绝对路径
if !filepath.IsAbs(appPath) {
appPath = string(filepath.Separator) + appPath
}
return appPath
}
}
return ""

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,17 +44,21 @@ static void gonaviTuneWindowTranslucency(NSWindow *window) {
[effectView release];
}
[effectView setMaterial:NSVisualEffectMaterialHUDWindow];
if (@available(macOS 10.14, *)) {
[effectView setMaterial:NSVisualEffectMaterialHUDWindow];
}
[effectView setBlendingMode:NSVisualEffectBlendingModeBehindWindow];
[effectView setState:NSVisualEffectStateActive];
[effectView setAlphaValue:0.72];
// 默认 alpha=0不可见由前端根据用户外观设置动态启用
[effectView setAlphaValue:0.0];
[effectView setWantsLayer:YES];
[[effectView layer] setCornerRadius:cornerRadius];
[[effectView layer] setMasksToBounds:YES];
}
static void gonaviApplyWindowTranslucencyFix() {
for (int i = 0; i < 24; i++) {
// 启动时应用窗口透明度修复,减少重试次数以降低启动期 GPU 负载
for (int i = 0; i < 8; i++) {
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(i * 250 * NSEC_PER_MSEC)), dispatch_get_main_queue(), ^{
for (NSWindow *window in [NSApp windows]) {
gonaviTuneWindowTranslucency(window);
@@ -62,9 +66,56 @@ static void gonaviApplyWindowTranslucencyFix() {
});
}
}
// 动态设置 NSVisualEffectView 的透明度和窗口不透明标志。
// alpha <= 0 时窗口标记为 opaqueGPU 不再持续计算窗口背后的模糊效果。
static void gonaviSetEffectViewAlpha(double alpha) {
dispatch_async(dispatch_get_main_queue(), ^{
for (NSWindow *window in [NSApp windows]) {
NSView *contentView = [window contentView];
if (contentView == nil) {
continue;
}
for (NSView *subview in [contentView subviews]) {
if ([subview isKindOfClass:[NSVisualEffectView class]]) {
NSVisualEffectView *effectView = (NSVisualEffectView *)subview;
[effectView setAlphaValue:alpha];
break;
}
}
if (alpha <= 0.01) {
[window setOpaque:YES];
} else {
[window setOpaque:NO];
[window setBackgroundColor:[NSColor clearColor]];
}
}
});
}
*/
import "C"
func applyMacWindowTranslucencyFix() {
C.gonaviApplyWindowTranslucencyFix()
}
// setMacWindowTranslucency 根据用户外观设置动态调整 macOS 窗口透明度。
// opacity=1.0 且 blur=0 时关闭 NSVisualEffectViewalpha=0窗口标记为 opaque
// GPU 不再持续计算窗口背后的模糊合成,显著降低 CPU/GPU 温度。
func setMacWindowTranslucency(opacity float64, blur float64) {
if opacity >= 0.999 && blur <= 0 {
C.gonaviSetEffectViewAlpha(C.double(0.0))
} else {
// 半透明模式NSVisualEffectView alpha 根据透明度动态映射
alpha := (1.0 - opacity) * 1.2
if alpha < 0.3 {
alpha = 0.3
}
if alpha > 0.85 {
alpha = 0.85
}
C.gonaviSetEffectViewAlpha(C.double(alpha))
}
}

View File

@@ -3,3 +3,5 @@
package app
func applyMacWindowTranslucencyFix() {}
func setMacWindowTranslucency(opacity float64, blur float64) {}

View File

@@ -9,20 +9,44 @@ type SSHConfig struct {
KeyPath string `json:"keyPath"`
}
// ProxyConfig holds proxy connection details
type ProxyConfig struct {
Type string `json:"type"` // socks5 | http
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user,omitempty"`
Password string `json:"password,omitempty"`
}
// ConnectionConfig holds database connection details including SSH
type ConnectionConfig struct {
Type string `json:"type"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
Database string `json:"database"`
UseSSH bool `json:"useSSH"`
SSH SSHConfig `json:"ssh"`
Driver string `json:"driver,omitempty"` // For custom connection
DSN string `json:"dsn,omitempty"` // For custom connection
Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30)
RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15)
Type string `json:"type"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection
Database string `json:"database"`
UseSSH bool `json:"useSSH"`
SSH SSHConfig `json:"ssh"`
UseProxy bool `json:"useProxy,omitempty"`
Proxy ProxyConfig `json:"proxy,omitempty"`
Driver string `json:"driver,omitempty"` // For custom connection
DSN string `json:"dsn,omitempty"` // For custom connection
Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30)
RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15)
URI string `json:"uri,omitempty"` // Connection URI for copy/paste
Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port
Topology string `json:"topology,omitempty"` // single | replica
MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user
MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` // MySQL replica auth password
ReplicaSet string `json:"replicaSet,omitempty"` // MongoDB replica set name
AuthSource string `json:"authSource,omitempty"` // MongoDB authSource
ReadPreference string `json:"readPreference,omitempty"` // MongoDB readPreference
MongoSRV bool `json:"mongoSrv,omitempty"` // MongoDB use mongodb+srv URI scheme
MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"` // MongoDB authMechanism
MongoReplicaUser string `json:"mongoReplicaUser,omitempty"` // MongoDB replica auth user
MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password
}
// QueryResult is the standard response format for Wails methods
@@ -89,3 +113,12 @@ type ChangeSet struct {
Updates []UpdateRow `json:"updates"`
Deletes []map[string]interface{} `json:"deletes"`
}
type MongoMemberInfo struct {
Host string `json:"host"`
Role string `json:"role"`
State string `json:"state"`
StateCode int `json:"stateCode,omitempty"`
Healthy bool `json:"healthy"`
IsSelf bool `json:"isSelf,omitempty"`
}

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,38 +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 "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 (
@@ -95,3 +97,20 @@ func TestKingbaseDSN_QuotesPasswordWithSpaces(t *testing.T) {
t.Fatalf("dsn 未对包含空格的密码进行引号包裹:%s", dsn)
}
}
func TestTDengineDSN_UsesWebSocketFormat(t *testing.T) {
td := &TDengineDB{}
cfg := connection.ConnectionConfig{
Type: "tdengine",
Host: "127.0.0.1",
Port: 6041,
User: "root",
Password: "taosdata",
Database: "power",
}
dsn := td.getDSN(cfg)
if !strings.HasPrefix(dsn, "root:taosdata@ws(127.0.0.1:6041)/power") {
t.Fatalf("tdengine dsn 格式不正确:%s", dsn)
}
}

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 (
@@ -5,12 +7,14 @@ import (
"fmt"
"net"
"net/url"
"sort"
"strconv"
"strings"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/logger"
proxytunnel "GoNavi-Wails/internal/proxy"
"GoNavi-Wails/internal/ssh"
"go.mongodb.org/mongo-driver/v2/bson"
@@ -26,53 +30,272 @@ type MongoDB struct {
forwarder *ssh.LocalForwarder
}
func (m *MongoDB) getURI(config connection.ConnectionConfig) string {
// mongodb://user:password@host:port/database?authSource=admin
host := config.Host
port := config.Port
if port == 0 {
port = 27017
type mongoProxyDialer struct {
proxyConfig connection.ProxyConfig
}
func (d *mongoProxyDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
return proxytunnel.DialContext(ctx, d.proxyConfig, network, address)
}
const defaultMongoPort = 27017
func normalizeMongoAddress(host string, port int) string {
h := strings.TrimSpace(host)
if h == "" {
h = "localhost"
}
p := port
if p <= 0 {
p = defaultMongoPort
}
return fmt.Sprintf("%s:%d", h, p)
}
func normalizeMongoSeed(raw string, defaultPort int, useSRV bool) (string, bool) {
host, port, ok := parseHostPortWithDefault(raw, defaultPort)
if !ok {
return "", false
}
uri := fmt.Sprintf("mongodb://%s:%d", host, port)
if useSRV {
normalized := strings.TrimSpace(host)
if normalized == "" {
return "", false
}
return normalized, true
}
if config.User != "" {
encodedUser := url.QueryEscape(config.User)
if config.Password != "" {
encodedPass := url.QueryEscape(config.Password)
uri = fmt.Sprintf("mongodb://%s:%s@%s:%d", encodedUser, encodedPass, host, port)
return normalizeMongoAddress(host, port), true
}
func collectMongoSeeds(config connection.ConnectionConfig) []string {
defaultPort := config.Port
if defaultPort <= 0 {
defaultPort = defaultMongoPort
}
useSRV := config.MongoSRV
candidates := make([]string, 0, len(config.Hosts)+1)
if len(config.Hosts) > 0 {
candidates = append(candidates, config.Hosts...)
} else {
if useSRV {
candidates = append(candidates, strings.TrimSpace(config.Host))
} else {
uri = fmt.Sprintf("mongodb://%s@%s:%d", encodedUser, host, port)
candidates = append(candidates, normalizeMongoAddress(config.Host, defaultPort))
}
}
// Add connection options
params := []string{}
timeout := getConnectTimeoutSeconds(config)
params = append(params, fmt.Sprintf("connectTimeoutMS=%d", timeout*1000))
params = append(params, fmt.Sprintf("serverSelectionTimeoutMS=%d", timeout*1000))
// authSource: 优先使用 config.Database为空时默认 admin
authSource := "admin"
if config.Database != "" {
authSource = config.Database
result := make([]string, 0, len(candidates))
seen := make(map[string]struct{}, len(candidates))
for _, entry := range candidates {
normalized, ok := normalizeMongoSeed(entry, defaultPort, useSRV)
if !ok {
continue
}
if _, exists := seen[normalized]; exists {
continue
}
seen[normalized] = struct{}{}
result = append(result, normalized)
}
params = append(params, fmt.Sprintf("authSource=%s", authSource))
if len(params) > 0 {
uri = uri + "/?" + strings.Join(params, "&")
return result
}
func applyMongoURI(config connection.ConnectionConfig) connection.ConnectionConfig {
uriText := strings.TrimSpace(config.URI)
if uriText == "" {
return config
}
lowerURI := strings.ToLower(uriText)
if strings.HasPrefix(lowerURI, "mongodb+srv://") {
config.MongoSRV = true
}
if !strings.HasPrefix(lowerURI, "mongodb://") && !strings.HasPrefix(lowerURI, "mongodb+srv://") {
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 = defaultMongoPort
}
hostsFromURI := make([]string, 0, 4)
hostText := strings.TrimSpace(parsed.Host)
if hostText != "" {
for _, entry := range strings.Split(hostText, ",") {
normalized, ok := normalizeMongoSeed(entry, defaultPort, config.MongoSRV)
if ok {
hostsFromURI = append(hostsFromURI, normalized)
}
}
}
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
}
}
query := parsed.Query()
if config.AuthSource == "" {
config.AuthSource = strings.TrimSpace(query.Get("authSource"))
}
if config.ReadPreference == "" {
config.ReadPreference = strings.TrimSpace(query.Get("readPreference"))
}
if config.ReplicaSet == "" {
config.ReplicaSet = strings.TrimSpace(query.Get("replicaSet"))
}
if config.MongoAuthMechanism == "" {
config.MongoAuthMechanism = strings.TrimSpace(query.Get("authMechanism"))
}
if config.Topology == "" {
if len(config.Hosts) > 1 || strings.TrimSpace(config.ReplicaSet) != "" {
config.Topology = "replica"
} else {
config.Topology = "single"
}
}
return config
}
func (m *MongoDB) getURI(config connection.ConnectionConfig) string {
if strings.TrimSpace(config.URI) != "" {
return strings.TrimSpace(config.URI)
}
seeds := collectMongoSeeds(config)
if len(seeds) == 0 {
if config.MongoSRV {
seed := strings.TrimSpace(config.Host)
if seed == "" {
seed = "localhost"
}
seeds = append(seeds, seed)
} else {
seeds = append(seeds, normalizeMongoAddress(config.Host, config.Port))
}
}
scheme := "mongodb"
if config.MongoSRV {
scheme = "mongodb+srv"
}
hostText := strings.Join(seeds, ",")
uri := fmt.Sprintf("%s://%s", scheme, hostText)
if config.User != "" {
var userinfo *url.Userinfo
if config.Password != "" {
userinfo = url.UserPassword(config.User, config.Password)
} else {
userinfo = url.User(config.User)
}
uri = fmt.Sprintf("%s://%s@%s", scheme, userinfo.String(), hostText)
}
path := "/"
if strings.TrimSpace(config.Database) != "" {
path = "/" + url.PathEscape(strings.TrimSpace(config.Database))
}
uri += path
params := url.Values{}
timeout := getConnectTimeoutSeconds(config)
params.Set("connectTimeoutMS", strconv.Itoa(timeout*1000))
params.Set("serverSelectionTimeoutMS", strconv.Itoa(timeout*1000))
authSource := strings.TrimSpace(config.AuthSource)
if authSource == "" && strings.TrimSpace(config.Database) != "" {
authSource = strings.TrimSpace(config.Database)
}
if authSource == "" {
authSource = "admin"
}
params.Set("authSource", authSource)
if replicaSet := strings.TrimSpace(config.ReplicaSet); replicaSet != "" {
params.Set("replicaSet", replicaSet)
}
if readPreference := strings.TrimSpace(config.ReadPreference); readPreference != "" {
params.Set("readPreference", readPreference)
}
if authMechanism := strings.TrimSpace(config.MongoAuthMechanism); authMechanism != "" {
params.Set("authMechanism", authMechanism)
}
if encoded := params.Encode(); encoded != "" {
uri += "?" + encoded
}
return uri
}
func buildMongoAuthAttempts(config connection.ConnectionConfig) []connection.ConnectionConfig {
attempts := []connection.ConnectionConfig{config}
replicaUser := strings.TrimSpace(config.MongoReplicaUser)
if replicaUser == "" {
return attempts
}
if replicaUser == strings.TrimSpace(config.User) && config.MongoReplicaPassword == config.Password {
return attempts
}
replicaConfig := config
replicaConfig.URI = ""
replicaConfig.User = replicaUser
replicaConfig.Password = config.MongoReplicaPassword
attempts = append(attempts, replicaConfig)
return attempts
}
func (m *MongoDB) Connect(config connection.ConnectionConfig) error {
var uri string
runConfig := applyMongoURI(config)
connectConfig := runConfig
if config.UseSSH {
logger.Infof("MongoDB 使用 SSH 连接:地址=%s:%d", config.Host, config.Port)
if runConfig.UseSSH && runConfig.MongoSRV {
return fmt.Errorf("MongoDB SRV 记录模式暂不支持 SSH 隧道")
}
forwarder, err := ssh.GetOrCreateLocalForwarder(config.SSH, config.Host, config.Port)
if runConfig.UseSSH {
seeds := collectMongoSeeds(runConfig)
if len(seeds) == 0 {
seeds = append(seeds, normalizeMongoAddress(runConfig.Host, runConfig.Port))
}
targetHost, targetPort, ok := parseHostPortWithDefault(seeds[0], defaultMongoPort)
if !ok {
return fmt.Errorf("MongoDB 连接失败:无效地址 %s", seeds[0])
}
logger.Infof("MongoDB 使用 SSH 连接:地址=%s:%d", targetHost, targetPort)
forwarder, err := ssh.GetOrCreateLocalForwarder(runConfig.SSH, targetHost, targetPort)
if err != nil {
return fmt.Errorf("创建 SSH 隧道失败:%w", err)
}
@@ -88,35 +311,58 @@ func (m *MongoDB) Connect(config connection.ConnectionConfig) error {
return fmt.Errorf("解析本地端口失败:%w", err)
}
localConfig := config
localConfig := runConfig
localConfig.Host = host
localConfig.Port = port
localConfig.UseSSH = false
uri = m.getURI(localConfig)
logger.Infof("MongoDB 通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, config.Host, config.Port)
} else {
uri = m.getURI(config)
localConfig.URI = ""
localConfig.Hosts = []string{normalizeMongoAddress(host, port)}
connectConfig = localConfig
logger.Infof("MongoDB 通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, targetHost, targetPort)
}
m.pingTimeout = getConnectTimeout(config)
m.database = config.Database
m.pingTimeout = getConnectTimeout(connectConfig)
m.database = connectConfig.Database
if m.database == "" {
m.database = "admin"
}
clientOpts := options.Client().ApplyURI(uri)
client, err := mongo.Connect(clientOpts)
if err != nil {
return fmt.Errorf("MongoDB 连接失败:%w", err)
}
m.client = client
attemptConfigs := buildMongoAuthAttempts(connectConfig)
var errorDetails []string
for index, attemptConfig := range attemptConfigs {
authLabel := "主库凭据"
if index > 0 {
authLabel = "从库凭据"
}
if err := m.Ping(); err != nil {
return fmt.Errorf("MongoDB 连接验证失败:%w", err)
uri := m.getURI(attemptConfig)
clientOpts := options.Client().ApplyURI(uri)
if attemptConfig.UseProxy {
clientOpts.SetDialer(&mongoProxyDialer{proxyConfig: attemptConfig.Proxy})
}
client, err := mongo.Connect(clientOpts)
if err != nil {
errorDetails = append(errorDetails, fmt.Sprintf("%s连接失败: %v", authLabel, err))
continue
}
m.client = client
if err := m.Ping(); err != nil {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
_ = client.Disconnect(ctx)
cancel()
m.client = nil
errorDetails = append(errorDetails, fmt.Sprintf("%s验证失败: %v", authLabel, err))
continue
}
return nil
}
return nil
if len(errorDetails) > 0 {
return fmt.Errorf("MongoDB 连接失败:%s", strings.Join(errorDetails, ""))
}
return fmt.Errorf("MongoDB 连接失败:无可用连接方案")
}
func (m *MongoDB) Close() error {
@@ -148,6 +394,243 @@ func (m *MongoDB) Ping() error {
return m.client.Ping(ctx, readpref.Primary())
}
func asMongoStringList(raw interface{}) []string {
values, ok := raw.(bson.A)
if !ok {
return nil
}
result := make([]string, 0, len(values))
for _, entry := range values {
text := strings.TrimSpace(fmt.Sprintf("%v", entry))
if text != "" {
result = append(result, text)
}
}
return result
}
func asMongoString(raw interface{}) string {
if raw == nil {
return ""
}
if value, ok := raw.(string); ok {
return strings.TrimSpace(value)
}
return strings.TrimSpace(fmt.Sprintf("%v", raw))
}
func asMongoInt(raw interface{}) int {
switch value := raw.(type) {
case int:
return value
case int32:
return int(value)
case int64:
return int(value)
case float32:
return int(value)
case float64:
return int(value)
default:
return 0
}
}
func asMongoBool(raw interface{}) bool {
switch value := raw.(type) {
case bool:
return value
case int:
return value != 0
case int32:
return value != 0
case int64:
return value != 0
case float32:
return value != 0
case float64:
return value != 0
default:
return false
}
}
func asMongoInt64(raw interface{}) int64 {
switch value := raw.(type) {
case int:
return int64(value)
case int32:
return int64(value)
case int64:
return value
case float32:
return int64(value)
case float64:
return int64(value)
default:
return 0
}
}
func mongoStateByCode(code int) string {
switch code {
case 1:
return "PRIMARY"
case 2:
return "SECONDARY"
case 3:
return "RECOVERING"
case 5:
return "STARTUP2"
case 6:
return "UNKNOWN"
case 7:
return "ARBITER"
case 8:
return "DOWN"
case 9:
return "ROLLBACK"
case 10:
return "REMOVED"
default:
return "UNKNOWN"
}
}
func normalizeMongoStateLabel(state string, stateCode int) string {
normalized := strings.ToUpper(strings.TrimSpace(state))
if normalized != "" {
return normalized
}
return mongoStateByCode(stateCode)
}
func buildMembersFromReplStatus(raw bson.M) []connection.MongoMemberInfo {
items, ok := raw["members"].(bson.A)
if !ok {
return nil
}
members := make([]connection.MongoMemberInfo, 0, len(items))
for _, entry := range items {
member, ok := entry.(bson.M)
if !ok {
continue
}
host := asMongoString(member["name"])
if host == "" {
continue
}
stateCode := asMongoInt(member["state"])
state := normalizeMongoStateLabel(asMongoString(member["stateStr"]), stateCode)
members = append(members, connection.MongoMemberInfo{
Host: host,
Role: state,
State: state,
StateCode: stateCode,
Healthy: asMongoInt(member["health"]) > 0 || asMongoBool(member["health"]),
IsSelf: asMongoBool(member["self"]),
})
}
sort.Slice(members, func(i, j int) bool {
return members[i].Host < members[j].Host
})
return members
}
func buildMembersFromHello(raw bson.M) []connection.MongoMemberInfo {
hosts := asMongoStringList(raw["hosts"])
if len(hosts) == 0 {
return nil
}
primary := asMongoString(raw["primary"])
selfHost := asMongoString(raw["me"])
passiveSet := make(map[string]struct{})
for _, host := range asMongoStringList(raw["passives"]) {
passiveSet[host] = struct{}{}
}
arbiterSet := make(map[string]struct{})
for _, host := range asMongoStringList(raw["arbiters"]) {
arbiterSet[host] = struct{}{}
}
members := make([]connection.MongoMemberInfo, 0, len(hosts))
for _, host := range hosts {
state := "SECONDARY"
stateCode := 2
if host == primary {
state = "PRIMARY"
stateCode = 1
} else if _, ok := arbiterSet[host]; ok {
state = "ARBITER"
stateCode = 7
} else if _, ok := passiveSet[host]; ok {
state = "PASSIVE"
stateCode = 6
}
members = append(members, connection.MongoMemberInfo{
Host: host,
Role: state,
State: state,
StateCode: stateCode,
Healthy: true,
IsSelf: host == selfHost,
})
}
sort.Slice(members, func(i, j int) bool {
return members[i].Host < members[j].Host
})
return members
}
func (m *MongoDB) DiscoverMembers() (string, []connection.MongoMemberInfo, error) {
if m.client == nil {
return "", nil, fmt.Errorf("connection not open")
}
timeout := m.pingTimeout
if timeout <= 0 {
timeout = 10 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
adminDB := m.client.Database("admin")
var replStatus bson.M
replErr := adminDB.RunCommand(ctx, bson.D{{Key: "replSetGetStatus", Value: 1}}).Decode(&replStatus)
if replErr == nil {
replicaSet := asMongoString(replStatus["set"])
members := buildMembersFromReplStatus(replStatus)
if len(members) > 0 {
return replicaSet, members, nil
}
}
var helloResult bson.M
helloErr := adminDB.RunCommand(ctx, bson.D{{Key: "hello", Value: 1}}).Decode(&helloResult)
if helloErr != nil {
if err := adminDB.RunCommand(ctx, bson.D{{Key: "isMaster", Value: 1}}).Decode(&helloResult); err != nil {
if replErr != nil {
return "", nil, fmt.Errorf("成员发现失败replSetGetStatus=%vhello=%v", replErr, err)
}
return "", nil, fmt.Errorf("成员发现失败hello=%w", err)
}
}
replicaSet := asMongoString(helloResult["setName"])
members := buildMembersFromHello(helloResult)
if len(members) == 0 {
if replErr != nil {
return replicaSet, nil, fmt.Errorf("未获取到成员信息replSetGetStatus=%v", replErr)
}
return replicaSet, nil, fmt.Errorf("未获取到成员信息")
}
return replicaSet, members, nil
}
// Query executes a MongoDB command and returns results
// Supports JSON format commands like: {"find": "collection", "filter": {}}
func (m *MongoDB) Query(query string) ([]map[string]interface{}, []string, error) {
@@ -161,6 +644,98 @@ func (m *MongoDB) QueryContext(ctx context.Context, query string) ([]map[string]
return m.queryWithContext(ctx, query)
}
// sqlToMongoFind 将前端生成的简单 SQL 转换为 MongoDB find 命令 JSON。
// 支持SELECT * FROM "coll" LIMIT n OFFSET m / SELECT COUNT(*) as total FROM "coll"
func sqlToMongoFind(sql string) (string, bool) {
lower := strings.ToLower(strings.TrimSpace(sql))
// SELECT COUNT(*) as total FROM "coll" ...
if strings.HasPrefix(lower, "select count(") {
coll := extractCollectionFromSQL(sql)
if coll == "" {
return "", false
}
return fmt.Sprintf(`{"count":"%s","query":{}}`, coll), true
}
// SELECT * FROM "coll" ... LIMIT n OFFSET m
if !strings.HasPrefix(lower, "select") {
return "", false
}
coll := extractCollectionFromSQL(sql)
if coll == "" {
return "", false
}
limit := int64(0)
skip := int64(0)
// 提取 LIMIT
if idx := strings.Index(lower, "limit "); idx >= 0 {
after := strings.TrimSpace(lower[idx+6:])
parts := strings.Fields(after)
if len(parts) > 0 {
if n, err := strconv.ParseInt(parts[0], 10, 64); err == nil {
limit = n
}
}
}
// 提取 OFFSET
if idx := strings.Index(lower, "offset "); idx >= 0 {
after := strings.TrimSpace(lower[idx+7:])
parts := strings.Fields(after)
if len(parts) > 0 {
if n, err := strconv.ParseInt(parts[0], 10, 64); err == nil {
skip = n
}
}
}
cmd := fmt.Sprintf(`{"find":"%s","filter":{}`, coll)
if limit > 0 {
cmd += fmt.Sprintf(`,"limit":%d`, limit)
}
if skip > 0 {
cmd += fmt.Sprintf(`,"skip":%d`, skip)
}
cmd += "}"
return cmd, true
}
// extractCollectionFromSQL 从 SQL 中提取 FROM 后的 collection 名称。
func extractCollectionFromSQL(sql string) string {
lower := strings.ToLower(sql)
idx := strings.Index(lower, "from ")
if idx < 0 {
return ""
}
after := strings.TrimSpace(sql[idx+5:])
// 去掉引号包裹
var coll string
if len(after) > 0 && after[0] == '"' {
end := strings.Index(after[1:], "\"")
if end < 0 {
return ""
}
coll = after[1 : end+1]
} else if len(after) > 0 && after[0] == '`' {
end := strings.Index(after[1:], "`")
if end < 0 {
return ""
}
coll = after[1 : end+1]
} else {
parts := strings.Fields(after)
if len(parts) == 0 {
return ""
}
coll = parts[0]
}
return strings.TrimSpace(coll)
}
func (m *MongoDB) queryWithContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
if m.client == nil {
return nil, nil, fmt.Errorf("connection not open")
@@ -171,18 +746,44 @@ func (m *MongoDB) queryWithContext(ctx context.Context, query string) ([]map[str
return nil, nil, fmt.Errorf("empty query")
}
// 如果输入是 SQL 语句(前端 DataViewer 统一生成),自动转换为 MongoDB JSON 命令
lowerQuery := strings.ToLower(query)
if strings.HasPrefix(lowerQuery, "select") || strings.HasPrefix(lowerQuery, "show") {
if converted, ok := sqlToMongoFind(query); ok {
query = converted
}
}
// Parse JSON command
var cmd bson.D
if err := bson.UnmarshalExtJSON([]byte(query), true, &cmd); err != nil {
return nil, nil, fmt.Errorf("invalid JSON command: %w", err)
}
// 对 find 和 count 命令使用原生 driver API避免 RunCommand 的 firstBatch 限制
if len(cmd) > 0 {
switch cmd[0].Key {
case "find":
return m.execFind(ctx, cmd)
case "count":
return m.execCount(ctx, cmd)
}
}
// 其他命令走 RunCommand
db := m.client.Database(m.database)
var result bson.M
if err := db.RunCommand(ctx, cmd).Decode(&result); err != nil {
return nil, nil, err
}
// Handle COUNT result (e.g. delete/update returns "n")
if n, ok := result["n"]; ok {
if _, hasCursor := result["cursor"]; !hasCursor {
return []map[string]interface{}{{"total": n}}, []string{"total"}, nil
}
}
// Convert result to standard format
data := []map[string]interface{}{{"result": result}}
columns := []string{"result"}
@@ -212,6 +813,156 @@ func (m *MongoDB) queryWithContext(ctx context.Context, query string) ([]map[str
return data, columns, nil
}
// execFind 使用原生 Collection.Find() 执行查询,正确处理游标迭代
func (m *MongoDB) execFind(ctx context.Context, cmd bson.D) ([]map[string]interface{}, []string, error) {
var collName string
var filter interface{}
var limit int64
var skip int64
var sortDoc interface{}
var projection interface{}
for _, elem := range cmd {
switch elem.Key {
case "find":
collName = fmt.Sprintf("%v", elem.Value)
case "filter":
filter = elem.Value
case "limit":
limit = asMongoInt64(elem.Value)
case "skip":
skip = asMongoInt64(elem.Value)
case "sort":
sortDoc = elem.Value
case "projection":
projection = elem.Value
}
}
if collName == "" {
return nil, nil, fmt.Errorf("find command missing collection name")
}
if filter == nil {
filter = bson.D{}
}
collection := m.client.Database(m.database).Collection(collName)
opts := options.Find()
if limit > 0 {
opts.SetLimit(limit)
}
if skip > 0 {
opts.SetSkip(skip)
}
if sortDoc != nil {
opts.SetSort(sortDoc)
}
if projection != nil {
opts.SetProjection(projection)
}
cursor, err := collection.Find(ctx, filter, opts)
if err != nil {
return nil, nil, err
}
defer cursor.Close(ctx)
var data []map[string]interface{}
columnSet := make(map[string]bool)
for cursor.Next(ctx) {
var doc bson.M
if err := cursor.Decode(&doc); err != nil {
continue
}
row := make(map[string]interface{})
for k, v := range doc {
row[k] = convertBsonValue(v)
columnSet[k] = true
}
data = append(data, row)
}
if err := cursor.Err(); err != nil {
return nil, nil, err
}
columns := make([]string, 0, len(columnSet))
for k := range columnSet {
columns = append(columns, k)
}
sort.Strings(columns)
// 将 _id 列置首
for i, col := range columns {
if col == "_id" && i > 0 {
columns = append(columns[:i], columns[i+1:]...)
columns = append([]string{"_id"}, columns...)
break
}
}
return data, columns, nil
}
// execCount 使用原生 Collection.CountDocuments() 执行计数
func (m *MongoDB) execCount(ctx context.Context, cmd bson.D) ([]map[string]interface{}, []string, error) {
var collName string
var filter interface{}
for _, elem := range cmd {
switch elem.Key {
case "count":
collName = fmt.Sprintf("%v", elem.Value)
case "query":
filter = elem.Value
}
}
if collName == "" {
return nil, nil, fmt.Errorf("count command missing collection name")
}
if filter == nil {
filter = bson.D{}
}
collection := m.client.Database(m.database).Collection(collName)
n, err := collection.CountDocuments(ctx, filter)
if err != nil {
return nil, nil, err
}
return []map[string]interface{}{{"total": n}}, []string{"total"}, nil
}
// convertBsonValue 将 BSON 特殊类型转换为前端可读的 JSON 友好值
func convertBsonValue(v interface{}) interface{} {
switch val := v.(type) {
case bson.ObjectID:
return val.Hex()
case bson.M:
result := make(map[string]interface{}, len(val))
for k, v2 := range val {
result[k] = convertBsonValue(v2)
}
return result
case bson.D:
result := make(map[string]interface{}, len(val))
for _, elem := range val {
result[elem.Key] = convertBsonValue(elem.Value)
}
return result
case bson.A:
result := make([]interface{}, len(val))
for i, v2 := range val {
result[i] = convertBsonValue(v2)
}
return result
default:
return v
}
}
func (m *MongoDB) Exec(query string) (int64, error) {
_, _, err := m.Query(query)
if err != nil {

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

@@ -4,6 +4,8 @@ import (
"context"
"database/sql"
"fmt"
"net/url"
"strconv"
"strings"
"time"
@@ -20,16 +22,162 @@ type MySQLDB struct {
pingTimeout time.Duration
}
const defaultMySQLPort = 3306
func parseHostPortWithDefault(raw string, defaultPort int) (string, int, bool) {
text := strings.TrimSpace(raw)
if text == "" {
return "", 0, false
}
if strings.HasPrefix(text, "[") {
end := strings.Index(text, "]")
if end < 0 {
return text, defaultPort, true
}
host := text[1:end]
portText := strings.TrimSpace(text[end+1:])
if strings.HasPrefix(portText, ":") {
if p, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(portText, ":"))); err == nil && p > 0 {
return host, p, true
}
}
return host, defaultPort, true
}
lastColon := strings.LastIndex(text, ":")
if lastColon > 0 && strings.Count(text, ":") == 1 {
host := strings.TrimSpace(text[:lastColon])
portText := strings.TrimSpace(text[lastColon+1:])
if host != "" {
if p, err := strconv.Atoi(portText); err == nil && p > 0 {
return host, p, true
}
return host, defaultPort, true
}
}
return text, defaultPort, true
}
func normalizeMySQLAddress(host string, port int) string {
h := strings.TrimSpace(host)
if h == "" {
h = "localhost"
}
p := port
if p <= 0 {
p = defaultMySQLPort
}
return fmt.Sprintf("%s:%d", h, p)
}
func applyMySQLURI(config connection.ConnectionConfig) connection.ConnectionConfig {
uriText := strings.TrimSpace(config.URI)
if uriText == "" {
return config
}
lowerURI := strings.ToLower(uriText)
if !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 = defaultMySQLPort
}
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 collectMySQLAddresses(config connection.ConnectionConfig) []string {
defaultPort := config.Port
if defaultPort <= 0 {
defaultPort = defaultMySQLPort
}
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 (m *MySQLDB) getDSN(config connection.ConnectionConfig) string {
database := config.Database
protocol := "tcp"
address := fmt.Sprintf("%s:%d", config.Host, config.Port)
address := normalizeMySQLAddress(config.Host, config.Port)
if config.UseSSH {
netName, err := ssh.RegisterSSHNetwork(config.SSH)
if err == nil {
protocol = netName
address = fmt.Sprintf("%s:%d", config.Host, config.Port)
address = normalizeMySQLAddress(config.Host, config.Port)
} else {
logger.Warnf("注册 SSH 网络失败,将尝试直连:地址=%s:%d 用户=%s原因%v", config.Host, config.Port, config.User, err)
}
@@ -41,20 +189,67 @@ func (m *MySQLDB) getDSN(config connection.ConnectionConfig) string {
config.User, config.Password, protocol, address, database, timeout)
}
func (m *MySQLDB) Connect(config connection.ConnectionConfig) error {
dsn := m.getDSN(config)
db, err := sql.Open("mysql", dsn)
if err != nil {
return fmt.Errorf("打开数据库连接失败:%w", err)
}
m.conn = db
m.pingTimeout = getConnectTimeout(config)
func resolveMySQLCredential(config connection.ConnectionConfig, addressIndex int) (string, string) {
primaryUser := strings.TrimSpace(config.User)
primaryPassword := config.Password
replicaUser := strings.TrimSpace(config.MySQLReplicaUser)
replicaPassword := config.MySQLReplicaPassword
// Force verification
if err := m.Ping(); err != nil {
return fmt.Errorf("连接建立后验证失败:%w", err)
if addressIndex > 0 && replicaUser != "" {
return replicaUser, replicaPassword
}
return nil
if primaryUser == "" && replicaUser != "" {
return replicaUser, replicaPassword
}
return config.User, primaryPassword
}
func (m *MySQLDB) Connect(config connection.ConnectionConfig) error {
runConfig := applyMySQLURI(config)
addresses := collectMySQLAddresses(runConfig)
if len(addresses) == 0 {
return fmt.Errorf("连接建立后验证失败:未找到可用的 MySQL 地址")
}
var errorDetails []string
for index, address := range addresses {
candidateConfig := runConfig
host, port, ok := parseHostPortWithDefault(address, defaultMySQLPort)
if !ok {
continue
}
candidateConfig.Host = host
candidateConfig.Port = port
candidateConfig.User, candidateConfig.Password = resolveMySQLCredential(runConfig, index)
dsn := m.getDSN(candidateConfig)
db, err := sql.Open("mysql", 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
}
m.conn = db
m.pingTimeout = timeout
return nil
}
if len(errorDetails) == 0 {
return fmt.Errorf("连接建立后验证失败:未找到可用的 MySQL 地址")
}
return fmt.Errorf("连接建立后验证失败:%s", strings.Join(errorDetails, ""))
}
func (m *MySQLDB) Close() error {
@@ -306,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
@@ -318,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
@@ -340,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 {
@@ -350,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 {
@@ -374,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
}
@@ -434,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

@@ -0,0 +1,48 @@
package db
import (
"reflect"
"testing"
"GoNavi-Wails/internal/connection"
)
func TestResolvePostgresConnectDatabases_ExplicitDatabase(t *testing.T) {
cfg := connection.ConnectionConfig{
Type: "postgres",
Database: "analytics",
User: "app_user",
}
got := resolvePostgresConnectDatabases(cfg)
want := []string{"analytics"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected databases, got=%v want=%v", got, want)
}
}
func TestResolvePostgresConnectDatabases_FallbackOrder(t *testing.T) {
cfg := connection.ConnectionConfig{
Type: "postgres",
User: "app_user",
}
got := resolvePostgresConnectDatabases(cfg)
want := []string{"postgres", "template1", "app_user"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected databases, got=%v want=%v", got, want)
}
}
func TestResolvePostgresConnectDatabases_DeduplicateUserDefault(t *testing.T) {
cfg := connection.ConnectionConfig{
Type: "postgres",
User: "postgres",
}
got := resolvePostgresConnectDatabases(cfg)
want := []string{"postgres", "template1"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected databases, got=%v want=%v", got, want)
}
}

View File

@@ -18,13 +18,35 @@ import (
_ "github.com/lib/pq"
)
type PostgresDB struct {
conn *sql.DB
pingTimeout time.Duration
forwarder *ssh.LocalForwarder // Store SSH tunnel forwarder
}
func resolvePostgresConnectDatabases(config connection.ConnectionConfig) []string {
explicit := strings.TrimSpace(config.Database)
if explicit != "" {
return []string{explicit}
}
candidates := []string{"postgres", "template1", strings.TrimSpace(config.User)}
seen := make(map[string]struct{}, len(candidates))
result := make([]string, 0, len(candidates))
for _, name := range candidates {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
continue
}
normalized := strings.ToLower(trimmed)
if _, exists := seen[normalized]; exists {
continue
}
seen[normalized] = struct{}{}
result = append(result, trimmed)
}
return result
}
func (p *PostgresDB) getDSN(config connection.ConnectionConfig) string {
// postgres://user:password@host:port/dbname?sslmode=disable
@@ -48,8 +70,30 @@ func (p *PostgresDB) getDSN(config connection.ConnectionConfig) string {
}
func (p *PostgresDB) Connect(config connection.ConnectionConfig) error {
var dsn string
var err error
if supported, reason := DriverRuntimeSupportStatus("postgres"); !supported {
if strings.TrimSpace(reason) == "" {
reason = "PostgreSQL 纯 Go 驱动未启用,请先在驱动管理中安装启用"
}
return fmt.Errorf("%s", reason)
}
runConfig := config
p.pingTimeout = getConnectTimeout(config)
cleanupOnFailure := true
defer func() {
if !cleanupOnFailure {
return
}
if p.conn != nil {
_ = p.conn.Close()
p.conn = nil
}
if p.forwarder != nil {
_ = p.forwarder.Close()
p.forwarder = nil
}
}()
if config.UseSSH {
// Create SSH tunnel with local port forwarding
@@ -78,27 +122,46 @@ func (p *PostgresDB) Connect(config connection.ConnectionConfig) error {
localConfig.Port = port
localConfig.UseSSH = false // Disable SSH flag for DSN generation
dsn = p.getDSN(localConfig)
runConfig = localConfig
logger.Infof("PostgreSQL 通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, config.Host, config.Port)
} else {
dsn = p.getDSN(config)
}
db, err := sql.Open("postgres", dsn)
if err != nil {
return fmt.Errorf("打开数据库连接失败:%w", err)
}
p.conn = db
p.pingTimeout = getConnectTimeout(config)
attemptDBs := resolvePostgresConnectDatabases(runConfig)
var failures []string
for _, dbName := range attemptDBs {
attemptConfig := runConfig
attemptConfig.Database = dbName
dsn := p.getDSN(attemptConfig)
// Force verification
if err := p.Ping(); err != nil {
return fmt.Errorf("连接建立后验证失败:%w", err)
dbConn, err := sql.Open("postgres", dsn)
if err != nil {
failures = append(failures, fmt.Sprintf("数据库=%s 打开连接失败: %v", dbName, err))
continue
}
p.conn = dbConn
// Force verification
if err := p.Ping(); err != nil {
failures = append(failures, fmt.Sprintf("数据库=%s 验证失败: %v", dbName, err))
_ = dbConn.Close()
p.conn = nil
continue
}
if strings.TrimSpace(config.Database) == "" && !strings.EqualFold(dbName, "postgres") {
logger.Infof("PostgreSQL 自动选择连接数据库:%s", dbName)
}
cleanupOnFailure = false
return nil
}
return nil
if len(failures) == 0 {
return fmt.Errorf("连接建立后验证失败:未找到可用的连接数据库")
}
return fmt.Errorf("连接建立后验证失败:%s", strings.Join(failures, ""))
}
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

@@ -0,0 +1,400 @@
//go:build gonavi_full_drivers || gonavi_tdengine_driver
package db
import (
"context"
"database/sql"
"fmt"
"net"
"strconv"
"strings"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/logger"
"GoNavi-Wails/internal/ssh"
"GoNavi-Wails/internal/utils"
_ "github.com/taosdata/driver-go/v3/taosWS"
)
// TDengineDB implements Database interface for TDengine.
// Uses taosWS driver via WebSocket (通常通过 taosAdapter 提供服务)。
type TDengineDB struct {
conn *sql.DB
pingTimeout time.Duration
forwarder *ssh.LocalForwarder
}
func (t *TDengineDB) getDSN(config connection.ConnectionConfig) string {
user := strings.TrimSpace(config.User)
if user == "" {
user = "root"
}
pass := config.Password
dbName := strings.TrimSpace(config.Database)
path := "/"
if dbName != "" {
path = "/" + dbName
}
return fmt.Sprintf("%s:%s@ws(%s)%s", user, pass, net.JoinHostPort(config.Host, strconv.Itoa(config.Port)), path)
}
func (t *TDengineDB) Connect(config connection.ConnectionConfig) error {
var dsn string
if config.UseSSH {
logger.Infof("TDengine 使用 SSH 连接:地址=%s:%d 用户=%s", config.Host, config.Port, config.User)
forwarder, err := ssh.GetOrCreateLocalForwarder(config.SSH, config.Host, config.Port)
if err != nil {
return fmt.Errorf("创建 SSH 隧道失败:%w", err)
}
t.forwarder = forwarder
host, portStr, err := net.SplitHostPort(forwarder.LocalAddr)
if err != nil {
return fmt.Errorf("解析本地转发地址失败:%w", err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
return fmt.Errorf("解析本地端口失败:%w", err)
}
localConfig := config
localConfig.Host = host
localConfig.Port = port
localConfig.UseSSH = false
dsn = t.getDSN(localConfig)
logger.Infof("TDengine 通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, config.Host, config.Port)
} else {
dsn = t.getDSN(config)
}
db, err := sql.Open("taosWS", dsn)
if err != nil {
return fmt.Errorf("打开数据库连接失败:%w", err)
}
t.conn = db
t.pingTimeout = getConnectTimeout(config)
if err := t.Ping(); err != nil {
return fmt.Errorf("连接建立后验证失败:%w", err)
}
return nil
}
func (t *TDengineDB) Close() error {
if t.forwarder != nil {
if err := t.forwarder.Close(); err != nil {
logger.Warnf("关闭 TDengine SSH 端口转发失败:%v", err)
}
t.forwarder = nil
}
if t.conn != nil {
return t.conn.Close()
}
return nil
}
func (t *TDengineDB) Ping() error {
if t.conn == nil {
return fmt.Errorf("connection not open")
}
timeout := t.pingTimeout
if timeout <= 0 {
timeout = 5 * time.Second
}
ctx, cancel := utils.ContextWithTimeout(timeout)
defer cancel()
return t.conn.PingContext(ctx)
}
func (t *TDengineDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
if t.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
}
rows, err := t.conn.QueryContext(ctx, query)
if err != nil {
return nil, nil, err
}
defer rows.Close()
return scanRows(rows)
}
func (t *TDengineDB) Query(query string) ([]map[string]interface{}, []string, error) {
if t.conn == nil {
return nil, nil, fmt.Errorf("connection not open")
}
rows, err := t.conn.Query(query)
if err != nil {
return nil, nil, err
}
defer rows.Close()
return scanRows(rows)
}
func (t *TDengineDB) ExecContext(ctx context.Context, query string) (int64, error) {
if t.conn == nil {
return 0, fmt.Errorf("connection not open")
}
res, err := t.conn.ExecContext(ctx, query)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (t *TDengineDB) Exec(query string) (int64, error) {
if t.conn == nil {
return 0, fmt.Errorf("connection not open")
}
res, err := t.conn.Exec(query)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (t *TDengineDB) GetDatabases() ([]string, error) {
data, _, err := t.Query("SHOW DATABASES")
if err != nil {
return nil, err
}
var dbs []string
for _, row := range data {
if val, ok := getValueFromRow(row, "name", "database", "Database", "db_name"); ok {
dbs = append(dbs, fmt.Sprintf("%v", val))
continue
}
for _, val := range row {
dbs = append(dbs, fmt.Sprintf("%v", val))
break
}
}
return dbs, nil
}
func (t *TDengineDB) GetTables(dbName string) ([]string, error) {
queries := make([]string, 0, 2)
if strings.TrimSpace(dbName) != "" {
queries = append(queries, fmt.Sprintf("SHOW TABLES FROM `%s`", escapeBacktickIdent(dbName)))
}
queries = append(queries, "SHOW TABLES")
var lastErr error
for _, query := range queries {
data, _, err := t.Query(query)
if err != nil {
lastErr = err
continue
}
var tables []string
for _, row := range data {
if val, ok := getValueFromRow(row, "table_name", "tablename", "name", "Table", "table"); ok {
tables = append(tables, fmt.Sprintf("%v", val))
continue
}
for _, val := range row {
tables = append(tables, fmt.Sprintf("%v", val))
break
}
}
return tables, nil
}
if lastErr != nil {
return nil, lastErr
}
return []string{}, nil
}
func (t *TDengineDB) GetCreateStatement(dbName, tableName string) (string, error) {
qualified := quoteTDengineTable(dbName, tableName)
queries := []string{
fmt.Sprintf("SHOW CREATE TABLE %s", qualified),
fmt.Sprintf("SHOW CREATE STABLE %s", qualified),
}
var lastErr error
for _, query := range queries {
data, _, err := t.Query(query)
if err != nil {
lastErr = err
continue
}
if len(data) == 0 {
continue
}
row := data[0]
if val, ok := getValueFromRow(row, "Create Table", "create table", "Create Stable", "create stable", "SQL", "sql"); ok {
return fmt.Sprintf("%v", val), nil
}
longest := ""
for _, val := range row {
text := fmt.Sprintf("%v", val)
if strings.Contains(strings.ToUpper(text), "CREATE ") && len(text) > len(longest) {
longest = text
}
}
if longest != "" {
return longest, nil
}
}
if lastErr != nil {
return "", lastErr
}
return "", fmt.Errorf("create statement not found")
}
func (t *TDengineDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
query := fmt.Sprintf("DESCRIBE %s", quoteTDengineTable(dbName, tableName))
data, _, err := t.Query(query)
if err != nil {
return nil, err
}
columns := make([]connection.ColumnDefinition, 0, len(data))
for _, row := range data {
name, _ := getValueFromRow(row, "Field", "field", "col_name", "column_name", "name")
colType, _ := getValueFromRow(row, "Type", "type", "data_type")
note, _ := getValueFromRow(row, "Note", "note", "Extra", "extra")
nullable, okNull := getValueFromRow(row, "Null", "null", "nullable")
comment, _ := getValueFromRow(row, "Comment", "comment")
defaultVal, hasDefault := getValueFromRow(row, "Default", "default")
col := connection.ColumnDefinition{
Name: fmt.Sprintf("%v", name),
Type: fmt.Sprintf("%v", colType),
Nullable: "YES",
Key: "",
Extra: fmt.Sprintf("%v", note),
Comment: fmt.Sprintf("%v", comment),
}
if okNull {
col.Nullable = strings.ToUpper(fmt.Sprintf("%v", nullable))
}
noteUpper := strings.ToUpper(fmt.Sprintf("%v", note))
if strings.Contains(noteUpper, "TAG") {
col.Key = "TAG"
}
if hasDefault && defaultVal != nil {
def := fmt.Sprintf("%v", defaultVal)
if def != "<nil>" {
col.Default = &def
}
}
columns = append(columns, col)
}
return columns, nil
}
func (t *TDengineDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
if strings.TrimSpace(dbName) == "" {
return nil, fmt.Errorf("database name required for GetAllColumns")
}
tables, err := t.GetTables(dbName)
if err != nil {
return nil, err
}
cols := make([]connection.ColumnDefinitionWithTable, 0)
for _, table := range tables {
tableCols, err := t.GetColumns(dbName, table)
if err != nil {
continue
}
for _, col := range tableCols {
cols = append(cols, connection.ColumnDefinitionWithTable{
TableName: table,
Name: col.Name,
Type: col.Type,
})
}
}
return cols, nil
}
func (t *TDengineDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
return []connection.IndexDefinition{}, nil
}
func (t *TDengineDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
return []connection.ForeignKeyDefinition{}, nil
}
func (t *TDengineDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
return []connection.TriggerDefinition{}, nil
}
func getValueFromRow(row map[string]interface{}, keys ...string) (interface{}, bool) {
if len(row) == 0 {
return nil, false
}
for _, key := range keys {
if val, ok := row[key]; ok {
return val, true
}
}
for existingKey, val := range row {
for _, key := range keys {
if strings.EqualFold(existingKey, key) {
return val, true
}
}
}
return nil, false
}
func escapeBacktickIdent(ident string) string {
return strings.ReplaceAll(strings.TrimSpace(ident), "`", "``")
}
func quoteTDengineTable(dbName, tableName string) string {
t := escapeBacktickIdent(tableName)
if t == "" {
return "``"
}
if strings.Contains(t, ".") {
parts := strings.Split(t, ".")
quoted := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
quoted = append(quoted, fmt.Sprintf("`%s`", escapeBacktickIdent(part)))
}
if len(quoted) > 0 {
return strings.Join(quoted, ".")
}
}
db := escapeBacktickIdent(dbName)
if db == "" {
return fmt.Sprintf("`%s`", t)
}
return fmt.Sprintf("`%s`.`%s`", db, t)
}

View File

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

344
internal/proxy/proxy.go Normal file
View File

@@ -0,0 +1,344 @@
package proxy
import (
"bufio"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/logger"
xproxy "golang.org/x/net/proxy"
)
const (
defaultDialTimeout = 8 * time.Second
)
type LocalForwarder struct {
LocalAddr string
RemoteAddr string
ProxyAddr string
ProxyType string
cfg connection.ProxyConfig
listener net.Listener
closeChan chan struct{}
closeOnce sync.Once
closed bool
closedMu sync.RWMutex
}
var (
forwarderMu sync.RWMutex
localForwarders = make(map[string]*LocalForwarder)
)
func NormalizeConfig(config connection.ProxyConfig) (connection.ProxyConfig, error) {
result := connection.ProxyConfig{
Type: strings.ToLower(strings.TrimSpace(config.Type)),
Host: strings.TrimSpace(config.Host),
Port: config.Port,
User: strings.TrimSpace(config.User),
Password: config.Password,
}
switch result.Type {
case "socks5", "socks5h", "http":
default:
return result, fmt.Errorf("不支持的代理类型:%s", config.Type)
}
if result.Type == "socks5h" {
result.Type = "socks5"
}
if result.Host == "" {
return result, fmt.Errorf("代理主机为空")
}
if result.Port <= 0 || result.Port > 65535 {
return result, fmt.Errorf("代理端口无效:%d", result.Port)
}
return result, nil
}
func GetOrCreateLocalForwarder(proxyConfig connection.ProxyConfig, remoteHost string, remotePort int) (*LocalForwarder, error) {
cfg, err := NormalizeConfig(proxyConfig)
if err != nil {
return nil, err
}
if strings.TrimSpace(remoteHost) == "" || remotePort <= 0 {
return nil, fmt.Errorf("无效的远端地址:%s:%d", remoteHost, remotePort)
}
key := forwarderCacheKey(cfg, remoteHost, remotePort)
forwarderMu.RLock()
forwarder, exists := localForwarders[key]
forwarderMu.RUnlock()
if exists && forwarder != nil && !forwarder.IsClosed() {
return forwarder, nil
}
if exists {
forwarderMu.Lock()
delete(localForwarders, key)
forwarderMu.Unlock()
}
next, err := NewLocalForwarder(cfg, remoteHost, remotePort)
if err != nil {
return nil, err
}
forwarderMu.Lock()
localForwarders[key] = next
forwarderMu.Unlock()
return next, nil
}
func forwarderCacheKey(cfg connection.ProxyConfig, remoteHost string, remotePort int) string {
trimmedHost := strings.TrimSpace(remoteHost)
credential := cfg.User + "\x00" + cfg.Password
credentialHash := sha256.Sum256([]byte(credential))
// 仅保留短指纹用于区分不同认证信息,避免在 key 日志中泄露明文口令。
fingerprint := hex.EncodeToString(credentialHash[:8])
return fmt.Sprintf("%s://%s:%d@%s:%d#%s", cfg.Type, cfg.Host, cfg.Port, trimmedHost, remotePort, fingerprint)
}
func NewLocalForwarder(proxyConfig connection.ProxyConfig, remoteHost string, remotePort int) (*LocalForwarder, error) {
cfg, err := NormalizeConfig(proxyConfig)
if err != nil {
return nil, err
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, fmt.Errorf("创建本地代理监听失败:%w", err)
}
localAddr := listener.Addr().String()
remoteAddr := net.JoinHostPort(strings.TrimSpace(remoteHost), fmt.Sprintf("%d", remotePort))
proxyAddr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
forwarder := &LocalForwarder{
LocalAddr: localAddr,
RemoteAddr: remoteAddr,
ProxyAddr: proxyAddr,
ProxyType: cfg.Type,
cfg: cfg,
listener: listener,
closeChan: make(chan struct{}),
}
go forwarder.forward()
logger.Infof("已创建代理端口转发:本地 %s -> 远端 %s代理 %s://%s", localAddr, remoteAddr, cfg.Type, proxyAddr)
return forwarder, nil
}
func (f *LocalForwarder) forward() {
for {
localConn, err := f.listener.Accept()
if err != nil {
select {
case <-f.closeChan:
return
default:
logger.Warnf("接受本地代理连接失败:%v", err)
return
}
}
go f.handleConnection(localConn)
}
}
func (f *LocalForwarder) handleConnection(localConn net.Conn) {
defer localConn.Close()
ctx, cancel := context.WithTimeout(context.Background(), defaultDialTimeout)
remoteConn, err := dialThroughProxy(ctx, f.cfg, "tcp", f.RemoteAddr)
cancel()
if err != nil {
logger.Warnf("通过代理连接远端失败:远端=%s 代理=%s://%s 错误=%v", f.RemoteAddr, f.ProxyType, f.ProxyAddr, err)
return
}
defer remoteConn.Close()
errc := make(chan error, 2)
var closeOnce sync.Once
closeBoth := func() {
_ = localConn.Close()
_ = remoteConn.Close()
}
go func() {
_, copyErr := io.Copy(remoteConn, localConn)
closeOnce.Do(closeBoth)
errc <- copyErr
}()
go func() {
_, copyErr := io.Copy(localConn, remoteConn)
closeOnce.Do(closeBoth)
errc <- copyErr
}()
<-errc
<-errc
}
func (f *LocalForwarder) Close() error {
var err error
f.closeOnce.Do(func() {
f.closedMu.Lock()
f.closed = true
f.closedMu.Unlock()
close(f.closeChan)
err = f.listener.Close()
if err != nil {
logger.Warnf("关闭代理端口转发失败:%v", err)
}
})
return err
}
func (f *LocalForwarder) IsClosed() bool {
f.closedMu.RLock()
defer f.closedMu.RUnlock()
return f.closed
}
func CloseAllForwarders() {
forwarderMu.Lock()
defer forwarderMu.Unlock()
for key, forwarder := range localForwarders {
if forwarder == nil {
continue
}
_ = forwarder.Close()
logger.Infof("已关闭代理端口转发:%s", key)
}
localForwarders = make(map[string]*LocalForwarder)
}
func DialContext(ctx context.Context, proxyConfig connection.ProxyConfig, network, address string) (net.Conn, error) {
cfg, err := NormalizeConfig(proxyConfig)
if err != nil {
return nil, err
}
return dialThroughProxy(ctx, cfg, network, address)
}
func dialThroughProxy(ctx context.Context, cfg connection.ProxyConfig, network, address string) (net.Conn, error) {
switch cfg.Type {
case "socks5":
return dialSOCKS5(ctx, cfg, network, address)
case "http":
return dialHTTPConnect(ctx, cfg, address)
default:
return nil, fmt.Errorf("不支持的代理类型:%s", cfg.Type)
}
}
func dialSOCKS5(ctx context.Context, cfg connection.ProxyConfig, network, address string) (net.Conn, error) {
proxyAddr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
var auth *xproxy.Auth
if cfg.User != "" || cfg.Password != "" {
auth = &xproxy.Auth{
User: cfg.User,
Password: cfg.Password,
}
}
dialer, err := xproxy.SOCKS5("tcp", proxyAddr, auth, &net.Dialer{Timeout: defaultDialTimeout})
if err != nil {
return nil, fmt.Errorf("创建 SOCKS5 代理拨号器失败:%w", err)
}
type result struct {
conn net.Conn
err error
}
ch := make(chan result, 1)
go func() {
conn, dialErr := dialer.Dial(network, address)
ch <- result{conn: conn, err: dialErr}
}()
select {
case <-ctx.Done():
go func() {
r := <-ch
if r.conn != nil {
_ = r.conn.Close()
}
}()
return nil, ctx.Err()
case r := <-ch:
if r.err != nil {
return nil, fmt.Errorf("SOCKS5 代理连接失败:%w", r.err)
}
return r.conn, nil
}
}
func dialHTTPConnect(ctx context.Context, cfg connection.ProxyConfig, address string) (net.Conn, error) {
proxyAddr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
dialer := &net.Dialer{Timeout: defaultDialTimeout}
conn, err := dialer.DialContext(ctx, "tcp", proxyAddr)
if err != nil {
return nil, fmt.Errorf("连接 HTTP 代理失败:%w", err)
}
connectReq := &http.Request{
Method: http.MethodConnect,
URL: &url.URL{Opaque: address},
Host: address,
Header: make(http.Header),
}
if cfg.User != "" || cfg.Password != "" {
raw := cfg.User + ":" + cfg.Password
connectReq.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(raw)))
}
if err := connectReq.Write(conn); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("发送 HTTP CONNECT 请求失败:%w", err)
}
reader := bufio.NewReader(conn)
resp, err := http.ReadResponse(reader, connectReq)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("读取 HTTP CONNECT 响应失败:%w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
_ = conn.Close()
return nil, fmt.Errorf("HTTP 代理 CONNECT 失败:%s", strings.TrimSpace(resp.Status))
}
if reader.Buffered() == 0 {
return conn, nil
}
return &bufferedConn{Conn: conn, reader: reader}, nil
}
type bufferedConn struct {
net.Conn
reader *bufio.Reader
}
func (c *bufferedConn) Read(p []byte) (int, error) {
if c.reader == nil {
return c.Conn.Read(p)
}
if c.reader.Buffered() == 0 {
c.reader = nil
return c.Conn.Read(p)
}
return c.reader.Read(p)
}

View File

@@ -0,0 +1,44 @@
package proxy
import (
"strings"
"testing"
"GoNavi-Wails/internal/connection"
)
func TestNormalizeConfigSupportsSocks5hAlias(t *testing.T) {
cfg, err := NormalizeConfig(connection.ProxyConfig{
Type: "SOCKS5H",
Host: "127.0.0.1",
Port: 1080,
})
if err != nil {
t.Fatalf("NormalizeConfig returned error: %v", err)
}
if cfg.Type != "socks5" {
t.Fatalf("expected normalized proxy type socks5, got %s", cfg.Type)
}
}
func TestForwarderCacheKeyIncludesCredentialFingerprint(t *testing.T) {
base := connection.ProxyConfig{
Type: "socks5",
Host: "127.0.0.1",
Port: 1080,
User: "tester",
Password: "first-password",
}
other := base
other.Password = "second-password"
keyA := forwarderCacheKey(base, "db.internal", 3306)
keyB := forwarderCacheKey(other, "db.internal", 3306)
if keyA == keyB {
t.Fatalf("expected different cache key for different credentials")
}
if strings.Contains(keyA, base.Password) || strings.Contains(keyB, other.Password) {
t.Fatalf("cache key should not contain raw password")
}
}

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

@@ -22,6 +22,14 @@ type RedisClientImpl struct {
forwarder *ssh.LocalForwarder
}
const (
redisScanDefaultTargetCount int64 = 2000
redisScanMaxTargetCount int64 = 10000
redisScanMinStepCount int64 = 200
redisScanMaxStepCount int64 = 2000
redisScanMaxRounds = 64
)
// NewRedisClient creates a new Redis client instance
func NewRedisClient() RedisClient {
return &RedisClientImpl{}
@@ -108,21 +116,70 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) (
if pattern == "" {
pattern = "*"
}
targetCount := normalizeRedisScanTargetCount(count)
scanStepCount := normalizeRedisScanStepCount(targetCount)
currentCursor := cursor
round := 0
keys := make([]string, 0, int(targetCount))
seen := make(map[string]struct{}, int(targetCount))
for len(keys) < int(targetCount) {
batch, nextCursor, err := r.client.Scan(ctx, currentCursor, pattern, scanStepCount).Result()
if err != nil {
return nil, err
}
for _, key := range batch {
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
keys = append(keys, key)
if len(keys) >= int(targetCount) {
break
}
}
currentCursor = nextCursor
round++
if currentCursor == 0 || round >= redisScanMaxRounds {
break
}
}
return &RedisScanResult{
Keys: r.loadRedisKeyInfos(ctx, keys),
Cursor: currentCursor,
}, nil
}
func normalizeRedisScanTargetCount(count int64) int64 {
if count <= 0 {
count = 100
return redisScanDefaultTargetCount
}
if count > redisScanMaxTargetCount {
return redisScanMaxTargetCount
}
return count
}
func normalizeRedisScanStepCount(targetCount int64) int64 {
if targetCount < redisScanMinStepCount {
return redisScanMinStepCount
}
if targetCount > redisScanMaxStepCount {
return redisScanMaxStepCount
}
return targetCount
}
func (r *RedisClientImpl) loadRedisKeyInfos(ctx context.Context, keys []string) []RedisKeyInfo {
result := make([]RedisKeyInfo, 0, len(keys))
if len(keys) == 0 {
return result
}
keys, nextCursor, err := r.client.Scan(ctx, cursor, pattern, count).Result()
if err != nil {
return nil, err
}
result := &RedisScanResult{
Keys: make([]RedisKeyInfo, 0, len(keys)),
Cursor: nextCursor,
}
// Get type and TTL for each key
pipe := r.client.Pipeline()
typeResults := make([]*redis.StatusCmd, len(keys))
ttlResults := make([]*redis.DurationCmd, len(keys))
@@ -132,37 +189,44 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) (
ttlResults[i] = pipe.TTL(ctx, key)
}
_, err = pipe.Exec(ctx)
_, err := pipe.Exec(ctx)
if err != nil && err != redis.Nil {
// Fallback: get info one by one
for _, key := range keys {
keyType, _ := r.GetKeyType(key)
ttl, _ := r.GetTTL(key)
result.Keys = append(result.Keys, RedisKeyInfo{
keyType, typeErr := r.client.Type(ctx, key).Result()
if typeErr != nil && typeErr != redis.Nil {
keyType = ""
}
ttlValue, ttlErr := r.client.TTL(ctx, key).Result()
if ttlErr != nil && ttlErr != redis.Nil {
ttlValue = -2
}
result = append(result, RedisKeyInfo{
Key: key,
Type: keyType,
TTL: ttl,
TTL: toRedisTTLSeconds(ttlValue),
})
}
return result, nil
return result
}
for i, key := range keys {
keyType := typeResults[i].Val()
ttl := int64(ttlResults[i].Val().Seconds())
if ttlResults[i].Val() == -1 {
ttl = -1
} else if ttlResults[i].Val() == -2 {
ttl = -2
}
result.Keys = append(result.Keys, RedisKeyInfo{
result = append(result, RedisKeyInfo{
Key: key,
Type: keyType,
TTL: ttl,
Type: typeResults[i].Val(),
TTL: toRedisTTLSeconds(ttlResults[i].Val()),
})
}
return result
}
return result, nil
func toRedisTTLSeconds(ttl time.Duration) int64 {
if ttl == -1 {
return -1
}
if ttl == -2 {
return -2
}
return int64(ttl.Seconds())
}
// GetKeyType returns the type of a key
@@ -334,6 +398,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 +607,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

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