From 8e24e40fdd8d0d1a6f4952bea8962e7b0b017eba Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 12:45:15 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(explain):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=20Oracle/SQLServer/ClickHouse=20=E8=A7=A3=E6=9E=90=E5=99=A8?= =?UTF-8?q?=E4=B8=8E=E7=B4=A2=E5=BC=95=E5=BB=BA=E8=AE=AE=E8=A7=84=E5=88=99?= =?UTF-8?q?=E5=BC=95=E6=93=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 方言解析:新增 Oracle DBMS_XPLAN 表格、SQLServer SHOWPLAN_XML、ClickHouse EXPLAIN JSON 解析器 - 规则引擎:新增 10 条跨方言规则(全表扫描、缺索引 JOIN、filesort、估算偏差、缓冲命中、Nested Loop 高扇出等) - 入口接入:DiagnoseQuery 返回的 Suggestions 自动填充规则匹配结果 - 容错增强:SQLServer strip 默认命名空间与 XML 声明;Oracle 表格列与独立 Predicate 段双源融合 - 测试覆盖:新增 27 个用例覆盖三方言解析与规则触发场景 --- internal/app/explain_parse_clickhouse.go | 241 +++++++++ internal/app/explain_parse_clickhouse_test.go | 128 +++++ internal/app/explain_parse_oracle.go | 457 ++++++++++++++++++ internal/app/explain_parse_oracle_test.go | 126 +++++ internal/app/explain_parse_sqlserver.go | 306 ++++++++++++ internal/app/explain_parse_sqlserver_test.go | 153 ++++++ internal/app/explain_rules.go | 434 +++++++++++++++++ internal/app/explain_rules_test.go | 249 ++++++++++ internal/app/methods_explain.go | 31 +- 9 files changed, 2100 insertions(+), 25 deletions(-) create mode 100644 internal/app/explain_parse_clickhouse.go create mode 100644 internal/app/explain_parse_clickhouse_test.go create mode 100644 internal/app/explain_parse_oracle.go create mode 100644 internal/app/explain_parse_oracle_test.go create mode 100644 internal/app/explain_parse_sqlserver.go create mode 100644 internal/app/explain_parse_sqlserver_test.go create mode 100644 internal/app/explain_rules.go create mode 100644 internal/app/explain_rules_test.go diff --git a/internal/app/explain_parse_clickhouse.go b/internal/app/explain_parse_clickhouse.go new file mode 100644 index 0000000..2b01dd9 --- /dev/null +++ b/internal/app/explain_parse_clickhouse.go @@ -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 +} diff --git a/internal/app/explain_parse_clickhouse_test.go b/internal/app/explain_parse_clickhouse_test.go new file mode 100644 index 0000000..43f5157 --- /dev/null +++ b/internal/app/explain_parse_clickhouse_test.go @@ -0,0 +1,128 @@ +package app + +import ( + "testing" + + "GoNavi-Wails/internal/connection" +) + +// ClickHouse EXPLAIN JSON fixture:ReadFromMergeTree + 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 + ReadFromMergeTree),got=%d", len(result.Nodes)) + } + aggNode := result.Nodes[0] + if aggNode.OpType != connection.ExplainOpAggregate { + t.Fatalf("Aggregating 应为 AGGREGATE,got=%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 应为 SCAN,got=%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") + } + // Edges:Aggregating -> 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 + ReadFromMergeTree),got=%d", len(result.Nodes)) + } + if result.Nodes[0].OpType != connection.ExplainOpLimit { + t.Fatalf("Limit 节点应为 LIMIT,got=%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") + } +} diff --git a/internal/app/explain_parse_oracle.go b/internal/app/explain_parse_oracle.go new file mode 100644 index 0000000..8cf6d77 --- /dev/null +++ b/internal/app/explain_parse_oracle.go @@ -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") +} diff --git a/internal/app/explain_parse_oracle_test.go b/internal/app/explain_parse_oracle_test.go new file mode 100644 index 0000000..4622086 --- /dev/null +++ b/internal/app/explain_parse_oracle_test.go @@ -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 ACCESS),got=%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 应为 SCAN,got=%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.filter,got=%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 应为 JOIN,got=%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") + } +} diff --git a/internal/app/explain_parse_sqlserver.go b/internal/app/explain_parse_sqlserver.go new file mode 100644 index 0000000..6448e73 --- /dev/null +++ b/internal/app/explain_parse_sqlserver.go @@ -0,0 +1,306 @@ +package app + +import ( + "encoding/xml" + "fmt" + "strings" + + "GoNavi-Wails/internal/connection" +) + +// SQL Server SHOWPLAN_XML 解析。 +// +// 典型 XML 结构(简化): +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// ... +// +// +// +// +// +// +// +// +// +// +// +// 解析要点: +// - 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 含 `` 声明,Go encoding/xml 不支持 utf-16 + // 直接报 "encoding declared but not supported"。从 标签开始截取即可规避。 + showPlanStart := strings.Index(trimmed, " 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 +} diff --git a/internal/app/explain_parse_sqlserver_test.go b/internal/app/explain_parse_sqlserver_test.go new file mode 100644 index 0000000..009f2d5 --- /dev/null +++ b/internal/app/explain_parse_sqlserver_test.go @@ -0,0 +1,153 @@ +package app + +import ( + "testing" + + "GoNavi-Wails/internal/connection" +) + +// SQL Server SHOWPLAN_XML fixture:单 Clustered Index Scan + Predicate + RunTime。 +const sqlServerShowPlanXMLSingleScan = ` + + + + + + + + + + + + + + + + + + +` + +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 应为 SCAN,got=%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 fixture:Nested Loops JOIN + 两个子节点。 +const sqlServerShowPlanXMLNestedLoops = ` + + + + + + + + + + + + + + + + + + + + + + + + + + +` + +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("顶层应为 JOIN,got=%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_id,got=%s", indexSeek.Index) + } +} + +func TestParseSQLServerExplain_InvalidXMLReturnsWarning(t *testing.T) { + result, err := parseSQLServerExplain("SELECT 1", " warning > info +// - 同一节点可能触发多条规则(如 FULL_SCAN 节点同时触发"全表扫描"+"缺索引") +// +// 规则 ID 列表(前端按 ID 显示本地化文案 + 图标): +// - full_scan_on_large_table:大表全表扫描(critical) +// - full_scan_with_filter:带 WHERE 的全表扫描(critical,索引建议价值最高) +// - missing_index_lookup:JOIN 中存在无索引扫描节点(critical) +// - filesort_on_large_result:大结果集排序(warning) +// - temp_table_for_distinct:DISTINCT/GROUP BY 物化临时表(warning) +// - low_buffer_hit_rate:缓冲命中率低(warning,需 ANALYZE 才有数据) +// - high_estimation_skew:估算与实际行数偏差大(info,需 ANALYZE) +// - high_total_cost:总成本过高(warning) +// - nested_loop_high_fanout:Nested Loop 高扇出(warning) +// - using_temp_btree_order:SQLite 风格 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, + } +} + +// ruleMissingIndexLookup:JOIN 中存在无索引扫描节点(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, + } +} + +// ruleNestedLoopHighFanout:Nested 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, + } +} + +// ruleUsingTempBTreeOrder:SQLite 风格的 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 != "" { + 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, ", ") +} diff --git a/internal/app/explain_rules_test.go b/internal/app/explain_rules_test.go new file mode 100644 index 0000000..d3710b8 --- /dev/null +++ b/internal/app/explain_rules_test.go @@ -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("大表全表扫描应为 critical,got=%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 应为 warning,got=%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("估算偏差应为 info,got=%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("首条建议应为 critical,got=%s(rule=%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 +} diff --git a/internal/app/methods_explain.go b/internal/app/methods_explain.go index ad54245..76022c4 100644 --- a/internal/app/methods_explain.go +++ b/internal/app/methods_explain.go @@ -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) }