Files
MyGoNavi/internal/app/explain_parse_mysql_test.go
Syngnat 04019135a0 🐛 fix(sql-diagnose): 兼容新版 MySQL JSON EXPLAIN 解析
- 兼容 query_plan 包装和新版 JSON V2 执行计划结构
- 补齐 covering index、table scan 等节点类型与统计归一化
- 增加新版 MySQL EXPLAIN 解析测试并修正 TotalCost 汇总逻辑
2026-06-20 14:08:02 +08:00

293 lines
9.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"
)
// 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")
}
}
const mySQLFormatJSONV2FilterWithCoveringIndex = `{
"query": "/* select#1 */ select ` + "`f1`" + ` from ` + "`t1`" + ` where (` + "`f2`" + ` > 3)",
"operation": "Filter: (` + "`t1`" + `. ` + "`f2`" + ` > 3)",
"access_type": "filter",
"estimated_rows": 2,
"estimated_total_cost": 2.65,
"condition": "(` + "`t1`" + `. ` + "`f2`" + ` > 3)",
"inputs": [
{
"operation": "Covering index scan on t1 using f1",
"access_type": "index",
"index_access_type": "index_scan",
"table_name": "t1",
"index_name": "f1",
"covering": true,
"estimated_rows": 3,
"estimated_total_cost": 1.56
}
]
}`
func TestParseMySQLExplain_JSONV2FilterWithCoveringIndex(t *testing.T) {
result, err := parseMySQLExplain("mysql", "SELECT f1 FROM t1 WHERE f2 > 3", mySQLFormatJSONV2FilterWithCoveringIndex, connection.ExplainFormatJSON)
if err != nil {
t.Fatalf("解析新版 JSON 失败:%v", err)
}
if len(result.Nodes) != 2 {
t.Fatalf("应有 2 个节点got=%d", len(result.Nodes))
}
if result.Nodes[0].OpType != connection.ExplainOpFilter {
t.Fatalf("根节点应为 FILTERgot=%s", result.Nodes[0].OpType)
}
if result.Nodes[1].OpType != connection.ExplainOpIndexOnly {
t.Fatalf("covering index scan 应为 INDEX_ONLYgot=%s", result.Nodes[1].OpType)
}
if result.Nodes[1].Table != "t1" {
t.Fatalf("子节点表名应为 t1got=%s", result.Nodes[1].Table)
}
if result.Nodes[1].Index != "f1" {
t.Fatalf("子节点索引应为 f1got=%s", result.Nodes[1].Index)
}
if len(result.Edges) != 1 || result.Edges[0].From != result.Nodes[0].ID || result.Edges[0].To != result.Nodes[1].ID {
t.Fatalf("应建立 FILTER -> INDEX_ONLY 边got=%v", result.Edges)
}
if result.Stats.TotalCost != 2.65 {
t.Fatalf("TotalCost got=%v want=2.65", result.Stats.TotalCost)
}
}
const mySQLFormatJSONV2WrappedTableScan = `{
"query_plan": {
"operation": "Table scan on messages",
"access_type": "table",
"table_name": "messages",
"estimated_rows": 958400,
"estimated_total_cost": 49827.15
}
}`
func TestParseMySQLExplain_JSONV2WrappedTableScan(t *testing.T) {
result, err := parseMySQLExplain("mysql", "SELECT * FROM messages", mySQLFormatJSONV2WrappedTableScan, connection.ExplainFormatJSON)
if err != nil {
t.Fatalf("解析 query_plan 包装的新版 JSON 失败:%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("table access 应为 SCANgot=%s", node.OpType)
}
if node.Table != "messages" {
t.Fatalf("table got=%q want=messages", node.Table)
}
if !containsFlag(node.Flags, connection.ExplainFlagFullScan) || !containsFlag(node.Flags, connection.ExplainFlagNoIndex) {
t.Fatalf("table scan 应标记 FULL_SCAN/NO_INDEXgot=%v", node.Flags)
}
if result.Stats.TotalCost != 49827.15 {
t.Fatalf("TotalCost got=%v want=49827.15", result.Stats.TotalCost)
}
}
// 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
}