驱动接入(全部本机真实例验收): - 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 同步
245 lines
6.9 KiB
Go
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 路径应报错")
|
|
}
|
|
}
|