Files
MyGoNavi/internal/app/explain_rules_test.go
Syngnat 8e24e40fdd feat(explain): 补齐 Oracle/SQLServer/ClickHouse 解析器与索引建议规则引擎
- 方言解析:新增 Oracle DBMS_XPLAN 表格、SQLServer SHOWPLAN_XML、ClickHouse EXPLAIN JSON 解析器
- 规则引擎:新增 10 条跨方言规则(全表扫描、缺索引 JOIN、filesort、估算偏差、缓冲命中、Nested Loop 高扇出等)
- 入口接入:DiagnoseQuery 返回的 Suggestions 自动填充规则匹配结果
- 容错增强:SQLServer strip 默认命名空间与 XML 声明;Oracle 表格列与独立 Predicate 段双源融合
- 测试覆盖:新增 27 个用例覆盖三方言解析与规则触发场景
2026-06-19 12:45:15 +08:00

250 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"
)
// 规则引擎测试:验证各规则在合成 ExplainNode 上的触发与排序。
func TestRunExplainRules_FullScanLargeTableCritical(t *testing.T) {
result := connection.ExplainResult{
DBType: "mysql",
SourceSQL: "SELECT * FROM users",
Nodes: []connection.ExplainNode{
{
ID: "n1",
OpType: connection.ExplainOpScan,
Table: "users",
EstRows: 100000,
Flags: []string{connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex},
},
},
}
suggestions := runExplainRules(result)
if len(suggestions) == 0 {
t.Fatal("全表扫描大表应触发建议")
}
top := suggestions[0]
if top.Severity != connection.SeverityCritical {
t.Fatalf("大表全表扫描应为 criticalgot=%s", top.Severity)
}
if top.Rule != "full_scan_with_filter" && top.Rule != "full_scan_on_large_table" {
t.Fatalf("首条建议应与全表扫描相关got=%s", top.Rule)
}
if top.AffectedTable != "users" {
t.Fatalf("AffectedTable got=%s want=users", top.AffectedTable)
}
}
func TestRunExplainRules_FullScanSmallTableSuppressed(t *testing.T) {
result := connection.ExplainResult{
DBType: "mysql",
SourceSQL: "SELECT * FROM small_table",
Nodes: []connection.ExplainNode{
{
ID: "n1",
OpType: connection.ExplainOpScan,
Table: "small_table",
EstRows: 100, // 远低于 1000 阈值
Flags: []string{connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex},
},
},
}
suggestions := runExplainRules(result)
for _, s := range suggestions {
if s.Rule == "full_scan_on_large_table" || s.Rule == "full_scan_with_filter" {
t.Fatalf("小表100 行)不应触发 full_scan 规则got=%+v", s)
}
}
}
func TestRunExplainRules_FullScanWithFilterExtractsColumns(t *testing.T) {
result := connection.ExplainResult{
DBType: "mysql",
SourceSQL: "SELECT * FROM users WHERE email = 'x' AND status = 1",
Nodes: []connection.ExplainNode{
{
ID: "n1",
OpType: connection.ExplainOpScan,
Table: "users",
EstRows: 10000,
Flags: []string{connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex},
Extra: map[string]any{"attachedCondition": "(email = 'x') AND (status = 1)"},
},
},
}
suggestions := runExplainRules(result)
foundFilterRule := false
for _, s := range suggestions {
if s.Rule == "full_scan_with_filter" {
foundFilterRule = true
if !contains(s.Reason, "email") || !contains(s.Reason, "status") {
t.Fatalf("Reason 应提及 email 和 status 列got=%s", s.Reason)
}
}
}
if !foundFilterRule {
t.Fatal("带 WHERE 的全表扫描应触发 full_scan_with_filter 规则")
}
}
func TestRunExplainRules_FilesortOnLargeResult(t *testing.T) {
result := connection.ExplainResult{
DBType: "postgres",
SourceSQL: "SELECT * FROM t ORDER BY id",
Nodes: []connection.ExplainNode{
{
ID: "n1",
OpType: connection.ExplainOpSort,
EstRows: 10000,
Flags: []string{connection.ExplainFlagFilesort},
},
},
}
suggestions := runExplainRules(result)
found := false
for _, s := range suggestions {
if s.Rule == "filesort_on_large_result" {
found = true
if s.Severity != connection.SeverityWarning {
t.Fatalf("filesort 应为 warninggot=%s", s.Severity)
}
}
}
if !found {
t.Fatal("大结果集 filesort 应触发建议")
}
}
func TestRunExplainRules_HighEstimationSkewRequiresAnalyze(t *testing.T) {
result := connection.ExplainResult{
DBType: "postgres",
SourceSQL: "SELECT * FROM t WHERE id > 0",
Nodes: []connection.ExplainNode{
{
ID: "n1",
OpType: connection.ExplainOpIndexScan,
EstRows: 100,
ActualRows: 50000, // 偏差 500 倍
},
},
}
suggestions := runExplainRules(result)
found := false
for _, s := range suggestions {
if s.Rule == "high_estimation_skew" {
found = true
if s.Severity != connection.SeverityInfo {
t.Fatalf("估算偏差应为 infogot=%s", s.Severity)
}
}
}
if !found {
t.Fatal("估算/实际偏差 > 10x 应触发建议")
}
}
func TestRunExplainRules_LowBufferHitRateGlobalRule(t *testing.T) {
result := connection.ExplainResult{
DBType: "postgres",
SourceSQL: "SELECT * FROM t",
Stats: connection.ExplainStats{
BufferHitRate: 0.2, // 20% 命中率
RowsRead: 10000,
},
}
suggestions := runExplainRules(result)
found := false
for _, s := range suggestions {
if s.Rule == "low_buffer_hit_rate" {
found = true
}
}
if !found {
t.Fatal("缓冲命中率 < 50% 应触发建议")
}
}
func TestRunExplainRules_NestedLoopHighFanout(t *testing.T) {
result := connection.ExplainResult{
DBType: "mysql",
SourceSQL: "SELECT * FROM a JOIN b ON a.id = b.aid",
Nodes: []connection.ExplainNode{
{ID: "n1", OpType: connection.ExplainOpJoin, Table: ""},
{ID: "n2", OpType: connection.ExplainOpScan, Table: "a", EstRows: 10},
{ID: "n3", OpType: connection.ExplainOpScan, Table: "b", EstRows: 50000},
},
Edges: []connection.ExplainEdge{
{From: "n1", To: "n2"},
{From: "n1", To: "n3"},
},
}
suggestions := runExplainRules(result)
found := false
for _, s := range suggestions {
if s.Rule == "nested_loop_high_fanout" {
found = true
}
}
if !found {
t.Fatal("Nested Loop 被驱动表 > 10000 行应触发 nested_loop_high_fanout")
}
}
func TestRunExplainRules_SortBySeverity(t *testing.T) {
result := connection.ExplainResult{
DBType: "mysql",
SourceSQL: "SELECT * FROM t1 JOIN t2 ON t1.id = t2.id ORDER BY t1.name",
Nodes: []connection.ExplainNode{
{
ID: "n1",
OpType: connection.ExplainOpScan,
Table: "t1",
EstRows: 50000,
Flags: []string{connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex},
},
{
ID: "n2",
OpType: connection.ExplainOpSort,
EstRows: 100,
Flags: []string{connection.ExplainFlagFilesort},
},
},
}
suggestions := runExplainRules(result)
if len(suggestions) < 2 {
t.Fatalf("应触发至少 2 条建议got=%d", len(suggestions))
}
// 第一条应是 critical全表扫描
if suggestions[0].Severity != connection.SeverityCritical {
t.Fatalf("首条建议应为 criticalgot=%srule=%s", suggestions[0].Severity, suggestions[0].Rule)
}
}
func TestRunExplainRules_EmptyResultNoSuggestions(t *testing.T) {
result := connection.ExplainResult{
DBType: "mysql",
SourceSQL: "SELECT 1",
}
suggestions := runExplainRules(result)
if len(suggestions) != 0 {
t.Fatalf("空 ExplainResult 不应产生建议got=%d", len(suggestions))
}
}
// contains 检查字符串包含(避免和 strings.Contains 冲突,这里独立实现)。
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || indexOfContains(s, substr) >= 0)
}
func indexOfContains(s, substr string) int {
for i := 0; i+len(substr) <= len(s); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}