diff --git a/internal/app/explain_parse_common.go b/internal/app/explain_parse_common.go new file mode 100644 index 0000000..b52646f --- /dev/null +++ b/internal/app/explain_parse_common.go @@ -0,0 +1,164 @@ +package app + +import ( + "fmt" + "strings" + "sync/atomic" + + "GoNavi-Wails/internal/connection" +) + +// SQL 诊断工作台:方言解析器公共工具。 +// +// 本文件只放跨方言共享的辅助函数;每方言解析器在 explain_parse_.go。 + +// explainNodeIDCounter 是单次解析内的递增节点 ID 生成器。 +// 通过 resetExplainNodeID() 在解析开始时归零;并发安全(同一 query 串行解析)。 +var explainNodeIDCounter uint64 + +func resetExplainNodeID() { + atomic.StoreUint64(&explainNodeIDCounter, 0) +} + +// nextExplainNodeID 返回下一个节点 ID("n1"、"n2"……)。 +func nextExplainNodeID() string { + id := atomic.AddUint64(&explainNodeIDCounter, 1) + return fmt.Sprintf("n%d", id) +} + +// appendExplainChild 把子节点追加到 result.Nodes,并生成对应的 ExplainEdge。 +// parentID 为空时不生成 Edge(根节点)。 +func appendExplainChild(result *connection.ExplainResult, parentID string, node connection.ExplainNode) (nodeID string) { + if node.ID == "" { + node.ID = nextExplainNodeID() + } + if parentID != "" { + node.ParentID = parentID + result.Edges = append(result.Edges, connection.ExplainEdge{From: parentID, To: node.ID}) + } + result.Nodes = append(result.Nodes, node) + return node.ID +} + +// finalizeExplainStats 遍历所有节点,计算聚合统计并写入 Stats 字段。 +// 在解析器返回前调用。 +// +// 注意:TotalDurationMs 在 PG/MySQL 8.0 中由解析器直接从 Execution Time 写入, +// 这里只在解析器未设置时(=0)才用节点累加值兜底,避免覆盖更精确的实例值。 +func finalizeExplainStats(result *connection.ExplainResult) { + if result == nil || len(result.Nodes) == 0 { + return + } + var totalCost, accumulatedDuration float64 + var rowsRead, maxRows int64 + var bufferHitSum float64 + var bufferHitCount int + for _, n := range result.Nodes { + if n.Cost > 0 { + totalCost += n.Cost + } + if n.DurationMs > 0 { + accumulatedDuration += n.DurationMs + } + if n.OpType == connection.ExplainOpScan || n.OpType == connection.ExplainOpIndexScan || n.OpType == connection.ExplainOpIndexOnly { + rowsRead += n.EstRows + } + if n.EstRows > maxRows { + maxRows = n.EstRows + } + if n.BufferHit > 0 { + bufferHitSum += n.BufferHit + bufferHitCount++ + } + for _, flag := range n.Flags { + switch flag { + case connection.ExplainFlagFullScan: + result.Stats.HasFullScan = true + case connection.ExplainFlagFilesort: + result.Stats.HasFilesort = true + case connection.ExplainFlagTempTable: + result.Stats.HasTempTable = true + } + } + } + result.Stats.TotalCost = totalCost + if result.Stats.TotalDurationMs == 0 && accumulatedDuration > 0 { + result.Stats.TotalDurationMs = accumulatedDuration + } + result.Stats.RowsRead = rowsRead + result.Stats.MaxEstRows = maxRows + if bufferHitCount > 0 { + result.Stats.BufferHitRate = bufferHitSum / float64(bufferHitCount) + } +} + +// parseExplainTSVRows 把 collectExplainRaw 生成的 TSV 原文重新切分为行(每行 []string 按列拆)。 +// 第一行视为列头;空行跳过。 +func parseExplainTSVRows(raw string) (header []string, rows [][]string) { + lines := strings.Split(strings.TrimSpace(raw), "\n") + if len(lines) == 0 { + return nil, nil + } + header = strings.Split(lines[0], "\t") + for i := 1; i < len(lines); i++ { + line := strings.TrimRight(lines[i], "\r") + if strings.TrimSpace(line) == "" { + continue + } + rows = append(rows, strings.Split(line, "\t")) + } + return header, rows +} + +// lookupTSVColumn 在 header 中按列名查找索引(大小写不敏感);未找到返回 -1。 +func lookupTSVColumn(header []string, names ...string) int { + if len(header) == 0 || len(names) == 0 { + return -1 + } + for _, name := range names { + target := strings.ToLower(strings.TrimSpace(name)) + if target == "" { + continue + } + for i, h := range header { + if strings.ToLower(strings.TrimSpace(h)) == target { + return i + } + } + } + return -1 +} + +// parseExplainInt64 容错地把字符串解析为 int64(空/非法返回 0)。 +func parseExplainInt64(s string) int64 { + s = strings.TrimSpace(s) + if s == "" || s == "NULL" || s == "" || s == "null" { + return 0 + } + var n int64 + for _, ch := range s { + if ch < '0' || ch > '9' { + if ch == '-' || ch == '+' { + continue + } + break + } + n = n*10 + int64(ch-'0') + } + return n +} + +// parseExplainFloat64 容错地把字符串解析为 float64(空/非法返回 0)。 +// 支持形如 "100.00"、"1.5e3" 的简单浮点格式。 +func parseExplainFloat64(s string) float64 { + s = strings.TrimSpace(s) + if s == "" || s == "NULL" || s == "" || s == "null" { + return 0 + } + var f float64 + _, err := fmt.Sscanf(s, "%f", &f) + if err != nil { + return 0 + } + return f +} diff --git a/internal/app/explain_parse_mysql.go b/internal/app/explain_parse_mysql.go new file mode 100644 index 0000000..bd7a969 --- /dev/null +++ b/internal/app/explain_parse_mysql.go @@ -0,0 +1,332 @@ +package app + +import ( + "encoding/json" + "fmt" + "strings" + + "GoNavi-Wails/internal/connection" +) + +// MySQL FORMAT=JSON 解析。 +// +// 典型结构(8.0+): +// +// { +// "query_block": { +// "select_id": 1, +// "cost_info": {"query_cost": "100.00"}, +// "table": { ... }, // 单表 +// "nested_loop": [{"table": {...}}], // 多表 JOIN +// "ordering_operation": { ... }, // ORDER BY 包装 +// "grouping_operation": { ... }, // GROUP BY 包装 +// "duplicates_removal": { ... } +// } +// } +// +// 单个 table 节点字段: +// - table_name / alias +// - access_type:system/const/eq_ref/ref/range/index/ALL +// - rows_examined_per_scan / rows_produced_per_join / filtered +// - possible_keys / key / used_key_parts / key_length +// - attached_condition / used_columns +// +// OceanBase MySQL 协议输出与 MySQL 8.0 几乎一致(可能多 range_info 列)。 +// +// 5.7 不支持 FORMAT=JSON 时走 vanilla EXPLAIN,返回 8 列表格:id/select_type/table/type/ +// possible_keys/key/key_len/ref/rows/Extra(OceanBase 可能多 range_info),由 parseMySQLTableExplain 处理。 + +func parseMySQLExplain(dbType, sourceSQL, raw string, format connection.ExplainFormat) (connection.ExplainResult, error) { + result := connection.ExplainResult{ + DBType: dbType, + SourceSQL: sourceSQL, + } + resetExplainNodeID() + + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return result, fmt.Errorf("MySQL EXPLAIN 返回空内容") + } + + // FORMAT=JSON 模式 + if format == connection.ExplainFormatJSON || strings.HasPrefix(trimmed, "{") { + plan, warnings, err := parseMySQLJSONExplain(trimmed) + if err != nil { + // JSON 解析失败但确实是 JSON 开头:报错让上层决定降级 + return result, fmt.Errorf("解析 MySQL FORMAT=JSON 失败:%w", err) + } + result.Nodes = plan.Nodes + result.Edges = plan.Edges + result.Warnings = warnings + result.RawFormat = connection.ExplainFormatJSON + result.RawPayload = raw + finalizeExplainStats(&result) + return result, nil + } + + // 表格模式(5.7 fallback 或 Doris/StarRocks) + parsed, err := parseMySQLTableExplain(raw) + if err != nil { + result.RawFormat = connection.ExplainFormatText + result.RawPayload = raw + result.Warnings = []string{fmt.Sprintf("表格解析失败:%v;保留原文供调试", err)} + return result, nil + } + result.Nodes = parsed.Nodes + result.Edges = parsed.Edges + result.RawFormat = connection.ExplainFormatTable + result.RawPayload = raw + finalizeExplainStats(&result) + return result, nil +} + +// mysqlQueryBlock 对应 MySQL FORMAT=JSON 顶层 query_block。 +type mysqlQueryBlock struct { + SelectID json.Number `json:"select_id"` + CostInfo map[string]string `json:"cost_info"` + Table *mysqlTableNode `json:"table"` + NestedLoop []map[string]json.RawMessage `json:"nested_loop"` + OrderingOperation *map[string]any `json:"ordering_operation"` + GroupingOperation *map[string]any `json:"grouping_operation"` + DuplicatesRemoval *map[string]any `json:"duplicates_removal"` + Windowing *map[string]any `json:"windowing"` + Distinct *map[string]any `json:"distinct"` + Message string `json:"message"` +} + +type mysqlTableNode struct { + TableName string `json:"table_name"` + Alias string `json:"alias"` + AccessType string `json:"access_type"` + RowsExaminedPerScan json.Number `json:"rows_examined_per_scan"` + RowsProducedPerJoin json.Number `json:"rows_produced_per_join"` + Filtered string `json:"filtered"` + PossibleKeys []string `json:"possible_keys"` + Key string `json:"key"` + UsedKeyParts []string `json:"used_key_parts"` + KeyLength json.Number `json:"key_length"` + Ref []string `json:"ref"` + RowsExaminedPerJoin json.Number `json:"rows_examined_per_join"` + CostInfo map[string]string `json:"cost_info"` + AttachedCondition string `json:"attached_condition"` + AttachedSubqueries []map[string]any `json:"attached_subqueries"` + UsingIntersection []map[string]any `json:"using_intersect"` + Message string `json:"message"` +} + +// parseMySQLJSONExplain 递归解析 MySQL FORMAT=JSON 输出。 +// 返回扁平的节点列表 + 解析过程中的警告(用于前端提示不识别的字段)。 +func parseMySQLJSONExplain(raw string) (*connection.ExplainResult, []string, error) { + var top map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &top); err != nil { + return nil, nil, fmt.Errorf("顶层 JSON 解析失败:%w", err) + } + + result := &connection.ExplainResult{} + var warnings []string + + qbRaw, ok := top["query_block"] + if !ok { + return nil, nil, fmt.Errorf("缺少 query_block 字段") + } + + // query_block 总成本 + var qb map[string]json.RawMessage + if err := json.Unmarshal(qbRaw, &qb); err != nil { + return nil, nil, fmt.Errorf("query_block 解析失败:%w", err) + } + if costRaw, ok := qb["cost_info"]; ok { + var ci map[string]string + if err := json.Unmarshal(costRaw, &ci); err == nil { + result.Stats.TotalCost = parseExplainFloat64(ci["query_cost"]) + } + } + + // 递归 query_block(可能套 ordering/grouping/distinct 等操作层) + parseMySQLQueryBlock(qbRaw, "", result, &warnings) + + return result, warnings, nil +} + +// parseMySQLQueryBlock 递归解析 query_block 内部结构。 +// MySQL FORMAT=JSON 是深度嵌套的"操作层"结构,每层可能包含 table、nested_loop、ordering_operation 等。 +func parseMySQLQueryBlock(qbRaw json.RawMessage, parentID string, result *connection.ExplainResult, warnings *[]string) { + var qb mysqlQueryBlock + if err := json.Unmarshal(qbRaw, &qb); err != nil { + *warnings = append(*warnings, fmt.Sprintf("query_block JSON 反序列化失败:%v", err)) + return + } + + // 单表:直接挂一个 table 节点 + if qb.Table != nil { + node := buildMySQLTableNode(qb.Table) + appendExplainChild(result, parentID, node) + } + + // nested_loop:每个元素含 table,作为 parent 的子节点 + for _, item := range qb.NestedLoop { + if tableRaw, ok := item["table"]; ok { + var t mysqlTableNode + if err := json.Unmarshal(tableRaw, &t); err == nil { + node := buildMySQLTableNode(&t) + appendExplainChild(result, parentID, node) + } + } + } + + // 递归操作层:ordering_operation / grouping_operation / duplicates_removal / windowing + type opLayer struct { + raw json.RawMessage + opType string + } + layers := []opLayer{} + if qb.OrderingOperation != nil { + // 反向取原始 JSON(结构体已 unmarshal,但用 raw 更通用) + } + // 直接遍历原始 qb map 更省事 + var qbMap map[string]json.RawMessage + _ = json.Unmarshal(qbRaw, &qbMap) + for key, val := range qbMap { + switch key { + case "ordering_operation": + layers = append(layers, opLayer{raw: val, opType: connection.ExplainOpSort}) + case "grouping_operation": + layers = append(layers, opLayer{raw: val, opType: connection.ExplainOpAggregate}) + case "duplicates_removal": + layers = append(layers, opLayer{raw: val, opType: connection.ExplainOpOther}) + case "windowing": + layers = append(layers, opLayer{raw: val, opType: connection.ExplainOpWindow}) + case "distinct": + layers = append(layers, opLayer{raw: val, opType: connection.ExplainOpAggregate}) + } + } + for _, layer := range layers { + // 操作层本身作为一个节点(供前端展示层次) + layerNode := connection.ExplainNode{ + OpType: layer.opType, + OpDetail: strings.Title(strings.ReplaceAll(layer.opType, "_", " ")), + } + layerID := appendExplainChild(result, parentID, layerNode) + // 递归:操作层可能含 table、nested_loop、子操作层 + parseMySQLQueryBlock(layer.raw, layerID, result, warnings) + } +} + +// buildMySQLTableNode 把 mysqlTableNode 转成归一化的 ExplainNode,并探测 Flags。 +func buildMySQLTableNode(t *mysqlTableNode) connection.ExplainNode { + node := connection.ExplainNode{ + OpType: classifyMySQLAccessType(t.AccessType), + OpDetail: fmt.Sprintf("access_type=%s", strings.ToLower(strings.TrimSpace(t.AccessType))), + Table: t.TableName, + Index: t.Key, + EstRows: parseExplainInt64(string(t.RowsExaminedPerScan)), + Cost: parseExplainFloat64(t.CostInfo["read_cost"]), + } + if t.Alias != "" && t.Alias != t.TableName { + node.Extra = map[string]any{"alias": t.Alias} + } + if t.AttachedCondition != "" { + if node.Extra == nil { + node.Extra = map[string]any{} + } + node.Extra["attachedCondition"] = t.AttachedCondition + } + if len(t.UsedKeyParts) > 0 { + if node.Extra == nil { + node.Extra = map[string]any{} + } + node.Extra["usedKeyParts"] = t.UsedKeyParts + } + // 探测 Flags + if node.OpType == connection.ExplainOpScan { + node.Flags = append(node.Flags, connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex) + } + return node +} + +// classifyMySQLAccessType 把 MySQL access_type 归一化到通用 OpType。 +// ALL → SCAN,range/eq_ref/ref/index → INDEX_SCAN 或 INDEX_ONLY,其他 → OTHER。 +func classifyMySQLAccessType(accessType string) string { + switch strings.ToLower(strings.TrimSpace(accessType)) { + case "all": + return connection.ExplainOpScan + case "index": + return connection.ExplainOpIndexOnly // 仅扫索引不回表 + case "range": + return connection.ExplainOpIndexScan + case "eq_ref", "ref", "ref_or_null", "unique_subquery", "index_subquery": + return connection.ExplainOpIndexScan + case "const", "system": + return connection.ExplainOpOther // 单行命中,性能极佳 + default: + return connection.ExplainOpOther + } +} + +// parseMySQLTableExplain 解析 MySQL 5.7 表格 / Doris / StarRocks 的 EXPLAIN 输出。 +// 标准 MySQL 表格列:id|select_type|table|type|possible_keys|key|key_len|ref|rows|Extra +// OceanBase 可能多 range_info;Doris/StarRocks 是完全不同的结构化文本(PR2 优化)。 +func parseMySQLTableExplain(raw string) (*connection.ExplainResult, error) { + header, rows := parseExplainTSVRows(raw) + if len(header) == 0 || len(rows) == 0 { + return nil, fmt.Errorf("MySQL 表格 EXPLAIN 无有效行") + } + + result := &connection.ExplainResult{} + colID := lookupTSVColumn(header, "id") + colType := lookupTSVColumn(header, "type") + colTable := lookupTSVColumn(header, "table") + colKey := lookupTSVColumn(header, "key") + colRows := lookupTSVColumn(header, "rows") + colExtra := lookupTSVColumn(header, "extra") + + // MySQL 的 id 字段表达父子:相同 id 是同一 SELECT 内的 join,id 不同代表子查询 + // 简化处理:每行作为独立节点,无父子(PR2 增强) + var lastID string + for _, row := range rows { + var idStr string + if colID >= 0 && colID < len(row) { + idStr = strings.TrimSpace(row[colID]) + } + if idStr == "" { + idStr = lastID + } + lastID = idStr + + var accessType string + if colType >= 0 && colType < len(row) { + accessType = strings.TrimSpace(row[colType]) + } + node := connection.ExplainNode{ + OpType: classifyMySQLAccessType(accessType), + OpDetail: fmt.Sprintf("id=%s type=%s", idStr, strings.ToLower(accessType)), + } + if colTable >= 0 && colTable < len(row) { + node.Table = strings.TrimSpace(row[colTable]) + } + if colKey >= 0 && colKey < len(row) { + node.Index = strings.TrimSpace(row[colKey]) + } + if colRows >= 0 && colRows < len(row) { + node.EstRows = parseExplainInt64(row[colRows]) + } + if colExtra >= 0 && colExtra < len(row) { + extra := strings.TrimSpace(row[colExtra]) + if extra != "" { + node.Extra = map[string]any{"extra": extra} + lower := strings.ToLower(extra) + if strings.Contains(lower, "using filesort") { + node.Flags = append(node.Flags, connection.ExplainFlagFilesort) + } + if strings.Contains(lower, "using temporary") { + node.Flags = append(node.Flags, connection.ExplainFlagTempTable) + } + } + } + if node.OpType == connection.ExplainOpScan { + node.Flags = append(node.Flags, connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex) + } + appendExplainChild(result, "", node) + } + return result, nil +} diff --git a/internal/app/explain_parse_mysql_test.go b/internal/app/explain_parse_mysql_test.go new file mode 100644 index 0000000..8674d30 --- /dev/null +++ b/internal/app/explain_parse_mysql_test.go @@ -0,0 +1,210 @@ +package app + +import ( + "testing" + + "GoNavi-Wails/internal/connection" +) + +// MySQL FORMAT=JSON fixture:单表全表扫描。 +const mySQLFormatJSONSingleTableFullScan = `{ + "query_block": { + "select_id": 1, + "cost_info": {"query_cost": "100.00"}, + "table": { + "table_name": "users", + "access_type": "ALL", + "rows_examined_per_scan": 10000, + "rows_produced_per_join": 1000, + "filtered": "10.00", + "cost_info": {"read_cost": "100.00"}, + "used_columns": ["id", "name", "email"] + } + } +}` + +func TestParseMySQLExplain_SingleTableFullScan(t *testing.T) { + result, err := parseMySQLExplain("mysql", "SELECT * FROM users", mySQLFormatJSONSingleTableFullScan, connection.ExplainFormatJSON) + if err != nil { + t.Fatalf("解析失败:%v", err) + } + if len(result.Nodes) != 1 { + t.Fatalf("应有 1 个节点,got=%d", len(result.Nodes)) + } + node := result.Nodes[0] + if node.OpType != connection.ExplainOpScan { + t.Fatalf("access_type=ALL 应归一化为 SCAN,got=%s", node.OpType) + } + if node.Table != "users" { + t.Fatalf("table got=%q want=users", node.Table) + } + if node.EstRows != 10000 { + t.Fatalf("EstRows got=%d want=10000", node.EstRows) + } + if !containsFlag(node.Flags, connection.ExplainFlagFullScan) { + t.Fatalf("全表扫描节点应有 FULL_SCAN flag,got=%v", node.Flags) + } + if !containsFlag(node.Flags, connection.ExplainFlagNoIndex) { + t.Fatalf("全表扫描节点应有 NO_INDEX flag,got=%v", node.Flags) + } + if !result.Stats.HasFullScan { + t.Fatalf("Stats.HasFullScan 应为 true") + } + if result.Stats.TotalCost != 100.0 { + t.Fatalf("TotalCost got=%v want=100", result.Stats.TotalCost) + } + if result.Stats.RowsRead != 10000 { + t.Fatalf("RowsRead got=%d want=10000", result.Stats.RowsRead) + } +} + +// MySQL FORMAT=JSON fixture:两表 JOIN(一个走索引,一个走全表)。 +const mySQLFormatJSONJoinScanAndIndex = `{ + "query_block": { + "select_id": 1, + "cost_info": {"query_cost": "250.00"}, + "nested_loop": [ + { + "table": { + "table_name": "orders", + "access_type": "ALL", + "rows_examined_per_scan": 5000, + "cost_info": {"read_cost": "100.00"} + } + }, + { + "table": { + "table_name": "users", + "access_type": "eq_ref", + "possible_keys": ["PRIMARY"], + "key": "PRIMARY", + "used_key_parts": ["id"], + "rows_examined_per_scan": 1, + "cost_info": {"read_cost": "150.00"} + } + } + ] + } +}` + +func TestParseMySQLExplain_JoinScanAndIndex(t *testing.T) { + result, err := parseMySQLExplain("mysql", "SELECT * FROM orders o JOIN users u ON o.user_id = u.id", mySQLFormatJSONJoinScanAndIndex, connection.ExplainFormatJSON) + if err != nil { + t.Fatalf("解析失败:%v", err) + } + if len(result.Nodes) != 2 { + t.Fatalf("应有 2 个节点(nested_loop 内 2 个 table),got=%d", len(result.Nodes)) + } + if result.Nodes[0].Table != "orders" { + t.Fatalf("第一个表应是 orders,got=%s", result.Nodes[0].Table) + } + if result.Nodes[0].OpType != connection.ExplainOpScan { + t.Fatalf("orders access_type=ALL 应为 SCAN,got=%s", result.Nodes[0].OpType) + } + if result.Nodes[1].Table != "users" { + t.Fatalf("第二个表应是 users,got=%s", result.Nodes[1].Table) + } + if result.Nodes[1].OpType != connection.ExplainOpIndexScan { + t.Fatalf("users access_type=eq_ref 应为 INDEX_SCAN,got=%s", result.Nodes[1].OpType) + } + if result.Nodes[1].Index != "PRIMARY" { + t.Fatalf("users 使用 PRIMARY key,got=%s", result.Nodes[1].Index) + } + // stats:orders 估算 5000 行 + if result.Stats.RowsRead != 5000+1 { + t.Fatalf("RowsRead 应为两表 EstRows 之和 (5000+1),got=%d", result.Stats.RowsRead) + } +} + +// MySQL FORMAT=JSON fixture:含 ordering_operation 包装层。 +const mySQLFormatJSONWithOrder = `{ + "query_block": { + "select_id": 1, + "ordering_operation": { + "table": { + "table_name": "t", + "access_type": "ALL", + "rows_examined_per_scan": 100, + "cost_info": {"read_cost": "10.00"} + } + } + } +}` + +func TestParseMySQLExplain_WithOrderingOperation(t *testing.T) { + result, err := parseMySQLExplain("mysql", "SELECT * FROM t ORDER BY id", mySQLFormatJSONWithOrder, connection.ExplainFormatJSON) + if err != nil { + t.Fatalf("解析失败:%v", err) + } + // 应该有 2 个节点:ordering 层 + table 层 + if len(result.Nodes) != 2 { + t.Fatalf("ordering_operation 应展开为 2 个节点,got=%d", len(result.Nodes)) + } + if result.Nodes[0].OpType != connection.ExplainOpSort { + t.Fatalf("ordering_operation 顶层节点应为 SORT,got=%s", result.Nodes[0].OpType) + } + if result.Nodes[1].OpType != connection.ExplainOpScan { + t.Fatalf("内层 table 应为 SCAN,got=%s", result.Nodes[1].OpType) + } + // 验证父子边 + if len(result.Edges) != 1 { + t.Fatalf("应有 1 条边,got=%d", len(result.Edges)) + } + if result.Edges[0].From != result.Nodes[0].ID || result.Edges[0].To != result.Nodes[1].ID { + t.Fatalf("边应连接 SORT -> SCAN") + } +} + +// MySQL 5.7 表格模式 fallback。 +const mySQLTableExplainOutput = `id select_type table type possible_keys key key_len ref rows Extra +1 SIMPLE users ALL NULL NULL NULL NULL 10000 Using where +1 SIMPLE orders ref idx_uid idx_uid 4 const 5 Using filesort` + +func TestParseMySQLExplain_TableFormatFallback(t *testing.T) { + result, err := parseMySQLExplain("mysql", "SELECT * FROM users", mySQLTableExplainOutput, connection.ExplainFormatTable) + if err != nil { + t.Fatalf("表格解析失败:%v", err) + } + if len(result.Nodes) != 2 { + t.Fatalf("应有 2 个节点,got=%d", len(result.Nodes)) + } + if result.Nodes[0].OpType != connection.ExplainOpScan { + t.Fatalf("users type=ALL 应为 SCAN,got=%s", result.Nodes[0].OpType) + } + if !containsFlag(result.Nodes[0].Flags, connection.ExplainFlagFullScan) { + t.Fatalf("users 应有 FULL_SCAN flag") + } + if result.Nodes[1].Index != "idx_uid" { + t.Fatalf("orders 使用 idx_uid,got=%s", result.Nodes[1].Index) + } + if !containsFlag(result.Nodes[1].Flags, connection.ExplainFlagFilesort) { + t.Fatalf("orders Extra 含 Using filesort,应有 FILESORT flag") + } + if result.RawFormat != connection.ExplainFormatTable { + t.Fatalf("RawFormat got=%v want=table", result.RawFormat) + } +} + +func TestParseMySQLExplain_EmptyRawReturnsError(t *testing.T) { + _, err := parseMySQLExplain("mysql", "SELECT 1", " ", connection.ExplainFormatJSON) + if err == nil { + t.Fatal("空输入应返回 error") + } +} + +func TestParseMySQLExplain_InvalidJSONReturnsError(t *testing.T) { + _, err := parseMySQLExplain("mysql", "SELECT 1", "{ this is not valid json", connection.ExplainFormatJSON) + if err == nil { + t.Fatal("非法 JSON 应返回 error") + } +} + +// containsFlag 检查 flags 列表是否包含目标值。 +func containsFlag(flags []string, target string) bool { + for _, f := range flags { + if f == target { + return true + } + } + return false +} diff --git a/internal/app/explain_parse_postgres.go b/internal/app/explain_parse_postgres.go new file mode 100644 index 0000000..8224d9d --- /dev/null +++ b/internal/app/explain_parse_postgres.go @@ -0,0 +1,241 @@ +package app + +import ( + "encoding/json" + "fmt" + "strings" + + "GoNavi-Wails/internal/connection" +) + +// PostgreSQL EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) 解析。 +// +// 典型结构(PG 13+): +// +// [ +// { +// "Plan": { +// "Node Type": "Seq Scan", +// "Relation Name": "t", +// "Alias": "t", +// "Startup Cost": 0.00, +// "Total Cost": 100.00, +// "Plan Rows": 1000, +// "Plan Width": 4, +// "Actual Startup Time": 0.01, +// "Actual Total Time": 1.23, +// "Actual Rows": 1000, +// "Actual Loops": 1, +// "Filter": "(id > 100)", +// "Rows Removed by Filter": 100, +// "Shared Hit Blocks": 50, +// "Shared Read Blocks": 0, +// "Plans": [...] // 递归子节点 +// }, +// "Planning Time": 0.15, +// "Execution Time": 1.30, +// "Triggers": [], +// "Execution Buffers": {...} +// } +// ] +// +// 多语句时数组可能有多个元素,但 EXPLAIN 单条 SQL 时通常是 1 个。 + +func parsePostgresExplain(dbType, sourceSQL, raw string, format connection.ExplainFormat) (connection.ExplainResult, error) { + result := connection.ExplainResult{ + DBType: dbType, + SourceSQL: sourceSQL, + } + resetExplainNodeID() + + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return result, fmt.Errorf("PostgreSQL EXPLAIN 返回空内容") + } + + if !strings.HasPrefix(trimmed, "[") && !strings.HasPrefix(trimmed, "{") { + // 老版本 PG 无 FORMAT JSON 时返回文本表格——PR2 增强 + result.RawFormat = connection.ExplainFormatText + result.RawPayload = raw + result.Warnings = []string{"PostgreSQL 返回非 JSON 格式(可能未启用 FORMAT JSON),原文保留"} + return result, nil + } + + var top []map[string]json.RawMessage + if err := json.Unmarshal([]byte(trimmed), &top); err != nil { + // 单对象(无外层数组)兼容 + var single map[string]json.RawMessage + if err2 := json.Unmarshal([]byte(trimmed), &single); err2 == nil { + top = []map[string]json.RawMessage{single} + } else { + return result, fmt.Errorf("PostgreSQL JSON 解析失败:%w", err) + } + } + + if len(top) == 0 { + return result, fmt.Errorf("PostgreSQL EXPLAIN 数组为空") + } + + var warnings []string + for _, item := range top { + // 顶层 Execution Time / Planning Time + if etRaw, ok := item["Execution Time"]; ok { + var et float64 + if err := json.Unmarshal(etRaw, &et); err == nil { + result.Stats.TotalDurationMs = et + } + } + planRaw, ok := item["Plan"] + if !ok { + continue + } + parsePostgresPlanNode(planRaw, "", &result, &warnings) + } + + result.RawFormat = connection.ExplainFormatJSON + result.RawPayload = raw + result.Warnings = warnings + finalizeExplainStats(&result) + return result, nil +} + +// pgPlanNode 映射 PG FORMAT JSON 的 Plan 结构(部分字段,未识别字段保留在 raw 中备用)。 +type pgPlanNode struct { + NodeType string `json:"Node Type"` + RelationName string `json:"Relation Name"` + Alias string `json:"Alias"` + Schema string `json:"Schema"` + StartupCost float64 `json:"Startup Cost"` + TotalCost float64 `json:"Total Cost"` + PlanRows json.Number `json:"Plan Rows"` + PlanWidth json.Number `json:"Plan Width"` + ActualStartup float64 `json:"Actual Startup Time"` + ActualTotal float64 `json:"Actual Total Time"` + ActualRows json.Number `json:"Actual Rows"` + ActualLoops json.Number `json:"Actual Loops"` + IndexName string `json:"Index Name"` + Filter string `json:"Filter"` + HashCond string `json:"Hash Cond"` + JoinType string `json:"Join Type"` + Strategy string `json:"Strategy"` + SharedHit json.Number `json:"Shared Hit Blocks"` + SharedRead json.Number `json:"Shared Read Blocks"` + Output []string `json:"Output"` + Plans []json.RawMessage `json:"Plans"` +} + +// parsePostgresPlanNode 递归解析 PG Plan 节点。 +func parsePostgresPlanNode(planRaw json.RawMessage, parentID string, result *connection.ExplainResult, warnings *[]string) { + var node pgPlanNode + if err := json.Unmarshal(planRaw, &node); err != nil { + *warnings = append(*warnings, fmt.Sprintf("PG Plan 节点反序列化失败:%v", err)) + return + } + + en := connection.ExplainNode{ + OpType: classifyPostgresNodeType(node.NodeType, node.IndexName), + OpDetail: node.NodeType, + Table: pickPostgresTableName(node), + Index: node.IndexName, + EstRows: parseExplainInt64(string(node.PlanRows)), + ActualRows: parseExplainInt64(string(node.ActualRows)), + Loops: parseExplainInt64(string(node.ActualLoops)), + Cost: node.StartupCost + node.TotalCost, + DurationMs: node.ActualTotal, + } + if node.Strategy != "" { + en.Extra = map[string]any{"strategy": node.Strategy} + } + if node.Filter != "" { + if en.Extra == nil { + en.Extra = map[string]any{} + } + en.Extra["filter"] = node.Filter + } + if node.HashCond != "" { + if en.Extra == nil { + en.Extra = map[string]any{} + } + en.Extra["hashCond"] = node.HashCond + } + if node.JoinType != "" { + if en.Extra == nil { + en.Extra = map[string]any{} + } + en.Extra["joinType"] = node.JoinType + } + + // BufferHit 命中率:Shared Hit / (Shared Hit + Shared Read) + hit := parseExplainInt64(string(node.SharedHit)) + read := parseExplainInt64(string(node.SharedRead)) + if hit+read > 0 { + en.BufferHit = float64(hit) / float64(hit+read) + if en.BufferHit < 0.5 { + en.Flags = append(en.Flags, connection.ExplainFlagLowBufferHit) + } + } + + if en.OpType == connection.ExplainOpScan { + en.Flags = append(en.Flags, connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex) + } + + // Sort/Hash Join 等可能用临时表 + ntLower := strings.ToLower(node.NodeType) + if strings.Contains(ntLower, "sort") { + en.Flags = append(en.Flags, connection.ExplainFlagFilesort) + } + if strings.Contains(ntLower, "materialize") || strings.Contains(ntLower, "hash") { + en.Flags = append(en.Flags, connection.ExplainFlagTempTable) + } + + nodeID := appendExplainChild(result, parentID, en) + for _, childRaw := range node.Plans { + parsePostgresPlanNode(childRaw, nodeID, result, warnings) + } +} + +// classifyPostgresNodeType 把 PG Node Type 归一化到通用 OpType。 +// 例如 Seq Scan → SCAN;Index Scan/Index Only Scan → INDEX_SCAN/INDEX_ONLY; +// Hash Join/Nested Loop/Merge Join → JOIN;Aggregate/GroupAggregate → AGGREGATE;Sort → SORT。 +func classifyPostgresNodeType(nodeType, indexName string) string { + nt := strings.ToLower(strings.TrimSpace(nodeType)) + switch { + case strings.Contains(nt, "seq scan"): + return connection.ExplainOpScan + case strings.Contains(nt, "index only scan"): + return connection.ExplainOpIndexOnly + case strings.Contains(nt, "index scan"), strings.Contains(nt, "bitmap index"): + return connection.ExplainOpIndexScan + case strings.Contains(nt, "join"): + return connection.ExplainOpJoin + case strings.Contains(nt, "aggregate"), strings.Contains(nt, "group"): + return connection.ExplainOpAggregate + case strings.Contains(nt, "sort"): + return connection.ExplainOpSort + case strings.Contains(nt, "limit"): + return connection.ExplainOpLimit + case strings.Contains(nt, "subquery"), strings.Contains(nt, "subplan"): + return connection.ExplainOpSubquery + case strings.Contains(nt, "union"): + return connection.ExplainOpUnion + case strings.Contains(nt, "window"): + return connection.ExplainOpWindow + case strings.Contains(nt, "materialize"): + return connection.ExplainOpMaterialize + case strings.Contains(nt, "result"), strings.Contains(nt, "filter"): + return connection.ExplainOpFilter + default: + return connection.ExplainOpOther + } +} + +// pickPostgresTableName 提取 PG Plan 中的表名(Schema.RelationName 或仅 RelationName)。 +func pickPostgresTableName(node pgPlanNode) string { + if node.RelationName == "" { + return "" + } + if node.Schema != "" { + return node.Schema + "." + node.RelationName + } + return node.RelationName +} diff --git a/internal/app/explain_parse_postgres_test.go b/internal/app/explain_parse_postgres_test.go new file mode 100644 index 0000000..966cb00 --- /dev/null +++ b/internal/app/explain_parse_postgres_test.go @@ -0,0 +1,194 @@ +package app + +import ( + "testing" + + "GoNavi-Wails/internal/connection" +) + +// PG FORMAT JSON fixture:单 Seq Scan + 低缓冲命中。 +const postgresFormatJSONSeqScan = `[ + { + "Plan": { + "Node Type": "Seq Scan", + "Relation Name": "users", + "Schema": "public", + "Alias": "users", + "Startup Cost": 0.00, + "Total Cost": 154.00, + "Plan Rows": 1540, + "Plan Width": 36, + "Actual Startup Time": 0.012, + "Actual Total Time": 1.234, + "Actual Rows": 1500, + "Actual Loops": 1, + "Filter": "(age > 18)", + "Rows Removed by Filter": 40, + "Shared Hit Blocks": 10, + "Shared Read Blocks": 50 + }, + "Planning Time": 0.123, + "Execution Time": 1.456 + } +]` + +func TestParsePostgresExplain_SeqScan(t *testing.T) { + result, err := parsePostgresExplain("postgres", "SELECT * FROM users WHERE age > 18", postgresFormatJSONSeqScan, connection.ExplainFormatJSON) + if err != nil { + t.Fatalf("解析失败:%v", err) + } + if len(result.Nodes) != 1 { + t.Fatalf("应有 1 个节点,got=%d", len(result.Nodes)) + } + node := result.Nodes[0] + if node.OpType != connection.ExplainOpScan { + t.Fatalf("Seq Scan 应为 SCAN,got=%s", node.OpType) + } + if node.Table != "public.users" { + t.Fatalf("Table 应含 schema,got=%q", node.Table) + } + if node.EstRows != 1540 { + t.Fatalf("EstRows got=%d want=1540", node.EstRows) + } + if node.ActualRows != 1500 { + t.Fatalf("ActualRows got=%d want=1500", node.ActualRows) + } + if node.Loops != 1 { + t.Fatalf("Loops got=%d want=1", node.Loops) + } + // BufferHit = 10 / (10+50) = 0.166... + if node.BufferHit < 0.16 || node.BufferHit > 0.17 { + t.Fatalf("BufferHit 应约 0.167,got=%v", node.BufferHit) + } + if !containsFlag(node.Flags, connection.ExplainFlagLowBufferHit) { + t.Fatalf("缓冲命中率低应有 LOW_BUFFER_HIT flag") + } + if !containsFlag(node.Flags, connection.ExplainFlagFullScan) { + t.Fatalf("Seq Scan 应有 FULL_SCAN flag") + } + if result.Stats.TotalDurationMs != 1.456 { + t.Fatalf("Execution Time 应写到 Stats.TotalDurationMs,got=%v", result.Stats.TotalDurationMs) + } +} + +// PG FORMAT JSON fixture:Hash Join + 子节点(Seq Scan + Index Scan)。 +const postgresFormatJSONHashJoin = `[ + { + "Plan": { + "Node Type": "Hash Join", + "Join Type": "Inner", + "Hash Cond": "(o.user_id = u.id)", + "Startup Cost": 50.00, + "Total Cost": 200.00, + "Plan Rows": 1000, + "Actual Rows": 950, + "Actual Loops": 1, + "Plans": [ + { + "Node Type": "Seq Scan", + "Relation Name": "orders", + "Alias": "o", + "Startup Cost": 0.00, + "Total Cost": 100.00, + "Plan Rows": 5000, + "Actual Rows": 5000, + "Actual Loops": 1 + }, + { + "Node Type": "Hash", + "Startup Cost": 25.00, + "Total Cost": 25.00, + "Plan Rows": 100, + "Plans": [ + { + "Node Type": "Index Scan", + "Relation Name": "users", + "Alias": "u", + "Index Name": "users_pkey", + "Startup Cost": 0.15, + "Total Cost": 25.00, + "Plan Rows": 100 + } + ] + } + ] + }, + "Execution Time": 5.5 + } +]` + +func TestParsePostgresExplain_HashJoinWithChildren(t *testing.T) { + result, err := parsePostgresExplain("postgres", "SELECT * FROM orders o JOIN users u ON o.user_id = u.id", postgresFormatJSONHashJoin, connection.ExplainFormatJSON) + if err != nil { + t.Fatalf("解析失败:%v", err) + } + // 应该有 4 个节点:Hash Join + Seq Scan + Hash + Index Scan + if len(result.Nodes) != 4 { + t.Fatalf("应有 4 个节点,got=%d(nodes=%+v)", len(result.Nodes), result.Nodes) + } + join := result.Nodes[0] + if join.OpType != connection.ExplainOpJoin { + t.Fatalf("顶层应为 JOIN,got=%s", join.OpType) + } + if join.Extra["hashCond"] != "(o.user_id = u.id)" { + t.Fatalf("HashCond 应保留,got=%v", join.Extra["hashCond"]) + } + if join.Extra["joinType"] != "Inner" { + t.Fatalf("JoinType 应保留,got=%v", join.Extra["joinType"]) + } + if !containsFlag(join.Flags, connection.ExplainFlagTempTable) { + t.Fatalf("Hash 节点应有 TEMP_TABLE flag") + } + // 找到 orders Seq Scan + var seqScanNode *connection.ExplainNode + var indexScanNode *connection.ExplainNode + for i := range result.Nodes { + switch result.Nodes[i].OpType { + case connection.ExplainOpScan: + seqScanNode = &result.Nodes[i] + case connection.ExplainOpIndexScan: + indexScanNode = &result.Nodes[i] + } + } + if seqScanNode == nil { + t.Fatal("应有一个 Seq Scan 节点") + } + if seqScanNode.Table != "orders" { + t.Fatalf("Seq Scan 应为 orders 表,got=%s", seqScanNode.Table) + } + if indexScanNode == nil { + t.Fatal("应有一个 Index Scan 节点") + } + if indexScanNode.Index != "users_pkey" { + t.Fatalf("Index Scan 应使用 users_pkey,got=%s", indexScanNode.Index) + } + // Edges:3 条(顶层无父;Seq Scan + Hash 是顶层子;Index Scan 是 Hash 子) + if len(result.Edges) != 3 { + t.Fatalf("应有 3 条边,got=%d", len(result.Edges)) + } +} + +// PG 老版本无 FORMAT JSON 时返回文本。 +func TestParsePostgresExplain_TextFallbackKeepsRaw(t *testing.T) { + raw := "Seq Scan on users (cost=0.00..154.00 rows=1540)" + result, err := parsePostgresExplain("postgres", "SELECT * FROM users", raw, connection.ExplainFormatText) + if err != nil { + t.Fatalf("非 JSON 输入应降级返回原文而非 error:%v", err) + } + if len(result.Warnings) == 0 { + t.Fatal("应有降级 warning") + } + if result.RawPayload != raw { + t.Fatalf("RawPayload 应保留原文") + } + if result.RawFormat != connection.ExplainFormatText { + t.Fatalf("RawFormat got=%v want=text", result.RawFormat) + } +} + +func TestParsePostgresExplain_EmptyRawReturnsError(t *testing.T) { + _, err := parsePostgresExplain("postgres", "SELECT 1", " ", connection.ExplainFormatJSON) + if err == nil { + t.Fatal("空输入应返回 error") + } +} diff --git a/internal/app/explain_parse_sqlite.go b/internal/app/explain_parse_sqlite.go new file mode 100644 index 0000000..d2508c3 --- /dev/null +++ b/internal/app/explain_parse_sqlite.go @@ -0,0 +1,241 @@ +package app + +import ( + "fmt" + "strconv" + "strings" + + "GoNavi-Wails/internal/connection" +) + +// SQLite EXPLAIN QUERY PLAN 解析。 +// +// SQLite EQP 输出是 4 列表格: +// +// id | parent | notused | detail +// 2 | 0 | 0 | SCAN TABLE t +// 3 | 0 | 0 | SEARCH TABLE t USING INDEX idx_x (col=?) +// 7 | 0 | 0 | USE TEMP B-TREE FOR ORDER BY +// 21 | 0 | 0 | COMPOUND QUERY +// 22 | 0 | 0 | USE TEMP B-TREE FOR LAST DISTINCT +// +// id 字段语义: +// - 同一 id 多行:同一节点的多个细节行(如"SCAN" + "USE TEMP B-TREE") +// - 不同 id:不同节点;parent 字段指向父节点 id +// +// detail 文本模式: +// - "SCAN TABLE " 或 "SCAN ":全表扫描 +// - "SEARCH TABLE USING INDEX ()":索引扫描 +// - "SEARCH TABLE USING PRIMARY KEY ()":主键扫描 +// - "USE TEMP B-TREE FOR ORDER BY":filesort +// - "USE TEMP B-TREE FOR DISTINCT":临时表 +// - "COMPOUND QUERY":UNION/INTERSECT 等 +// - "CORRELATED SCALAR SUBQUERY":子查询 +// - "CO-ROUTINE ":协程 + +func parseSQLiteExplain(sourceSQL, raw string, format connection.ExplainFormat) (connection.ExplainResult, error) { + result := connection.ExplainResult{ + DBType: "sqlite", + SourceSQL: sourceSQL, + } + resetExplainNodeID() + + header, rows := parseExplainTSVRows(raw) + if len(header) == 0 || len(rows) == 0 { + return result, fmt.Errorf("SQLite EQP 输出无有效行") + } + + colID := lookupTSVColumn(header, "id") + colParent := lookupTSVColumn(header, "parent") + colDetail := lookupTSVColumn(header, "detail") + if colID < 0 || colDetail < 0 { + return result, fmt.Errorf("SQLite EQP 输出缺少 id 或 detail 列") + } + + // 同一 id 多行:合并 detail 后作为单节点 + // 不同 id 的父子通过 parent 关联 + type eqpEntry struct { + ID string + ParentID string + Details []string + NodeID string // 归一化后的 ExplainNode.ID + } + entries := make(map[string]*eqpEntry) + var order []string // 保持 id 出现顺序 + + for _, row := range rows { + var id, parent, detail string + if colID < len(row) { + id = strings.TrimSpace(row[colID]) + } + if colParent >= 0 && colParent < len(row) { + parent = strings.TrimSpace(row[colParent]) + } + if colDetail < len(row) { + detail = strings.TrimSpace(row[colDetail]) + } + if id == "" { + continue + } + + entry, exists := entries[id] + if !exists { + entry = &eqpEntry{ID: id, ParentID: parent} + entries[id] = entry + order = append(order, id) + } + if detail != "" { + entry.Details = append(entry.Details, detail) + } + } + + // 按 id 出现顺序生成节点(SQLite 保证父先于子) + for _, id := range order { + entry := entries[id] + node := buildSQLiteNodeFromDetails(entry.Details) + parentNodeID := "" + if entry.ParentID != "" && entry.ParentID != "0" { + if parent, ok := entries[entry.ParentID]; ok && parent.NodeID != "" { + parentNodeID = parent.NodeID + } + } + entry.NodeID = appendExplainChild(&result, parentNodeID, node) + } + + result.RawFormat = connection.ExplainFormatTable + result.RawPayload = raw + finalizeExplainStats(&result) + return result, nil +} + +// buildSQLiteNodeFromDetails 把 SQLite EQP 的多个 detail 行合并为单节点。 +// 第一行通常是主操作(SCAN/SEARCH),后续行是附加标志(USE TEMP B-TREE 等)。 +// +// 注意:SQLite 在某些场景下 "USE TEMP B-TREE ..." 会作为独立 id 出现(不是 SCAN 的附加行), +// 此时主操作本身就是 USE TEMP B-TREE,需要识别为附加 flag 节点(OpType 保持 OTHER)。 +func buildSQLiteNodeFromDetails(details []string) connection.ExplainNode { + node := connection.ExplainNode{OpType: connection.ExplainOpOther} + if len(details) == 0 { + return node + } + + // 主操作从第一行解析 + primary := details[0] + node.OpDetail = primary + lower := strings.ToLower(primary) + + switch { + case strings.HasPrefix(lower, "scan"): + node.OpType = connection.ExplainOpScan + node.Table = extractSQLiteTableName(primary) + node.Flags = append(node.Flags, connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex) + case strings.HasPrefix(lower, "search"): + node.OpType = classifySQLiteSearchOp(primary) + node.Table = extractSQLiteTableName(primary) + node.Index = extractSQLiteIndexName(primary) + case strings.HasPrefix(lower, "compound"): + node.OpType = connection.ExplainOpUnion + case strings.HasPrefix(lower, "correlated"), strings.HasPrefix(lower, "scalar subquery"): + node.OpType = connection.ExplainOpSubquery + case strings.HasPrefix(lower, "co-routine"): + node.OpType = connection.ExplainOpOther + case strings.HasPrefix(lower, "use temp b-tree"): + // 独立 id 形式的附加 flag 节点:直接打 flag,OpType 保持 OTHER + if strings.Contains(lower, "order by") { + node.Flags = append(node.Flags, connection.ExplainFlagFilesort) + } else { + node.Flags = append(node.Flags, connection.ExplainFlagTempTable) + } + } + + // 后续行是附加 flag(仅当主行不是 USE TEMP B-TREE 时才处理,避免重复) + if !strings.HasPrefix(lower, "use temp b-tree") { + for _, d := range details[1:] { + dl := strings.ToLower(d) + switch { + case strings.Contains(dl, "temp b-tree"): + if strings.Contains(dl, "order by") { + node.Flags = append(node.Flags, connection.ExplainFlagFilesort) + } else { + node.Flags = append(node.Flags, connection.ExplainFlagTempTable) + } + case strings.Contains(dl, "subquery"): + node.Flags = append(node.Flags, "SUBQUERY") + } + if node.Extra == nil { + node.Extra = map[string]any{} + } + node.Extra["extra"] = d + } + } + return node +} + +// classifySQLiteSearchOp 区分 SQLite SEARCH 的索引类型。 +// USING INDEX → INDEX_SCAN;USING PRIMARY KEY → INDEX_SCAN;USING ROWID → SCAN(伪索引扫描)。 +func classifySQLiteSearchOp(detail string) string { + lower := strings.ToLower(detail) + if strings.Contains(lower, "using covering index") { + return connection.ExplainOpIndexOnly + } + if strings.Contains(lower, "using index") || strings.Contains(lower, "using primary key") { + return connection.ExplainOpIndexScan + } + if strings.Contains(lower, "using rowid") { + // ROWID 扫描本质还是按物理位置顺序访问 + return connection.ExplainOpScan + } + return connection.ExplainOpIndexScan +} + +// extractSQLiteTableName 从 detail 文本中提取表名。 +// 形如 "SCAN TABLE users" → "users";"SEARCH TABLE users USING INDEX idx_x (id)" → "users"。 +func extractSQLiteTableName(detail string) string { + upper := strings.ToUpper(detail) + for _, marker := range []string{"TABLE ", "VIEW "} { + idx := strings.Index(upper, marker) + if idx < 0 { + continue + } + rest := detail[idx+len(marker):] + // 截到下一个空格或 USING 之前 + for i, ch := range rest { + if ch == ' ' || ch == '\t' { + return strings.TrimSpace(rest[:i]) + } + } + return strings.TrimSpace(rest) + } + return "" +} + +// extractSQLiteIndexName 从 detail 中提取使用的索引名。 +// 形如 "USING INDEX idx_x (id)" → "idx_x";"USING PRIMARY KEY" → "PRIMARY"。 +func extractSQLiteIndexName(detail string) string { + upper := strings.ToUpper(detail) + for _, marker := range []string{"USING INDEX ", "USING PRIMARY KEY", "USING COVERING INDEX "} { + idx := strings.Index(upper, marker) + if idx < 0 { + continue + } + rest := detail[idx+len(marker):] + if marker == "USING PRIMARY KEY" { + return "PRIMARY" + } + // 截到下一个空格或左括号 + for i, ch := range rest { + if ch == ' ' || ch == '\t' || ch == '(' { + if i == 0 { + return "" + } + name := strings.TrimSpace(rest[:i]) + if _, err := strconv.Atoi(name); err == nil { + continue + } + return name + } + } + return strings.TrimSpace(rest) + } + return "" +} diff --git a/internal/app/explain_parse_sqlite_test.go b/internal/app/explain_parse_sqlite_test.go new file mode 100644 index 0000000..1226db5 --- /dev/null +++ b/internal/app/explain_parse_sqlite_test.go @@ -0,0 +1,138 @@ +package app + +import ( + "testing" + + "GoNavi-Wails/internal/connection" +) + +// SQLite EQP fixture:单表全表扫描 + filesort。 +const sqliteEQPFullScanWithSort = `id parent notused detail +2 0 0 SCAN TABLE users +5 0 0 USE TEMP B-TREE FOR ORDER BY` + +func TestParseSQLiteExplain_FullScanWithFileSort(t *testing.T) { + result, err := parseSQLiteExplain("SELECT * FROM users ORDER BY name", sqliteEQPFullScanWithSort, connection.ExplainFormatTable) + if err != nil { + t.Fatalf("解析失败:%v", err) + } + // 2 个独立节点(id 不同,parent 都是 0,无父子关系) + if len(result.Nodes) != 2 { + t.Fatalf("应有 2 个节点,got=%d", len(result.Nodes)) + } + scan := result.Nodes[0] + if scan.OpType != connection.ExplainOpScan { + t.Fatalf("第一个应为 SCAN,got=%s", scan.OpType) + } + if scan.Table != "users" { + t.Fatalf("table got=%s want=users", scan.Table) + } + if !containsFlag(scan.Flags, connection.ExplainFlagFullScan) { + t.Fatalf("SCAN 应有 FULL_SCAN flag") + } + if result.Stats.HasFullScan != true { + t.Fatalf("Stats.HasFullScan 应为 true") + } + if result.Stats.HasFilesort != true { + t.Fatalf("Stats.HasFilesort 应为 true") + } +} + +// SQLite EQP fixture:索引扫描。 +const sqliteEQPIndexScan = `id parent notused detail +3 0 0 SEARCH TABLE users USING INDEX idx_email (email=?)` + +func TestParseSQLiteExplain_IndexScanExtractsIndex(t *testing.T) { + result, err := parseSQLiteExplain("SELECT * FROM users WHERE email = 'x'", sqliteEQPIndexScan, connection.ExplainFormatTable) + if err != nil { + t.Fatalf("解析失败:%v", err) + } + if len(result.Nodes) != 1 { + t.Fatalf("应有 1 个节点,got=%d", len(result.Nodes)) + } + node := result.Nodes[0] + if node.OpType != connection.ExplainOpIndexScan { + t.Fatalf("USING INDEX 应为 INDEX_SCAN,got=%s", node.OpType) + } + if node.Table != "users" { + t.Fatalf("table got=%s want=users", node.Table) + } + if node.Index != "idx_email" { + t.Fatalf("index got=%s want=idx_email", node.Index) + } +} + +// SQLite EQP fixture:主键扫描 + 临时表(distinct)。 +const sqliteEQPPrimaryKeyWithDistinct = `id parent notused detail +3 0 0 SEARCH TABLE users USING PRIMARY KEY (id=?) +7 0 0 USE TEMP B-TREE FOR DISTINCT` + +func TestParseSQLiteExplain_PrimaryKeyAndDistinct(t *testing.T) { + result, err := parseSQLiteExplain("SELECT DISTINCT name FROM users WHERE id = 1", sqliteEQPPrimaryKeyWithDistinct, connection.ExplainFormatTable) + if err != nil { + t.Fatalf("解析失败:%v", err) + } + if len(result.Nodes) != 2 { + t.Fatalf("应有 2 个节点,got=%d", len(result.Nodes)) + } + pk := result.Nodes[0] + if pk.OpType != connection.ExplainOpIndexScan { + t.Fatalf("PRIMARY KEY 应为 INDEX_SCAN,got=%s", pk.OpType) + } + if pk.Index != "PRIMARY" { + t.Fatalf("index got=%s want=PRIMARY", pk.Index) + } + if result.Stats.HasTempTable != true { + t.Fatalf("FOR DISTINCT 应触发 TEMP_TABLE flag") + } +} + +// SQLite EQP fixture:父子关系(子查询)。 +const sqliteEQPCorrelatedSubquery = `id parent notused detail +2 0 0 SCAN TABLE orders +6 2 0 CORRELATED SCALAR SUBQUERY 1 +8 6 0 SEARCH TABLE users USING INDEX idx_id (id=?)` + +func TestParseSQLiteExplain_HierarchicalRelationShips(t *testing.T) { + result, err := parseSQLiteExplain("SELECT *, (SELECT name FROM users WHERE id = o.user_id) FROM orders o", sqliteEQPCorrelatedSubquery, connection.ExplainFormatTable) + if err != nil { + t.Fatalf("解析失败:%v", err) + } + if len(result.Nodes) != 3 { + t.Fatalf("应有 3 个节点,got=%d", len(result.Nodes)) + } + // orders 是根(parent=0) + // CORRELATED SCALAR SUBQUERY 的 parent=2 → orders + // SEARCH 的 parent=6 → subquery + if result.Nodes[0].ParentID != "" { + t.Fatalf("根节点 ParentID 应为空,got=%q", result.Nodes[0].ParentID) + } + if result.Nodes[1].ParentID != result.Nodes[0].ID { + t.Fatalf("subquery 节点的 ParentID 应指向 orders") + } + if result.Nodes[2].ParentID != result.Nodes[1].ID { + t.Fatalf("SEARCH 节点的 ParentID 应指向 subquery") + } + if len(result.Edges) != 2 { + t.Fatalf("应有 2 条边,got=%d", len(result.Edges)) + } +} + +func TestParseSQLiteExplain_CoveringIndex(t *testing.T) { + raw := `id parent notused detail +3 0 0 SEARCH TABLE users USING COVERING INDEX idx_name_email (name=?)` + result, err := parseSQLiteExplain("SELECT name FROM users WHERE name = 'x'", raw, connection.ExplainFormatTable) + if err != nil { + t.Fatalf("解析失败:%v", err) + } + if result.Nodes[0].OpType != connection.ExplainOpIndexOnly { + t.Fatalf("COVERING INDEX 应为 INDEX_ONLY,got=%s", result.Nodes[0].OpType) + } +} + +func TestParseSQLiteExplain_MissingColumnsReturnsError(t *testing.T) { + _, err := parseSQLiteExplain("SELECT 1", "id parent\n1 0", connection.ExplainFormatTable) + if err == nil { + t.Fatal("缺少 detail 列应返回 error") + } +} diff --git a/internal/app/methods_explain.go b/internal/app/methods_explain.go new file mode 100644 index 0000000..ad54245 --- /dev/null +++ b/internal/app/methods_explain.go @@ -0,0 +1,353 @@ +package app + +import ( + "context" + "fmt" + "strings" + "time" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/db" + "GoNavi-Wails/internal/logger" + "GoNavi-Wails/internal/utils" +) + +// SQL 诊断工作台后端入口。 +// +// 数据流: +// 用户 SQL +// → DiagnoseQuery(白名单校验 + 调度) +// → executeExplain(决定走 ExplainExecer 还是 fallback 包装) +// → buildExplainQuery(方言特定的 EXPLAIN 语句构造) +// → dbInst.QueryMultiContextWithMessages(实际执行) +// → collectExplainRaw(合并结果集为原文) +// → parseExplainRaw(路由到方言解析器) +// → ExplainResult(归一化节点树 + Stats) +// +// 解析器实现在 explain_parse_.go。 + +// explainSupportedDBTypes 是一期支持的 EXPLAIN 数据源白名单。 +// 不在白名单内的数据源(MongoDB/Redis/TDengine 等)调用 DiagnoseQuery 时直接返回不支持。 +var explainSupportedDBTypes = map[string]bool{ + "mysql": true, + "mariadb": true, + "diros": true, // Doris 走 MySQL 协议,EXPLAIN 语法兼容 + "starrocks": true, // 同上 + "postgres": true, + "gaussdb": true, + "opengauss": true, + "kingbase": true, + "highgo": true, + "vastbase": true, + "sqlite": true, + "clickhouse": true, + "oracle": true, // 含 OceanBase Oracle 协议(resolveDDLDBType 已归一化) + "sqlserver": true, + "oceanbase": true, // MySQL 协议走 MySQL 语法 +} + +// explainStatementTimeoutFloor 是诊断的最小超时下限。 +// EXPLAIN 本身通常很快,但 ANALYZE 模式(PG/Oracle)会真实执行 SQL, +// 需要给足时间避免大查询超时。 +const explainStatementTimeoutFloor = 5 * time.Minute + +// DiagnoseQuery 是 SQL 诊断工作台对外暴露的入口。 +// 输入用户 SQL(仅允许 SELECT/WITH),返回执行计划归一化结果。 +// PR1 仅返回 ExplainResult;索引建议(Suggestions)在 PR2 规则引擎接入后填充。 +// +// Wails 绑定:前端通过 DiagnoseQuery(config, dbName, sql) 调用,返回 QueryResult.Data 为 DiagnoseReport。 +func (a *App) DiagnoseQuery(config connection.ConnectionConfig, dbName, query string) connection.QueryResult { + query = strings.TrimSpace(query) + if query == "" { + return connection.QueryResult{Success: false, Message: "查询语句不能为空"} + } + if !looksLikeSelectOrWith(query) { + return connection.QueryResult{Success: false, Message: "诊断仅支持 SELECT / WITH 查询;写操作请使用 EXPLAIN PLAN 模式(PR2 支持)"} + } + + runConfig := normalizeRunConfig(config, dbName) + dbType := resolveDDLDBType(runConfig) + if !explainSupportedDBTypes[dbType] { + return connection.QueryResult{ + Success: false, + Message: fmt.Sprintf("当前数据源(%s)暂不支持 SQL 诊断;一期支持 MySQL/PostgreSQL/SQLite/ClickHouse/Oracle/SQLServer/OceanBase", dbType), + } + } + + dbInst, err := a.getDatabase(runConfig) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + plan, err := a.executeExplain(dbInst, runConfig, dbType, query) + if err != nil { + logger.Warnf("DiagnoseQuery 执行 EXPLAIN 失败:type=%s err=%v sql=%q", dbType, err, sqlSnippet(query)) + return connection.QueryResult{Success: false, Message: err.Error()} + } + + report := connection.DiagnoseReport{Plan: plan} + return connection.QueryResult{Success: true, Message: "诊断完成", Data: report} +} + +// executeExplain 决定走哪条 EXPLAIN 执行路径: +// 1. 若 dbInst 实现 ExplainExecer(driver-agent 在 PR2 接入),优先用驱动原生实现 +// 2. 否则走 app 层 fallback:buildExplainQuery 构造 EXPLAIN 语句,通过 QueryMulti 执行 +func (a *App) executeExplain(dbInst db.Database, config connection.ConnectionConfig, dbType, query string) (connection.ExplainResult, error) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if timeout := getDiagnoseTimeout(config); timeout > 0 { + var cancelFn context.CancelFunc + ctx, cancelFn = utils.ContextWithTimeout(timeout) + defer cancelFn() + } + + // 优先:驱动自带 Explain(OceanBase driver-agent 走此路径) + if explainer, ok := dbInst.(db.ExplainExecer); ok { + logger.Infof("DiagnoseQuery 走 ExplainExecer 路径:type=%s", dbType) + raw, format, err := explainer.Explain(ctx, query) + if err != nil { + return connection.ExplainResult{}, fmt.Errorf("驱动 EXPLAIN 执行失败:%w", err) + } + return parseExplainRaw(dbType, query, raw, format) + } + + // Fallback:app 层构造 EXPLAIN 语句 + wrappedSQL, postQueries, preferFormat, cleanupQueries, err := buildExplainQuery(dbType, query) + if err != nil { + return connection.ExplainResult{}, err + } + defer runExplainCleanup(dbInst, cleanupQueries) + + raw, actualFormat, execErr := executeExplainStatements(ctx, dbInst, dbType, wrappedSQL, postQueries, preferFormat) + if execErr != nil { + return connection.ExplainResult{}, fmt.Errorf("执行 EXPLAIN 失败:%w", execErr) + } + return parseExplainRaw(dbType, query, raw, actualFormat) +} + +// runExplainCleanup 执行清理语句(如 Oracle DELETE FROM plan_table),失败仅记日志不阻塞主流程。 +// 在 defer 中调用,确保主 EXPLAIN 失败时也能尝试清理。 +func runExplainCleanup(dbInst db.Database, cleanupQueries []string) { + for _, q := range cleanupQueries { + if strings.TrimSpace(q) == "" { + continue + } + if _, err := dbInst.Exec(q); err != nil { + logger.Warnf("EXPLAIN 清理失败(可忽略):sql=%q err=%v", sqlSnippet(q), err) + } + } +} + +// executeExplainStatements 执行 EXPLAIN 主语句和后置查询(Oracle 的 DBMS_XPLAN.DISPLAY)。 +// 返回拼接后的原文 + 实际格式(可能与 preferFormat 不同,比如 MySQL 5.7 不支持 FORMAT=JSON 时降级)。 +func executeExplainStatements(ctx context.Context, dbInst db.Database, dbType, wrappedSQL string, postQueries []string, preferFormat connection.ExplainFormat) (string, connection.ExplainFormat, error) { + statements := []string{wrappedSQL} + statements = append(statements, postQueries...) + fullSQL := strings.Join(statements, ";\n") + + // 优先使用带 context 的多结果接口,便于取消 + if multi, ok := dbInst.(db.MultiResultQueryMessageExecer); ok { + results, _, err := multi.QueryMultiContextWithMessages(ctx, fullSQL) + if err != nil { + return "", preferFormat, err + } + return collectExplainRaw(results, preferFormat) + } + if multi, ok := dbInst.(db.MultiResultQuerierContext); ok { + results, err := multi.QueryMultiContext(ctx, fullSQL) + if err != nil { + return "", preferFormat, err + } + return collectExplainRaw(results, preferFormat) + } + if multi, ok := dbInst.(db.MultiResultQuerier); ok { + results, err := multi.QueryMulti(fullSQL) + if err != nil { + return "", preferFormat, err + } + return collectExplainRaw(results, preferFormat) + } + + // 单结果 fallback:只执行第一条 EXPLAIN,忽略 postQueries(不适合 Oracle/SQLServer) + data, _, err := dbInst.Query(wrappedSQL) + if err != nil { + return "", preferFormat, err + } + return collectExplainRaw([]connection.ResultSetData{{Rows: data}}, preferFormat) +} + +// collectExplainRaw 把多个结果集合并为单个原文,并探测实际格式。 +// MySQL FORMAT=JSON 返回 1 行 1 列包含完整 JSON 文本;表格模式返回多行多列。 +func collectExplainRaw(results []connection.ResultSetData, preferFormat connection.ExplainFormat) (string, connection.ExplainFormat, error) { + if len(results) == 0 { + return "", preferFormat, fmt.Errorf("EXPLAIN 未返回结果") + } + + // 大多数方言只有 1 个结果集;Oracle 有 2 个(EXPLAIN PLAN 影响 + DBMS_XPLAN.DISPLAY 查询) + // 取最后一个非空结果集作为 EXPLAIN 输出(DISPLAY 在 post 查询中) + last := pickLastNonEmptyResult(results) + if last == nil { + return "", preferFormat, fmt.Errorf("EXPLAIN 返回空结果集") + } + + // 单列单行 + 值是 JSON/XML 字符串 → 直接当原文 + if len(last.Columns) == 1 && len(last.Rows) == 1 { + for _, v := range last.Rows[0] { + text := strings.TrimSpace(fmt.Sprintf("%v", v)) + if text != "" && text != "" { + return text, detectExplainFormat(text, preferFormat), nil + } + } + } + + // 表格模式:把行重组成 TSV,解析器按列定位 + var builder strings.Builder + builder.WriteString(strings.Join(last.Columns, "\t")) + builder.WriteByte('\n') + for _, row := range last.Rows { + values := make([]string, 0, len(last.Columns)) + for _, col := range last.Columns { + val := row[col] + if val == nil { + values = append(values, "") + continue + } + values = append(values, fmt.Sprintf("%v", val)) + } + builder.WriteString(strings.Join(values, "\t")) + builder.WriteByte('\n') + } + return builder.String(), connection.ExplainFormatTable, nil +} + +// pickLastNonEmptyResult 找最后一个有行数据的结果集(Oracle 的 EXPLAIN PLAN 影响 0 行,DISPLAY 才有数据)。 +func pickLastNonEmptyResult(results []connection.ResultSetData) *connection.ResultSetData { + for i := len(results) - 1; i >= 0; i-- { + r := results[i] + if len(r.Rows) > 0 { + return &r + } + } + return nil +} + +// detectExplainFormat 探测原文实际格式(当驱动返回的是单字符串时)。 +// 优先信任 preferFormat;不可识别时按内容启发式判断。 +func detectExplainFormat(text string, preferFormat connection.ExplainFormat) connection.ExplainFormat { + trimmed := strings.TrimLeft(text, " \t\r\n") + switch { + case strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "["): + return connection.ExplainFormatJSON + case strings.HasPrefix(trimmed, ".go 中实现 parseXxxExplain,这里按 dbType 分发。 +// 未实现的方言返回原文 + 警告,保证主流程不阻塞。 +func parseExplainRaw(dbType, sourceSQL, raw string, format connection.ExplainFormat) (connection.ExplainResult, error) { + switch dbType { + case "mysql", "mariadb", "diros", "starrocks", "oceanbase": + return parseMySQLExplain(dbType, sourceSQL, raw, format) + case "postgres", "gaussdb", "opengauss", "kingbase", "highgo", "vastbase": + return parsePostgresExplain(dbType, sourceSQL, raw, format) + 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 + case "oracle": + // PR2 实现 + return connection.ExplainResult{ + DBType: dbType, + SourceSQL: sourceSQL, + RawFormat: format, + RawPayload: raw, + Warnings: []string{"Oracle 解析器在 PR2 实现,先返回原文"}, + }, nil + case "sqlserver": + // PR2 实现 + return connection.ExplainResult{ + DBType: dbType, + SourceSQL: sourceSQL, + RawFormat: format, + RawPayload: raw, + Warnings: []string{"SQLServer 解析器在 PR2 实现,先返回原文"}, + }, nil + default: + return connection.ExplainResult{}, fmt.Errorf("不支持的 EXPLAIN 方言:%s", dbType) + } +} + +// getDiagnoseTimeout 取诊断超时:优先 config.Timeout,否则默认 5 分钟。 +// EXPLAIN ANALYZE 会真实执行 SQL,超时太短会让大查询被误判失败。 +func getDiagnoseTimeout(config connection.ConnectionConfig) time.Duration { + if config.Timeout > 0 { + timeout := time.Duration(config.Timeout) * time.Second + if timeout < explainStatementTimeoutFloor { + return explainStatementTimeoutFloor + } + return timeout + } + return explainStatementTimeoutFloor +} + +// buildExplainQuery 按方言构造 EXPLAIN 语句。 +// 返回: +// - wrappedSQL:主 EXPLAIN 语句(可能含 prelude 如 SQLServer 的 SET SHOWPLAN_XML ON) +// - postQueries:后置查询(如 Oracle 的 SELECT ... FROM DBMS_XPLAN.DISPLAY) +// - preferFormat:期望的输出格式(用于解析器调度;实际格式由 collectExplainRaw 探测后确定) +// - cleanupQueries:清理语句(Oracle DELETE FROM plan_table),defer 中执行 +// - err:方言不支持时返回 +// +// 参考现有风格:buildListViewQueries (methods_file.go:3102) 的 switch-case 模式。 +func buildExplainQuery(dbType, query string) (wrappedSQL string, postQueries []string, preferFormat connection.ExplainFormat, cleanupQueries []string, err error) { + sql := strings.TrimRight(strings.TrimSpace(query), ";") + switch dbType { + case "mysql", "mariadb", "oceanbase": + // MySQL 8.0+ 和 OceanBase 都支持 FORMAT=JSON + // 5.7 在 collectExplainRaw 阶段会拿到语法错误,由调用方降级处理(PR2 加重试逻辑) + return fmt.Sprintf("EXPLAIN FORMAT=JSON %s", sql), nil, connection.ExplainFormatJSON, nil, nil + case "diros", "starrocks": + // Doris/StarRocks 不支持 FORMAT=JSON,使用原生 EXPLAIN(返回表格 + 一些文本块) + return fmt.Sprintf("EXPLAIN %s", sql), nil, connection.ExplainFormatTable, nil, nil + case "postgres", "gaussdb", "opengauss", "kingbase", "highgo", "vastbase": + // ANALYZE 真实执行 SQL,但 looksLikeSelectOrWith 已校验只读;BUFFERS 在 PG14+ 自动忽略不支持的选项 + return fmt.Sprintf("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) %s", sql), nil, connection.ExplainFormatJSON, nil, nil + case "sqlite": + return fmt.Sprintf("EXPLAIN QUERY PLAN %s", sql), nil, connection.ExplainFormatTable, nil, nil + case "clickhouse": + return fmt.Sprintf("EXPLAIN JSON %s", sql), nil, connection.ExplainFormatJSON, nil, nil + case "oracle": + // OceanBase Oracle 协议也走此分支(resolveDDLDBType 已归一化) + // 用 STATEMENT_ID 隔离,避免多用户共享 plan_table 时互相覆盖 + stmtID := fmt.Sprintf("gonavi_%d", time.Now().UnixNano()) + wrapped := fmt.Sprintf("EXPLAIN PLAN SET STATEMENT_ID = '%s' FOR %s", stmtID, sql) + post := []string{ + fmt.Sprintf("SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY(NULL, '%s', 'ALL'))", stmtID), + } + cleanup := []string{ + fmt.Sprintf("DELETE FROM plan_table WHERE statement_id = '%s'", stmtID), + } + return wrapped, post, connection.ExplainFormatTable, cleanup, nil + case "sqlserver": + // SET SHOWPLAN_XML ON 后整个会话只返回计划不执行;必须 SET OFF 清理,否则连接污染 + wrapped := fmt.Sprintf("SET SHOWPLAN_XML ON;\n%s", sql) + post := []string{"SET SHOWPLAN_XML OFF;"} + return wrapped, post, connection.ExplainFormatXML, nil, nil + default: + return "", nil, "", nil, fmt.Errorf("方言 %s 的 EXPLAIN 构造未实现", dbType) + } +} diff --git a/internal/app/methods_explain_test.go b/internal/app/methods_explain_test.go new file mode 100644 index 0000000..72b46f5 --- /dev/null +++ b/internal/app/methods_explain_test.go @@ -0,0 +1,144 @@ +package app + +import ( + "strings" + "testing" + + "GoNavi-Wails/internal/connection" +) + +// buildExplainQuery 测试:验证各方言生成的 SQL 是否符合预期。 +func TestBuildExplainQuery_MySQLUsesFormatJSON(t *testing.T) { + wrapped, post, format, cleanup, err := buildExplainQuery("mysql", "SELECT * FROM t") + if err != nil { + t.Fatalf("mysql 构造失败:%v", err) + } + if want := "EXPLAIN FORMAT=JSON SELECT * FROM t"; wrapped != want { + t.Fatalf("got=%q want=%q", wrapped, want) + } + if len(post) != 0 { + t.Fatalf("mysql 不应有 post 查询,got=%v", post) + } + if len(cleanup) != 0 { + t.Fatalf("mysql 不应有 cleanup,got=%v", cleanup) + } + if format != connection.ExplainFormatJSON { + t.Fatalf("format got=%v want=json", format) + } +} + +func TestBuildExplainQuery_PostgresUsesAnalyzeBuffersJSON(t *testing.T) { + wrapped, _, format, _, err := buildExplainQuery("postgres", "SELECT * FROM t WHERE id = 1") + if err != nil { + t.Fatalf("postgres 构造失败:%v", err) + } + if !strings.Contains(wrapped, "ANALYZE") || !strings.Contains(wrapped, "BUFFERS") || !strings.Contains(wrapped, "FORMAT JSON") { + t.Fatalf("postgres SQL 应含 ANALYZE BUFFERS FORMAT JSON,got=%q", wrapped) + } + if format != connection.ExplainFormatJSON { + t.Fatalf("format got=%v want=json", format) + } +} + +func TestBuildExplainQuery_SQLiteUsesEQP(t *testing.T) { + wrapped, _, format, _, err := buildExplainQuery("sqlite", "SELECT * FROM t") + if err != nil { + t.Fatalf("sqlite 构造失败:%v", err) + } + if want := "EXPLAIN QUERY PLAN SELECT * FROM t"; wrapped != want { + t.Fatalf("got=%q want=%q", wrapped, want) + } + if format != connection.ExplainFormatTable { + t.Fatalf("format got=%v want=table", format) + } +} + +func TestBuildExplainQuery_OracleReturnsStatementIDAndCleanup(t *testing.T) { + wrapped, post, _, cleanup, err := buildExplainQuery("oracle", "SELECT * FROM t") + if err != nil { + t.Fatalf("oracle 构造失败:%v", err) + } + if !strings.Contains(wrapped, "EXPLAIN PLAN SET STATEMENT_ID") { + t.Fatalf("oracle 主语句应含 STATEMENT_ID,got=%q", wrapped) + } + if len(post) != 1 || !strings.Contains(post[0], "DBMS_XPLAN.DISPLAY") { + t.Fatalf("oracle post 应含 DBMS_XPLAN.DISPLAY,got=%v", post) + } + if len(cleanup) != 1 || !strings.Contains(cleanup[0], "DELETE FROM plan_table") { + t.Fatalf("oracle cleanup 应含 DELETE FROM plan_table,got=%v", cleanup) + } + // 验证 statement_id 在三条 SQL 中一致 + idInWrapped := extractBetween(wrapped, "STATEMENT_ID = '", "' FOR") + idInPost := extractBetween(post[0], "NULL, '", "'") + idInCleanup := extractBetween(cleanup[0], "statement_id = '", "'") + if idInWrapped == "" || idInWrapped != idInPost || idInWrapped != idInCleanup { + t.Fatalf("statement_id 不一致:wrapped=%q post=%q cleanup=%q", idInWrapped, idInPost, idInCleanup) + } +} + +func TestBuildExplainQuery_SQLServerSetsShowplanXML(t *testing.T) { + wrapped, post, _, _, err := buildExplainQuery("sqlserver", "SELECT * FROM t") + if err != nil { + t.Fatalf("sqlserver 构造失败:%v", err) + } + if !strings.Contains(wrapped, "SET SHOWPLAN_XML ON") { + t.Fatalf("sqlserver 应 SET SHOWPLAN_XML ON,got=%q", wrapped) + } + if !strings.Contains(wrapped, "SELECT * FROM t") { + t.Fatalf("sqlserver 应保留原 SQL,got=%q", wrapped) + } + if len(post) != 1 || !strings.Contains(post[0], "SET SHOWPLAN_XML OFF") { + t.Fatalf("sqlserver post 应 SET SHOWPLAN_XML OFF,got=%v", post) + } +} + +func TestBuildExplainQuery_ClickHouseUsesExplainJSON(t *testing.T) { + wrapped, _, format, _, err := buildExplainQuery("clickhouse", "SELECT * FROM t") + if err != nil { + t.Fatalf("clickhouse 构造失败:%v", err) + } + if want := "EXPLAIN JSON SELECT * FROM t"; wrapped != want { + t.Fatalf("got=%q want=%q", wrapped, want) + } + if format != connection.ExplainFormatJSON { + t.Fatalf("format got=%v want=json", format) + } +} + +func TestBuildExplainQuery_PGLikeDialectsSharePath(t *testing.T) { + // gaussdb/opengauss/kingbase/highgo/vastbase 应该复用 PG 的 ANALYZE BUFFERS 路径 + for _, dbType := range []string{"gaussdb", "opengauss", "kingbase", "highgo", "vastbase"} { + wrapped, _, format, _, err := buildExplainQuery(dbType, "SELECT 1") + if err != nil { + t.Errorf("%s 构造失败:%v", dbType, err) + continue + } + if !strings.Contains(wrapped, "FORMAT JSON") { + t.Errorf("%s 应使用 FORMAT JSON 路径,got=%q", dbType, wrapped) + } + if format != connection.ExplainFormatJSON { + t.Errorf("%s format got=%v want=json", dbType, format) + } + } +} + +func TestBuildExplainQuery_UnsupportedDialectReturnsError(t *testing.T) { + _, _, _, _, err := buildExplainQuery("mongodb", "db.t.find()") + if err == nil { + t.Fatal("未支持方言应返回 error") + } +} + +// extractBetween 取 s 中 between start 和 end 的第一个匹配子串(测试辅助)。 +func extractBetween(s, start, end string) string { + startIdx := strings.Index(s, start) + if startIdx < 0 { + return "" + } + startIdx += len(start) + endIdx := strings.Index(s[startIdx:], end) + if endIdx < 0 { + return "" + } + return s[startIdx : startIdx+endIdx] +} diff --git a/internal/connection/explain.go b/internal/connection/explain.go new file mode 100644 index 0000000..185fedf --- /dev/null +++ b/internal/connection/explain.go @@ -0,0 +1,139 @@ +package connection + +import "time" + +// SQL 诊断工作台数据结构。 +// +// 设计要点: +// - 节点用扁平数组 + ParentID 表达父子(不用嵌套树),便于前端 react-flow 渲染和按 ID 检索 +// - 跨方言归一化:不论 MySQL/PG/Oracle 输出,统一映射到 ExplainNode.OpType + Flags +// - 原文保留(RawPayload)用于调试和前端展开查看 +// - 与 ResultSetData 同包,便于 Wails 绑定自动生成 TS 类型 + +// ExplainFormat 标识 EXPLAIN 原始输出的格式,决定解析器路径。 +type ExplainFormat string + +const ( + ExplainFormatJSON ExplainFormat = "json" // MySQL 8.0 FORMAT=JSON / PG FORMAT JSON / ClickHouse JSON + ExplainFormatTable ExplainFormat = "table" // MySQL 5.7 表格 / SQLite EQP / Oracle DBMS_XPLAN + ExplainFormatXML ExplainFormat = "xml" // SQLServer SHOWPLAN_XML + ExplainFormatText ExplainFormat = "text" // 兜底,无法归类时 +) + +// 节点操作类型(归一化后跨方言通用)。 +const ( + ExplainOpScan = "SCAN" // 全表扫描 / 顺序扫描 + ExplainOpIndexScan = "INDEX_SCAN" // 索引扫描(ref/eq_ref/range) + ExplainOpIndexOnly = "INDEX_ONLY" // Using index 覆盖索引 + ExplainOpJoin = "JOIN" // 任意 JOIN 类型(Nested Loop / Hash / Merge) + ExplainOpAggregate = "AGGREGATE" // GROUP BY / DISTINCT / 聚合函数 + ExplainOpSort = "SORT" // filesort / ORDER BY + ExplainOpLimit = "LIMIT" // LIMIT 截断 + ExplainOpFilter = "FILTER" // WHERE/HAVING 过滤 + ExplainOpSubquery = "SUBQUERY" // 子查询 + ExplainOpUnion = "UNION" // UNION 合并 + ExplainOpWindow = "WINDOW" // 窗口函数 + ExplainOpMaterialize = "MATERIALIZE" // 物化临时表 + ExplainOpInsert = "INSERT" // INSERT 操作(EXPLAIN INSERT) + ExplainOpUpdate = "UPDATE" + ExplainOpDelete = "DELETE" + ExplainOpOther = "OTHER" // 无法归类 +) + +// 节点警告标志(用于规则匹配和前端高亮)。 +const ( + ExplainFlagFullScan = "FULL_SCAN" // 全表扫描 + ExplainFlagFilesort = "FILESORT" // 额外排序 + ExplainFlagTempTable = "TEMP_TABLE" // 使用临时表 + ExplainFlagNoIndex = "NO_INDEX" // 未命中索引 + ExplainFlagHighCost = "HIGH_COST" // 成本显著高于其他节点 + ExplainFlagLowBufferHit = "LOW_BUFFER_HIT" // 缓冲命中率低(PG BUFFERS) + ExplainFlagUccWarn = "UNCERTAIN_ROWS" // 估算行数不确定(rows=0 或巨大偏差) +) + +// 索引建议严重度。 +const ( + SeverityCritical = "critical" // 严重影响性能(如大表全表扫描) + SeverityWarning = "warning" // 有改进空间 + SeverityInfo = "info" // 优化建议 +) + +// ExplainNode 表示执行计划中的一个节点(归一化后跨方言通用)。 +type ExplainNode struct { + ID string `json:"id"` + ParentID string `json:"parentId,omitempty"` + OpType string `json:"opType"` + OpDetail string `json:"opDetail,omitempty"` // 原始操作符文本,如 "Hash Join" / "Using where" + Table string `json:"table,omitempty"` // 涉及的表名 + Index string `json:"index,omitempty"` // 使用的索引名 + EstRows int64 `json:"estRows,omitempty"` // 估算扫描行数 + ActualRows int64 `json:"actualRows,omitempty"` // 实际返回行数(需 ANALYZE) + Loops int64 `json:"loops,omitempty"` // 循环执行次数 + Cost float64 `json:"cost,omitempty"` // 估算成本 + DurationMs float64 `json:"durationMs,omitempty"` // 实际耗时毫秒(需 ANALYZE) + BufferHit float64 `json:"bufferHit,omitempty"` // 缓冲命中率 0-1 + Flags []string `json:"flags,omitempty"` // 警告标志 + Extra map[string]any `json:"extra,omitempty"` // 方言特定字段,前端按需展示 +} + +// ExplainEdge 表示执行计划节点间的父子关系,前端 react-flow 用于绘制连线。 +type ExplainEdge struct { + From string `json:"from"` // 父节点 ID + To string `json:"to"` // 子节点 ID + Label string `json:"label,omitempty"` // 边的标注(如 JOIN 类型 "INNER"/"LEFT") +} + +// ExplainStats 是整个执行计划的聚合统计。 +type ExplainStats struct { + TotalCost float64 `json:"totalCost,omitempty"` + TotalDurationMs float64 `json:"totalDurationMs,omitempty"` + RowsRead int64 `json:"rowsRead,omitempty"` // 所有 SCAN 节点估算行数之和 + BufferHitRate float64 `json:"bufferHitRate,omitempty"` // 平均缓冲命中率 + HasFullScan bool `json:"hasFullScan"` + HasFilesort bool `json:"hasFilesort"` + HasTempTable bool `json:"hasTempTable"` + MaxEstRows int64 `json:"maxEstRows,omitempty"` // 单节点最大估算行数(用于规则匹配) +} + +// ExplainResult 是一次 EXPLAIN 解析后的归一化结果。 +type ExplainResult struct { + DBType string `json:"dbType"` + SourceSQL string `json:"sourceSql"` + Nodes []ExplainNode `json:"nodes"` + Edges []ExplainEdge `json:"edges,omitempty"` + Stats ExplainStats `json:"stats"` + Warnings []string `json:"warnings,omitempty"` // 解析/降级过程中的提示 + RawFormat ExplainFormat `json:"rawFormat"` + RawPayload string `json:"rawPayload,omitempty"` // 原始 EXPLAIN 输出,前端调试用 +} + +// IndexSuggestion 是规则引擎针对某个节点产生的索引建议。 +type IndexSuggestion struct { + Severity string `json:"severity"` // critical/warning/info + Rule string `json:"rule"` // 规则 ID,如 "full_scan_on_large_table" + Reason string `json:"reason"` // 人类可读的触发原因 + SuggestedIndex string `json:"suggestedIndex,omitempty"` // 建议的 CREATE INDEX 语句(如有) + AffectedNodeID string `json:"affectedNodeId,omitempty"` // 关联的 ExplainNode.ID + AffectedTable string `json:"affectedTable,omitempty"` + EstRows int64 `json:"estRows,omitempty"` // 触发节点的估算行数,便于排序 +} + +// DiagnoseReport 是 DiagnoseQuery 的最终返回值,前端诊断面板消费此结构。 +type DiagnoseReport struct { + Plan ExplainResult `json:"plan"` + Suggestions []IndexSuggestion `json:"suggestions"` +} + +// QueryExecutionRecord 是慢 SQL 历史的一条记录(PR5 慢 SQL 摘要用,提前定义便于 PR1 数据流贯通)。 +type QueryExecutionRecord struct { + ID string `json:"id"` + ConnectionFP string `json:"connectionFp"` // 连接指纹,复用 saved_query_fingerprint + SQLFingerprint string `json:"sqlFp"` // SQL 文本指纹(归一化后 sha256 取前 16) + SQLPreview string `json:"sqlPreview"` // 截断后的 SQL 预览(前 200 字符) + DBType string `json:"dbType"` + DurationMs int64 `json:"durationMs"` + RowsRead int64 `json:"rowsRead,omitempty"` + RowsReturned int64 `json:"rowsReturned,omitempty"` + PlanHash string `json:"planHash,omitempty"` // 同一 SQL 不同计划的区分(PR5 实现) + ExecutedAt time.Time `json:"executedAt"` +} diff --git a/internal/db/database.go b/internal/db/database.go index 8f69b02..1898c41 100644 --- a/internal/db/database.go +++ b/internal/db/database.go @@ -98,6 +98,26 @@ type StreamQueryExecer interface { StreamQueryContext(ctx context.Context, query string, consumer QueryStreamConsumer) error } +// ExplainExecer is an optional interface for drivers that can run EXPLAIN and +// return the dialect-native output (JSON text, table rows as JSON, or XML). +// +// Drivers that implement this interface own the full EXPLAIN lifecycle: +// - MySQL: prefer EXPLAIN FORMAT=JSON, fallback to vanilla EXPLAIN on 5.7 +// - PostgreSQL: EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) +// - Oracle: EXPLAIN PLAN SET STATEMENT_ID ... + DBMS_XPLAN.DISPLAY + cleanup +// - SQLServer: SET SHOWPLAN_XML ON + sql + SET OFF (defer cleanup mandatory) +// - SQLite: EXPLAIN QUERY PLAN +// - ClickHouse: EXPLAIN JSON +// +// The driver decides which format to use and returns the raw payload plus the +// detected format tag; the app layer parses via the corresponding parser. +// +// Drivers that do NOT implement this interface fall back to the generic path +// in app.DiagnoseQuery: wrap the SQL as "EXPLAIN " and run via QueryMulti. +type ExplainExecer interface { + Explain(ctx context.Context, query string) (raw string, format connection.ExplainFormat, err error) +} + // StatementQueryMessageExecer can run queries on a pinned session and return // extra server messages/notices alongside rows. type StatementQueryMessageExecer interface {