diff --git a/internal/app/explain_parse_common.go b/internal/app/explain_parse_common.go index b52646f..865bb01 100644 --- a/internal/app/explain_parse_common.go +++ b/internal/app/explain_parse_common.go @@ -81,7 +81,9 @@ func finalizeExplainStats(result *connection.ExplainResult) { } } } - result.Stats.TotalCost = totalCost + if result.Stats.TotalCost == 0 { + result.Stats.TotalCost = totalCost + } if result.Stats.TotalDurationMs == 0 && accumulatedDuration > 0 { result.Stats.TotalDurationMs = accumulatedDuration } diff --git a/internal/app/explain_parse_mysql.go b/internal/app/explain_parse_mysql.go index bd7a969..8b01f8f 100644 --- a/internal/app/explain_parse_mysql.go +++ b/internal/app/explain_parse_mysql.go @@ -82,36 +82,36 @@ func parseMySQLExplain(dbType, sourceSQL, raw string, format connection.ExplainF // 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"` + 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"` + 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 输出。 @@ -122,29 +122,103 @@ func parseMySQLJSONExplain(raw string) (*connection.ExplainResult, []string, err 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 字段") + if ok { + result := &connection.ExplainResult{} + var warnings []string + + // 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 } - // 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 rootRaw, ok := resolveMySQLJSONV2Root(top, raw); ok { + return parseMySQLJSONExplainV2(rootRaw) } - 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"]) + + return nil, nil, fmt.Errorf("缺少 query_block 字段") +} + +type mysqlJSONV2Node struct { + Query string `json:"query"` + Operation string `json:"operation"` + AccessType string `json:"access_type"` + TableName string `json:"table_name"` + Alias string `json:"alias"` + SchemaName string `json:"schema_name"` + IndexName string `json:"index_name"` + IndexAccessType string `json:"index_access_type"` + Condition string `json:"condition"` + LookupCondition string `json:"lookup_condition"` + JoinType string `json:"join_type"` + JoinAlgorithm string `json:"join_algorithm"` + EstimatedRows json.Number `json:"estimated_rows"` + EstimatedTotalCost json.Number `json:"estimated_total_cost"` + ActualRows json.Number `json:"actual_rows"` + ActualLoops json.Number `json:"actual_loops"` + ActualFirstRowMs json.Number `json:"actual_first_row_ms"` + ActualLastRowMs json.Number `json:"actual_last_row_ms"` + Covering *bool `json:"covering"` + UsedColumns []string `json:"used_columns"` + KeyColumns []string `json:"key_columns"` + Ranges []string `json:"ranges"` + Inputs []json.RawMessage `json:"inputs"` +} + +func resolveMySQLJSONV2Root(top map[string]json.RawMessage, raw string) (json.RawMessage, bool) { + if queryPlanRaw, ok := top["query_plan"]; ok && len(strings.TrimSpace(string(queryPlanRaw))) > 0 { + return queryPlanRaw, true + } + if isMySQLJSONV2Root(top) { + return json.RawMessage(raw), true + } + return nil, false +} + +func isMySQLJSONV2Root(top map[string]json.RawMessage) bool { + if len(top) == 0 { + return false + } + for _, key := range []string{"operation", "inputs", "access_type", "table_name", "estimated_total_cost"} { + if _, ok := top[key]; ok { + return true } } + return false +} - // 递归 query_block(可能套 ordering/grouping/distinct 等操作层) - parseMySQLQueryBlock(qbRaw, "", result, &warnings) +func parseMySQLJSONExplainV2(rootRaw json.RawMessage) (*connection.ExplainResult, []string, error) { + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(rootRaw, &rawMap); err != nil { + return nil, nil, fmt.Errorf("新版根节点解析失败:%w", err) + } + var root mysqlJSONV2Node + if err := json.Unmarshal(rootRaw, &root); err != nil { + return nil, nil, fmt.Errorf("新版根节点反序列化失败:%w", err) + } + result := &connection.ExplainResult{} + var warnings []string + if root.EstimatedTotalCost != "" { + result.Stats.TotalCost = parseExplainFloat64(root.EstimatedTotalCost.String()) + } + parseMySQLJSONV2Node(rootRaw, "", result, &warnings, true) + if len(result.Nodes) == 0 { + return nil, nil, fmt.Errorf("新版 JSON 结构未解析出有效节点") + } return result, warnings, nil } @@ -212,6 +286,221 @@ func parseMySQLQueryBlock(qbRaw json.RawMessage, parentID string, result *connec } } +func parseMySQLJSONV2Node(raw json.RawMessage, parentID string, result *connection.ExplainResult, warnings *[]string, isRoot bool) { + var node mysqlJSONV2Node + if err := json.Unmarshal(raw, &node); err != nil { + *warnings = append(*warnings, fmt.Sprintf("新版 JSON 节点反序列化失败:%v", err)) + return + } + + nextParentID := parentID + if shouldAppendMySQLJSONV2Node(&node, isRoot) { + nextParentID = appendExplainChild(result, parentID, buildMySQLJSONV2ExplainNode(&node, isRoot)) + } + + for _, child := range node.Inputs { + parseMySQLJSONV2Node(child, nextParentID, result, warnings, false) + } +} + +func shouldAppendMySQLJSONV2Node(node *mysqlJSONV2Node, isRoot bool) bool { + if node == nil { + return false + } + if strings.TrimSpace(node.Operation) != "" { + return true + } + if strings.TrimSpace(node.AccessType) != "" { + return true + } + if strings.TrimSpace(node.TableName) != "" || strings.TrimSpace(node.IndexName) != "" { + return true + } + if len(node.Inputs) == 0 && isRoot { + return true + } + return false +} + +func buildMySQLJSONV2ExplainNode(node *mysqlJSONV2Node, isRoot bool) connection.ExplainNode { + if node == nil { + return connection.ExplainNode{OpType: connection.ExplainOpOther} + } + + lowerOperation := strings.ToLower(strings.TrimSpace(node.Operation)) + explainNode := connection.ExplainNode{ + OpType: classifyMySQLJSONV2OpType(node.AccessType, node.Operation, node.Covering), + OpDetail: strings.TrimSpace(node.Operation), + Table: strings.TrimSpace(node.TableName), + Index: strings.TrimSpace(node.IndexName), + EstRows: parseExplainInt64(node.EstimatedRows.String()), + ActualRows: parseExplainInt64(node.ActualRows.String()), + Loops: parseExplainInt64(node.ActualLoops.String()), + DurationMs: parseExplainFloat64(node.ActualLastRowMs.String()), + } + if explainNode.DurationMs == 0 { + explainNode.DurationMs = parseExplainFloat64(node.ActualFirstRowMs.String()) + } + if cost := parseExplainFloat64(node.EstimatedTotalCost.String()); cost > 0 { + if isRoot { + explainNode.Cost = cost + } else { + if explainNode.Extra == nil { + explainNode.Extra = map[string]any{} + } + explainNode.Extra["estimatedTotalCost"] = cost + } + } + + if strings.TrimSpace(node.Alias) != "" && node.Alias != node.TableName { + if explainNode.Extra == nil { + explainNode.Extra = map[string]any{} + } + explainNode.Extra["alias"] = node.Alias + } + if strings.TrimSpace(node.SchemaName) != "" { + if explainNode.Extra == nil { + explainNode.Extra = map[string]any{} + } + explainNode.Extra["schemaName"] = node.SchemaName + } + if strings.TrimSpace(node.Query) != "" { + if explainNode.Extra == nil { + explainNode.Extra = map[string]any{} + } + explainNode.Extra["query"] = node.Query + } + if strings.TrimSpace(node.Condition) != "" { + if explainNode.Extra == nil { + explainNode.Extra = map[string]any{} + } + explainNode.Extra["condition"] = node.Condition + } + if strings.TrimSpace(node.LookupCondition) != "" { + if explainNode.Extra == nil { + explainNode.Extra = map[string]any{} + } + explainNode.Extra["lookupCondition"] = node.LookupCondition + } + if strings.TrimSpace(node.JoinType) != "" { + if explainNode.Extra == nil { + explainNode.Extra = map[string]any{} + } + explainNode.Extra["joinType"] = node.JoinType + } + if strings.TrimSpace(node.JoinAlgorithm) != "" { + if explainNode.Extra == nil { + explainNode.Extra = map[string]any{} + } + explainNode.Extra["joinAlgorithm"] = node.JoinAlgorithm + } + if strings.TrimSpace(node.IndexAccessType) != "" { + if explainNode.Extra == nil { + explainNode.Extra = map[string]any{} + } + explainNode.Extra["indexAccessType"] = node.IndexAccessType + } + if node.Covering != nil { + if explainNode.Extra == nil { + explainNode.Extra = map[string]any{} + } + explainNode.Extra["covering"] = *node.Covering + } + if len(node.UsedColumns) > 0 { + if explainNode.Extra == nil { + explainNode.Extra = map[string]any{} + } + explainNode.Extra["usedColumns"] = node.UsedColumns + } + if len(node.KeyColumns) > 0 { + if explainNode.Extra == nil { + explainNode.Extra = map[string]any{} + } + explainNode.Extra["keyColumns"] = node.KeyColumns + } + if len(node.Ranges) > 0 { + if explainNode.Extra == nil { + explainNode.Extra = map[string]any{} + } + explainNode.Extra["ranges"] = node.Ranges + } + + if explainNode.OpType == connection.ExplainOpScan || strings.Contains(lowerOperation, "table scan") { + explainNode.Flags = append(explainNode.Flags, connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex) + } + if explainNode.OpType == connection.ExplainOpSort || strings.Contains(lowerOperation, "filesort") { + explainNode.Flags = append(explainNode.Flags, connection.ExplainFlagFilesort) + } + if strings.Contains(lowerOperation, "temporary") || strings.Contains(lowerOperation, "temp table") || strings.Contains(lowerOperation, "materialize") { + explainNode.Flags = append(explainNode.Flags, connection.ExplainFlagTempTable) + } + + return explainNode +} + +func classifyMySQLJSONV2OpType(accessType, operation string, covering *bool) string { + switch strings.ToLower(strings.TrimSpace(accessType)) { + case "table": + return connection.ExplainOpScan + case "index": + if covering != nil && *covering { + return connection.ExplainOpIndexOnly + } + return connection.ExplainOpIndexScan + case "filter": + return connection.ExplainOpFilter + case "join": + return connection.ExplainOpJoin + case "sort": + return connection.ExplainOpSort + case "aggregate": + return connection.ExplainOpAggregate + case "limit": + return connection.ExplainOpLimit + case "materialize": + return connection.ExplainOpMaterialize + case "window": + return connection.ExplainOpWindow + case "union": + return connection.ExplainOpUnion + case "subquery": + return connection.ExplainOpSubquery + } + + lowerOperation := strings.ToLower(strings.TrimSpace(operation)) + switch { + case strings.Contains(lowerOperation, "table scan"): + return connection.ExplainOpScan + case strings.Contains(lowerOperation, "covering index"): + return connection.ExplainOpIndexOnly + case strings.Contains(lowerOperation, "index lookup"), + strings.Contains(lowerOperation, "index range"), + strings.Contains(lowerOperation, "index scan"): + return connection.ExplainOpIndexScan + case strings.Contains(lowerOperation, "filter"): + return connection.ExplainOpFilter + case strings.Contains(lowerOperation, "join"): + return connection.ExplainOpJoin + case strings.Contains(lowerOperation, "sort"): + return connection.ExplainOpSort + case strings.Contains(lowerOperation, "aggregate"), + strings.Contains(lowerOperation, "group"): + return connection.ExplainOpAggregate + case strings.Contains(lowerOperation, "limit"): + return connection.ExplainOpLimit + case strings.Contains(lowerOperation, "materialize"): + return connection.ExplainOpMaterialize + case strings.Contains(lowerOperation, "window"): + return connection.ExplainOpWindow + case strings.Contains(lowerOperation, "union"): + return connection.ExplainOpUnion + case strings.Contains(lowerOperation, "subquery"): + return connection.ExplainOpSubquery + default: + return connection.ExplainOpOther + } +} + // buildMySQLTableNode 把 mysqlTableNode 转成归一化的 ExplainNode,并探测 Flags。 func buildMySQLTableNode(t *mysqlTableNode) connection.ExplainNode { node := connection.ExplainNode{ @@ -298,7 +587,7 @@ func parseMySQLTableExplain(raw string) (*connection.ExplainResult, error) { accessType = strings.TrimSpace(row[colType]) } node := connection.ExplainNode{ - OpType: classifyMySQLAccessType(accessType), + OpType: classifyMySQLAccessType(accessType), OpDetail: fmt.Sprintf("id=%s type=%s", idStr, strings.ToLower(accessType)), } if colTable >= 0 && colTable < len(row) { diff --git a/internal/app/explain_parse_mysql_test.go b/internal/app/explain_parse_mysql_test.go index 8674d30..7c39b2d 100644 --- a/internal/app/explain_parse_mysql_test.go +++ b/internal/app/explain_parse_mysql_test.go @@ -155,6 +155,88 @@ func TestParseMySQLExplain_WithOrderingOperation(t *testing.T) { } } +const mySQLFormatJSONV2FilterWithCoveringIndex = `{ + "query": "/* select#1 */ select ` + "`f1`" + ` from ` + "`t1`" + ` where (` + "`f2`" + ` > 3)", + "operation": "Filter: (` + "`t1`" + `. ` + "`f2`" + ` > 3)", + "access_type": "filter", + "estimated_rows": 2, + "estimated_total_cost": 2.65, + "condition": "(` + "`t1`" + `. ` + "`f2`" + ` > 3)", + "inputs": [ + { + "operation": "Covering index scan on t1 using f1", + "access_type": "index", + "index_access_type": "index_scan", + "table_name": "t1", + "index_name": "f1", + "covering": true, + "estimated_rows": 3, + "estimated_total_cost": 1.56 + } + ] +}` + +func TestParseMySQLExplain_JSONV2FilterWithCoveringIndex(t *testing.T) { + result, err := parseMySQLExplain("mysql", "SELECT f1 FROM t1 WHERE f2 > 3", mySQLFormatJSONV2FilterWithCoveringIndex, connection.ExplainFormatJSON) + if err != nil { + t.Fatalf("解析新版 JSON 失败:%v", err) + } + if len(result.Nodes) != 2 { + t.Fatalf("应有 2 个节点,got=%d", len(result.Nodes)) + } + if result.Nodes[0].OpType != connection.ExplainOpFilter { + t.Fatalf("根节点应为 FILTER,got=%s", result.Nodes[0].OpType) + } + if result.Nodes[1].OpType != connection.ExplainOpIndexOnly { + t.Fatalf("covering index scan 应为 INDEX_ONLY,got=%s", result.Nodes[1].OpType) + } + if result.Nodes[1].Table != "t1" { + t.Fatalf("子节点表名应为 t1,got=%s", result.Nodes[1].Table) + } + if result.Nodes[1].Index != "f1" { + t.Fatalf("子节点索引应为 f1,got=%s", result.Nodes[1].Index) + } + if len(result.Edges) != 1 || result.Edges[0].From != result.Nodes[0].ID || result.Edges[0].To != result.Nodes[1].ID { + t.Fatalf("应建立 FILTER -> INDEX_ONLY 边,got=%v", result.Edges) + } + if result.Stats.TotalCost != 2.65 { + t.Fatalf("TotalCost got=%v want=2.65", result.Stats.TotalCost) + } +} + +const mySQLFormatJSONV2WrappedTableScan = `{ + "query_plan": { + "operation": "Table scan on messages", + "access_type": "table", + "table_name": "messages", + "estimated_rows": 958400, + "estimated_total_cost": 49827.15 + } +}` + +func TestParseMySQLExplain_JSONV2WrappedTableScan(t *testing.T) { + result, err := parseMySQLExplain("mysql", "SELECT * FROM messages", mySQLFormatJSONV2WrappedTableScan, connection.ExplainFormatJSON) + if err != nil { + t.Fatalf("解析 query_plan 包装的新版 JSON 失败:%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("table access 应为 SCAN,got=%s", node.OpType) + } + if node.Table != "messages" { + t.Fatalf("table got=%q want=messages", node.Table) + } + if !containsFlag(node.Flags, connection.ExplainFlagFullScan) || !containsFlag(node.Flags, connection.ExplainFlagNoIndex) { + t.Fatalf("table scan 应标记 FULL_SCAN/NO_INDEX,got=%v", node.Flags) + } + if result.Stats.TotalCost != 49827.15 { + t.Fatalf("TotalCost got=%v want=49827.15", result.Stats.TotalCost) + } +} + // 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