diff --git a/internal/app/explain_parse_clickhouse.go b/internal/app/explain_parse_clickhouse.go
new file mode 100644
index 00000000..2b01dd97
--- /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 00000000..43f51578
--- /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 00000000..8cf6d775
--- /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 00000000..46220862
--- /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 00000000..6448e73a
--- /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 00000000..009f2d5e
--- /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 00000000..d3710b83
--- /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 ad542457..76022c49 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)
}