Files
MyGoNavi/internal/app/explain_parse_sqlite_test.go
Syngnat b997788437 feat(explain): 新增 SQL 诊断工作台后端 EXPLAIN 基建
- 数据结构:新增 ExplainResult/Node/Stats/IndexSuggestion/DiagnoseReport 等归一化模型,跨方言通用
- 接口扩展:Database 接口新增 ExplainExecer 可选能力,支持驱动自带 EXPLAIN 实现
- 核心入口:DiagnoseQuery 支持 SELECT/WITH 白名单校验、方言调度、原生与 fallback 两条执行路径
- 方言适配:buildExplainQuery 覆盖 MySQL/PostgreSQL/SQLite/Oracle/SQLServer/ClickHouse 7 大主流
- 解析器:MySQL FORMAT=JSON 含表格 fallback、PostgreSQL ANALYZE BUFFERS JSON、SQLite EQP 层级解析
- 测试覆盖:新增 27 个单元测试覆盖 SQL 构造与三方言解析器
2026-06-19 12:30:56 +08:00

139 lines
4.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"testing"
"GoNavi-Wails/internal/connection"
)
// SQLite EQP fixture单表全表扫描 + filesort。
const sqliteEQPFullScanWithSort = `id parent notused detail
2 0 0 SCAN TABLE users
5 0 0 USE TEMP B-TREE FOR ORDER BY`
func TestParseSQLiteExplain_FullScanWithFileSort(t *testing.T) {
result, err := parseSQLiteExplain("SELECT * FROM users ORDER BY name", sqliteEQPFullScanWithSort, connection.ExplainFormatTable)
if err != nil {
t.Fatalf("解析失败:%v", err)
}
// 2 个独立节点id 不同parent 都是 0无父子关系
if len(result.Nodes) != 2 {
t.Fatalf("应有 2 个节点got=%d", len(result.Nodes))
}
scan := result.Nodes[0]
if scan.OpType != connection.ExplainOpScan {
t.Fatalf("第一个应为 SCANgot=%s", scan.OpType)
}
if scan.Table != "users" {
t.Fatalf("table got=%s want=users", scan.Table)
}
if !containsFlag(scan.Flags, connection.ExplainFlagFullScan) {
t.Fatalf("SCAN 应有 FULL_SCAN flag")
}
if result.Stats.HasFullScan != true {
t.Fatalf("Stats.HasFullScan 应为 true")
}
if result.Stats.HasFilesort != true {
t.Fatalf("Stats.HasFilesort 应为 true")
}
}
// SQLite EQP fixture索引扫描。
const sqliteEQPIndexScan = `id parent notused detail
3 0 0 SEARCH TABLE users USING INDEX idx_email (email=?)`
func TestParseSQLiteExplain_IndexScanExtractsIndex(t *testing.T) {
result, err := parseSQLiteExplain("SELECT * FROM users WHERE email = 'x'", sqliteEQPIndexScan, connection.ExplainFormatTable)
if err != nil {
t.Fatalf("解析失败:%v", err)
}
if len(result.Nodes) != 1 {
t.Fatalf("应有 1 个节点got=%d", len(result.Nodes))
}
node := result.Nodes[0]
if node.OpType != connection.ExplainOpIndexScan {
t.Fatalf("USING INDEX 应为 INDEX_SCANgot=%s", node.OpType)
}
if node.Table != "users" {
t.Fatalf("table got=%s want=users", node.Table)
}
if node.Index != "idx_email" {
t.Fatalf("index got=%s want=idx_email", node.Index)
}
}
// SQLite EQP fixture主键扫描 + 临时表distinct
const sqliteEQPPrimaryKeyWithDistinct = `id parent notused detail
3 0 0 SEARCH TABLE users USING PRIMARY KEY (id=?)
7 0 0 USE TEMP B-TREE FOR DISTINCT`
func TestParseSQLiteExplain_PrimaryKeyAndDistinct(t *testing.T) {
result, err := parseSQLiteExplain("SELECT DISTINCT name FROM users WHERE id = 1", sqliteEQPPrimaryKeyWithDistinct, connection.ExplainFormatTable)
if err != nil {
t.Fatalf("解析失败:%v", err)
}
if len(result.Nodes) != 2 {
t.Fatalf("应有 2 个节点got=%d", len(result.Nodes))
}
pk := result.Nodes[0]
if pk.OpType != connection.ExplainOpIndexScan {
t.Fatalf("PRIMARY KEY 应为 INDEX_SCANgot=%s", pk.OpType)
}
if pk.Index != "PRIMARY" {
t.Fatalf("index got=%s want=PRIMARY", pk.Index)
}
if result.Stats.HasTempTable != true {
t.Fatalf("FOR DISTINCT 应触发 TEMP_TABLE flag")
}
}
// SQLite EQP fixture父子关系子查询
const sqliteEQPCorrelatedSubquery = `id parent notused detail
2 0 0 SCAN TABLE orders
6 2 0 CORRELATED SCALAR SUBQUERY 1
8 6 0 SEARCH TABLE users USING INDEX idx_id (id=?)`
func TestParseSQLiteExplain_HierarchicalRelationShips(t *testing.T) {
result, err := parseSQLiteExplain("SELECT *, (SELECT name FROM users WHERE id = o.user_id) FROM orders o", sqliteEQPCorrelatedSubquery, connection.ExplainFormatTable)
if err != nil {
t.Fatalf("解析失败:%v", err)
}
if len(result.Nodes) != 3 {
t.Fatalf("应有 3 个节点got=%d", len(result.Nodes))
}
// orders 是根parent=0
// CORRELATED SCALAR SUBQUERY 的 parent=2 → orders
// SEARCH 的 parent=6 → subquery
if result.Nodes[0].ParentID != "" {
t.Fatalf("根节点 ParentID 应为空got=%q", result.Nodes[0].ParentID)
}
if result.Nodes[1].ParentID != result.Nodes[0].ID {
t.Fatalf("subquery 节点的 ParentID 应指向 orders")
}
if result.Nodes[2].ParentID != result.Nodes[1].ID {
t.Fatalf("SEARCH 节点的 ParentID 应指向 subquery")
}
if len(result.Edges) != 2 {
t.Fatalf("应有 2 条边got=%d", len(result.Edges))
}
}
func TestParseSQLiteExplain_CoveringIndex(t *testing.T) {
raw := `id parent notused detail
3 0 0 SEARCH TABLE users USING COVERING INDEX idx_name_email (name=?)`
result, err := parseSQLiteExplain("SELECT name FROM users WHERE name = 'x'", raw, connection.ExplainFormatTable)
if err != nil {
t.Fatalf("解析失败:%v", err)
}
if result.Nodes[0].OpType != connection.ExplainOpIndexOnly {
t.Fatalf("COVERING INDEX 应为 INDEX_ONLYgot=%s", result.Nodes[0].OpType)
}
}
func TestParseSQLiteExplain_MissingColumnsReturnsError(t *testing.T) {
_, err := parseSQLiteExplain("SELECT 1", "id parent\n1 0", connection.ExplainFormatTable)
if err == nil {
t.Fatal("缺少 detail 列应返回 error")
}
}