Files
opsxdb/apps/server/internal/db/session_test.go
T
logan a9510235bb
CI / Go server (push) Canceled after 0s
CI / Web + core packages (push) Canceled after 0s
CI / Desktop (Tauri) cargo check (push) Canceled after 0s
feat: 18 引擎驱动全家桶 + 驱动管理/离线包 + 设置中心 + 工作台交互打磨
驱动接入(全部本机真实例验收):
- Elasticsearch 原生 SQL API、InfluxDB 原生 HTTP+Flux(官方 JDBC 付费许可,实测弃用)
- ZooKeeper/MongoDB JVM agent(连接时自动下载官方驱动 jar,sha256 钉版本到 ~/.opsxdb/drivers/)
- 达梦 JDBC agent 桥就绪(待厂商 jar+实例);TiDB 真实例验收
- 协议族代码审查放行 PolarDB/TDSQL/OceanBase/Kingbase/GaussDB/Oracle
- 驱动能力模型:按驱动开关对象树分组/右键菜单/行编辑,NoSQL 树拍平(对象直挂库节点)

驱动管理:
- 驱动管理弹框(状态/预装/离线包导入/JDK 检测)+ /api/drivers/* 三接口
- opsxdb-driver 驱动商店仓库(清单/fetch.sh/pack.sh 离线包/import.sh)
- 连接时懒加载:DMG 零 Java 产物,首连自动下载(实测 ZK 九件套 1.35s),离线包自动回退

UI/交互:
- 设置中心(⚙):常规/执行/结果/驱动/快捷键/关于,左导航+右面板(对标 opsxshell)
- 实例级展开箭头(行首 SVG chevron,替代字体回退失真的 Unicode 三角)
- 树内嵌套渲染修复:对象树挂在激活连接下;工具栏窄窗口不断行(按钮 nowrap+整颗换行)
- 侧栏连接行精简(图标+连接名)、SQLite SSH 守卫、Oracle 死参数清理

质量:
- Go 协议层/离线回退单测、E2E 门控测试(ES/ZK/Influx/Mongo/TiDB/JDBC 桥)
- 驱动注册表单一事实来源 connection-types.json,README/BACKLOG/AGENTS 同步
2026-09-22 01:07:46 +08:00

245 lines
6.9 KiB
Go

package db
import (
"context"
"database/sql"
"os"
"path/filepath"
"testing"
"time"
)
// touchFile 先建空文件(零字节即合法 SQLite 库),绕开存在性检查。
func touchFile(t *testing.T, path string) {
t.Helper()
if err := os.WriteFile(path, []byte{}, 0600); err != nil {
t.Fatal(err)
}
}
func newTestSession(driver, database string) *Session {
return &Session{
ID: "test-pool",
Conn: ConnReq{Driver: driver, Database: database},
sqlPool: map[string]*sql.DB{},
poolUsed: map[string]time.Time{},
LastUsed: time.Now(),
}
}
// 池化:同 database 复用同一 *sql.DB 句柄。
func TestSessionSqlDBReuse(t *testing.T) {
dbFile := filepath.Join(t.TempDir(), "pool.db")
touchFile(t, dbFile)
s := newTestSession("sqlite", dbFile)
defer s.closeAll()
a, err := s.sqlDB("")
if err != nil {
t.Fatal(err)
}
b, err := s.sqlDB("")
if err != nil {
t.Fatal(err)
}
if a != b {
t.Fatal("同 database 应复用同一 *sql.DB 句柄")
}
// sqlite 下 database 参数不覆盖文件路径
c, err := s.sqlDB("main")
if err != nil {
t.Fatal(err)
}
if c != a {
t.Fatal("sqlite 会话应忽略 database 复用同一句柄")
}
}
// 池化上限:超过 8 个句柄时关闭最久未用(LRU 轮换)。
func TestSessionSqlDBLRU(t *testing.T) {
// mysql 驱动 sql.Open 不立即建连,可离线验证池管理逻辑
s := newTestSession("mysql", "")
defer s.closeAll()
first, err := s.sqlDB("db0")
if err != nil {
t.Fatal(err)
}
for i := 1; i <= maxPoolHandles; i++ {
time.Sleep(2 * time.Millisecond) // 保证 LRU 时间序确定
if _, err := s.sqlDB(string(rune('a' + i))); err != nil {
t.Fatal(err)
}
}
s.mu.Lock()
n := len(s.sqlPool)
_, firstAlive := s.sqlPool["db0"]
s.mu.Unlock()
if n > maxPoolHandles {
t.Fatalf("池句柄数 %d 超上限 %d", n, maxPoolHandles)
}
if firstAlive {
t.Fatal("最久未用句柄应被淘汰")
}
_ = first
}
// 会话路径端到端:ExecSQL 走池化,两次执行共享句柄且结果正确。
func TestExecSQLSessionPooling(t *testing.T) {
dbFile := filepath.Join(t.TempDir(), "exec.db")
touchFile(t, dbFile)
id, err := OpenSession(ConnReq{Driver: "sqlite", Database: dbFile})
if err != nil {
t.Fatal(err)
}
defer CloseSession(id)
mk := func(sqlText string) ConnReq {
c, err := ResolveConn(ConnReq{SessionID: id, SQL: sqlText})
if err != nil {
t.Fatal(err)
}
return c
}
if r := ExecSQL(context.Background(), mk("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")); r.Error != "" {
t.Fatal(r.Error)
}
if r := ExecSQL(context.Background(), mk("INSERT INTO t (v) VALUES ('a'),('b'),('c')")); r.Error != "" {
t.Fatal(r.Error)
}
r := ExecSQL(context.Background(), mk("SELECT * FROM t ORDER BY id"))
if r.Error != "" || r.RowCount != 3 {
t.Fatalf("查询结果错误: %#v", r)
}
s, err := GetSession(id)
if err != nil {
t.Fatal(err)
}
s.mu.Lock()
poolSize := len(s.sqlPool)
s.mu.Unlock()
if poolSize != 1 {
t.Fatalf("会话池应仅 1 个句柄,实际 %d", poolSize)
}
// offset 分页:跳过前 1 行取 1 行
p := mk("SELECT * FROM t ORDER BY id")
p.Offset = 1
p.Limit = 1
r2 := ExecSQL(context.Background(), p)
if r2.Error != "" || r2.RowCount != 1 {
t.Fatalf("offset 分页错误: %#v", r2)
}
if r2.Rows[0]["v"] != "b" {
t.Fatalf("offset=1 应返回第 2 行,实际 %#v", r2.Rows[0])
}
if r2.Offset != 1 || !r2.Truncated {
t.Fatalf("分页元数据错误: offset=%d truncated=%v", r2.Offset, r2.Truncated)
}
// 会话路径危险确认:ConfirmDanger 必须经 ConnFor 透传
d := mk("DROP TABLE t")
if r := ExecSQL(context.Background(), d); !r.NeedConfirm {
t.Fatalf("会话路径未确认应拦截: %#v", r)
}
d.ConfirmDanger = true
if r := ExecSQL(context.Background(), d); r.Error != "" || r.NeedConfirm {
t.Fatalf("会话路径确认后应放行: %#v", r)
}
// 逐请求字段透传:RequestID/TimeoutMs 丢失会让取消/超时在前端(全走会话路径)静默失效
s2, err := GetSession(id)
if err != nil {
t.Fatal(err)
}
c2 := s2.ConnFor(ConnReq{Driver: "sqlite", SessionID: id, SQL: "SELECT 1", RequestID: "req-1", TimeoutMs: 5000})
if c2.RequestID != "req-1" || c2.TimeoutMs != 5000 {
t.Fatalf("ConnFor 应透传 RequestID/TimeoutMs: %#v", c2)
}
}
// 危险语句二段流:未确认拦截,确认后放行。
func TestExecSQLDangerConfirm(t *testing.T) {
dbFile := filepath.Join(t.TempDir(), "danger.db")
touchFile(t, dbFile)
c := ConnReq{Driver: "sqlite", Database: dbFile}
r := ExecSQL(context.Background(), ConnReq{Driver: "sqlite", Database: dbFile, SQL: "CREATE TABLE t (id int)"})
if r.Error != "" {
t.Fatal(r.Error)
}
// 未确认:DROP 不执行
r = ExecSQL(context.Background(), ConnReq{Driver: "sqlite", Database: dbFile, SQL: "DROP TABLE t"})
if !r.NeedConfirm {
t.Fatalf("应 needConfirm: %#v", r)
}
if r.Error == "" {
t.Fatal("应返回命中原因")
}
// 表仍在
r = ExecSQL(context.Background(), ConnReq{Driver: "sqlite", Database: dbFile, SQL: "SELECT count(*) AS n FROM t"})
if r.Error != "" {
t.Fatalf("DROP 不应已执行: %s", r.Error)
}
// 确认后放行
c.SQL = "DROP TABLE t"
c.ConfirmDanger = true
r = ExecSQL(context.Background(), c)
if r.Error != "" || r.NeedConfirm {
t.Fatalf("确认后应放行: %#v", r)
}
// 表已删
r = ExecSQL(context.Background(), ConnReq{Driver: "sqlite", Database: dbFile, SQL: "SELECT count(*) FROM t"})
if r.Error == "" {
t.Fatal("确认后 DROP 应已生效")
}
}
// bigint 精度:超 2^53 的 int64 以字符串无损返回。
func TestExecSQLBigint(t *testing.T) {
dbFile := filepath.Join(t.TempDir(), "bigint.db")
touchFile(t, dbFile)
mk := func(sqlText string) ConnReq {
return ConnReq{Driver: "sqlite", Database: dbFile, SQL: sqlText}
}
if r := ExecSQL(context.Background(), mk("CREATE TABLE b (id INTEGER PRIMARY KEY)")); r.Error != "" {
t.Fatal(r.Error)
}
if r := ExecSQL(context.Background(), mk("INSERT INTO b (id) VALUES (9223372036854775807)")); r.Error != "" {
t.Fatal(r.Error)
}
r := ExecSQL(context.Background(), mk("SELECT id FROM b"))
if r.Error != "" {
t.Fatal(r.Error)
}
if r.Rows[0]["id"] != "9223372036854775807" {
t.Fatalf("bigint 应字符串保真: %#v", r.Rows[0]["id"])
}
}
// EXPLAIN:sqlite 行列计划非空。
func TestExplainSQLite(t *testing.T) {
dbFile := filepath.Join(t.TempDir(), "explain.db")
touchFile(t, dbFile)
c := ConnReq{Driver: "sqlite", Database: dbFile, SQL: "CREATE TABLE e (id int)"}
if r := ExecSQL(context.Background(), c); r.Error != "" {
t.Fatal(r.Error)
}
r := ExplainSQL(context.Background(), ConnReq{Driver: "sqlite", Database: dbFile, SQL: "SELECT * FROM e"})
if r.Error != "" {
t.Fatal(r.Error)
}
if len(r.Rows) == 0 {
t.Fatal("sqlite EXPLAIN QUERY PLAN 应返回行列计划")
}
}
// SQLite 打开不存在文件应报错而非静默建空库。
func TestSQLiteMissingFile(t *testing.T) {
_, err := Open(ConnReq{Driver: "sqlite", Database: filepath.Join(t.TempDir(), "not-exist", "x.db")})
if err == nil {
t.Fatal("不存在的 sqlite 路径应报错")
}
}