Files
MyGoNavi/internal/app/explain_parse_clickhouse.go
Syngnat 8e24e40fdd feat(explain): 补齐 Oracle/SQLServer/ClickHouse 解析器与索引建议规则引擎
- 方言解析:新增 Oracle DBMS_XPLAN 表格、SQLServer SHOWPLAN_XML、ClickHouse EXPLAIN JSON 解析器
- 规则引擎:新增 10 条跨方言规则(全表扫描、缺索引 JOIN、filesort、估算偏差、缓冲命中、Nested Loop 高扇出等)
- 入口接入:DiagnoseQuery 返回的 Suggestions 自动填充规则匹配结果
- 容错增强:SQLServer strip 默认命名空间与 XML 声明;Oracle 表格列与独立 Predicate 段双源融合
- 测试覆盖:新增 27 个用例覆盖三方言解析与规则触发场景
2026-06-19 12:45:15 +08:00

242 lines
7.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}