Files
MyGoNavi/internal/app/explain_parse_mysql_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

211 lines
6.8 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"
)
// MySQL FORMAT=JSON fixture单表全表扫描。
const mySQLFormatJSONSingleTableFullScan = `{
"query_block": {
"select_id": 1,
"cost_info": {"query_cost": "100.00"},
"table": {
"table_name": "users",
"access_type": "ALL",
"rows_examined_per_scan": 10000,
"rows_produced_per_join": 1000,
"filtered": "10.00",
"cost_info": {"read_cost": "100.00"},
"used_columns": ["id", "name", "email"]
}
}
}`
func TestParseMySQLExplain_SingleTableFullScan(t *testing.T) {
result, err := parseMySQLExplain("mysql", "SELECT * FROM users", mySQLFormatJSONSingleTableFullScan, connection.ExplainFormatJSON)
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.ExplainOpScan {
t.Fatalf("access_type=ALL 应归一化为 SCANgot=%s", node.OpType)
}
if node.Table != "users" {
t.Fatalf("table got=%q want=users", node.Table)
}
if node.EstRows != 10000 {
t.Fatalf("EstRows got=%d want=10000", node.EstRows)
}
if !containsFlag(node.Flags, connection.ExplainFlagFullScan) {
t.Fatalf("全表扫描节点应有 FULL_SCAN flaggot=%v", node.Flags)
}
if !containsFlag(node.Flags, connection.ExplainFlagNoIndex) {
t.Fatalf("全表扫描节点应有 NO_INDEX flaggot=%v", node.Flags)
}
if !result.Stats.HasFullScan {
t.Fatalf("Stats.HasFullScan 应为 true")
}
if result.Stats.TotalCost != 100.0 {
t.Fatalf("TotalCost got=%v want=100", result.Stats.TotalCost)
}
if result.Stats.RowsRead != 10000 {
t.Fatalf("RowsRead got=%d want=10000", result.Stats.RowsRead)
}
}
// MySQL FORMAT=JSON fixture两表 JOIN一个走索引一个走全表
const mySQLFormatJSONJoinScanAndIndex = `{
"query_block": {
"select_id": 1,
"cost_info": {"query_cost": "250.00"},
"nested_loop": [
{
"table": {
"table_name": "orders",
"access_type": "ALL",
"rows_examined_per_scan": 5000,
"cost_info": {"read_cost": "100.00"}
}
},
{
"table": {
"table_name": "users",
"access_type": "eq_ref",
"possible_keys": ["PRIMARY"],
"key": "PRIMARY",
"used_key_parts": ["id"],
"rows_examined_per_scan": 1,
"cost_info": {"read_cost": "150.00"}
}
}
]
}
}`
func TestParseMySQLExplain_JoinScanAndIndex(t *testing.T) {
result, err := parseMySQLExplain("mysql", "SELECT * FROM orders o JOIN users u ON o.user_id = u.id", mySQLFormatJSONJoinScanAndIndex, connection.ExplainFormatJSON)
if err != nil {
t.Fatalf("解析失败:%v", err)
}
if len(result.Nodes) != 2 {
t.Fatalf("应有 2 个节点nested_loop 内 2 个 tablegot=%d", len(result.Nodes))
}
if result.Nodes[0].Table != "orders" {
t.Fatalf("第一个表应是 ordersgot=%s", result.Nodes[0].Table)
}
if result.Nodes[0].OpType != connection.ExplainOpScan {
t.Fatalf("orders access_type=ALL 应为 SCANgot=%s", result.Nodes[0].OpType)
}
if result.Nodes[1].Table != "users" {
t.Fatalf("第二个表应是 usersgot=%s", result.Nodes[1].Table)
}
if result.Nodes[1].OpType != connection.ExplainOpIndexScan {
t.Fatalf("users access_type=eq_ref 应为 INDEX_SCANgot=%s", result.Nodes[1].OpType)
}
if result.Nodes[1].Index != "PRIMARY" {
t.Fatalf("users 使用 PRIMARY keygot=%s", result.Nodes[1].Index)
}
// statsorders 估算 5000 行
if result.Stats.RowsRead != 5000+1 {
t.Fatalf("RowsRead 应为两表 EstRows 之和 (5000+1)got=%d", result.Stats.RowsRead)
}
}
// MySQL FORMAT=JSON fixture含 ordering_operation 包装层。
const mySQLFormatJSONWithOrder = `{
"query_block": {
"select_id": 1,
"ordering_operation": {
"table": {
"table_name": "t",
"access_type": "ALL",
"rows_examined_per_scan": 100,
"cost_info": {"read_cost": "10.00"}
}
}
}
}`
func TestParseMySQLExplain_WithOrderingOperation(t *testing.T) {
result, err := parseMySQLExplain("mysql", "SELECT * FROM t ORDER BY id", mySQLFormatJSONWithOrder, connection.ExplainFormatJSON)
if err != nil {
t.Fatalf("解析失败:%v", err)
}
// 应该有 2 个节点ordering 层 + table 层
if len(result.Nodes) != 2 {
t.Fatalf("ordering_operation 应展开为 2 个节点got=%d", len(result.Nodes))
}
if result.Nodes[0].OpType != connection.ExplainOpSort {
t.Fatalf("ordering_operation 顶层节点应为 SORTgot=%s", result.Nodes[0].OpType)
}
if result.Nodes[1].OpType != connection.ExplainOpScan {
t.Fatalf("内层 table 应为 SCANgot=%s", result.Nodes[1].OpType)
}
// 验证父子边
if len(result.Edges) != 1 {
t.Fatalf("应有 1 条边got=%d", len(result.Edges))
}
if result.Edges[0].From != result.Nodes[0].ID || result.Edges[0].To != result.Nodes[1].ID {
t.Fatalf("边应连接 SORT -> SCAN")
}
}
// MySQL 5.7 表格模式 fallback。
const mySQLTableExplainOutput = `id select_type table type possible_keys key key_len ref rows Extra
1 SIMPLE users ALL NULL NULL NULL NULL 10000 Using where
1 SIMPLE orders ref idx_uid idx_uid 4 const 5 Using filesort`
func TestParseMySQLExplain_TableFormatFallback(t *testing.T) {
result, err := parseMySQLExplain("mysql", "SELECT * FROM users", mySQLTableExplainOutput, connection.ExplainFormatTable)
if err != nil {
t.Fatalf("表格解析失败:%v", err)
}
if len(result.Nodes) != 2 {
t.Fatalf("应有 2 个节点got=%d", len(result.Nodes))
}
if result.Nodes[0].OpType != connection.ExplainOpScan {
t.Fatalf("users type=ALL 应为 SCANgot=%s", result.Nodes[0].OpType)
}
if !containsFlag(result.Nodes[0].Flags, connection.ExplainFlagFullScan) {
t.Fatalf("users 应有 FULL_SCAN flag")
}
if result.Nodes[1].Index != "idx_uid" {
t.Fatalf("orders 使用 idx_uidgot=%s", result.Nodes[1].Index)
}
if !containsFlag(result.Nodes[1].Flags, connection.ExplainFlagFilesort) {
t.Fatalf("orders Extra 含 Using filesort应有 FILESORT flag")
}
if result.RawFormat != connection.ExplainFormatTable {
t.Fatalf("RawFormat got=%v want=table", result.RawFormat)
}
}
func TestParseMySQLExplain_EmptyRawReturnsError(t *testing.T) {
_, err := parseMySQLExplain("mysql", "SELECT 1", " ", connection.ExplainFormatJSON)
if err == nil {
t.Fatal("空输入应返回 error")
}
}
func TestParseMySQLExplain_InvalidJSONReturnsError(t *testing.T) {
_, err := parseMySQLExplain("mysql", "SELECT 1", "{ this is not valid json", connection.ExplainFormatJSON)
if err == nil {
t.Fatal("非法 JSON 应返回 error")
}
}
// containsFlag 检查 flags 列表是否包含目标值。
func containsFlag(flags []string, target string) bool {
for _, f := range flags {
if f == target {
return true
}
}
return false
}