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 个用例覆盖三方言解析与规则触发场景
This commit is contained in:
Syngnat
2026-06-19 12:45:15 +08:00
parent 85648b1e5a
commit 8e24e40fdd
9 changed files with 2100 additions and 25 deletions

View File

@@ -0,0 +1,241 @@
package app
import (
"encoding/json"
"fmt"
"strings"
"GoNavi-Wails/internal/connection"
)
// ClickHouse EXPLAIN 解析。
//
// ClickHouse 支持多种 EXPLAIN 模式:
// - EXPLAIN默认 PLAN 模式):返回 1 行 1 列,列值是缩进文本树
// - EXPLAIN JSON返回 1 行 1 列,列值是 JSON 字符串
// - EXPLAIN AST返回抽象语法树
// - EXPLAIN SYNTAX返回重写后的 SQL
// - EXPLAIN PIPELINE返回执行算子管道
//
// 本解析器只处理 JSON 模式(由 buildExplainQuery 选用)。
//
// JSON 结构PLAN 模式):
//
// {
// "Plan": {
// "Node Type": "ReadFromMergeTree",
// "Joined Plans": [],
// "ReadType": "Default",
// "Parts": 12,
// "Index Granules": 240,
// "Result Schema": {...}
// },
// "Plan": {
// "Node Type": "Aggregating",
// "Aggregation": {
// "Keys": ["user_id"],
// "Functions": ["count()"]
// }
// }
// }
//
// 注意CH EXPLAIN JSON 的顶层是 {"Plan": {...}},但通过 collectExplainRaw 收集后,
// 单行单列的 JSON 文本可能被多次封装。本解析器直接处理 JSON 字符串。
func parseClickHouseExplain(sourceSQL, raw string, format connection.ExplainFormat) (connection.ExplainResult, error) {
result := connection.ExplainResult{
DBType: "clickhouse",
SourceSQL: sourceSQL,
}
resetExplainNodeID()
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return result, fmt.Errorf("ClickHouse EXPLAIN 输出为空")
}
// ClickHouse EXPLAIN JSON 可能是对象或数组形式
var top map[string]json.RawMessage
var topArr []map[string]json.RawMessage
isArr := strings.HasPrefix(trimmed, "[")
if isArr {
if err := json.Unmarshal([]byte(trimmed), &topArr); err != nil {
return result, fmt.Errorf("ClickHouse JSON 数组解析失败:%w", err)
}
} else {
if err := json.Unmarshal([]byte(trimmed), &top); err != nil {
return result, fmt.Errorf("ClickHouse JSON 对象解析失败:%w", err)
}
}
var warnings []string
// 兼容两种形式
plans := []map[string]json.RawMessage{}
if isArr {
plans = topArr
} else {
plans = append(plans, top)
}
for _, item := range plans {
planRaw, ok := item["Plan"]
if !ok {
// CH 默认 EXPLAIN 模式可能不返回 Plan 而是直接给节点字段
planRaw, ok = jsonMarshalRaw(item)
if !ok {
continue
}
}
parseClickHousePlan(planRaw, "", &result, &warnings)
}
if len(result.Nodes) == 0 {
result.RawFormat = connection.ExplainFormatText
result.RawPayload = raw
result.Warnings = append(warnings, "未提取到 ClickHouse 计划节点,可能不是 PLAN 模式")
return result, nil
}
result.RawFormat = connection.ExplainFormatJSON
result.RawPayload = raw
result.Warnings = warnings
finalizeExplainStats(&result)
return result, nil
}
// clickHousePlanNode 映射 CH EXPLAIN JSON 的 Plan 结构。
type clickHousePlanNode struct {
NodeType string `json:"Node Type"`
Operation string `json:"Operation"`
ReadType string `json:"ReadType"`
Parts int64 `json:"Parts"`
IndexGranules int64 `json:"Index Granules"`
SelectedMarks int64 `json:"Selected Marks"`
ResultSchema map[string]any `json:"Result Schema"`
Aggregation map[string]any `json:"Aggregation"`
Join map[string]any `json:"Join"`
Expression map[string]any `json:"Expression"`
Table string `json:"Table"`
Database string `json:"Database"`
JoinedPlans []json.RawMessage `json:"Joined Plans"`
Children []json.RawMessage `json:"Children"` // 部分版本用此字段
}
// parseClickHousePlan 递归解析 CH Plan 节点。
// CH 通常用 "Joined Plans" 数组持有子节点(不同于其他 DB 的 "Plans")。
func parseClickHousePlan(planRaw json.RawMessage, parentID string, result *connection.ExplainResult, warnings *[]string) {
var node clickHousePlanNode
if err := json.Unmarshal(planRaw, &node); err != nil {
*warnings = append(*warnings, fmt.Sprintf("CH Plan 节点反序列化失败:%v", err))
return
}
en := connection.ExplainNode{
OpType: classifyClickHouseNodeType(node.NodeType),
OpDetail: node.NodeType,
}
if node.Operation != "" {
en.OpDetail = en.OpDetail + " / " + node.Operation
}
// 表/库信息
if node.Table != "" {
if node.Database != "" {
en.Table = node.Database + "." + node.Table
} else {
en.Table = node.Table
}
}
// 行数估算CH 没有"估算行数"概念,用 Parts × Index Granules 作为扫描量的粗略代理
// 这是 CH 的特点粒度granule是默认 8192 行,所以 granules × 8192 ≈ 扫描行数
if node.Parts > 0 || node.IndexGranules > 0 {
en.EstRows = node.IndexGranules * 8192
en.Extra = map[string]any{
"parts": node.Parts,
"indexGranules": node.IndexGranules,
"selectedMarks": node.SelectedMarks,
}
}
// Aggregation/Join 等元信息
if len(node.Aggregation) > 0 {
if en.Extra == nil {
en.Extra = map[string]any{}
}
en.Extra["aggregation"] = node.Aggregation
en.Flags = append(en.Flags, connection.ExplainFlagTempTable)
}
if len(node.Join) > 0 {
if en.Extra == nil {
en.Extra = map[string]any{}
}
en.Extra["join"] = node.Join
}
// CH 的 ReadFromMergeTree 在没有索引筛选时类似全表扫描
if strings.Contains(strings.ToLower(node.NodeType), "readfrommergetree") {
// ReadType=Default 表示未使用 primary key 裁剪
if strings.ToLower(node.ReadType) == "default" || node.ReadType == "" {
en.Flags = append(en.Flags, connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex)
}
}
// Sort/OrderBy
if strings.Contains(strings.ToLower(node.NodeType), "sorting") || strings.Contains(strings.ToLower(node.NodeType), "orderby") {
en.Flags = append(en.Flags, connection.ExplainFlagFilesort)
}
nodeID := appendExplainChild(result, parentID, en)
// 递归子节点CH 用 "Joined Plans",部分版本可能用 "Children"
for _, childRaw := range node.JoinedPlans {
parseClickHousePlan(childRaw, nodeID, result, warnings)
}
for _, childRaw := range node.Children {
parseClickHousePlan(childRaw, nodeID, result, warnings)
}
}
// classifyClickHouseNodeType 把 CH Node Type 归一化到通用 OpType。
// 参考https://clickhouse.com/docs/en/operations/explain
func classifyClickHouseNodeType(nodeType string) string {
nt := strings.ToLower(strings.TrimSpace(nodeType))
switch {
case strings.Contains(nt, "readfrommergetree"), strings.Contains(nt, "readfromstorage"), strings.Contains(nt, "readfrom"):
return connection.ExplainOpScan
case strings.Contains(nt, "filter"):
return connection.ExplainOpFilter
case strings.Contains(nt, "aggregating"), strings.Contains(nt, "aggregatingtransform"):
return connection.ExplainOpAggregate
case strings.Contains(nt, "sorting"), strings.Contains(nt, "orderby"):
return connection.ExplainOpSort
case strings.Contains(nt, "limit"):
return connection.ExplainOpLimit
case strings.Contains(nt, "join"):
return connection.ExplainOpJoin
case strings.Contains(nt, "union"), strings.Contains(nt, "concat"):
return connection.ExplainOpUnion
case strings.Contains(nt, "expression"), strings.Contains(nt, "computescope"):
return connection.ExplainOpOther
case strings.Contains(nt, "creatingsets"), strings.Contains(nt, "creatingsetandfilter"):
return connection.ExplainOpMaterialize
case strings.Contains(nt, "window"):
return connection.ExplainOpWindow
default:
return connection.ExplainOpOther
}
}
// jsonMarshalRaw 把已解析的 map 重新序列化为 RawMessage辅助工具
func jsonMarshalRaw(m map[string]json.RawMessage) (json.RawMessage, bool) {
if len(m) == 0 {
return nil, false
}
b, err := json.Marshal(m)
if err != nil {
return nil, false
}
return b, true
}

View File

@@ -0,0 +1,128 @@
package app
import (
"testing"
"GoNavi-Wails/internal/connection"
)
// ClickHouse EXPLAIN JSON fixtureReadFromMergeTree + Aggregating。
const clickHouseExplainJSONScanAndAggregate = `{
"Plan": {
"Node Type": "Aggregating",
"Aggregation": {
"Keys": ["user_id"],
"Functions": ["count()"]
},
"Joined Plans": [
{
"Node Type": "ReadFromMergeTree",
"ReadType": "Default",
"Parts": 12,
"Index Granules": 240,
"Table": "events",
"Database": "default"
}
]
}
}`
func TestParseClickHouseExplain_ScanAndAggregate(t *testing.T) {
result, err := parseClickHouseExplain("SELECT user_id, count() FROM events GROUP BY user_id", clickHouseExplainJSONScanAndAggregate, connection.ExplainFormatJSON)
if err != nil {
t.Fatalf("解析失败:%v", err)
}
if len(result.Nodes) != 2 {
t.Fatalf("应有 2 个节点Aggregating + ReadFromMergeTreegot=%d", len(result.Nodes))
}
aggNode := result.Nodes[0]
if aggNode.OpType != connection.ExplainOpAggregate {
t.Fatalf("Aggregating 应为 AGGREGATEgot=%s", aggNode.OpType)
}
if !containsFlag(aggNode.Flags, connection.ExplainFlagTempTable) {
t.Fatalf("Aggregating 应有 TEMP_TABLE flag")
}
scanNode := result.Nodes[1]
if scanNode.OpType != connection.ExplainOpScan {
t.Fatalf("ReadFromMergeTree 应为 SCANgot=%s", scanNode.OpType)
}
if scanNode.Table != "default.events" {
t.Fatalf("Table got=%s want=default.events", scanNode.Table)
}
// EstRows = Index Granules × 8192 = 240 × 8192 = 1966080
if scanNode.EstRows != 240*8192 {
t.Fatalf("EstRows got=%d want=%d", scanNode.EstRows, 240*8192)
}
if !containsFlag(scanNode.Flags, connection.ExplainFlagFullScan) {
t.Fatalf("ReadType=Default 的 MergeTree 应有 FULL_SCAN flag")
}
if !containsFlag(scanNode.Flags, connection.ExplainFlagNoIndex) {
t.Fatalf("ReadType=Default 的 MergeTree 应有 NO_INDEX flag")
}
// EdgesAggregating -> ReadFromMergeTree
if len(result.Edges) != 1 || result.Edges[0].From != aggNode.ID || result.Edges[0].To != scanNode.ID {
t.Fatalf("应有 1 条边连接 AGGREGATE -> SCAN")
}
}
func TestParseClickHouseExplain_IndexedReadNoFullScanFlag(t *testing.T) {
raw := `{
"Plan": {
"Node Type": "ReadFromMergeTree",
"ReadType": "InReverseOrder",
"Parts": 5,
"Index Granules": 30,
"Table": "t",
"Database": "default"
}
}`
result, err := parseClickHouseExplain("SELECT * FROM default.t ORDER BY id DESC", raw, 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]
// ReadType 不是 Default不应触发 FULL_SCAN
if containsFlag(node.Flags, connection.ExplainFlagFullScan) {
t.Fatalf("ReadType=InReverseOrder 不应是 FULL_SCAN")
}
}
func TestParseClickHouseExplain_ArrayForm(t *testing.T) {
raw := `[
{
"Plan": {
"Node Type": "Limit",
"Joined Plans": [
{"Node Type": "ReadFromMergeTree", "ReadType": "Default", "Parts": 1, "Index Granules": 10, "Table": "t"}
]
}
}
]`
result, err := parseClickHouseExplain("SELECT * FROM t LIMIT 10", raw, connection.ExplainFormatJSON)
if err != nil {
t.Fatalf("数组形式解析失败:%v", err)
}
if len(result.Nodes) != 2 {
t.Fatalf("应有 2 个节点Limit + ReadFromMergeTreegot=%d", len(result.Nodes))
}
if result.Nodes[0].OpType != connection.ExplainOpLimit {
t.Fatalf("Limit 节点应为 LIMITgot=%s", result.Nodes[0].OpType)
}
}
func TestParseClickHouseExplain_InvalidJSONReturnsError(t *testing.T) {
_, err := parseClickHouseExplain("SELECT 1", "{ not valid json", connection.ExplainFormatJSON)
if err == nil {
t.Fatal("非法 JSON 应返回 error")
}
}
func TestParseClickHouseExplain_EmptyReturnsError(t *testing.T) {
_, err := parseClickHouseExplain("SELECT 1", " ", connection.ExplainFormatJSON)
if err == nil {
t.Fatal("空输入应返回 error")
}
}

View File

@@ -0,0 +1,457 @@
package app
import (
"fmt"
"regexp"
"strconv"
"strings"
"GoNavi-Wails/internal/connection"
)
// Oracle DBMS_XPLAN.DISPLAY 表格解析。
//
// 典型输出FORMAT=ALL
//
// Plan hash value: 1234567890
//
// --------------------------------------------------------------------------------------------------
// | Id | Operation | Name | Rows | Bytes | Cost (%CPU)| Time | Predicate Information |
// --------------------------------------------------------------------------------------------------
// | 0 | SELECT STATEMENT | | 10000 | 200K| 50 (4)| 00:00:01 | |
// |* 1 | TABLE ACCESS FULL| USERS | 10000 | 200K| 50 (4)| 00:00:01 | filter ("AGE">18) |
// --------------------------------------------------------------------------------------------------
//
// Query Block Name / Object Alias (identified by operation id):
// -------------------------------------------------------------
// 1 - SEL$1 / USERS@SEL$1
//
// Column Projection Information (identified by operation id):
// -----------------------------------------------------------
// 1 - "ID"[NUMBER,22], "NAME"[VARCHAR2,100]
//
// 解析要点:
// - Id 列含 "*"(带 Predicate或空格无 Predicate+ 数字 + 可能空格缩进
// - Operation 列含前导空格(表达层级深度,每 2 空格代表一层)
// - Name 列通常是表名或索引名
// - Rows 是估算行数Bytes 也会给但本解析器暂不消费)
// - Cost (%CPU) 含百分比50 (4) 表示 cost=50 CPU 占比 4%
// - Predicate Information下方独立段落按 Operation Id 列出 Predicate 文本
// - 多个段落用空行分隔,关键段落:"Plan hash value"、"Query Block Name"、"Predicate Information"、"Column Projection"
func parseOracleExplain(sourceSQL, raw string, format connection.ExplainFormat) (connection.ExplainResult, error) {
result := connection.ExplainResult{
DBType: "oracle",
SourceSQL: sourceSQL,
}
resetExplainNodeID()
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return result, fmt.Errorf("Oracle DBMS_XPLAN 输出为空")
}
// 抽取 Predicate Information 段落(按 id 索引)
predicates := extractOraclePredicates(trimmed)
// 抽取主表格
tableSection := extractOraclePlanTable(trimmed)
if tableSection == "" {
result.RawFormat = connection.ExplainFormatText
result.RawPayload = raw
result.Warnings = []string{"未识别到 DBMS_XPLAN 表格段落,可能版本不兼容"}
return result, nil
}
// 解析表格行(管道符分隔的列)
rows := extractOracleTableRows(tableSection)
if len(rows) == 0 {
return result, fmt.Errorf("DBMS_XPLAN 表格无有效行")
}
// 解析列头识别列索引
headerCols := splitOracleTableRow(rows[0])
colID := findOracleColumnIndex(headerCols, "Id")
colOp := findOracleColumnIndex(headerCols, "Operation")
colName := findOracleColumnIndex(headerCols, "Name")
colRows := findOracleColumnIndex(headerCols, "Rows")
colCost := findOracleColumnIndex(headerCols, "Cost")
colTime := findOracleColumnIndex(headerCols, "Time")
colPredicate := findOracleColumnIndex(headerCols, "Predicate")
if colID < 0 || colOp < 0 {
return result, fmt.Errorf("DBMS_XPLAN 表格缺少 Id 或 Operation 列")
}
// 按 Operation 的缩进推断父子(每 2 个前导空格代表一层)
type pendingNode struct {
node connection.ExplainNode
indent int
}
var stack []pendingNode // 每层保留最近一个节点
for i := 1; i < len(rows); i++ {
cols := splitOracleTableRow(rows[i])
if len(cols) == 0 {
continue
}
idRaw := strings.TrimSpace(safeOracleColumn(cols, colID))
idNum := parseOracleIDNumber(idRaw)
if idNum < 0 {
continue
}
opText := strings.TrimSpace(safeOracleColumn(cols, colOp))
indent := countLeadingSpaces(safeOracleColumn(cols, colOp))
name := strings.TrimSpace(safeOracleColumn(cols, colName))
rowsEst := parseExplainInt64(strings.TrimSpace(safeOracleColumn(cols, colRows)))
cost, _ := parseOracleCost(safeOracleColumn(cols, colCost))
timeMs := parseOracleTimeMs(safeOracleColumn(cols, colTime))
node := connection.ExplainNode{
OpType: classifyOracleOperation(opText),
OpDetail: opText,
Table: name,
EstRows: rowsEst,
Cost: cost,
DurationMs: timeMs,
}
// TABLE ACCESS FULL 是全表扫描
if isOracleFullScan(opText) {
node.Flags = append(node.Flags, connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex)
} else if isOracleIndexAccess(opText) {
node.Index = name
}
// 关联 Predicate Information先从表格内 Predicate 列取(简短摘要)
if colPredicate >= 0 && colPredicate < len(cols) {
predCell := strings.TrimSpace(safeOracleColumn(cols, colPredicate))
if predCell != "" {
if node.Extra == nil {
node.Extra = map[string]any{}
}
node.Extra["filter"] = predCell
if strings.Contains(strings.ToLower(predCell), "filter") {
node.Flags = append(node.Flags, connection.ExplainFlagFullScan)
}
}
}
// 独立 Predicate Information 段落更详细,覆盖表格列的简短摘要
if pred, ok := predicates[idNum]; ok && pred != "" {
if node.Extra == nil {
node.Extra = map[string]any{}
}
node.Extra["filter"] = pred
if strings.Contains(strings.ToLower(pred), "filter") {
node.Flags = append(node.Flags, connection.ExplainFlagFullScan)
}
}
// 推断父子:弹出栈中 indent >= 当前的节点
for len(stack) > 0 && stack[len(stack)-1].indent >= indent {
stack = stack[:len(stack)-1]
}
parentNodeID := ""
if len(stack) > 0 {
parentNodeID = stack[len(stack)-1].node.ID
}
nodeID := appendExplainChild(&result, parentNodeID, node)
stack = append(stack, pendingNode{node: connection.ExplainNode{ID: nodeID}, indent: indent})
}
result.RawFormat = connection.ExplainFormatTable
result.RawPayload = raw
finalizeExplainStats(&result)
return result, nil
}
// extractOraclePlanTable 提取主表格段落。
// DBMS_XPLAN 表格结构:
//
// [空行]
// Plan hash value: ...
// [空行]
// --------- ← 上边界分隔线
// | header |
// --------- ← 表头/数据分隔(可选)
// | data |
// --------- ← 下边界分隔线
// [空行] ← 表格段结束(之后的 Query Block Name / Predicate Information 等段落不再计入)
//
// 实现策略:找到第一条分隔线后开始累积;跳过所有分隔线、保留表头+数据;遇到空行结束。
func extractOraclePlanTable(raw string) string {
lines := strings.Split(raw, "\n")
startIdx := -1
for i, line := range lines {
if isOracleTableSeparator(line) {
startIdx = i
break
}
}
if startIdx < 0 {
return ""
}
var builder strings.Builder
for i := startIdx; i < len(lines); i++ {
line := lines[i]
if strings.TrimSpace(line) == "" {
break // 空行 = 表格段结束
}
if isOracleTableSeparator(line) {
continue // 跳过表格内的所有分隔线(上边界/表头分隔/下边界)
}
builder.WriteString(line)
builder.WriteByte('\n')
}
return builder.String()
}
// isOracleTableSeparator 判断是否是 DBMS_XPLAN 表格的分隔线(全是 -)。
func isOracleTableSeparator(line string) bool {
trimmed := strings.TrimSpace(line)
if len(trimmed) < 10 {
return false
}
for _, ch := range trimmed {
if ch != '-' {
return false
}
}
return true
}
// extractOracleTableRows 提取表格的每行内容(去掉首尾管道符,保留中间内容)。
// 返回不含分隔线的纯数据行。
func extractOracleTableRows(table string) []string {
lines := strings.Split(strings.TrimSpace(table), "\n")
var rows []string
for _, line := range lines {
trimmed := strings.TrimRight(line, "\r\n ")
if strings.TrimSpace(trimmed) == "" {
continue
}
if isOracleTableSeparator(trimmed) {
continue
}
rows = append(rows, trimmed)
}
return rows
}
// splitOracleTableRow 按管道符 | 切分行。
// 处理:去首尾管道符 → 按 | 切分 → 不 trim保留前导空格用于缩进判断
func splitOracleTableRow(line string) []string {
// 去掉首尾的 | 和前后空白
text := strings.TrimSpace(line)
text = strings.TrimPrefix(text, "|")
text = strings.TrimSuffix(text, "|")
if text == "" {
return nil
}
parts := strings.Split(text, "|")
// 不 trim保留前导空格用于 Operation 缩进分析)
return parts
}
// findOracleColumnIndex 在表头中按列名查找索引。
func findOracleColumnIndex(headerCols []string, name string) int {
target := strings.ToLower(strings.TrimSpace(name))
for i, col := range headerCols {
if strings.ToLower(strings.TrimSpace(col)) == target {
return i
}
}
// 模糊匹配("Cost (%CPU)" 可能被切成 "Cost " 和 " (%CPU)"
for i, col := range headerCols {
if strings.Contains(strings.ToLower(strings.TrimSpace(col)), target) {
return i
}
}
return -1
}
// safeOracleColumn 安全取 cols[idx]idx 越界返回空)。
func safeOracleColumn(cols []string, idx int) string {
if idx < 0 || idx >= len(cols) {
return ""
}
return cols[idx]
}
// parseOracleIDNumber 解析 "Id" 列:形如 " 0"、"* 1"、" 2"。
// 返回数字部分;前缀 "*" 表示带 Predicate返回正值无数字返回 -1。
func parseOracleIDNumber(s string) int {
trimmed := strings.TrimSpace(s)
if trimmed == "" {
return -1
}
// 去掉可能的 Predicate 标记
trimmed = strings.TrimLeft(trimmed, "* ")
n, err := strconv.Atoi(trimmed)
if err != nil {
return -1
}
return n
}
// parseOracleCost 解析 "Cost (%CPU)" 列,形如 "50 (4)"。
// 返回 cost 数值 + CPU 百分比(百分比暂未使用)。
func parseOracleCost(s string) (float64, int) {
trimmed := strings.TrimSpace(s)
if trimmed == "" {
return 0, 0
}
// 取第一个空白前的数字
for i, ch := range trimmed {
if ch == ' ' || ch == '\t' {
n, _ := strconv.ParseFloat(trimmed[:i], 64)
return n, 0
}
}
n, _ := strconv.ParseFloat(trimmed, 64)
return n, 0
}
// parseOracleTimeMs 解析 "Time" 列,形如 "00:00:01"。
// 转换为毫秒(粗略,仅用于 stats
func parseOracleTimeMs(s string) float64 {
trimmed := strings.TrimSpace(s)
if trimmed == "" {
return 0
}
parts := strings.Split(trimmed, ":")
if len(parts) != 3 {
return 0
}
h, _ := strconv.Atoi(strings.TrimSpace(parts[0]))
m, _ := strconv.Atoi(strings.TrimSpace(parts[1]))
sec, _ := strconv.Atoi(strings.TrimSpace(parts[2]))
return float64(h*3600+m*60+sec) * 1000
}
// countLeadingSpaces 数字符串的前导空格数(用于推断 Oracle Operation 缩进层级)。
func countLeadingSpaces(s string) int {
n := 0
for _, ch := range s {
if ch == ' ' {
n++
continue
}
break
}
return n
}
// classifyOracleOperation 把 Oracle Operation 文本归一化。
// 形如 "TABLE ACCESS FULL" → SCAN"INDEX RANGE SCAN" → INDEX_SCAN"HASH JOIN" → JOIN。
func classifyOracleOperation(op string) string {
upper := strings.ToUpper(strings.TrimSpace(op))
switch {
case strings.Contains(upper, "TABLE ACCESS") && strings.Contains(upper, "FULL"):
return connection.ExplainOpScan
case strings.Contains(upper, "INDEX") && (strings.Contains(upper, "RANGE SCAN") || strings.Contains(upper, "UNIQUE SCAN") || strings.Contains(upper, "SKIP SCAN")):
return connection.ExplainOpIndexScan
case strings.Contains(upper, "INDEX") && strings.Contains(upper, "FAST FULL"):
return connection.ExplainOpIndexOnly
case strings.Contains(upper, "HASH JOIN"):
return connection.ExplainOpJoin
case strings.Contains(upper, "NESTED LOOPS"):
return connection.ExplainOpJoin
case strings.Contains(upper, "MERGE JOIN"):
return connection.ExplainOpJoin
case strings.Contains(upper, "SORT") && strings.Contains(upper, "ORDER BY"):
return connection.ExplainOpSort
case strings.Contains(upper, "SORT") && strings.Contains(upper, "GROUP BY"):
return connection.ExplainOpAggregate
case strings.Contains(upper, "HASH GROUP BY") || strings.Contains(upper, "AGGREGATE"):
return connection.ExplainOpAggregate
case strings.Contains(upper, "COUNT"):
return connection.ExplainOpAggregate
case strings.Contains(upper, "VIEW"):
return connection.ExplainOpOther
case strings.Contains(upper, "UNION"):
return connection.ExplainOpUnion
case strings.Contains(upper, "FILTER"):
return connection.ExplainOpFilter
case strings.Contains(upper, "SELECT STATEMENT"):
return connection.ExplainOpOther
default:
return connection.ExplainOpOther
}
}
// isOracleFullScan 判断是否是全表扫描。
func isOracleFullScan(op string) bool {
return strings.Contains(strings.ToUpper(op), "TABLE ACCESS") && strings.Contains(strings.ToUpper(op), "FULL")
}
// isOracleIndexAccess 判断是否是索引访问(用于决定 Name 字段是索引名)。
func isOracleIndexAccess(op string) bool {
upper := strings.ToUpper(op)
if !strings.Contains(upper, "INDEX") {
return false
}
return strings.Contains(upper, "SCAN") || strings.Contains(upper, "RANGE") || strings.Contains(upper, "UNIQUE")
}
// extractOraclePredicates 从原文中提取 "Predicate Information" 段落,按 id 索引。
// 返回 map[int]string键是 Operation Id值是对应的 Predicate 文本(多行合并)。
func extractOraclePredicates(raw string) map[int]string {
result := map[int]string{}
lines := strings.Split(raw, "\n")
inSection := false
currentID := -1
var buffer strings.Builder
idPattern := regexp.MustCompile(`^\s*\*?(\d+)\s*-?\s*(.*)$`)
flush := func() {
if currentID >= 0 {
text := strings.TrimSpace(buffer.String())
if text != "" {
result[currentID] = text
}
}
currentID = -1
buffer.Reset()
}
for _, line := range lines {
trimmed := strings.TrimSpace(line)
lower := strings.ToLower(trimmed)
if strings.HasPrefix(lower, "predicate information") {
inSection = true
continue
}
if !inSection {
continue
}
// 进入段落后的空行或下一个段落标题 → 结束
if trimmed == "" || isOracleNextSectionHeader(lower) {
flush()
break
}
// 匹配 " 1 - access("ID"=1)" 或 " 1 - filter(...)"
match := idPattern.FindStringSubmatch(line)
if match != nil {
flush()
id, _ := strconv.Atoi(match[1])
currentID = id
buffer.WriteString(strings.TrimSpace(match[2]))
continue
}
// 多行 Predicate 续行
if currentID >= 0 {
buffer.WriteByte(' ')
buffer.WriteString(trimmed)
}
}
flush()
return result
}
// isOracleNextSectionHeader 判断是否是 DBMS_XPLAN 的下一个段落标题(结束 Predicate 段)。
func isOracleNextSectionHeader(lower string) bool {
return strings.HasPrefix(lower, "query block name") ||
strings.HasPrefix(lower, "column projection") ||
strings.HasPrefix(lower, "note") ||
strings.HasPrefix(lower, "hint")
}

View File

@@ -0,0 +1,126 @@
package app
import (
"testing"
"GoNavi-Wails/internal/connection"
)
// Oracle DBMS_XPLAN.DISPLAY fixture含主表 + Predicate + 多段落。
const oracleXPlanOutput = `Plan hash value: 1234567890
-----------------------------------------------------------------------------------------------------------
| Id | Operation | Name | Rows | Bytes | Cost (%CPU)| Time | Predicate Information |
-----------------------------------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 10000 | 200K| 50 (4)| 00:00:01 | |
|* 1 | TABLE ACCESS FULL| USERS | 10000 | 200K| 50 (4)| 00:00:01 | filter ("AGE">18) |
-----------------------------------------------------------------------------------------------------------
Query Block Name / Object Alias (identified by operation id):
-------------------------------------------------------------
1 - SEL$1 / USERS@SEL$1
Column Projection Information (identified by operation id):
-----------------------------------------------------------
1 - "ID"[NUMBER,22], "NAME"[VARCHAR2,100]
`
func TestParseOracleExplain_TableAccessFullWithPredicate(t *testing.T) {
result, err := parseOracleExplain("SELECT * FROM users WHERE age > 18", oracleXPlanOutput, connection.ExplainFormatTable)
if err != nil {
t.Fatalf("解析失败:%v", err)
}
if len(result.Nodes) != 2 {
t.Fatalf("应有 2 个节点SELECT STATEMENT + TABLE ACCESSgot=%d", len(result.Nodes))
}
// 节点 0 是 SELECT STATEMENT节点 1 是 TABLE ACCESS FULL带缩进挂在 0 下)
scan := result.Nodes[1]
if scan.OpType != connection.ExplainOpScan {
t.Fatalf("TABLE ACCESS FULL 应为 SCANgot=%s", scan.OpType)
}
if scan.Table != "USERS" {
t.Fatalf("table got=%s want=USERS", scan.Table)
}
if scan.EstRows != 10000 {
t.Fatalf("EstRows got=%d want=10000", scan.EstRows)
}
if scan.Cost != 50 {
t.Fatalf("Cost got=%v want=50", scan.Cost)
}
if !containsFlag(scan.Flags, connection.ExplainFlagFullScan) {
t.Fatalf("TABLE ACCESS FULL 应有 FULL_SCAN flag")
}
if scan.Extra["filter"] != `filter ("AGE">18)` {
t.Fatalf("Predicate 应附加到 Extra.filtergot=%v", scan.Extra["filter"])
}
// SELECT STATEMENT 是父节点
if len(result.Edges) != 1 || result.Edges[0].To != scan.ID {
t.Fatalf("应有 1 条边指向 TABLE ACCESS 节点")
}
}
const oracleXPlanHashJoin = `Plan hash value: 9876543210
-------------------------------------------------------------------------
| Id | Operation | Name | Rows | Cost | Predicate Info |
-------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1000 | 200 | |
| 1 | HASH JOIN | | 1000 | 200 | |
| 2 | TABLE ACCESS FULL| USERS | 100 | 10 | |
| 3 | INDEX RANGE SCAN| ORD_IX | 50000 | 20 |access("UID" = 1) |
-------------------------------------------------------------------------
`
func TestParseOracleExplain_HashJoinWithNestedChildren(t *testing.T) {
result, err := parseOracleExplain("SELECT * FROM users u JOIN orders o ON u.id = o.user_id", oracleXPlanHashJoin, connection.ExplainFormatTable)
if err != nil {
t.Fatalf("解析失败:%v", err)
}
if len(result.Nodes) != 4 {
t.Fatalf("应有 4 个节点SELECT + HASH JOIN + 2 子节点got=%d", len(result.Nodes))
}
// HASH JOIN 是 SELECT STATEMENT 的子
// TABLE ACCESS FULL 和 INDEX RANGE SCAN 是 HASH JOIN 的子(缩进更深)
hashJoin := result.Nodes[1]
if hashJoin.OpType != connection.ExplainOpJoin {
t.Fatalf("HASH JOIN 应为 JOINgot=%s", hashJoin.OpType)
}
// 找到 INDEX RANGE SCAN 节点
var indexNode *connection.ExplainNode
for i := range result.Nodes {
if result.Nodes[i].OpType == connection.ExplainOpIndexScan {
indexNode = &result.Nodes[i]
break
}
}
if indexNode == nil {
t.Fatal("应有一个 INDEX RANGE SCAN 节点")
}
if indexNode.Index != "ORD_IX" {
t.Fatalf("Index got=%s want=ORD_IX", indexNode.Index)
}
// Predicate 关联id=3独立 Predicate 段落覆盖了表格列的简短摘要)
if indexNode.Extra["filter"] != `access("UID" = 1)` {
t.Fatalf("Predicate 应附加到 INDEX RANGE SCAN 节点got=%v", indexNode.Extra["filter"])
}
}
func TestParseOracleExplain_EmptyReturnsError(t *testing.T) {
_, err := parseOracleExplain("SELECT 1", " ", connection.ExplainFormatTable)
if err == nil {
t.Fatal("空输入应返回 error")
}
}
func TestParseOracleExplain_NoTableReturnsWarning(t *testing.T) {
result, err := parseOracleExplain("SELECT 1", "Plan hash value: 1\nsome random text", connection.ExplainFormatTable)
if err != nil {
t.Fatalf("无表格的输入应降级返回 warning 而非 error%v", err)
}
if result.RawFormat != connection.ExplainFormatText {
t.Fatalf("RawFormat got=%v want=text", result.RawFormat)
}
if len(result.Warnings) == 0 {
t.Fatal("应有降级 warning")
}
}

View File

@@ -0,0 +1,306 @@
package app
import (
"encoding/xml"
"fmt"
"strings"
"GoNavi-Wails/internal/connection"
)
// SQL Server SHOWPLAN_XML 解析。
//
// 典型 XML 结构(简化):
//
// <ShowPlanXML Version="1.5" ...>
// <BatchSequence>
// <Batch>
// <Statements>
// <StmtSimple StatementText="SELECT * FROM users" ...>
// <QueryPlan ...>
// <RelOp NodeId="0" PhysicalOp="Clustered Index Scan" LogicalOp="Clustered Scan"
// EstimateRows="1000" EstimateIO="0.1" EstimateCPU="0.001" EstimatedTotalSubtreeCost="0.2"
// Parallel="0" EstimateRebinds="0">
// <Object Database="[db]" Schema="[dbo]" Table="[users]" Index="[PK_users]" />
// <RunTimeInformation>
// <RunTimeCountersPerThread ... ActualRows="1000" ActualElapsedms="5" ActualScans="1" />
// </RunTimeInformation>
// <IndexScan Ordered="0" ...>
// <Object .../>
// <Predicate>
// <ScalarOperator ScalarString="[age]>(18)">
// ...
// </ScalarOperator>
// </Predicate>
// </IndexScan>
// </RelOp>
// </QueryPlan>
// </StmtSimple>
// </Statements>
// </Batch>
// </BatchSequence>
// </ShowPlanXML>
//
// 解析要点:
// - RelOp 是核心节点(递归嵌套),每个含 PhysicalOp + LogicalOp + EstimateRows + EstimatedTotalSubtreeCost
// - 嵌套在 RelOp 内的同级 RelOp在 IndexScan/NestedLoops/Hash 等子元素下)是子节点
// - PhysicalOp 直接对应执行算子Clustered Index Scan / Index Seek / Hash Match / Sort / ...
// - Object 子元素含 Table/Index 信息
// - Predicate 的 ScalarOperator ScalarString 含过滤条件(供规则引擎提取列名)
// - RunTimeCountersPerThread 含 ActualRows对应 ANALYZE 信息)
// sqlServerShowPlanXML 是 SHOWPLAN_XML 顶层文档的 Go 结构(部分字段,未识别的留 raw
type sqlServerShowPlanXML struct {
XMLName xml.Name `xml:"ShowPlanXML"`
Batches []sqlServerXMLBatch `xml:"BatchSequence>Batch"`
}
type sqlServerXMLBatch struct {
Statements []sqlServerXMLStmtSimple `xml:"Statements>StmtSimple"`
}
type sqlServerXMLStmtSimple struct {
StatementText string `xml:"StatementText,attr"`
QueryPlan *sqlServerXMLPlan `xml:"QueryPlan"`
}
type sqlServerXMLPlan struct {
RelOps []sqlServerXMLRelOp `xml:"RelOp"`
}
// sqlServerXMLRelOp 是 SHOWPLAN_XML 的核心节点;嵌套子 RelOp 通过多种容器元素持有。
// 为简化解析:先把所有层级的 RelOp 平铺出来(按 NodeId 排序),再按 NodeId 父子推断。
type sqlServerXMLRelOp struct {
NodeID int `xml:"NodeId,attr"`
PhysicalOp string `xml:"PhysicalOp,attr"`
LogicalOp string `xml:"LogicalOp,attr"`
EstimateRows float64 `xml:"EstimateRows,attr"`
EstimateIO float64 `xml:"EstimateIO,attr"`
EstimateCPU float64 `xml:"EstimateCPU,attr"`
EstimatedTotalSubtreeCost float64 `xml:"EstimatedTotalSubtreeCost,attr"`
EstimateRebinds float64 `xml:"EstimateRebinds,attr"`
Parallel int `xml:"Parallel,attr"`
// 子节点容器(不同 PhysicalOp 对应不同容器名,这里全收)
Objects []sqlServerXMLObject `xml:"Object"`
IndexScan *sqlServerXMLContainer `xml:"IndexScan"`
NestedLoops *sqlServerXMLContainer `xml:"NestedLoops"`
Hash *sqlServerXMLContainer `xml:"Hash"`
Merge *sqlServerXMLContainer `xml:"Merge"`
Concat *sqlServerXMLContainer `xml:"Concat"`
Sort *sqlServerXMLContainer `xml:"Sort"`
Filter *sqlServerXMLContainer `xml:"Filter"`
ComputeScalar *sqlServerXMLContainer `xml:"ComputeScalar"`
Top *sqlServerXMLContainer `xml:"Top"`
GenericRelOps []sqlServerXMLContainer `xml:",any"` // 兜底:未识别容器
Predicate *sqlServerXMLPredicate `xml:"Predicate"`
RunTimeInfo *sqlServerXMLRunTimeInfo `xml:"RunTimeInformation"`
}
type sqlServerXMLObject struct {
Database string `xml:"Database,attr"`
Schema string `xml:"Schema,attr"`
Table string `xml:"Table,attr"`
Index string `xml:"Index,attr"`
}
type sqlServerXMLContainer struct {
Objects []sqlServerXMLObject `xml:"Object"`
RelOps []sqlServerXMLRelOp `xml:"RelOp"`
Predicate *sqlServerXMLPredicate `xml:"Predicate"`
}
type sqlServerXMLPredicate struct {
ScalarString string `xml:"ScalarOperator>ScalarString"`
}
type sqlServerXMLRunTimeInfo struct {
RunTimeCounters []sqlServerXMLRunTimeCounter `xml:"RunTimeCountersPerThread"`
}
type sqlServerXMLRunTimeCounter struct {
ActualRows int64 `xml:"ActualRows,attr"`
ActualElapsedMs float64 `xml:"ActualElapsedms,attr"`
ActualScans int64 `xml:"ActualScans,attr"`
}
func parseSQLServerExplain(sourceSQL, raw string, format connection.ExplainFormat) (connection.ExplainResult, error) {
result := connection.ExplainResult{
DBType: "sqlserver",
SourceSQL: sourceSQL,
}
resetExplainNodeID()
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return result, fmt.Errorf("SHOWPLAN_XML 输出为空")
}
// SQL Server 输出的 XML 含 `<?xml encoding="utf-16"?>` 声明Go encoding/xml 不支持 utf-16
// 直接报 "encoding declared but not supported"。从 <ShowPlanXML> 标签开始截取即可规避。
showPlanStart := strings.Index(trimmed, "<ShowPlanXML")
if showPlanStart < 0 {
showPlanStart = 0
}
body := trimmed[showPlanStart:]
// SHOWPLAN_XML 携带默认命名空间 xmlns="http://schemas.microsoft.com/sqlserver/2004/07/showplan"
// Go encoding/xml 严格按 namespace 匹配,导致子元素无法识别。
// 该 namespace URL 是固定的(微软规范),直接 strip 掉即可让 struct 按 local name 匹配。
cleaned := stripSQLServerDefaultNamespace(body)
var doc sqlServerShowPlanXML
if err := xml.Unmarshal([]byte(cleaned), &doc); err != nil {
result.RawFormat = connection.ExplainFormatText
result.RawPayload = raw
result.Warnings = []string{fmt.Sprintf("SHOWPLAN_XML 解析失败:%v", err)}
return result, nil
}
// 平铺 RelOp + 记录 NodeId 到内部 ID 的映射
nodeByOpID := map[int]string{}
for _, batch := range doc.Batches {
for _, stmt := range batch.Statements {
if stmt.QueryPlan == nil {
continue
}
for _, relOp := range stmt.QueryPlan.RelOps {
parseSQLServerRelOp(&relOp, "", &result, nodeByOpID)
}
}
}
if len(result.Nodes) == 0 {
result.Warnings = append(result.Warnings, "SHOWPLANXML 解析未提取到任何 RelOp 节点")
}
result.RawFormat = connection.ExplainFormatXML
result.RawPayload = raw
finalizeExplainStats(&result)
return result, nil
}
// stripSQLServerDefaultNamespace 去掉 SHOWPLAN_XML 的默认命名空间属性。
// 该 namespace URL 是 SQL Server 固定规范值,从 SQL Server 2005 起未变化。
func stripSQLServerDefaultNamespace(xmlText string) string {
const ns = ` xmlns="http://schemas.microsoft.com/sqlserver/2004/07/showplan"`
return strings.ReplaceAll(xmlText, ns, "")
}
// parseSQLServerRelOp 递归解析 RelOp 节点及其所有子 RelOp在多种容器元素中
func parseSQLServerRelOp(rel *sqlServerXMLRelOp, parentID string, result *connection.ExplainResult, nodeByOpID map[int]string) {
if rel == nil {
return
}
node := connection.ExplainNode{
OpType: classifySQLServerPhysicalOp(rel.PhysicalOp, rel.LogicalOp),
OpDetail: rel.PhysicalOp,
EstRows: int64(rel.EstimateRows),
Cost: rel.EstimatedTotalSubtreeCost,
}
// 取第一个 Object 作为表/索引来源
if len(rel.Objects) > 0 {
obj := rel.Objects[0]
node.Table = stripSQLServerBrackets(obj.Table)
node.Index = stripSQLServerBrackets(obj.Index)
}
// Predicate
if rel.Predicate != nil && rel.Predicate.ScalarString != "" {
if node.Extra == nil {
node.Extra = map[string]any{}
}
node.Extra["filter"] = rel.Predicate.ScalarString
}
// ActualRows来自 RunTimeCountersPerThread 累加)
if rel.RunTimeInfo != nil {
var actualRows int64
var elapsedMs float64
for _, c := range rel.RunTimeInfo.RunTimeCounters {
actualRows += c.ActualRows
elapsedMs += c.ActualElapsedMs
}
node.ActualRows = actualRows
node.DurationMs = elapsedMs
}
// 物理算子归类
switch node.OpType {
case connection.ExplainOpScan:
node.Flags = append(node.Flags, connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex)
case connection.ExplainOpSort:
node.Flags = append(node.Flags, connection.ExplainFlagFilesort)
case connection.ExplainOpAggregate, connection.ExplainOpMaterialize:
node.Flags = append(node.Flags, connection.ExplainFlagTempTable)
}
// 关联 LogicalOp 优化提示
if strings.Contains(strings.ToLower(rel.LogicalOp), "aggregate") {
node.Flags = append(node.Flags, connection.ExplainFlagTempTable)
}
nodeID := appendExplainChild(result, parentID, node)
nodeByOpID[rel.NodeID] = nodeID
// 递归所有可能的容器
containers := []*sqlServerXMLContainer{
rel.IndexScan, rel.NestedLoops, rel.Hash, rel.Merge,
rel.Concat, rel.Sort, rel.Filter, rel.ComputeScalar, rel.Top,
}
for _, c := range containers {
if c == nil {
continue
}
for i := range c.RelOps {
parseSQLServerRelOp(&c.RelOps[i], nodeID, result, nodeByOpID)
}
}
// GenericRelOps 是 ,any 兜底容器,按需递归
for i := range rel.GenericRelOps {
for j := range rel.GenericRelOps[i].RelOps {
parseSQLServerRelOp(&rel.GenericRelOps[i].RelOps[j], nodeID, result, nodeByOpID)
}
}
}
// classifySQLServerPhysicalOp 把 SQLServer PhysicalOp/LogicalOp 归一化到通用 OpType。
// 参考官方文档Clustered Index Scan / Index Seek / RID Lookup / Key Lookup / Hash Match / Nested Loops /
// Merge Join / Sort / Stream Aggregate / Filter / Compute Scalar / Top / Spool / Table-valued function。
func classifySQLServerPhysicalOp(physical, logical string) string {
p := strings.ToLower(strings.TrimSpace(physical))
l := strings.ToLower(strings.TrimSpace(logical))
switch {
case strings.Contains(p, "index scan"), strings.Contains(p, "clustered index scan"), strings.Contains(p, "table scan"):
return connection.ExplainOpScan
case strings.Contains(p, "index seek"), strings.Contains(p, "clustered index seek"):
return connection.ExplainOpIndexScan
case strings.Contains(p, "key lookup"), strings.Contains(p, "rid lookup"):
return connection.ExplainOpIndexScan
case strings.Contains(p, "hash match"), strings.Contains(p, "nested loops"), strings.Contains(p, "merge join"):
return connection.ExplainOpJoin
case strings.Contains(p, "sort"):
return connection.ExplainOpSort
case strings.Contains(l, "aggregate"), strings.Contains(p, "stream aggregate"), strings.Contains(p, "hash match") && strings.Contains(l, "aggregate"):
return connection.ExplainOpAggregate
case strings.Contains(p, "filter"):
return connection.ExplainOpFilter
case strings.Contains(p, "top"):
return connection.ExplainOpLimit
case strings.Contains(p, "spool"):
return connection.ExplainOpMaterialize
case strings.Contains(p, "compute scalar"):
return connection.ExplainOpOther
default:
return connection.ExplainOpOther
}
}
// stripSQLServerBrackets 去掉 SQLServer 标识符的方括号:[users] → users。
func stripSQLServerBrackets(s string) string {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "[")
s = strings.TrimSuffix(s, "]")
return s
}

View File

@@ -0,0 +1,153 @@
package app
import (
"testing"
"GoNavi-Wails/internal/connection"
)
// SQL Server SHOWPLAN_XML fixture单 Clustered Index Scan + Predicate + RunTime。
const sqlServerShowPlanXMLSingleScan = `<?xml version="1.0" encoding="utf-16"?>
<ShowPlanXML Version="1.481" Build="15.0.2000.0" xmlns="http://schemas.microsoft.com/sqlserver/2004/07/showplan">
<BatchSequence>
<Batch>
<Statements>
<StmtSimple StatementText="SELECT * FROM users WHERE age &gt; 18" StatementId="1">
<QueryPlan DegreeOfParallelism="1">
<RelOp NodeId="0" PhysicalOp="Clustered Index Scan" LogicalOp="Clustered Scan"
EstimateRows="10000" EstimateIO="0.1" EstimateCPU="0.001"
EstimatedTotalSubtreeCost="0.2" Parallel="0" EstimateRebinds="0">
<Object Database="[db]" Schema="[dbo]" Table="[users]" Index="[PK_users]" />
<RunTimeInformation>
<RunTimeCountersPerThread ActualRows="10000" ActualElapsedms="5" ActualScans="1" />
</RunTimeInformation>
<IndexScan Ordered="0" />
</RelOp>
</QueryPlan>
</StmtSimple>
</Statements>
</Batch>
</BatchSequence>
</ShowPlanXML>`
func TestParseSQLServerExplain_ClusteredIndexScan(t *testing.T) {
result, err := parseSQLServerExplain("SELECT * FROM users WHERE age > 18", sqlServerShowPlanXMLSingleScan, connection.ExplainFormatXML)
if err != nil {
t.Fatalf("解析失败:%v", err)
}
if len(result.Nodes) != 1 {
t.Fatalf("应有 1 个 RelOp 节点got=%d", len(result.Nodes))
}
node := result.Nodes[0]
if node.OpType != connection.ExplainOpScan {
t.Fatalf("Clustered Index Scan 应为 SCANgot=%s", node.OpType)
}
if node.Table != "users" {
t.Fatalf("Table got=%s want=users", node.Table)
}
if node.Index != "PK_users" {
t.Fatalf("Index got=%s want=PK_users", node.Index)
}
if node.EstRows != 10000 {
t.Fatalf("EstRows got=%d want=10000", node.EstRows)
}
if node.ActualRows != 10000 {
t.Fatalf("ActualRows got=%d want=10000", node.ActualRows)
}
if node.DurationMs != 5 {
t.Fatalf("DurationMs got=%v want=5", node.DurationMs)
}
if !containsFlag(node.Flags, connection.ExplainFlagFullScan) {
t.Fatalf("Clustered Scan 应有 FULL_SCAN flag")
}
}
// SQL Server fixtureNested Loops JOIN + 两个子节点。
const sqlServerShowPlanXMLNestedLoops = `<?xml version="1.0" encoding="utf-16"?>
<ShowPlanXML xmlns="http://schemas.microsoft.com/sqlserver/2004/07/showplan">
<BatchSequence>
<Batch>
<Statements>
<StmtSimple StatementText="SELECT * FROM orders o JOIN users u ON o.user_id = u.id">
<QueryPlan>
<RelOp NodeId="0" PhysicalOp="Nested Loops" LogicalOp="Inner Join"
EstimateRows="100" EstimatedTotalSubtreeCost="1.5">
<NestedLoops>
<OuterReferences>
<ColumnReference Database="[db]" Table="[orders]" Column="user_id" />
</OuterReferences>
<RelOp NodeId="1" PhysicalOp="Clustered Index Scan" LogicalOp="Clustered Scan"
EstimateRows="50000" EstimatedTotalSubtreeCost="0.5">
<Object Database="[db]" Table="[orders]" Index="[PK_orders]" />
<IndexScan />
</RelOp>
<RelOp NodeId="2" PhysicalOp="Index Seek" LogicalOp="Index Seek"
EstimateRows="1" EstimatedTotalSubtreeCost="0.003">
<Object Database="[db]" Table="[users]" Index="[IX_users_id]" />
<IndexScan Ordered="1" />
</RelOp>
</NestedLoops>
</RelOp>
</QueryPlan>
</StmtSimple>
</Statements>
</Batch>
</BatchSequence>
</ShowPlanXML>`
func TestParseSQLServerExplain_NestedLoopsRecursesChildren(t *testing.T) {
result, err := parseSQLServerExplain("SELECT * FROM orders o JOIN users u ON o.user_id = u.id", sqlServerShowPlanXMLNestedLoops, connection.ExplainFormatXML)
if err != nil {
t.Fatalf("解析失败:%v", err)
}
if len(result.Nodes) != 3 {
t.Fatalf("应有 3 个节点Nested Loops + 2 子got=%d", len(result.Nodes))
}
joinNode := result.Nodes[0]
if joinNode.OpType != connection.ExplainOpJoin {
t.Fatalf("顶层应为 JOINgot=%s", joinNode.OpType)
}
// 两个子节点(通过 Edges 验证)
childCount := 0
for _, e := range result.Edges {
if e.From == joinNode.ID {
childCount++
}
}
if childCount != 2 {
t.Fatalf("JOIN 应有 2 个直接子节点got=%d", childCount)
}
// 找到 Index Seek 子节点
var indexSeek *connection.ExplainNode
for i := range result.Nodes {
if result.Nodes[i].OpType == connection.ExplainOpIndexScan {
indexSeek = &result.Nodes[i]
}
}
if indexSeek == nil {
t.Fatal("应有一个 Index Seek 节点")
}
if indexSeek.Index != "IX_users_id" {
t.Fatalf("Index Seek 应使用 IX_users_idgot=%s", indexSeek.Index)
}
}
func TestParseSQLServerExplain_InvalidXMLReturnsWarning(t *testing.T) {
result, err := parseSQLServerExplain("SELECT 1", "<not valid xml", connection.ExplainFormatXML)
if err != nil {
t.Fatalf("非法 XML 应降级返回 warning 而非 error%v", err)
}
if result.RawFormat != connection.ExplainFormatText {
t.Fatalf("RawFormat got=%v want=text", result.RawFormat)
}
if len(result.Warnings) == 0 {
t.Fatal("应有解析失败 warning")
}
}
func TestParseSQLServerExplain_EmptyReturnsError(t *testing.T) {
_, err := parseSQLServerExplain("SELECT 1", " ", connection.ExplainFormatXML)
if err == nil {
t.Fatal("空输入应返回 error")
}
}

View File

@@ -0,0 +1,434 @@
package app
import (
"fmt"
"strings"
"GoNavi-Wails/internal/connection"
)
// SQL 诊断工作台规则引擎。
//
// 设计要点:
// - 规则跨方言通用,基于归一化后的 ExplainNode 字段匹配
// - 每条规则只描述问题 + 给出 Reason不强行生成 CREATE INDEX避免瞎猜列名误导用户
// - 规则触发按 Severity 严重度排序critical > warning > info
// - 同一节点可能触发多条规则(如 FULL_SCAN 节点同时触发"全表扫描"+"缺索引"
//
// 规则 ID 列表(前端按 ID 显示本地化文案 + 图标):
// - full_scan_on_large_table大表全表扫描critical
// - full_scan_with_filter带 WHERE 的全表扫描critical索引建议价值最高
// - missing_index_lookupJOIN 中存在无索引扫描节点critical
// - filesort_on_large_result大结果集排序warning
// - temp_table_for_distinctDISTINCT/GROUP BY 物化临时表warning
// - low_buffer_hit_rate缓冲命中率低warning需 ANALYZE 才有数据)
// - high_estimation_skew估算与实际行数偏差大info需 ANALYZE
// - high_total_cost总成本过高warning
// - nested_loop_high_fanoutNested Loop 高扇出warning
// - using_temp_btree_orderSQLite 风格 ORDER BY 临时表info
// 规则阈值常量。值的选择基于工程经验:
// - 1000 行:单节点扫描超过此值视为"非小表"
// - 10000 行:超过此值视为"大表",触发 critical 建议索引
// - 0.5:缓冲命中率低于 50% 视为差
// - 10x估算与实际偏差超过 10 倍视为显著
const (
ruleFullScanLargeTableRows int64 = 10000
ruleFullScanSmallTableRows int64 = 1000
ruleFilesortRowsThreshold int64 = 5000
ruleLowBufferHitThreshold float64 = 0.5
ruleEstimationSkewRatio float64 = 10.0
ruleHighTotalCostThreshold float64 = 1000.0
ruleNestedLoopFanoutRows int64 = 10000
)
// runExplainRules 对归一化的 ExplainResult 跑全部规则,返回排序后的建议列表。
// 按 Severity 排序critical > warning > info同 Severity 内按 EstRows 降序。
func runExplainRules(result connection.ExplainResult) []connection.IndexSuggestion {
var suggestions []connection.IndexSuggestion
// 全局规则(基于 Stats
if s := ruleHighTotalCost(result); s != nil {
suggestions = append(suggestions, *s)
}
if s := ruleLowBufferHitRate(result); s != nil {
suggestions = append(suggestions, *s)
}
// 节点级规则
for _, node := range result.Nodes {
rules := []func(connection.ExplainResult, connection.ExplainNode) *connection.IndexSuggestion{
ruleFullScanLargeTable,
ruleFullScanWithFilter,
ruleMissingIndexLookup,
ruleFilesortOnLargeResult,
ruleTempTableForDistinct,
ruleHighEstimationSkew,
ruleNestedLoopHighFanout,
ruleUsingTempBTreeOrder,
}
for _, ruleFn := range rules {
if s := ruleFn(result, node); s != nil {
suggestions = append(suggestions, *s)
}
}
}
sortExplainSuggestions(suggestions)
return suggestions
}
// sortExplainSuggestions 按 Severity + EstRows 排序in-place
func sortExplainSuggestions(s []connection.IndexSuggestion) {
// 简单插入排序:建议数量通常 < 20无需 sort.Slice 的反射开销
severityRank := map[string]int{
connection.SeverityCritical: 0,
connection.SeverityWarning: 1,
connection.SeverityInfo: 2,
}
for i := 1; i < len(s); i++ {
for j := i; j > 0; j-- {
si := severityRank[s[j].Severity]
sj := severityRank[s[j-1].Severity]
if si < sj || (si == sj && s[j].EstRows > s[j-1].EstRows) {
s[j], s[j-1] = s[j-1], s[j]
continue
}
break
}
}
}
// ruleFullScanLargeTable单节点全表扫描 + 估算行数超过阈值。
// 严重度EstRows > 10000 → critical> 1000 → warning否则不触发。
func ruleFullScanLargeTable(_ connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion {
if !hasFlag(node.Flags, connection.ExplainFlagFullScan) {
return nil
}
if node.EstRows < ruleFullScanSmallTableRows {
return nil
}
severity := connection.SeverityWarning
if node.EstRows >= ruleFullScanLargeTableRows {
severity = connection.SeverityCritical
}
return &connection.IndexSuggestion{
Severity: severity,
Rule: "full_scan_on_large_table",
Reason: fmt.Sprintf("表 %s 全表扫描,估算扫描 %d 行;考虑为 WHERE/JOIN 条件字段添加索引", node.Table, node.EstRows),
AffectedNodeID: node.ID,
AffectedTable: node.Table,
EstRows: node.EstRows,
}
}
// ruleFullScanWithFilter带 WHERE 的全表扫描(最有价值的索引建议场景)。
// 从 attachedCondition / Filter / Extra 提取等式字段,提示用户考虑建索引。
func ruleFullScanWithFilter(_ connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion {
if !hasFlag(node.Flags, connection.ExplainFlagFullScan) {
return nil
}
filter := extractNodeFilterText(node)
if filter == "" {
return nil
}
columns := extractEqualityColumns(filter)
if len(columns) == 0 {
return nil
}
return &connection.IndexSuggestion{
Severity: connection.SeverityCritical,
Rule: "full_scan_with_filter",
Reason: fmt.Sprintf("表 %s 全表扫描但带 WHERE 条件 %q建议为字段 %s 建立索引", node.Table, truncateForReason(filter, 60), joinColumnsForReason(columns)),
AffectedNodeID: node.ID,
AffectedTable: node.Table,
EstRows: node.EstRows,
}
}
// ruleMissingIndexLookupJOIN 中存在无索引扫描节点NO_INDEX flag
func ruleMissingIndexLookup(_ connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion {
if !hasFlag(node.Flags, connection.ExplainFlagNoIndex) {
return nil
}
// 已被 full_scan_on_large_table 覆盖时跳过,避免重复
if hasFlag(node.Flags, connection.ExplainFlagFullScan) {
return nil
}
if node.EstRows < ruleFullScanSmallTableRows {
return nil
}
return &connection.IndexSuggestion{
Severity: connection.SeverityCritical,
Rule: "missing_index_lookup",
Reason: fmt.Sprintf("JOIN 节点 %s 未命中索引,估算扫描 %d 行JOIN 字段需要索引", node.Table, node.EstRows),
AffectedNodeID: node.ID,
AffectedTable: node.Table,
EstRows: node.EstRows,
}
}
// ruleFilesortOnLargeResult大结果集排序。
func ruleFilesortOnLargeResult(_ connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion {
if !hasFlag(node.Flags, connection.ExplainFlagFilesort) {
return nil
}
if node.EstRows < ruleFilesortRowsThreshold {
return nil
}
return &connection.IndexSuggestion{
Severity: connection.SeverityWarning,
Rule: "filesort_on_large_result",
Reason: fmt.Sprintf("对约 %d 行做额外排序;考虑为 ORDER BY 字段建立索引以避免 filesort", node.EstRows),
AffectedNodeID: node.ID,
AffectedTable: node.Table,
EstRows: node.EstRows,
}
}
// ruleTempTableForDistinct使用临时表DISTINCT/GROUP BY
func ruleTempTableForDistinct(_ connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion {
if !hasFlag(node.Flags, connection.ExplainFlagTempTable) {
return nil
}
// OpDetail 含 distinct/group 时给出更精准的建议
detail := strings.ToLower(node.OpDetail)
var hint string
switch {
case strings.Contains(detail, "distinct"):
hint = "DISTINCT 物化了临时表"
case strings.Contains(detail, "group"):
hint = "GROUP BY 物化了临时表"
default:
hint = "查询使用了临时表"
}
return &connection.IndexSuggestion{
Severity: connection.SeverityWarning,
Rule: "temp_table_for_distinct",
Reason: fmt.Sprintf("%s考虑为分组字段建立索引避免物化", hint),
AffectedNodeID: node.ID,
AffectedTable: node.Table,
EstRows: node.EstRows,
}
}
// ruleHighEstimationSkew估算与实际行数偏差大需 ANALYZE 才有数据)。
func ruleHighEstimationSkew(_ connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion {
if node.EstRows <= 0 || node.ActualRows <= 0 {
return nil
}
ratio := float64(node.ActualRows) / float64(node.EstRows)
if ratio < ruleEstimationSkewRatio && ratio > 1.0/ruleEstimationSkewRatio {
return nil
}
return &connection.IndexSuggestion{
Severity: connection.SeverityInfo,
Rule: "high_estimation_skew",
Reason: fmt.Sprintf("估算 %d 行 / 实际 %d 行(偏差 %.1fx);统计信息可能过期,考虑 ANALYZE TABLE", node.EstRows, node.ActualRows, ratio),
AffectedNodeID: node.ID,
AffectedTable: node.Table,
EstRows: node.EstRows,
}
}
// ruleNestedLoopHighFanoutNested Loop 高扇出。
// 触发条件JOIN 节点 + 子节点(被驱动表)估算行数 > 10000。
func ruleNestedLoopHighFanout(result connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion {
if node.OpType != connection.ExplainOpJoin {
return nil
}
// 找到该 JOIN 的直接子节点(被驱动表)
var maxChildRows int64
for _, edge := range result.Edges {
if edge.From != node.ID {
continue
}
for _, child := range result.Nodes {
if child.ID == edge.To && child.EstRows > maxChildRows {
maxChildRows = child.EstRows
}
}
}
if maxChildRows < ruleNestedLoopFanoutRows {
return nil
}
return &connection.IndexSuggestion{
Severity: connection.SeverityWarning,
Rule: "nested_loop_high_fanout",
Reason: fmt.Sprintf("Nested Loop JOIN 被驱动表估算 %d 行,扇出过大;考虑改用 Hash Join 或为 JOIN 字段加索引", maxChildRows),
AffectedNodeID: node.ID,
AffectedTable: node.Table,
EstRows: maxChildRows,
}
}
// ruleUsingTempBTreeOrderSQLite 风格的 ORDER BY 临时表Info 级,提示性)。
func ruleUsingTempBTreeOrder(_ connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion {
if !hasFlag(node.Flags, connection.ExplainFlagFilesort) {
return nil
}
if node.EstRows >= ruleFilesortRowsThreshold {
return nil // 已被 filesort_on_large_result 覆盖
}
return &connection.IndexSuggestion{
Severity: connection.SeverityInfo,
Rule: "using_temp_btree_order",
Reason: "ORDER BY 使用临时 B-Tree如频繁执行为排序字段建立索引可消除该开销",
AffectedNodeID: node.ID,
AffectedTable: node.Table,
EstRows: node.EstRows,
}
}
// ruleHighTotalCost总成本过高全局规则
func ruleHighTotalCost(result connection.ExplainResult) *connection.IndexSuggestion {
if result.Stats.TotalCost < ruleHighTotalCostThreshold {
return nil
}
return &connection.IndexSuggestion{
Severity: connection.SeverityWarning,
Rule: "high_total_cost",
Reason: fmt.Sprintf("执行计划总成本 %.1f;考虑重写查询或加索引降低扫描量", result.Stats.TotalCost),
EstRows: result.Stats.RowsRead,
}
}
// ruleLowBufferHitRate缓冲命中率低全局规则PG/Oracle 才有此数据)。
func ruleLowBufferHitRate(result connection.ExplainResult) *connection.IndexSuggestion {
if result.Stats.BufferHitRate <= 0 || result.Stats.BufferHitRate >= ruleLowBufferHitThreshold {
return nil
}
return &connection.IndexSuggestion{
Severity: connection.SeverityWarning,
Rule: "low_buffer_hit_rate",
Reason: fmt.Sprintf("缓冲命中率仅 %.1f%%;热门数据可能未被缓存,考虑增大 shared_buffers 或检查访问模式", result.Stats.BufferHitRate*100),
EstRows: result.Stats.RowsRead,
}
}
// hasFlag 检查节点是否含指定 flag。
func hasFlag(flags []string, target string) bool {
for _, f := range flags {
if f == target {
return true
}
}
return false
}
// extractNodeFilterText 从节点的 attached_condition / Filter / Extra 中提取过滤条件文本。
func extractNodeFilterText(node connection.ExplainNode) string {
if node.Extra == nil {
return ""
}
for _, key := range []string{"attachedCondition", "filter"} {
if v, ok := node.Extra[key]; ok {
text := strings.TrimSpace(fmt.Sprintf("%v", v))
if text != "" && text != "<nil>" {
return text
}
}
}
return ""
}
// extractEqualityColumns 从 SQL 过滤条件中提取等值条件的列名(粗略解析)。
// 仅识别 "col = ?" / "col = literal" 形式不处理复杂表达式OR/函数调用)。
func extractEqualityColumns(filter string) []string {
if filter == "" {
return nil
}
// 简化:按 AND 切分后取每个等值条件的左边
parts := splitTopLevelByKeyword(filter, " and ")
seen := make(map[string]struct{})
var columns []string
for _, part := range parts {
part = strings.TrimSpace(part)
// 去除括号
part = strings.Trim(part, "() ")
eqIdx := strings.Index(part, "=")
if eqIdx <= 0 {
continue
}
left := strings.TrimSpace(part[:eqIdx])
// 必须是简单标识符(字母数字下划线 + 点)
if !isSimpleIdentifier(left) {
continue
}
// 右边不是另一个列引用(粗略判断:不含点/字母前缀的字段)
right := strings.TrimSpace(part[eqIdx+1:])
if isSimpleIdentifier(right) {
continue // col1 = col2 形式不算索引候选
}
if _, exists := seen[left]; !exists {
seen[left] = struct{}{}
columns = append(columns, left)
}
}
return columns
}
// splitTopLevelByKeyword 按关键字(不区分大小写)切分字符串,忽略嵌套括号内的匹配。
func splitTopLevelByKeyword(text, keyword string) []string {
var parts []string
depth := 0
lower := strings.ToLower(text)
kw := strings.ToLower(keyword)
start := 0
for i := 0; i < len(lower); i++ {
switch lower[i] {
case '(':
depth++
case ')':
if depth > 0 {
depth--
}
}
if depth > 0 {
continue
}
if strings.HasPrefix(lower[i:], kw) {
parts = append(parts, text[start:i])
i += len(kw)
start = i
}
}
parts = append(parts, text[start:])
return parts
}
// isSimpleIdentifier 判断字符串是否是简单 SQL 标识符(支持 schema.table 形式)。
func isSimpleIdentifier(s string) bool {
if s == "" {
return false
}
for i, ch := range s {
ok := (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '.'
if !ok {
return false
}
if i == 0 && ch >= '0' && ch <= '9' {
return false
}
}
return true
}
// truncateForReason 截断字符串到 maxLen超出加省略号。
func truncateForReason(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen-1] + "…"
}
// joinColumnsForReason 把列名列表格式化为人类可读的列表(最多 3 个)。
func joinColumnsForReason(columns []string) string {
if len(columns) == 0 {
return ""
}
if len(columns) > 3 {
return strings.Join(columns[:3], ", ") + " 等"
}
return strings.Join(columns, ", ")
}

View File

@@ -0,0 +1,249 @@
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
}

View File

@@ -85,7 +85,9 @@ func (a *App) DiagnoseQuery(config connection.ConnectionConfig, dbName, query st
return connection.QueryResult{Success: false, Message: err.Error()}
}
report := connection.DiagnoseReport{Plan: plan}
suggestions := runExplainRules(plan)
report := connection.DiagnoseReport{Plan: plan, Suggestions: suggestions}
logger.Infof("DiagnoseQuery 完成type=%s nodes=%d suggestions=%d", dbType, len(plan.Nodes), len(suggestions))
return connection.QueryResult{Success: true, Message: "诊断完成", Data: report}
}
@@ -260,32 +262,11 @@ func parseExplainRaw(dbType, sourceSQL, raw string, format connection.ExplainFor
case "sqlite":
return parseSQLiteExplain(sourceSQL, raw, format)
case "clickhouse":
// PR2 实现
return connection.ExplainResult{
DBType: dbType,
SourceSQL: sourceSQL,
RawFormat: format,
RawPayload: raw,
Warnings: []string{"ClickHouse 解析器在 PR2 实现,先返回原文"},
}, nil
return parseClickHouseExplain(sourceSQL, raw, format)
case "oracle":
// PR2 实现
return connection.ExplainResult{
DBType: dbType,
SourceSQL: sourceSQL,
RawFormat: format,
RawPayload: raw,
Warnings: []string{"Oracle 解析器在 PR2 实现,先返回原文"},
}, nil
return parseOracleExplain(sourceSQL, raw, format)
case "sqlserver":
// PR2 实现
return connection.ExplainResult{
DBType: dbType,
SourceSQL: sourceSQL,
RawFormat: format,
RawPayload: raw,
Warnings: []string{"SQLServer 解析器在 PR2 实现,先返回原文"},
}, nil
return parseSQLServerExplain(sourceSQL, raw, format)
default:
return connection.ExplainResult{}, fmt.Errorf("不支持的 EXPLAIN 方言:%s", dbType)
}