mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-06 10:31:30 +08:00
✨ 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 个用例覆盖三方言解析与规则触发场景
This commit is contained in:
241
internal/app/explain_parse_clickhouse.go
Normal file
241
internal/app/explain_parse_clickhouse.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
// ClickHouse EXPLAIN 解析。
|
||||
//
|
||||
// ClickHouse 支持多种 EXPLAIN 模式:
|
||||
// - EXPLAIN(默认 PLAN 模式):返回 1 行 1 列,列值是缩进文本树
|
||||
// - EXPLAIN JSON:返回 1 行 1 列,列值是 JSON 字符串
|
||||
// - EXPLAIN AST:返回抽象语法树
|
||||
// - EXPLAIN SYNTAX:返回重写后的 SQL
|
||||
// - EXPLAIN PIPELINE:返回执行算子管道
|
||||
//
|
||||
// 本解析器只处理 JSON 模式(由 buildExplainQuery 选用)。
|
||||
//
|
||||
// JSON 结构(PLAN 模式):
|
||||
//
|
||||
// {
|
||||
// "Plan": {
|
||||
// "Node Type": "ReadFromMergeTree",
|
||||
// "Joined Plans": [],
|
||||
// "ReadType": "Default",
|
||||
// "Parts": 12,
|
||||
// "Index Granules": 240,
|
||||
// "Result Schema": {...}
|
||||
// },
|
||||
// "Plan": {
|
||||
// "Node Type": "Aggregating",
|
||||
// "Aggregation": {
|
||||
// "Keys": ["user_id"],
|
||||
// "Functions": ["count()"]
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// 注意:CH EXPLAIN JSON 的顶层是 {"Plan": {...}},但通过 collectExplainRaw 收集后,
|
||||
// 单行单列的 JSON 文本可能被多次封装。本解析器直接处理 JSON 字符串。
|
||||
|
||||
func parseClickHouseExplain(sourceSQL, raw string, format connection.ExplainFormat) (connection.ExplainResult, error) {
|
||||
result := connection.ExplainResult{
|
||||
DBType: "clickhouse",
|
||||
SourceSQL: sourceSQL,
|
||||
}
|
||||
resetExplainNodeID()
|
||||
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return result, fmt.Errorf("ClickHouse EXPLAIN 输出为空")
|
||||
}
|
||||
|
||||
// ClickHouse EXPLAIN JSON 可能是对象或数组形式
|
||||
var top map[string]json.RawMessage
|
||||
var topArr []map[string]json.RawMessage
|
||||
|
||||
isArr := strings.HasPrefix(trimmed, "[")
|
||||
if isArr {
|
||||
if err := json.Unmarshal([]byte(trimmed), &topArr); err != nil {
|
||||
return result, fmt.Errorf("ClickHouse JSON 数组解析失败:%w", err)
|
||||
}
|
||||
} else {
|
||||
if err := json.Unmarshal([]byte(trimmed), &top); err != nil {
|
||||
return result, fmt.Errorf("ClickHouse JSON 对象解析失败:%w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var warnings []string
|
||||
// 兼容两种形式
|
||||
plans := []map[string]json.RawMessage{}
|
||||
if isArr {
|
||||
plans = topArr
|
||||
} else {
|
||||
plans = append(plans, top)
|
||||
}
|
||||
|
||||
for _, item := range plans {
|
||||
planRaw, ok := item["Plan"]
|
||||
if !ok {
|
||||
// CH 默认 EXPLAIN 模式可能不返回 Plan 而是直接给节点字段
|
||||
planRaw, ok = jsonMarshalRaw(item)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
parseClickHousePlan(planRaw, "", &result, &warnings)
|
||||
}
|
||||
|
||||
if len(result.Nodes) == 0 {
|
||||
result.RawFormat = connection.ExplainFormatText
|
||||
result.RawPayload = raw
|
||||
result.Warnings = append(warnings, "未提取到 ClickHouse 计划节点,可能不是 PLAN 模式")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
result.RawFormat = connection.ExplainFormatJSON
|
||||
result.RawPayload = raw
|
||||
result.Warnings = warnings
|
||||
finalizeExplainStats(&result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// clickHousePlanNode 映射 CH EXPLAIN JSON 的 Plan 结构。
|
||||
type clickHousePlanNode struct {
|
||||
NodeType string `json:"Node Type"`
|
||||
Operation string `json:"Operation"`
|
||||
ReadType string `json:"ReadType"`
|
||||
Parts int64 `json:"Parts"`
|
||||
IndexGranules int64 `json:"Index Granules"`
|
||||
SelectedMarks int64 `json:"Selected Marks"`
|
||||
ResultSchema map[string]any `json:"Result Schema"`
|
||||
Aggregation map[string]any `json:"Aggregation"`
|
||||
Join map[string]any `json:"Join"`
|
||||
Expression map[string]any `json:"Expression"`
|
||||
Table string `json:"Table"`
|
||||
Database string `json:"Database"`
|
||||
JoinedPlans []json.RawMessage `json:"Joined Plans"`
|
||||
Children []json.RawMessage `json:"Children"` // 部分版本用此字段
|
||||
}
|
||||
|
||||
// parseClickHousePlan 递归解析 CH Plan 节点。
|
||||
// CH 通常用 "Joined Plans" 数组持有子节点(不同于其他 DB 的 "Plans")。
|
||||
func parseClickHousePlan(planRaw json.RawMessage, parentID string, result *connection.ExplainResult, warnings *[]string) {
|
||||
var node clickHousePlanNode
|
||||
if err := json.Unmarshal(planRaw, &node); err != nil {
|
||||
*warnings = append(*warnings, fmt.Sprintf("CH Plan 节点反序列化失败:%v", err))
|
||||
return
|
||||
}
|
||||
|
||||
en := connection.ExplainNode{
|
||||
OpType: classifyClickHouseNodeType(node.NodeType),
|
||||
OpDetail: node.NodeType,
|
||||
}
|
||||
if node.Operation != "" {
|
||||
en.OpDetail = en.OpDetail + " / " + node.Operation
|
||||
}
|
||||
|
||||
// 表/库信息
|
||||
if node.Table != "" {
|
||||
if node.Database != "" {
|
||||
en.Table = node.Database + "." + node.Table
|
||||
} else {
|
||||
en.Table = node.Table
|
||||
}
|
||||
}
|
||||
|
||||
// 行数估算:CH 没有"估算行数"概念,用 Parts × Index Granules 作为扫描量的粗略代理
|
||||
// 这是 CH 的特点:粒度(granule)是默认 8192 行,所以 granules × 8192 ≈ 扫描行数
|
||||
if node.Parts > 0 || node.IndexGranules > 0 {
|
||||
en.EstRows = node.IndexGranules * 8192
|
||||
en.Extra = map[string]any{
|
||||
"parts": node.Parts,
|
||||
"indexGranules": node.IndexGranules,
|
||||
"selectedMarks": node.SelectedMarks,
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregation/Join 等元信息
|
||||
if len(node.Aggregation) > 0 {
|
||||
if en.Extra == nil {
|
||||
en.Extra = map[string]any{}
|
||||
}
|
||||
en.Extra["aggregation"] = node.Aggregation
|
||||
en.Flags = append(en.Flags, connection.ExplainFlagTempTable)
|
||||
}
|
||||
if len(node.Join) > 0 {
|
||||
if en.Extra == nil {
|
||||
en.Extra = map[string]any{}
|
||||
}
|
||||
en.Extra["join"] = node.Join
|
||||
}
|
||||
|
||||
// CH 的 ReadFromMergeTree 在没有索引筛选时类似全表扫描
|
||||
if strings.Contains(strings.ToLower(node.NodeType), "readfrommergetree") {
|
||||
// ReadType=Default 表示未使用 primary key 裁剪
|
||||
if strings.ToLower(node.ReadType) == "default" || node.ReadType == "" {
|
||||
en.Flags = append(en.Flags, connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort/OrderBy
|
||||
if strings.Contains(strings.ToLower(node.NodeType), "sorting") || strings.Contains(strings.ToLower(node.NodeType), "orderby") {
|
||||
en.Flags = append(en.Flags, connection.ExplainFlagFilesort)
|
||||
}
|
||||
|
||||
nodeID := appendExplainChild(result, parentID, en)
|
||||
|
||||
// 递归子节点:CH 用 "Joined Plans",部分版本可能用 "Children"
|
||||
for _, childRaw := range node.JoinedPlans {
|
||||
parseClickHousePlan(childRaw, nodeID, result, warnings)
|
||||
}
|
||||
for _, childRaw := range node.Children {
|
||||
parseClickHousePlan(childRaw, nodeID, result, warnings)
|
||||
}
|
||||
}
|
||||
|
||||
// classifyClickHouseNodeType 把 CH Node Type 归一化到通用 OpType。
|
||||
// 参考:https://clickhouse.com/docs/en/operations/explain
|
||||
func classifyClickHouseNodeType(nodeType string) string {
|
||||
nt := strings.ToLower(strings.TrimSpace(nodeType))
|
||||
switch {
|
||||
case strings.Contains(nt, "readfrommergetree"), strings.Contains(nt, "readfromstorage"), strings.Contains(nt, "readfrom"):
|
||||
return connection.ExplainOpScan
|
||||
case strings.Contains(nt, "filter"):
|
||||
return connection.ExplainOpFilter
|
||||
case strings.Contains(nt, "aggregating"), strings.Contains(nt, "aggregatingtransform"):
|
||||
return connection.ExplainOpAggregate
|
||||
case strings.Contains(nt, "sorting"), strings.Contains(nt, "orderby"):
|
||||
return connection.ExplainOpSort
|
||||
case strings.Contains(nt, "limit"):
|
||||
return connection.ExplainOpLimit
|
||||
case strings.Contains(nt, "join"):
|
||||
return connection.ExplainOpJoin
|
||||
case strings.Contains(nt, "union"), strings.Contains(nt, "concat"):
|
||||
return connection.ExplainOpUnion
|
||||
case strings.Contains(nt, "expression"), strings.Contains(nt, "computescope"):
|
||||
return connection.ExplainOpOther
|
||||
case strings.Contains(nt, "creatingsets"), strings.Contains(nt, "creatingsetandfilter"):
|
||||
return connection.ExplainOpMaterialize
|
||||
case strings.Contains(nt, "window"):
|
||||
return connection.ExplainOpWindow
|
||||
default:
|
||||
return connection.ExplainOpOther
|
||||
}
|
||||
}
|
||||
|
||||
// jsonMarshalRaw 把已解析的 map 重新序列化为 RawMessage(辅助工具)。
|
||||
func jsonMarshalRaw(m map[string]json.RawMessage) (json.RawMessage, bool) {
|
||||
if len(m) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return b, true
|
||||
}
|
||||
128
internal/app/explain_parse_clickhouse_test.go
Normal file
128
internal/app/explain_parse_clickhouse_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
// ClickHouse EXPLAIN JSON fixture:ReadFromMergeTree + Aggregating。
|
||||
const clickHouseExplainJSONScanAndAggregate = `{
|
||||
"Plan": {
|
||||
"Node Type": "Aggregating",
|
||||
"Aggregation": {
|
||||
"Keys": ["user_id"],
|
||||
"Functions": ["count()"]
|
||||
},
|
||||
"Joined Plans": [
|
||||
{
|
||||
"Node Type": "ReadFromMergeTree",
|
||||
"ReadType": "Default",
|
||||
"Parts": 12,
|
||||
"Index Granules": 240,
|
||||
"Table": "events",
|
||||
"Database": "default"
|
||||
}
|
||||
]
|
||||
}
|
||||
}`
|
||||
|
||||
func TestParseClickHouseExplain_ScanAndAggregate(t *testing.T) {
|
||||
result, err := parseClickHouseExplain("SELECT user_id, count() FROM events GROUP BY user_id", clickHouseExplainJSONScanAndAggregate, connection.ExplainFormatJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败:%v", err)
|
||||
}
|
||||
if len(result.Nodes) != 2 {
|
||||
t.Fatalf("应有 2 个节点(Aggregating + ReadFromMergeTree),got=%d", len(result.Nodes))
|
||||
}
|
||||
aggNode := result.Nodes[0]
|
||||
if aggNode.OpType != connection.ExplainOpAggregate {
|
||||
t.Fatalf("Aggregating 应为 AGGREGATE,got=%s", aggNode.OpType)
|
||||
}
|
||||
if !containsFlag(aggNode.Flags, connection.ExplainFlagTempTable) {
|
||||
t.Fatalf("Aggregating 应有 TEMP_TABLE flag")
|
||||
}
|
||||
scanNode := result.Nodes[1]
|
||||
if scanNode.OpType != connection.ExplainOpScan {
|
||||
t.Fatalf("ReadFromMergeTree 应为 SCAN,got=%s", scanNode.OpType)
|
||||
}
|
||||
if scanNode.Table != "default.events" {
|
||||
t.Fatalf("Table got=%s want=default.events", scanNode.Table)
|
||||
}
|
||||
// EstRows = Index Granules × 8192 = 240 × 8192 = 1966080
|
||||
if scanNode.EstRows != 240*8192 {
|
||||
t.Fatalf("EstRows got=%d want=%d", scanNode.EstRows, 240*8192)
|
||||
}
|
||||
if !containsFlag(scanNode.Flags, connection.ExplainFlagFullScan) {
|
||||
t.Fatalf("ReadType=Default 的 MergeTree 应有 FULL_SCAN flag")
|
||||
}
|
||||
if !containsFlag(scanNode.Flags, connection.ExplainFlagNoIndex) {
|
||||
t.Fatalf("ReadType=Default 的 MergeTree 应有 NO_INDEX flag")
|
||||
}
|
||||
// Edges:Aggregating -> ReadFromMergeTree
|
||||
if len(result.Edges) != 1 || result.Edges[0].From != aggNode.ID || result.Edges[0].To != scanNode.ID {
|
||||
t.Fatalf("应有 1 条边连接 AGGREGATE -> SCAN")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClickHouseExplain_IndexedReadNoFullScanFlag(t *testing.T) {
|
||||
raw := `{
|
||||
"Plan": {
|
||||
"Node Type": "ReadFromMergeTree",
|
||||
"ReadType": "InReverseOrder",
|
||||
"Parts": 5,
|
||||
"Index Granules": 30,
|
||||
"Table": "t",
|
||||
"Database": "default"
|
||||
}
|
||||
}`
|
||||
result, err := parseClickHouseExplain("SELECT * FROM default.t ORDER BY id DESC", raw, connection.ExplainFormatJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败:%v", err)
|
||||
}
|
||||
if len(result.Nodes) != 1 {
|
||||
t.Fatalf("应有 1 个节点,got=%d", len(result.Nodes))
|
||||
}
|
||||
node := result.Nodes[0]
|
||||
// ReadType 不是 Default,不应触发 FULL_SCAN
|
||||
if containsFlag(node.Flags, connection.ExplainFlagFullScan) {
|
||||
t.Fatalf("ReadType=InReverseOrder 不应是 FULL_SCAN")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClickHouseExplain_ArrayForm(t *testing.T) {
|
||||
raw := `[
|
||||
{
|
||||
"Plan": {
|
||||
"Node Type": "Limit",
|
||||
"Joined Plans": [
|
||||
{"Node Type": "ReadFromMergeTree", "ReadType": "Default", "Parts": 1, "Index Granules": 10, "Table": "t"}
|
||||
]
|
||||
}
|
||||
}
|
||||
]`
|
||||
result, err := parseClickHouseExplain("SELECT * FROM t LIMIT 10", raw, connection.ExplainFormatJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("数组形式解析失败:%v", err)
|
||||
}
|
||||
if len(result.Nodes) != 2 {
|
||||
t.Fatalf("应有 2 个节点(Limit + ReadFromMergeTree),got=%d", len(result.Nodes))
|
||||
}
|
||||
if result.Nodes[0].OpType != connection.ExplainOpLimit {
|
||||
t.Fatalf("Limit 节点应为 LIMIT,got=%s", result.Nodes[0].OpType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClickHouseExplain_InvalidJSONReturnsError(t *testing.T) {
|
||||
_, err := parseClickHouseExplain("SELECT 1", "{ not valid json", connection.ExplainFormatJSON)
|
||||
if err == nil {
|
||||
t.Fatal("非法 JSON 应返回 error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClickHouseExplain_EmptyReturnsError(t *testing.T) {
|
||||
_, err := parseClickHouseExplain("SELECT 1", " ", connection.ExplainFormatJSON)
|
||||
if err == nil {
|
||||
t.Fatal("空输入应返回 error")
|
||||
}
|
||||
}
|
||||
457
internal/app/explain_parse_oracle.go
Normal file
457
internal/app/explain_parse_oracle.go
Normal file
@@ -0,0 +1,457 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
// Oracle DBMS_XPLAN.DISPLAY 表格解析。
|
||||
//
|
||||
// 典型输出(FORMAT=ALL):
|
||||
//
|
||||
// Plan hash value: 1234567890
|
||||
//
|
||||
// --------------------------------------------------------------------------------------------------
|
||||
// | Id | Operation | Name | Rows | Bytes | Cost (%CPU)| Time | Predicate Information |
|
||||
// --------------------------------------------------------------------------------------------------
|
||||
// | 0 | SELECT STATEMENT | | 10000 | 200K| 50 (4)| 00:00:01 | |
|
||||
// |* 1 | TABLE ACCESS FULL| USERS | 10000 | 200K| 50 (4)| 00:00:01 | filter ("AGE">18) |
|
||||
// --------------------------------------------------------------------------------------------------
|
||||
//
|
||||
// Query Block Name / Object Alias (identified by operation id):
|
||||
// -------------------------------------------------------------
|
||||
// 1 - SEL$1 / USERS@SEL$1
|
||||
//
|
||||
// Column Projection Information (identified by operation id):
|
||||
// -----------------------------------------------------------
|
||||
// 1 - "ID"[NUMBER,22], "NAME"[VARCHAR2,100]
|
||||
//
|
||||
// 解析要点:
|
||||
// - Id 列含 "*"(带 Predicate)或空格(无 Predicate)+ 数字 + 可能空格缩进
|
||||
// - Operation 列含前导空格(表达层级深度,每 2 空格代表一层)
|
||||
// - Name 列通常是表名或索引名
|
||||
// - Rows 是估算行数(Bytes 也会给但本解析器暂不消费)
|
||||
// - Cost (%CPU) 含百分比:50 (4) 表示 cost=50 CPU 占比 4%
|
||||
// - Predicate Information(下方独立段落)按 Operation Id 列出 Predicate 文本
|
||||
// - 多个段落用空行分隔,关键段落:"Plan hash value"、"Query Block Name"、"Predicate Information"、"Column Projection"
|
||||
|
||||
func parseOracleExplain(sourceSQL, raw string, format connection.ExplainFormat) (connection.ExplainResult, error) {
|
||||
result := connection.ExplainResult{
|
||||
DBType: "oracle",
|
||||
SourceSQL: sourceSQL,
|
||||
}
|
||||
resetExplainNodeID()
|
||||
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return result, fmt.Errorf("Oracle DBMS_XPLAN 输出为空")
|
||||
}
|
||||
|
||||
// 抽取 Predicate Information 段落(按 id 索引)
|
||||
predicates := extractOraclePredicates(trimmed)
|
||||
|
||||
// 抽取主表格
|
||||
tableSection := extractOraclePlanTable(trimmed)
|
||||
if tableSection == "" {
|
||||
result.RawFormat = connection.ExplainFormatText
|
||||
result.RawPayload = raw
|
||||
result.Warnings = []string{"未识别到 DBMS_XPLAN 表格段落,可能版本不兼容"}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 解析表格行(管道符分隔的列)
|
||||
rows := extractOracleTableRows(tableSection)
|
||||
if len(rows) == 0 {
|
||||
return result, fmt.Errorf("DBMS_XPLAN 表格无有效行")
|
||||
}
|
||||
|
||||
// 解析列头识别列索引
|
||||
headerCols := splitOracleTableRow(rows[0])
|
||||
colID := findOracleColumnIndex(headerCols, "Id")
|
||||
colOp := findOracleColumnIndex(headerCols, "Operation")
|
||||
colName := findOracleColumnIndex(headerCols, "Name")
|
||||
colRows := findOracleColumnIndex(headerCols, "Rows")
|
||||
colCost := findOracleColumnIndex(headerCols, "Cost")
|
||||
colTime := findOracleColumnIndex(headerCols, "Time")
|
||||
colPredicate := findOracleColumnIndex(headerCols, "Predicate")
|
||||
if colID < 0 || colOp < 0 {
|
||||
return result, fmt.Errorf("DBMS_XPLAN 表格缺少 Id 或 Operation 列")
|
||||
}
|
||||
|
||||
// 按 Operation 的缩进推断父子(每 2 个前导空格代表一层)
|
||||
type pendingNode struct {
|
||||
node connection.ExplainNode
|
||||
indent int
|
||||
}
|
||||
var stack []pendingNode // 每层保留最近一个节点
|
||||
|
||||
for i := 1; i < len(rows); i++ {
|
||||
cols := splitOracleTableRow(rows[i])
|
||||
if len(cols) == 0 {
|
||||
continue
|
||||
}
|
||||
idRaw := strings.TrimSpace(safeOracleColumn(cols, colID))
|
||||
idNum := parseOracleIDNumber(idRaw)
|
||||
if idNum < 0 {
|
||||
continue
|
||||
}
|
||||
opText := strings.TrimSpace(safeOracleColumn(cols, colOp))
|
||||
indent := countLeadingSpaces(safeOracleColumn(cols, colOp))
|
||||
name := strings.TrimSpace(safeOracleColumn(cols, colName))
|
||||
rowsEst := parseExplainInt64(strings.TrimSpace(safeOracleColumn(cols, colRows)))
|
||||
cost, _ := parseOracleCost(safeOracleColumn(cols, colCost))
|
||||
timeMs := parseOracleTimeMs(safeOracleColumn(cols, colTime))
|
||||
|
||||
node := connection.ExplainNode{
|
||||
OpType: classifyOracleOperation(opText),
|
||||
OpDetail: opText,
|
||||
Table: name,
|
||||
EstRows: rowsEst,
|
||||
Cost: cost,
|
||||
DurationMs: timeMs,
|
||||
}
|
||||
// TABLE ACCESS FULL 是全表扫描
|
||||
if isOracleFullScan(opText) {
|
||||
node.Flags = append(node.Flags, connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex)
|
||||
} else if isOracleIndexAccess(opText) {
|
||||
node.Index = name
|
||||
}
|
||||
// 关联 Predicate Information:先从表格内 Predicate 列取(简短摘要)
|
||||
if colPredicate >= 0 && colPredicate < len(cols) {
|
||||
predCell := strings.TrimSpace(safeOracleColumn(cols, colPredicate))
|
||||
if predCell != "" {
|
||||
if node.Extra == nil {
|
||||
node.Extra = map[string]any{}
|
||||
}
|
||||
node.Extra["filter"] = predCell
|
||||
if strings.Contains(strings.ToLower(predCell), "filter") {
|
||||
node.Flags = append(node.Flags, connection.ExplainFlagFullScan)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 独立 Predicate Information 段落更详细,覆盖表格列的简短摘要
|
||||
if pred, ok := predicates[idNum]; ok && pred != "" {
|
||||
if node.Extra == nil {
|
||||
node.Extra = map[string]any{}
|
||||
}
|
||||
node.Extra["filter"] = pred
|
||||
if strings.Contains(strings.ToLower(pred), "filter") {
|
||||
node.Flags = append(node.Flags, connection.ExplainFlagFullScan)
|
||||
}
|
||||
}
|
||||
|
||||
// 推断父子:弹出栈中 indent >= 当前的节点
|
||||
for len(stack) > 0 && stack[len(stack)-1].indent >= indent {
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
parentNodeID := ""
|
||||
if len(stack) > 0 {
|
||||
parentNodeID = stack[len(stack)-1].node.ID
|
||||
}
|
||||
nodeID := appendExplainChild(&result, parentNodeID, node)
|
||||
stack = append(stack, pendingNode{node: connection.ExplainNode{ID: nodeID}, indent: indent})
|
||||
}
|
||||
|
||||
result.RawFormat = connection.ExplainFormatTable
|
||||
result.RawPayload = raw
|
||||
finalizeExplainStats(&result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// extractOraclePlanTable 提取主表格段落。
|
||||
// DBMS_XPLAN 表格结构:
|
||||
//
|
||||
// [空行]
|
||||
// Plan hash value: ...
|
||||
// [空行]
|
||||
// --------- ← 上边界分隔线
|
||||
// | header |
|
||||
// --------- ← 表头/数据分隔(可选)
|
||||
// | data |
|
||||
// --------- ← 下边界分隔线
|
||||
// [空行] ← 表格段结束(之后的 Query Block Name / Predicate Information 等段落不再计入)
|
||||
//
|
||||
// 实现策略:找到第一条分隔线后开始累积;跳过所有分隔线、保留表头+数据;遇到空行结束。
|
||||
func extractOraclePlanTable(raw string) string {
|
||||
lines := strings.Split(raw, "\n")
|
||||
startIdx := -1
|
||||
for i, line := range lines {
|
||||
if isOracleTableSeparator(line) {
|
||||
startIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if startIdx < 0 {
|
||||
return ""
|
||||
}
|
||||
var builder strings.Builder
|
||||
for i := startIdx; i < len(lines); i++ {
|
||||
line := lines[i]
|
||||
if strings.TrimSpace(line) == "" {
|
||||
break // 空行 = 表格段结束
|
||||
}
|
||||
if isOracleTableSeparator(line) {
|
||||
continue // 跳过表格内的所有分隔线(上边界/表头分隔/下边界)
|
||||
}
|
||||
builder.WriteString(line)
|
||||
builder.WriteByte('\n')
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// isOracleTableSeparator 判断是否是 DBMS_XPLAN 表格的分隔线(全是 -)。
|
||||
func isOracleTableSeparator(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if len(trimmed) < 10 {
|
||||
return false
|
||||
}
|
||||
for _, ch := range trimmed {
|
||||
if ch != '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// extractOracleTableRows 提取表格的每行内容(去掉首尾管道符,保留中间内容)。
|
||||
// 返回不含分隔线的纯数据行。
|
||||
func extractOracleTableRows(table string) []string {
|
||||
lines := strings.Split(strings.TrimSpace(table), "\n")
|
||||
var rows []string
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimRight(line, "\r\n ")
|
||||
if strings.TrimSpace(trimmed) == "" {
|
||||
continue
|
||||
}
|
||||
if isOracleTableSeparator(trimmed) {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, trimmed)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// splitOracleTableRow 按管道符 | 切分行。
|
||||
// 处理:去首尾管道符 → 按 | 切分 → 不 trim(保留前导空格用于缩进判断)。
|
||||
func splitOracleTableRow(line string) []string {
|
||||
// 去掉首尾的 | 和前后空白
|
||||
text := strings.TrimSpace(line)
|
||||
text = strings.TrimPrefix(text, "|")
|
||||
text = strings.TrimSuffix(text, "|")
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(text, "|")
|
||||
// 不 trim,保留前导空格(用于 Operation 缩进分析)
|
||||
return parts
|
||||
}
|
||||
|
||||
// findOracleColumnIndex 在表头中按列名查找索引。
|
||||
func findOracleColumnIndex(headerCols []string, name string) int {
|
||||
target := strings.ToLower(strings.TrimSpace(name))
|
||||
for i, col := range headerCols {
|
||||
if strings.ToLower(strings.TrimSpace(col)) == target {
|
||||
return i
|
||||
}
|
||||
}
|
||||
// 模糊匹配("Cost (%CPU)" 可能被切成 "Cost " 和 " (%CPU)")
|
||||
for i, col := range headerCols {
|
||||
if strings.Contains(strings.ToLower(strings.TrimSpace(col)), target) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// safeOracleColumn 安全取 cols[idx](idx 越界返回空)。
|
||||
func safeOracleColumn(cols []string, idx int) string {
|
||||
if idx < 0 || idx >= len(cols) {
|
||||
return ""
|
||||
}
|
||||
return cols[idx]
|
||||
}
|
||||
|
||||
// parseOracleIDNumber 解析 "Id" 列:形如 " 0"、"* 1"、" 2"。
|
||||
// 返回数字部分;前缀 "*" 表示带 Predicate,返回正值;无数字返回 -1。
|
||||
func parseOracleIDNumber(s string) int {
|
||||
trimmed := strings.TrimSpace(s)
|
||||
if trimmed == "" {
|
||||
return -1
|
||||
}
|
||||
// 去掉可能的 Predicate 标记
|
||||
trimmed = strings.TrimLeft(trimmed, "* ")
|
||||
n, err := strconv.Atoi(trimmed)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// parseOracleCost 解析 "Cost (%CPU)" 列,形如 "50 (4)"。
|
||||
// 返回 cost 数值 + CPU 百分比(百分比暂未使用)。
|
||||
func parseOracleCost(s string) (float64, int) {
|
||||
trimmed := strings.TrimSpace(s)
|
||||
if trimmed == "" {
|
||||
return 0, 0
|
||||
}
|
||||
// 取第一个空白前的数字
|
||||
for i, ch := range trimmed {
|
||||
if ch == ' ' || ch == '\t' {
|
||||
n, _ := strconv.ParseFloat(trimmed[:i], 64)
|
||||
return n, 0
|
||||
}
|
||||
}
|
||||
n, _ := strconv.ParseFloat(trimmed, 64)
|
||||
return n, 0
|
||||
}
|
||||
|
||||
// parseOracleTimeMs 解析 "Time" 列,形如 "00:00:01"。
|
||||
// 转换为毫秒(粗略,仅用于 stats)。
|
||||
func parseOracleTimeMs(s string) float64 {
|
||||
trimmed := strings.TrimSpace(s)
|
||||
if trimmed == "" {
|
||||
return 0
|
||||
}
|
||||
parts := strings.Split(trimmed, ":")
|
||||
if len(parts) != 3 {
|
||||
return 0
|
||||
}
|
||||
h, _ := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
m, _ := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
sec, _ := strconv.Atoi(strings.TrimSpace(parts[2]))
|
||||
return float64(h*3600+m*60+sec) * 1000
|
||||
}
|
||||
|
||||
// countLeadingSpaces 数字符串的前导空格数(用于推断 Oracle Operation 缩进层级)。
|
||||
func countLeadingSpaces(s string) int {
|
||||
n := 0
|
||||
for _, ch := range s {
|
||||
if ch == ' ' {
|
||||
n++
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// classifyOracleOperation 把 Oracle Operation 文本归一化。
|
||||
// 形如 "TABLE ACCESS FULL" → SCAN;"INDEX RANGE SCAN" → INDEX_SCAN;"HASH JOIN" → JOIN。
|
||||
func classifyOracleOperation(op string) string {
|
||||
upper := strings.ToUpper(strings.TrimSpace(op))
|
||||
switch {
|
||||
case strings.Contains(upper, "TABLE ACCESS") && strings.Contains(upper, "FULL"):
|
||||
return connection.ExplainOpScan
|
||||
case strings.Contains(upper, "INDEX") && (strings.Contains(upper, "RANGE SCAN") || strings.Contains(upper, "UNIQUE SCAN") || strings.Contains(upper, "SKIP SCAN")):
|
||||
return connection.ExplainOpIndexScan
|
||||
case strings.Contains(upper, "INDEX") && strings.Contains(upper, "FAST FULL"):
|
||||
return connection.ExplainOpIndexOnly
|
||||
case strings.Contains(upper, "HASH JOIN"):
|
||||
return connection.ExplainOpJoin
|
||||
case strings.Contains(upper, "NESTED LOOPS"):
|
||||
return connection.ExplainOpJoin
|
||||
case strings.Contains(upper, "MERGE JOIN"):
|
||||
return connection.ExplainOpJoin
|
||||
case strings.Contains(upper, "SORT") && strings.Contains(upper, "ORDER BY"):
|
||||
return connection.ExplainOpSort
|
||||
case strings.Contains(upper, "SORT") && strings.Contains(upper, "GROUP BY"):
|
||||
return connection.ExplainOpAggregate
|
||||
case strings.Contains(upper, "HASH GROUP BY") || strings.Contains(upper, "AGGREGATE"):
|
||||
return connection.ExplainOpAggregate
|
||||
case strings.Contains(upper, "COUNT"):
|
||||
return connection.ExplainOpAggregate
|
||||
case strings.Contains(upper, "VIEW"):
|
||||
return connection.ExplainOpOther
|
||||
case strings.Contains(upper, "UNION"):
|
||||
return connection.ExplainOpUnion
|
||||
case strings.Contains(upper, "FILTER"):
|
||||
return connection.ExplainOpFilter
|
||||
case strings.Contains(upper, "SELECT STATEMENT"):
|
||||
return connection.ExplainOpOther
|
||||
default:
|
||||
return connection.ExplainOpOther
|
||||
}
|
||||
}
|
||||
|
||||
// isOracleFullScan 判断是否是全表扫描。
|
||||
func isOracleFullScan(op string) bool {
|
||||
return strings.Contains(strings.ToUpper(op), "TABLE ACCESS") && strings.Contains(strings.ToUpper(op), "FULL")
|
||||
}
|
||||
|
||||
// isOracleIndexAccess 判断是否是索引访问(用于决定 Name 字段是索引名)。
|
||||
func isOracleIndexAccess(op string) bool {
|
||||
upper := strings.ToUpper(op)
|
||||
if !strings.Contains(upper, "INDEX") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(upper, "SCAN") || strings.Contains(upper, "RANGE") || strings.Contains(upper, "UNIQUE")
|
||||
}
|
||||
|
||||
// extractOraclePredicates 从原文中提取 "Predicate Information" 段落,按 id 索引。
|
||||
// 返回 map[int]string,键是 Operation Id,值是对应的 Predicate 文本(多行合并)。
|
||||
func extractOraclePredicates(raw string) map[int]string {
|
||||
result := map[int]string{}
|
||||
lines := strings.Split(raw, "\n")
|
||||
inSection := false
|
||||
currentID := -1
|
||||
var buffer strings.Builder
|
||||
|
||||
idPattern := regexp.MustCompile(`^\s*\*?(\d+)\s*-?\s*(.*)$`)
|
||||
|
||||
flush := func() {
|
||||
if currentID >= 0 {
|
||||
text := strings.TrimSpace(buffer.String())
|
||||
if text != "" {
|
||||
result[currentID] = text
|
||||
}
|
||||
}
|
||||
currentID = -1
|
||||
buffer.Reset()
|
||||
}
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
lower := strings.ToLower(trimmed)
|
||||
|
||||
if strings.HasPrefix(lower, "predicate information") {
|
||||
inSection = true
|
||||
continue
|
||||
}
|
||||
if !inSection {
|
||||
continue
|
||||
}
|
||||
// 进入段落后的空行或下一个段落标题 → 结束
|
||||
if trimmed == "" || isOracleNextSectionHeader(lower) {
|
||||
flush()
|
||||
break
|
||||
}
|
||||
// 匹配 " 1 - access("ID"=1)" 或 " 1 - filter(...)"
|
||||
match := idPattern.FindStringSubmatch(line)
|
||||
if match != nil {
|
||||
flush()
|
||||
id, _ := strconv.Atoi(match[1])
|
||||
currentID = id
|
||||
buffer.WriteString(strings.TrimSpace(match[2]))
|
||||
continue
|
||||
}
|
||||
// 多行 Predicate 续行
|
||||
if currentID >= 0 {
|
||||
buffer.WriteByte(' ')
|
||||
buffer.WriteString(trimmed)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return result
|
||||
}
|
||||
|
||||
// isOracleNextSectionHeader 判断是否是 DBMS_XPLAN 的下一个段落标题(结束 Predicate 段)。
|
||||
func isOracleNextSectionHeader(lower string) bool {
|
||||
return strings.HasPrefix(lower, "query block name") ||
|
||||
strings.HasPrefix(lower, "column projection") ||
|
||||
strings.HasPrefix(lower, "note") ||
|
||||
strings.HasPrefix(lower, "hint")
|
||||
}
|
||||
126
internal/app/explain_parse_oracle_test.go
Normal file
126
internal/app/explain_parse_oracle_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
// Oracle DBMS_XPLAN.DISPLAY fixture:含主表 + Predicate + 多段落。
|
||||
const oracleXPlanOutput = `Plan hash value: 1234567890
|
||||
|
||||
-----------------------------------------------------------------------------------------------------------
|
||||
| Id | Operation | Name | Rows | Bytes | Cost (%CPU)| Time | Predicate Information |
|
||||
-----------------------------------------------------------------------------------------------------------
|
||||
| 0 | SELECT STATEMENT | | 10000 | 200K| 50 (4)| 00:00:01 | |
|
||||
|* 1 | TABLE ACCESS FULL| USERS | 10000 | 200K| 50 (4)| 00:00:01 | filter ("AGE">18) |
|
||||
-----------------------------------------------------------------------------------------------------------
|
||||
|
||||
Query Block Name / Object Alias (identified by operation id):
|
||||
-------------------------------------------------------------
|
||||
1 - SEL$1 / USERS@SEL$1
|
||||
|
||||
Column Projection Information (identified by operation id):
|
||||
-----------------------------------------------------------
|
||||
1 - "ID"[NUMBER,22], "NAME"[VARCHAR2,100]
|
||||
`
|
||||
|
||||
func TestParseOracleExplain_TableAccessFullWithPredicate(t *testing.T) {
|
||||
result, err := parseOracleExplain("SELECT * FROM users WHERE age > 18", oracleXPlanOutput, connection.ExplainFormatTable)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败:%v", err)
|
||||
}
|
||||
if len(result.Nodes) != 2 {
|
||||
t.Fatalf("应有 2 个节点(SELECT STATEMENT + TABLE ACCESS),got=%d", len(result.Nodes))
|
||||
}
|
||||
// 节点 0 是 SELECT STATEMENT,节点 1 是 TABLE ACCESS FULL(带缩进,挂在 0 下)
|
||||
scan := result.Nodes[1]
|
||||
if scan.OpType != connection.ExplainOpScan {
|
||||
t.Fatalf("TABLE ACCESS FULL 应为 SCAN,got=%s", scan.OpType)
|
||||
}
|
||||
if scan.Table != "USERS" {
|
||||
t.Fatalf("table got=%s want=USERS", scan.Table)
|
||||
}
|
||||
if scan.EstRows != 10000 {
|
||||
t.Fatalf("EstRows got=%d want=10000", scan.EstRows)
|
||||
}
|
||||
if scan.Cost != 50 {
|
||||
t.Fatalf("Cost got=%v want=50", scan.Cost)
|
||||
}
|
||||
if !containsFlag(scan.Flags, connection.ExplainFlagFullScan) {
|
||||
t.Fatalf("TABLE ACCESS FULL 应有 FULL_SCAN flag")
|
||||
}
|
||||
if scan.Extra["filter"] != `filter ("AGE">18)` {
|
||||
t.Fatalf("Predicate 应附加到 Extra.filter,got=%v", scan.Extra["filter"])
|
||||
}
|
||||
// SELECT STATEMENT 是父节点
|
||||
if len(result.Edges) != 1 || result.Edges[0].To != scan.ID {
|
||||
t.Fatalf("应有 1 条边指向 TABLE ACCESS 节点")
|
||||
}
|
||||
}
|
||||
|
||||
const oracleXPlanHashJoin = `Plan hash value: 9876543210
|
||||
|
||||
-------------------------------------------------------------------------
|
||||
| Id | Operation | Name | Rows | Cost | Predicate Info |
|
||||
-------------------------------------------------------------------------
|
||||
| 0 | SELECT STATEMENT | | 1000 | 200 | |
|
||||
| 1 | HASH JOIN | | 1000 | 200 | |
|
||||
| 2 | TABLE ACCESS FULL| USERS | 100 | 10 | |
|
||||
| 3 | INDEX RANGE SCAN| ORD_IX | 50000 | 20 |access("UID" = 1) |
|
||||
-------------------------------------------------------------------------
|
||||
`
|
||||
|
||||
func TestParseOracleExplain_HashJoinWithNestedChildren(t *testing.T) {
|
||||
result, err := parseOracleExplain("SELECT * FROM users u JOIN orders o ON u.id = o.user_id", oracleXPlanHashJoin, connection.ExplainFormatTable)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败:%v", err)
|
||||
}
|
||||
if len(result.Nodes) != 4 {
|
||||
t.Fatalf("应有 4 个节点(SELECT + HASH JOIN + 2 子节点),got=%d", len(result.Nodes))
|
||||
}
|
||||
// HASH JOIN 是 SELECT STATEMENT 的子
|
||||
// TABLE ACCESS FULL 和 INDEX RANGE SCAN 是 HASH JOIN 的子(缩进更深)
|
||||
hashJoin := result.Nodes[1]
|
||||
if hashJoin.OpType != connection.ExplainOpJoin {
|
||||
t.Fatalf("HASH JOIN 应为 JOIN,got=%s", hashJoin.OpType)
|
||||
}
|
||||
// 找到 INDEX RANGE SCAN 节点
|
||||
var indexNode *connection.ExplainNode
|
||||
for i := range result.Nodes {
|
||||
if result.Nodes[i].OpType == connection.ExplainOpIndexScan {
|
||||
indexNode = &result.Nodes[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if indexNode == nil {
|
||||
t.Fatal("应有一个 INDEX RANGE SCAN 节点")
|
||||
}
|
||||
if indexNode.Index != "ORD_IX" {
|
||||
t.Fatalf("Index got=%s want=ORD_IX", indexNode.Index)
|
||||
}
|
||||
// Predicate 关联(id=3,独立 Predicate 段落覆盖了表格列的简短摘要)
|
||||
if indexNode.Extra["filter"] != `access("UID" = 1)` {
|
||||
t.Fatalf("Predicate 应附加到 INDEX RANGE SCAN 节点,got=%v", indexNode.Extra["filter"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOracleExplain_EmptyReturnsError(t *testing.T) {
|
||||
_, err := parseOracleExplain("SELECT 1", " ", connection.ExplainFormatTable)
|
||||
if err == nil {
|
||||
t.Fatal("空输入应返回 error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOracleExplain_NoTableReturnsWarning(t *testing.T) {
|
||||
result, err := parseOracleExplain("SELECT 1", "Plan hash value: 1\nsome random text", connection.ExplainFormatTable)
|
||||
if err != nil {
|
||||
t.Fatalf("无表格的输入应降级返回 warning 而非 error:%v", err)
|
||||
}
|
||||
if result.RawFormat != connection.ExplainFormatText {
|
||||
t.Fatalf("RawFormat got=%v want=text", result.RawFormat)
|
||||
}
|
||||
if len(result.Warnings) == 0 {
|
||||
t.Fatal("应有降级 warning")
|
||||
}
|
||||
}
|
||||
306
internal/app/explain_parse_sqlserver.go
Normal file
306
internal/app/explain_parse_sqlserver.go
Normal file
@@ -0,0 +1,306 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
// SQL Server SHOWPLAN_XML 解析。
|
||||
//
|
||||
// 典型 XML 结构(简化):
|
||||
//
|
||||
// <ShowPlanXML Version="1.5" ...>
|
||||
// <BatchSequence>
|
||||
// <Batch>
|
||||
// <Statements>
|
||||
// <StmtSimple StatementText="SELECT * FROM users" ...>
|
||||
// <QueryPlan ...>
|
||||
// <RelOp NodeId="0" PhysicalOp="Clustered Index Scan" LogicalOp="Clustered Scan"
|
||||
// EstimateRows="1000" EstimateIO="0.1" EstimateCPU="0.001" EstimatedTotalSubtreeCost="0.2"
|
||||
// Parallel="0" EstimateRebinds="0">
|
||||
// <Object Database="[db]" Schema="[dbo]" Table="[users]" Index="[PK_users]" />
|
||||
// <RunTimeInformation>
|
||||
// <RunTimeCountersPerThread ... ActualRows="1000" ActualElapsedms="5" ActualScans="1" />
|
||||
// </RunTimeInformation>
|
||||
// <IndexScan Ordered="0" ...>
|
||||
// <Object .../>
|
||||
// <Predicate>
|
||||
// <ScalarOperator ScalarString="[age]>(18)">
|
||||
// ...
|
||||
// </ScalarOperator>
|
||||
// </Predicate>
|
||||
// </IndexScan>
|
||||
// </RelOp>
|
||||
// </QueryPlan>
|
||||
// </StmtSimple>
|
||||
// </Statements>
|
||||
// </Batch>
|
||||
// </BatchSequence>
|
||||
// </ShowPlanXML>
|
||||
//
|
||||
// 解析要点:
|
||||
// - RelOp 是核心节点(递归嵌套),每个含 PhysicalOp + LogicalOp + EstimateRows + EstimatedTotalSubtreeCost
|
||||
// - 嵌套在 RelOp 内的同级 RelOp(在 IndexScan/NestedLoops/Hash 等子元素下)是子节点
|
||||
// - PhysicalOp 直接对应执行算子(Clustered Index Scan / Index Seek / Hash Match / Sort / ...)
|
||||
// - Object 子元素含 Table/Index 信息
|
||||
// - Predicate 的 ScalarOperator ScalarString 含过滤条件(供规则引擎提取列名)
|
||||
// - RunTimeCountersPerThread 含 ActualRows(对应 ANALYZE 信息)
|
||||
|
||||
// sqlServerShowPlanXML 是 SHOWPLAN_XML 顶层文档的 Go 结构(部分字段,未识别的留 raw)。
|
||||
type sqlServerShowPlanXML struct {
|
||||
XMLName xml.Name `xml:"ShowPlanXML"`
|
||||
Batches []sqlServerXMLBatch `xml:"BatchSequence>Batch"`
|
||||
}
|
||||
|
||||
type sqlServerXMLBatch struct {
|
||||
Statements []sqlServerXMLStmtSimple `xml:"Statements>StmtSimple"`
|
||||
}
|
||||
|
||||
type sqlServerXMLStmtSimple struct {
|
||||
StatementText string `xml:"StatementText,attr"`
|
||||
QueryPlan *sqlServerXMLPlan `xml:"QueryPlan"`
|
||||
}
|
||||
|
||||
type sqlServerXMLPlan struct {
|
||||
RelOps []sqlServerXMLRelOp `xml:"RelOp"`
|
||||
}
|
||||
|
||||
// sqlServerXMLRelOp 是 SHOWPLAN_XML 的核心节点;嵌套子 RelOp 通过多种容器元素持有。
|
||||
// 为简化解析:先把所有层级的 RelOp 平铺出来(按 NodeId 排序),再按 NodeId 父子推断。
|
||||
type sqlServerXMLRelOp struct {
|
||||
NodeID int `xml:"NodeId,attr"`
|
||||
PhysicalOp string `xml:"PhysicalOp,attr"`
|
||||
LogicalOp string `xml:"LogicalOp,attr"`
|
||||
EstimateRows float64 `xml:"EstimateRows,attr"`
|
||||
EstimateIO float64 `xml:"EstimateIO,attr"`
|
||||
EstimateCPU float64 `xml:"EstimateCPU,attr"`
|
||||
EstimatedTotalSubtreeCost float64 `xml:"EstimatedTotalSubtreeCost,attr"`
|
||||
EstimateRebinds float64 `xml:"EstimateRebinds,attr"`
|
||||
Parallel int `xml:"Parallel,attr"`
|
||||
// 子节点容器(不同 PhysicalOp 对应不同容器名,这里全收)
|
||||
Objects []sqlServerXMLObject `xml:"Object"`
|
||||
IndexScan *sqlServerXMLContainer `xml:"IndexScan"`
|
||||
NestedLoops *sqlServerXMLContainer `xml:"NestedLoops"`
|
||||
Hash *sqlServerXMLContainer `xml:"Hash"`
|
||||
Merge *sqlServerXMLContainer `xml:"Merge"`
|
||||
Concat *sqlServerXMLContainer `xml:"Concat"`
|
||||
Sort *sqlServerXMLContainer `xml:"Sort"`
|
||||
Filter *sqlServerXMLContainer `xml:"Filter"`
|
||||
ComputeScalar *sqlServerXMLContainer `xml:"ComputeScalar"`
|
||||
Top *sqlServerXMLContainer `xml:"Top"`
|
||||
GenericRelOps []sqlServerXMLContainer `xml:",any"` // 兜底:未识别容器
|
||||
Predicate *sqlServerXMLPredicate `xml:"Predicate"`
|
||||
RunTimeInfo *sqlServerXMLRunTimeInfo `xml:"RunTimeInformation"`
|
||||
}
|
||||
|
||||
type sqlServerXMLObject struct {
|
||||
Database string `xml:"Database,attr"`
|
||||
Schema string `xml:"Schema,attr"`
|
||||
Table string `xml:"Table,attr"`
|
||||
Index string `xml:"Index,attr"`
|
||||
}
|
||||
|
||||
type sqlServerXMLContainer struct {
|
||||
Objects []sqlServerXMLObject `xml:"Object"`
|
||||
RelOps []sqlServerXMLRelOp `xml:"RelOp"`
|
||||
Predicate *sqlServerXMLPredicate `xml:"Predicate"`
|
||||
}
|
||||
|
||||
type sqlServerXMLPredicate struct {
|
||||
ScalarString string `xml:"ScalarOperator>ScalarString"`
|
||||
}
|
||||
|
||||
type sqlServerXMLRunTimeInfo struct {
|
||||
RunTimeCounters []sqlServerXMLRunTimeCounter `xml:"RunTimeCountersPerThread"`
|
||||
}
|
||||
|
||||
type sqlServerXMLRunTimeCounter struct {
|
||||
ActualRows int64 `xml:"ActualRows,attr"`
|
||||
ActualElapsedMs float64 `xml:"ActualElapsedms,attr"`
|
||||
ActualScans int64 `xml:"ActualScans,attr"`
|
||||
}
|
||||
|
||||
func parseSQLServerExplain(sourceSQL, raw string, format connection.ExplainFormat) (connection.ExplainResult, error) {
|
||||
result := connection.ExplainResult{
|
||||
DBType: "sqlserver",
|
||||
SourceSQL: sourceSQL,
|
||||
}
|
||||
resetExplainNodeID()
|
||||
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return result, fmt.Errorf("SHOWPLAN_XML 输出为空")
|
||||
}
|
||||
|
||||
// SQL Server 输出的 XML 含 `<?xml encoding="utf-16"?>` 声明,Go encoding/xml 不支持 utf-16
|
||||
// 直接报 "encoding declared but not supported"。从 <ShowPlanXML> 标签开始截取即可规避。
|
||||
showPlanStart := strings.Index(trimmed, "<ShowPlanXML")
|
||||
if showPlanStart < 0 {
|
||||
showPlanStart = 0
|
||||
}
|
||||
body := trimmed[showPlanStart:]
|
||||
|
||||
// SHOWPLAN_XML 携带默认命名空间 xmlns="http://schemas.microsoft.com/sqlserver/2004/07/showplan",
|
||||
// Go encoding/xml 严格按 namespace 匹配,导致子元素无法识别。
|
||||
// 该 namespace URL 是固定的(微软规范),直接 strip 掉即可让 struct 按 local name 匹配。
|
||||
cleaned := stripSQLServerDefaultNamespace(body)
|
||||
|
||||
var doc sqlServerShowPlanXML
|
||||
if err := xml.Unmarshal([]byte(cleaned), &doc); err != nil {
|
||||
result.RawFormat = connection.ExplainFormatText
|
||||
result.RawPayload = raw
|
||||
result.Warnings = []string{fmt.Sprintf("SHOWPLAN_XML 解析失败:%v", err)}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 平铺 RelOp + 记录 NodeId 到内部 ID 的映射
|
||||
nodeByOpID := map[int]string{}
|
||||
for _, batch := range doc.Batches {
|
||||
for _, stmt := range batch.Statements {
|
||||
if stmt.QueryPlan == nil {
|
||||
continue
|
||||
}
|
||||
for _, relOp := range stmt.QueryPlan.RelOps {
|
||||
parseSQLServerRelOp(&relOp, "", &result, nodeByOpID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(result.Nodes) == 0 {
|
||||
result.Warnings = append(result.Warnings, "SHOWPLANXML 解析未提取到任何 RelOp 节点")
|
||||
}
|
||||
|
||||
result.RawFormat = connection.ExplainFormatXML
|
||||
result.RawPayload = raw
|
||||
finalizeExplainStats(&result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// stripSQLServerDefaultNamespace 去掉 SHOWPLAN_XML 的默认命名空间属性。
|
||||
// 该 namespace URL 是 SQL Server 固定规范值,从 SQL Server 2005 起未变化。
|
||||
func stripSQLServerDefaultNamespace(xmlText string) string {
|
||||
const ns = ` xmlns="http://schemas.microsoft.com/sqlserver/2004/07/showplan"`
|
||||
return strings.ReplaceAll(xmlText, ns, "")
|
||||
}
|
||||
|
||||
// parseSQLServerRelOp 递归解析 RelOp 节点及其所有子 RelOp(在多种容器元素中)。
|
||||
func parseSQLServerRelOp(rel *sqlServerXMLRelOp, parentID string, result *connection.ExplainResult, nodeByOpID map[int]string) {
|
||||
if rel == nil {
|
||||
return
|
||||
}
|
||||
|
||||
node := connection.ExplainNode{
|
||||
OpType: classifySQLServerPhysicalOp(rel.PhysicalOp, rel.LogicalOp),
|
||||
OpDetail: rel.PhysicalOp,
|
||||
EstRows: int64(rel.EstimateRows),
|
||||
Cost: rel.EstimatedTotalSubtreeCost,
|
||||
}
|
||||
|
||||
// 取第一个 Object 作为表/索引来源
|
||||
if len(rel.Objects) > 0 {
|
||||
obj := rel.Objects[0]
|
||||
node.Table = stripSQLServerBrackets(obj.Table)
|
||||
node.Index = stripSQLServerBrackets(obj.Index)
|
||||
}
|
||||
|
||||
// Predicate
|
||||
if rel.Predicate != nil && rel.Predicate.ScalarString != "" {
|
||||
if node.Extra == nil {
|
||||
node.Extra = map[string]any{}
|
||||
}
|
||||
node.Extra["filter"] = rel.Predicate.ScalarString
|
||||
}
|
||||
|
||||
// ActualRows(来自 RunTimeCountersPerThread 累加)
|
||||
if rel.RunTimeInfo != nil {
|
||||
var actualRows int64
|
||||
var elapsedMs float64
|
||||
for _, c := range rel.RunTimeInfo.RunTimeCounters {
|
||||
actualRows += c.ActualRows
|
||||
elapsedMs += c.ActualElapsedMs
|
||||
}
|
||||
node.ActualRows = actualRows
|
||||
node.DurationMs = elapsedMs
|
||||
}
|
||||
|
||||
// 物理算子归类
|
||||
switch node.OpType {
|
||||
case connection.ExplainOpScan:
|
||||
node.Flags = append(node.Flags, connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex)
|
||||
case connection.ExplainOpSort:
|
||||
node.Flags = append(node.Flags, connection.ExplainFlagFilesort)
|
||||
case connection.ExplainOpAggregate, connection.ExplainOpMaterialize:
|
||||
node.Flags = append(node.Flags, connection.ExplainFlagTempTable)
|
||||
}
|
||||
|
||||
// 关联 LogicalOp 优化提示
|
||||
if strings.Contains(strings.ToLower(rel.LogicalOp), "aggregate") {
|
||||
node.Flags = append(node.Flags, connection.ExplainFlagTempTable)
|
||||
}
|
||||
|
||||
nodeID := appendExplainChild(result, parentID, node)
|
||||
nodeByOpID[rel.NodeID] = nodeID
|
||||
|
||||
// 递归所有可能的容器
|
||||
containers := []*sqlServerXMLContainer{
|
||||
rel.IndexScan, rel.NestedLoops, rel.Hash, rel.Merge,
|
||||
rel.Concat, rel.Sort, rel.Filter, rel.ComputeScalar, rel.Top,
|
||||
}
|
||||
for _, c := range containers {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
for i := range c.RelOps {
|
||||
parseSQLServerRelOp(&c.RelOps[i], nodeID, result, nodeByOpID)
|
||||
}
|
||||
}
|
||||
// GenericRelOps 是 ,any 兜底容器,按需递归
|
||||
for i := range rel.GenericRelOps {
|
||||
for j := range rel.GenericRelOps[i].RelOps {
|
||||
parseSQLServerRelOp(&rel.GenericRelOps[i].RelOps[j], nodeID, result, nodeByOpID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// classifySQLServerPhysicalOp 把 SQLServer PhysicalOp/LogicalOp 归一化到通用 OpType。
|
||||
// 参考官方文档:Clustered Index Scan / Index Seek / RID Lookup / Key Lookup / Hash Match / Nested Loops /
|
||||
// Merge Join / Sort / Stream Aggregate / Filter / Compute Scalar / Top / Spool / Table-valued function。
|
||||
func classifySQLServerPhysicalOp(physical, logical string) string {
|
||||
p := strings.ToLower(strings.TrimSpace(physical))
|
||||
l := strings.ToLower(strings.TrimSpace(logical))
|
||||
switch {
|
||||
case strings.Contains(p, "index scan"), strings.Contains(p, "clustered index scan"), strings.Contains(p, "table scan"):
|
||||
return connection.ExplainOpScan
|
||||
case strings.Contains(p, "index seek"), strings.Contains(p, "clustered index seek"):
|
||||
return connection.ExplainOpIndexScan
|
||||
case strings.Contains(p, "key lookup"), strings.Contains(p, "rid lookup"):
|
||||
return connection.ExplainOpIndexScan
|
||||
case strings.Contains(p, "hash match"), strings.Contains(p, "nested loops"), strings.Contains(p, "merge join"):
|
||||
return connection.ExplainOpJoin
|
||||
case strings.Contains(p, "sort"):
|
||||
return connection.ExplainOpSort
|
||||
case strings.Contains(l, "aggregate"), strings.Contains(p, "stream aggregate"), strings.Contains(p, "hash match") && strings.Contains(l, "aggregate"):
|
||||
return connection.ExplainOpAggregate
|
||||
case strings.Contains(p, "filter"):
|
||||
return connection.ExplainOpFilter
|
||||
case strings.Contains(p, "top"):
|
||||
return connection.ExplainOpLimit
|
||||
case strings.Contains(p, "spool"):
|
||||
return connection.ExplainOpMaterialize
|
||||
case strings.Contains(p, "compute scalar"):
|
||||
return connection.ExplainOpOther
|
||||
default:
|
||||
return connection.ExplainOpOther
|
||||
}
|
||||
}
|
||||
|
||||
// stripSQLServerBrackets 去掉 SQLServer 标识符的方括号:[users] → users。
|
||||
func stripSQLServerBrackets(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.TrimPrefix(s, "[")
|
||||
s = strings.TrimSuffix(s, "]")
|
||||
return s
|
||||
}
|
||||
153
internal/app/explain_parse_sqlserver_test.go
Normal file
153
internal/app/explain_parse_sqlserver_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
// SQL Server SHOWPLAN_XML fixture:单 Clustered Index Scan + Predicate + RunTime。
|
||||
const sqlServerShowPlanXMLSingleScan = `<?xml version="1.0" encoding="utf-16"?>
|
||||
<ShowPlanXML Version="1.481" Build="15.0.2000.0" xmlns="http://schemas.microsoft.com/sqlserver/2004/07/showplan">
|
||||
<BatchSequence>
|
||||
<Batch>
|
||||
<Statements>
|
||||
<StmtSimple StatementText="SELECT * FROM users WHERE age > 18" StatementId="1">
|
||||
<QueryPlan DegreeOfParallelism="1">
|
||||
<RelOp NodeId="0" PhysicalOp="Clustered Index Scan" LogicalOp="Clustered Scan"
|
||||
EstimateRows="10000" EstimateIO="0.1" EstimateCPU="0.001"
|
||||
EstimatedTotalSubtreeCost="0.2" Parallel="0" EstimateRebinds="0">
|
||||
<Object Database="[db]" Schema="[dbo]" Table="[users]" Index="[PK_users]" />
|
||||
<RunTimeInformation>
|
||||
<RunTimeCountersPerThread ActualRows="10000" ActualElapsedms="5" ActualScans="1" />
|
||||
</RunTimeInformation>
|
||||
<IndexScan Ordered="0" />
|
||||
</RelOp>
|
||||
</QueryPlan>
|
||||
</StmtSimple>
|
||||
</Statements>
|
||||
</Batch>
|
||||
</BatchSequence>
|
||||
</ShowPlanXML>`
|
||||
|
||||
func TestParseSQLServerExplain_ClusteredIndexScan(t *testing.T) {
|
||||
result, err := parseSQLServerExplain("SELECT * FROM users WHERE age > 18", sqlServerShowPlanXMLSingleScan, connection.ExplainFormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败:%v", err)
|
||||
}
|
||||
if len(result.Nodes) != 1 {
|
||||
t.Fatalf("应有 1 个 RelOp 节点,got=%d", len(result.Nodes))
|
||||
}
|
||||
node := result.Nodes[0]
|
||||
if node.OpType != connection.ExplainOpScan {
|
||||
t.Fatalf("Clustered Index Scan 应为 SCAN,got=%s", node.OpType)
|
||||
}
|
||||
if node.Table != "users" {
|
||||
t.Fatalf("Table got=%s want=users", node.Table)
|
||||
}
|
||||
if node.Index != "PK_users" {
|
||||
t.Fatalf("Index got=%s want=PK_users", node.Index)
|
||||
}
|
||||
if node.EstRows != 10000 {
|
||||
t.Fatalf("EstRows got=%d want=10000", node.EstRows)
|
||||
}
|
||||
if node.ActualRows != 10000 {
|
||||
t.Fatalf("ActualRows got=%d want=10000", node.ActualRows)
|
||||
}
|
||||
if node.DurationMs != 5 {
|
||||
t.Fatalf("DurationMs got=%v want=5", node.DurationMs)
|
||||
}
|
||||
if !containsFlag(node.Flags, connection.ExplainFlagFullScan) {
|
||||
t.Fatalf("Clustered Scan 应有 FULL_SCAN flag")
|
||||
}
|
||||
}
|
||||
|
||||
// SQL Server fixture:Nested Loops JOIN + 两个子节点。
|
||||
const sqlServerShowPlanXMLNestedLoops = `<?xml version="1.0" encoding="utf-16"?>
|
||||
<ShowPlanXML xmlns="http://schemas.microsoft.com/sqlserver/2004/07/showplan">
|
||||
<BatchSequence>
|
||||
<Batch>
|
||||
<Statements>
|
||||
<StmtSimple StatementText="SELECT * FROM orders o JOIN users u ON o.user_id = u.id">
|
||||
<QueryPlan>
|
||||
<RelOp NodeId="0" PhysicalOp="Nested Loops" LogicalOp="Inner Join"
|
||||
EstimateRows="100" EstimatedTotalSubtreeCost="1.5">
|
||||
<NestedLoops>
|
||||
<OuterReferences>
|
||||
<ColumnReference Database="[db]" Table="[orders]" Column="user_id" />
|
||||
</OuterReferences>
|
||||
<RelOp NodeId="1" PhysicalOp="Clustered Index Scan" LogicalOp="Clustered Scan"
|
||||
EstimateRows="50000" EstimatedTotalSubtreeCost="0.5">
|
||||
<Object Database="[db]" Table="[orders]" Index="[PK_orders]" />
|
||||
<IndexScan />
|
||||
</RelOp>
|
||||
<RelOp NodeId="2" PhysicalOp="Index Seek" LogicalOp="Index Seek"
|
||||
EstimateRows="1" EstimatedTotalSubtreeCost="0.003">
|
||||
<Object Database="[db]" Table="[users]" Index="[IX_users_id]" />
|
||||
<IndexScan Ordered="1" />
|
||||
</RelOp>
|
||||
</NestedLoops>
|
||||
</RelOp>
|
||||
</QueryPlan>
|
||||
</StmtSimple>
|
||||
</Statements>
|
||||
</Batch>
|
||||
</BatchSequence>
|
||||
</ShowPlanXML>`
|
||||
|
||||
func TestParseSQLServerExplain_NestedLoopsRecursesChildren(t *testing.T) {
|
||||
result, err := parseSQLServerExplain("SELECT * FROM orders o JOIN users u ON o.user_id = u.id", sqlServerShowPlanXMLNestedLoops, connection.ExplainFormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败:%v", err)
|
||||
}
|
||||
if len(result.Nodes) != 3 {
|
||||
t.Fatalf("应有 3 个节点(Nested Loops + 2 子),got=%d", len(result.Nodes))
|
||||
}
|
||||
joinNode := result.Nodes[0]
|
||||
if joinNode.OpType != connection.ExplainOpJoin {
|
||||
t.Fatalf("顶层应为 JOIN,got=%s", joinNode.OpType)
|
||||
}
|
||||
// 两个子节点(通过 Edges 验证)
|
||||
childCount := 0
|
||||
for _, e := range result.Edges {
|
||||
if e.From == joinNode.ID {
|
||||
childCount++
|
||||
}
|
||||
}
|
||||
if childCount != 2 {
|
||||
t.Fatalf("JOIN 应有 2 个直接子节点,got=%d", childCount)
|
||||
}
|
||||
// 找到 Index Seek 子节点
|
||||
var indexSeek *connection.ExplainNode
|
||||
for i := range result.Nodes {
|
||||
if result.Nodes[i].OpType == connection.ExplainOpIndexScan {
|
||||
indexSeek = &result.Nodes[i]
|
||||
}
|
||||
}
|
||||
if indexSeek == nil {
|
||||
t.Fatal("应有一个 Index Seek 节点")
|
||||
}
|
||||
if indexSeek.Index != "IX_users_id" {
|
||||
t.Fatalf("Index Seek 应使用 IX_users_id,got=%s", indexSeek.Index)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSQLServerExplain_InvalidXMLReturnsWarning(t *testing.T) {
|
||||
result, err := parseSQLServerExplain("SELECT 1", "<not valid xml", connection.ExplainFormatXML)
|
||||
if err != nil {
|
||||
t.Fatalf("非法 XML 应降级返回 warning 而非 error:%v", err)
|
||||
}
|
||||
if result.RawFormat != connection.ExplainFormatText {
|
||||
t.Fatalf("RawFormat got=%v want=text", result.RawFormat)
|
||||
}
|
||||
if len(result.Warnings) == 0 {
|
||||
t.Fatal("应有解析失败 warning")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSQLServerExplain_EmptyReturnsError(t *testing.T) {
|
||||
_, err := parseSQLServerExplain("SELECT 1", " ", connection.ExplainFormatXML)
|
||||
if err == nil {
|
||||
t.Fatal("空输入应返回 error")
|
||||
}
|
||||
}
|
||||
434
internal/app/explain_rules.go
Normal file
434
internal/app/explain_rules.go
Normal file
@@ -0,0 +1,434 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
// SQL 诊断工作台规则引擎。
|
||||
//
|
||||
// 设计要点:
|
||||
// - 规则跨方言通用,基于归一化后的 ExplainNode 字段匹配
|
||||
// - 每条规则只描述问题 + 给出 Reason;不强行生成 CREATE INDEX(避免瞎猜列名误导用户)
|
||||
// - 规则触发按 Severity 严重度排序:critical > warning > info
|
||||
// - 同一节点可能触发多条规则(如 FULL_SCAN 节点同时触发"全表扫描"+"缺索引")
|
||||
//
|
||||
// 规则 ID 列表(前端按 ID 显示本地化文案 + 图标):
|
||||
// - full_scan_on_large_table:大表全表扫描(critical)
|
||||
// - full_scan_with_filter:带 WHERE 的全表扫描(critical,索引建议价值最高)
|
||||
// - missing_index_lookup:JOIN 中存在无索引扫描节点(critical)
|
||||
// - filesort_on_large_result:大结果集排序(warning)
|
||||
// - temp_table_for_distinct:DISTINCT/GROUP BY 物化临时表(warning)
|
||||
// - low_buffer_hit_rate:缓冲命中率低(warning,需 ANALYZE 才有数据)
|
||||
// - high_estimation_skew:估算与实际行数偏差大(info,需 ANALYZE)
|
||||
// - high_total_cost:总成本过高(warning)
|
||||
// - nested_loop_high_fanout:Nested Loop 高扇出(warning)
|
||||
// - using_temp_btree_order:SQLite 风格 ORDER BY 临时表(info)
|
||||
|
||||
// 规则阈值常量。值的选择基于工程经验:
|
||||
// - 1000 行:单节点扫描超过此值视为"非小表"
|
||||
// - 10000 行:超过此值视为"大表",触发 critical 建议索引
|
||||
// - 0.5:缓冲命中率低于 50% 视为差
|
||||
// - 10x:估算与实际偏差超过 10 倍视为显著
|
||||
const (
|
||||
ruleFullScanLargeTableRows int64 = 10000
|
||||
ruleFullScanSmallTableRows int64 = 1000
|
||||
ruleFilesortRowsThreshold int64 = 5000
|
||||
ruleLowBufferHitThreshold float64 = 0.5
|
||||
ruleEstimationSkewRatio float64 = 10.0
|
||||
ruleHighTotalCostThreshold float64 = 1000.0
|
||||
ruleNestedLoopFanoutRows int64 = 10000
|
||||
)
|
||||
|
||||
// runExplainRules 对归一化的 ExplainResult 跑全部规则,返回排序后的建议列表。
|
||||
// 按 Severity 排序(critical > warning > info),同 Severity 内按 EstRows 降序。
|
||||
func runExplainRules(result connection.ExplainResult) []connection.IndexSuggestion {
|
||||
var suggestions []connection.IndexSuggestion
|
||||
|
||||
// 全局规则(基于 Stats)
|
||||
if s := ruleHighTotalCost(result); s != nil {
|
||||
suggestions = append(suggestions, *s)
|
||||
}
|
||||
if s := ruleLowBufferHitRate(result); s != nil {
|
||||
suggestions = append(suggestions, *s)
|
||||
}
|
||||
|
||||
// 节点级规则
|
||||
for _, node := range result.Nodes {
|
||||
rules := []func(connection.ExplainResult, connection.ExplainNode) *connection.IndexSuggestion{
|
||||
ruleFullScanLargeTable,
|
||||
ruleFullScanWithFilter,
|
||||
ruleMissingIndexLookup,
|
||||
ruleFilesortOnLargeResult,
|
||||
ruleTempTableForDistinct,
|
||||
ruleHighEstimationSkew,
|
||||
ruleNestedLoopHighFanout,
|
||||
ruleUsingTempBTreeOrder,
|
||||
}
|
||||
for _, ruleFn := range rules {
|
||||
if s := ruleFn(result, node); s != nil {
|
||||
suggestions = append(suggestions, *s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sortExplainSuggestions(suggestions)
|
||||
return suggestions
|
||||
}
|
||||
|
||||
// sortExplainSuggestions 按 Severity + EstRows 排序(in-place)。
|
||||
func sortExplainSuggestions(s []connection.IndexSuggestion) {
|
||||
// 简单插入排序:建议数量通常 < 20,无需 sort.Slice 的反射开销
|
||||
severityRank := map[string]int{
|
||||
connection.SeverityCritical: 0,
|
||||
connection.SeverityWarning: 1,
|
||||
connection.SeverityInfo: 2,
|
||||
}
|
||||
for i := 1; i < len(s); i++ {
|
||||
for j := i; j > 0; j-- {
|
||||
si := severityRank[s[j].Severity]
|
||||
sj := severityRank[s[j-1].Severity]
|
||||
if si < sj || (si == sj && s[j].EstRows > s[j-1].EstRows) {
|
||||
s[j], s[j-1] = s[j-1], s[j]
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ruleFullScanLargeTable:单节点全表扫描 + 估算行数超过阈值。
|
||||
// 严重度:EstRows > 10000 → critical;> 1000 → warning;否则不触发。
|
||||
func ruleFullScanLargeTable(_ connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion {
|
||||
if !hasFlag(node.Flags, connection.ExplainFlagFullScan) {
|
||||
return nil
|
||||
}
|
||||
if node.EstRows < ruleFullScanSmallTableRows {
|
||||
return nil
|
||||
}
|
||||
severity := connection.SeverityWarning
|
||||
if node.EstRows >= ruleFullScanLargeTableRows {
|
||||
severity = connection.SeverityCritical
|
||||
}
|
||||
return &connection.IndexSuggestion{
|
||||
Severity: severity,
|
||||
Rule: "full_scan_on_large_table",
|
||||
Reason: fmt.Sprintf("表 %s 全表扫描,估算扫描 %d 行;考虑为 WHERE/JOIN 条件字段添加索引", node.Table, node.EstRows),
|
||||
AffectedNodeID: node.ID,
|
||||
AffectedTable: node.Table,
|
||||
EstRows: node.EstRows,
|
||||
}
|
||||
}
|
||||
|
||||
// ruleFullScanWithFilter:带 WHERE 的全表扫描(最有价值的索引建议场景)。
|
||||
// 从 attachedCondition / Filter / Extra 提取等式字段,提示用户考虑建索引。
|
||||
func ruleFullScanWithFilter(_ connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion {
|
||||
if !hasFlag(node.Flags, connection.ExplainFlagFullScan) {
|
||||
return nil
|
||||
}
|
||||
filter := extractNodeFilterText(node)
|
||||
if filter == "" {
|
||||
return nil
|
||||
}
|
||||
columns := extractEqualityColumns(filter)
|
||||
if len(columns) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &connection.IndexSuggestion{
|
||||
Severity: connection.SeverityCritical,
|
||||
Rule: "full_scan_with_filter",
|
||||
Reason: fmt.Sprintf("表 %s 全表扫描但带 WHERE 条件 %q;建议为字段 %s 建立索引", node.Table, truncateForReason(filter, 60), joinColumnsForReason(columns)),
|
||||
AffectedNodeID: node.ID,
|
||||
AffectedTable: node.Table,
|
||||
EstRows: node.EstRows,
|
||||
}
|
||||
}
|
||||
|
||||
// ruleMissingIndexLookup:JOIN 中存在无索引扫描节点(NO_INDEX flag)。
|
||||
func ruleMissingIndexLookup(_ connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion {
|
||||
if !hasFlag(node.Flags, connection.ExplainFlagNoIndex) {
|
||||
return nil
|
||||
}
|
||||
// 已被 full_scan_on_large_table 覆盖时跳过,避免重复
|
||||
if hasFlag(node.Flags, connection.ExplainFlagFullScan) {
|
||||
return nil
|
||||
}
|
||||
if node.EstRows < ruleFullScanSmallTableRows {
|
||||
return nil
|
||||
}
|
||||
return &connection.IndexSuggestion{
|
||||
Severity: connection.SeverityCritical,
|
||||
Rule: "missing_index_lookup",
|
||||
Reason: fmt.Sprintf("JOIN 节点 %s 未命中索引,估算扫描 %d 行;JOIN 字段需要索引", node.Table, node.EstRows),
|
||||
AffectedNodeID: node.ID,
|
||||
AffectedTable: node.Table,
|
||||
EstRows: node.EstRows,
|
||||
}
|
||||
}
|
||||
|
||||
// ruleFilesortOnLargeResult:大结果集排序。
|
||||
func ruleFilesortOnLargeResult(_ connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion {
|
||||
if !hasFlag(node.Flags, connection.ExplainFlagFilesort) {
|
||||
return nil
|
||||
}
|
||||
if node.EstRows < ruleFilesortRowsThreshold {
|
||||
return nil
|
||||
}
|
||||
return &connection.IndexSuggestion{
|
||||
Severity: connection.SeverityWarning,
|
||||
Rule: "filesort_on_large_result",
|
||||
Reason: fmt.Sprintf("对约 %d 行做额外排序;考虑为 ORDER BY 字段建立索引以避免 filesort", node.EstRows),
|
||||
AffectedNodeID: node.ID,
|
||||
AffectedTable: node.Table,
|
||||
EstRows: node.EstRows,
|
||||
}
|
||||
}
|
||||
|
||||
// ruleTempTableForDistinct:使用临时表(DISTINCT/GROUP BY)。
|
||||
func ruleTempTableForDistinct(_ connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion {
|
||||
if !hasFlag(node.Flags, connection.ExplainFlagTempTable) {
|
||||
return nil
|
||||
}
|
||||
// OpDetail 含 distinct/group 时给出更精准的建议
|
||||
detail := strings.ToLower(node.OpDetail)
|
||||
var hint string
|
||||
switch {
|
||||
case strings.Contains(detail, "distinct"):
|
||||
hint = "DISTINCT 物化了临时表"
|
||||
case strings.Contains(detail, "group"):
|
||||
hint = "GROUP BY 物化了临时表"
|
||||
default:
|
||||
hint = "查询使用了临时表"
|
||||
}
|
||||
return &connection.IndexSuggestion{
|
||||
Severity: connection.SeverityWarning,
|
||||
Rule: "temp_table_for_distinct",
|
||||
Reason: fmt.Sprintf("%s;考虑为分组字段建立索引避免物化", hint),
|
||||
AffectedNodeID: node.ID,
|
||||
AffectedTable: node.Table,
|
||||
EstRows: node.EstRows,
|
||||
}
|
||||
}
|
||||
|
||||
// ruleHighEstimationSkew:估算与实际行数偏差大(需 ANALYZE 才有数据)。
|
||||
func ruleHighEstimationSkew(_ connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion {
|
||||
if node.EstRows <= 0 || node.ActualRows <= 0 {
|
||||
return nil
|
||||
}
|
||||
ratio := float64(node.ActualRows) / float64(node.EstRows)
|
||||
if ratio < ruleEstimationSkewRatio && ratio > 1.0/ruleEstimationSkewRatio {
|
||||
return nil
|
||||
}
|
||||
return &connection.IndexSuggestion{
|
||||
Severity: connection.SeverityInfo,
|
||||
Rule: "high_estimation_skew",
|
||||
Reason: fmt.Sprintf("估算 %d 行 / 实际 %d 行(偏差 %.1fx);统计信息可能过期,考虑 ANALYZE TABLE", node.EstRows, node.ActualRows, ratio),
|
||||
AffectedNodeID: node.ID,
|
||||
AffectedTable: node.Table,
|
||||
EstRows: node.EstRows,
|
||||
}
|
||||
}
|
||||
|
||||
// ruleNestedLoopHighFanout:Nested Loop 高扇出。
|
||||
// 触发条件:JOIN 节点 + 子节点(被驱动表)估算行数 > 10000。
|
||||
func ruleNestedLoopHighFanout(result connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion {
|
||||
if node.OpType != connection.ExplainOpJoin {
|
||||
return nil
|
||||
}
|
||||
// 找到该 JOIN 的直接子节点(被驱动表)
|
||||
var maxChildRows int64
|
||||
for _, edge := range result.Edges {
|
||||
if edge.From != node.ID {
|
||||
continue
|
||||
}
|
||||
for _, child := range result.Nodes {
|
||||
if child.ID == edge.To && child.EstRows > maxChildRows {
|
||||
maxChildRows = child.EstRows
|
||||
}
|
||||
}
|
||||
}
|
||||
if maxChildRows < ruleNestedLoopFanoutRows {
|
||||
return nil
|
||||
}
|
||||
return &connection.IndexSuggestion{
|
||||
Severity: connection.SeverityWarning,
|
||||
Rule: "nested_loop_high_fanout",
|
||||
Reason: fmt.Sprintf("Nested Loop JOIN 被驱动表估算 %d 行,扇出过大;考虑改用 Hash Join 或为 JOIN 字段加索引", maxChildRows),
|
||||
AffectedNodeID: node.ID,
|
||||
AffectedTable: node.Table,
|
||||
EstRows: maxChildRows,
|
||||
}
|
||||
}
|
||||
|
||||
// ruleUsingTempBTreeOrder:SQLite 风格的 ORDER BY 临时表(Info 级,提示性)。
|
||||
func ruleUsingTempBTreeOrder(_ connection.ExplainResult, node connection.ExplainNode) *connection.IndexSuggestion {
|
||||
if !hasFlag(node.Flags, connection.ExplainFlagFilesort) {
|
||||
return nil
|
||||
}
|
||||
if node.EstRows >= ruleFilesortRowsThreshold {
|
||||
return nil // 已被 filesort_on_large_result 覆盖
|
||||
}
|
||||
return &connection.IndexSuggestion{
|
||||
Severity: connection.SeverityInfo,
|
||||
Rule: "using_temp_btree_order",
|
||||
Reason: "ORDER BY 使用临时 B-Tree;如频繁执行,为排序字段建立索引可消除该开销",
|
||||
AffectedNodeID: node.ID,
|
||||
AffectedTable: node.Table,
|
||||
EstRows: node.EstRows,
|
||||
}
|
||||
}
|
||||
|
||||
// ruleHighTotalCost:总成本过高(全局规则)。
|
||||
func ruleHighTotalCost(result connection.ExplainResult) *connection.IndexSuggestion {
|
||||
if result.Stats.TotalCost < ruleHighTotalCostThreshold {
|
||||
return nil
|
||||
}
|
||||
return &connection.IndexSuggestion{
|
||||
Severity: connection.SeverityWarning,
|
||||
Rule: "high_total_cost",
|
||||
Reason: fmt.Sprintf("执行计划总成本 %.1f;考虑重写查询或加索引降低扫描量", result.Stats.TotalCost),
|
||||
EstRows: result.Stats.RowsRead,
|
||||
}
|
||||
}
|
||||
|
||||
// ruleLowBufferHitRate:缓冲命中率低(全局规则,PG/Oracle 才有此数据)。
|
||||
func ruleLowBufferHitRate(result connection.ExplainResult) *connection.IndexSuggestion {
|
||||
if result.Stats.BufferHitRate <= 0 || result.Stats.BufferHitRate >= ruleLowBufferHitThreshold {
|
||||
return nil
|
||||
}
|
||||
return &connection.IndexSuggestion{
|
||||
Severity: connection.SeverityWarning,
|
||||
Rule: "low_buffer_hit_rate",
|
||||
Reason: fmt.Sprintf("缓冲命中率仅 %.1f%%;热门数据可能未被缓存,考虑增大 shared_buffers 或检查访问模式", result.Stats.BufferHitRate*100),
|
||||
EstRows: result.Stats.RowsRead,
|
||||
}
|
||||
}
|
||||
|
||||
// hasFlag 检查节点是否含指定 flag。
|
||||
func hasFlag(flags []string, target string) bool {
|
||||
for _, f := range flags {
|
||||
if f == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extractNodeFilterText 从节点的 attached_condition / Filter / Extra 中提取过滤条件文本。
|
||||
func extractNodeFilterText(node connection.ExplainNode) string {
|
||||
if node.Extra == nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"attachedCondition", "filter"} {
|
||||
if v, ok := node.Extra[key]; ok {
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", v))
|
||||
if text != "" && text != "<nil>" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractEqualityColumns 从 SQL 过滤条件中提取等值条件的列名(粗略解析)。
|
||||
// 仅识别 "col = ?" / "col = literal" 形式;不处理复杂表达式(OR/函数调用)。
|
||||
func extractEqualityColumns(filter string) []string {
|
||||
if filter == "" {
|
||||
return nil
|
||||
}
|
||||
// 简化:按 AND 切分后取每个等值条件的左边
|
||||
parts := splitTopLevelByKeyword(filter, " and ")
|
||||
seen := make(map[string]struct{})
|
||||
var columns []string
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
// 去除括号
|
||||
part = strings.Trim(part, "() ")
|
||||
eqIdx := strings.Index(part, "=")
|
||||
if eqIdx <= 0 {
|
||||
continue
|
||||
}
|
||||
left := strings.TrimSpace(part[:eqIdx])
|
||||
// 必须是简单标识符(字母数字下划线 + 点)
|
||||
if !isSimpleIdentifier(left) {
|
||||
continue
|
||||
}
|
||||
// 右边不是另一个列引用(粗略判断:不含点/字母前缀的字段)
|
||||
right := strings.TrimSpace(part[eqIdx+1:])
|
||||
if isSimpleIdentifier(right) {
|
||||
continue // col1 = col2 形式不算索引候选
|
||||
}
|
||||
if _, exists := seen[left]; !exists {
|
||||
seen[left] = struct{}{}
|
||||
columns = append(columns, left)
|
||||
}
|
||||
}
|
||||
return columns
|
||||
}
|
||||
|
||||
// splitTopLevelByKeyword 按关键字(不区分大小写)切分字符串,忽略嵌套括号内的匹配。
|
||||
func splitTopLevelByKeyword(text, keyword string) []string {
|
||||
var parts []string
|
||||
depth := 0
|
||||
lower := strings.ToLower(text)
|
||||
kw := strings.ToLower(keyword)
|
||||
start := 0
|
||||
for i := 0; i < len(lower); i++ {
|
||||
switch lower[i] {
|
||||
case '(':
|
||||
depth++
|
||||
case ')':
|
||||
if depth > 0 {
|
||||
depth--
|
||||
}
|
||||
}
|
||||
if depth > 0 {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(lower[i:], kw) {
|
||||
parts = append(parts, text[start:i])
|
||||
i += len(kw)
|
||||
start = i
|
||||
}
|
||||
}
|
||||
parts = append(parts, text[start:])
|
||||
return parts
|
||||
}
|
||||
|
||||
// isSimpleIdentifier 判断字符串是否是简单 SQL 标识符(支持 schema.table 形式)。
|
||||
func isSimpleIdentifier(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for i, ch := range s {
|
||||
ok := (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '.'
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if i == 0 && ch >= '0' && ch <= '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// truncateForReason 截断字符串到 maxLen,超出加省略号。
|
||||
func truncateForReason(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen-1] + "…"
|
||||
}
|
||||
|
||||
// joinColumnsForReason 把列名列表格式化为人类可读的列表(最多 3 个)。
|
||||
func joinColumnsForReason(columns []string) string {
|
||||
if len(columns) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(columns) > 3 {
|
||||
return strings.Join(columns[:3], ", ") + " 等"
|
||||
}
|
||||
return strings.Join(columns, ", ")
|
||||
}
|
||||
249
internal/app/explain_rules_test.go
Normal file
249
internal/app/explain_rules_test.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
// 规则引擎测试:验证各规则在合成 ExplainNode 上的触发与排序。
|
||||
|
||||
func TestRunExplainRules_FullScanLargeTableCritical(t *testing.T) {
|
||||
result := connection.ExplainResult{
|
||||
DBType: "mysql",
|
||||
SourceSQL: "SELECT * FROM users",
|
||||
Nodes: []connection.ExplainNode{
|
||||
{
|
||||
ID: "n1",
|
||||
OpType: connection.ExplainOpScan,
|
||||
Table: "users",
|
||||
EstRows: 100000,
|
||||
Flags: []string{connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex},
|
||||
},
|
||||
},
|
||||
}
|
||||
suggestions := runExplainRules(result)
|
||||
if len(suggestions) == 0 {
|
||||
t.Fatal("全表扫描大表应触发建议")
|
||||
}
|
||||
top := suggestions[0]
|
||||
if top.Severity != connection.SeverityCritical {
|
||||
t.Fatalf("大表全表扫描应为 critical,got=%s", top.Severity)
|
||||
}
|
||||
if top.Rule != "full_scan_with_filter" && top.Rule != "full_scan_on_large_table" {
|
||||
t.Fatalf("首条建议应与全表扫描相关,got=%s", top.Rule)
|
||||
}
|
||||
if top.AffectedTable != "users" {
|
||||
t.Fatalf("AffectedTable got=%s want=users", top.AffectedTable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExplainRules_FullScanSmallTableSuppressed(t *testing.T) {
|
||||
result := connection.ExplainResult{
|
||||
DBType: "mysql",
|
||||
SourceSQL: "SELECT * FROM small_table",
|
||||
Nodes: []connection.ExplainNode{
|
||||
{
|
||||
ID: "n1",
|
||||
OpType: connection.ExplainOpScan,
|
||||
Table: "small_table",
|
||||
EstRows: 100, // 远低于 1000 阈值
|
||||
Flags: []string{connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex},
|
||||
},
|
||||
},
|
||||
}
|
||||
suggestions := runExplainRules(result)
|
||||
for _, s := range suggestions {
|
||||
if s.Rule == "full_scan_on_large_table" || s.Rule == "full_scan_with_filter" {
|
||||
t.Fatalf("小表(100 行)不应触发 full_scan 规则,got=%+v", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExplainRules_FullScanWithFilterExtractsColumns(t *testing.T) {
|
||||
result := connection.ExplainResult{
|
||||
DBType: "mysql",
|
||||
SourceSQL: "SELECT * FROM users WHERE email = 'x' AND status = 1",
|
||||
Nodes: []connection.ExplainNode{
|
||||
{
|
||||
ID: "n1",
|
||||
OpType: connection.ExplainOpScan,
|
||||
Table: "users",
|
||||
EstRows: 10000,
|
||||
Flags: []string{connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex},
|
||||
Extra: map[string]any{"attachedCondition": "(email = 'x') AND (status = 1)"},
|
||||
},
|
||||
},
|
||||
}
|
||||
suggestions := runExplainRules(result)
|
||||
foundFilterRule := false
|
||||
for _, s := range suggestions {
|
||||
if s.Rule == "full_scan_with_filter" {
|
||||
foundFilterRule = true
|
||||
if !contains(s.Reason, "email") || !contains(s.Reason, "status") {
|
||||
t.Fatalf("Reason 应提及 email 和 status 列,got=%s", s.Reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundFilterRule {
|
||||
t.Fatal("带 WHERE 的全表扫描应触发 full_scan_with_filter 规则")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExplainRules_FilesortOnLargeResult(t *testing.T) {
|
||||
result := connection.ExplainResult{
|
||||
DBType: "postgres",
|
||||
SourceSQL: "SELECT * FROM t ORDER BY id",
|
||||
Nodes: []connection.ExplainNode{
|
||||
{
|
||||
ID: "n1",
|
||||
OpType: connection.ExplainOpSort,
|
||||
EstRows: 10000,
|
||||
Flags: []string{connection.ExplainFlagFilesort},
|
||||
},
|
||||
},
|
||||
}
|
||||
suggestions := runExplainRules(result)
|
||||
found := false
|
||||
for _, s := range suggestions {
|
||||
if s.Rule == "filesort_on_large_result" {
|
||||
found = true
|
||||
if s.Severity != connection.SeverityWarning {
|
||||
t.Fatalf("filesort 应为 warning,got=%s", s.Severity)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("大结果集 filesort 应触发建议")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExplainRules_HighEstimationSkewRequiresAnalyze(t *testing.T) {
|
||||
result := connection.ExplainResult{
|
||||
DBType: "postgres",
|
||||
SourceSQL: "SELECT * FROM t WHERE id > 0",
|
||||
Nodes: []connection.ExplainNode{
|
||||
{
|
||||
ID: "n1",
|
||||
OpType: connection.ExplainOpIndexScan,
|
||||
EstRows: 100,
|
||||
ActualRows: 50000, // 偏差 500 倍
|
||||
},
|
||||
},
|
||||
}
|
||||
suggestions := runExplainRules(result)
|
||||
found := false
|
||||
for _, s := range suggestions {
|
||||
if s.Rule == "high_estimation_skew" {
|
||||
found = true
|
||||
if s.Severity != connection.SeverityInfo {
|
||||
t.Fatalf("估算偏差应为 info,got=%s", s.Severity)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("估算/实际偏差 > 10x 应触发建议")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExplainRules_LowBufferHitRateGlobalRule(t *testing.T) {
|
||||
result := connection.ExplainResult{
|
||||
DBType: "postgres",
|
||||
SourceSQL: "SELECT * FROM t",
|
||||
Stats: connection.ExplainStats{
|
||||
BufferHitRate: 0.2, // 20% 命中率
|
||||
RowsRead: 10000,
|
||||
},
|
||||
}
|
||||
suggestions := runExplainRules(result)
|
||||
found := false
|
||||
for _, s := range suggestions {
|
||||
if s.Rule == "low_buffer_hit_rate" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("缓冲命中率 < 50% 应触发建议")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExplainRules_NestedLoopHighFanout(t *testing.T) {
|
||||
result := connection.ExplainResult{
|
||||
DBType: "mysql",
|
||||
SourceSQL: "SELECT * FROM a JOIN b ON a.id = b.aid",
|
||||
Nodes: []connection.ExplainNode{
|
||||
{ID: "n1", OpType: connection.ExplainOpJoin, Table: ""},
|
||||
{ID: "n2", OpType: connection.ExplainOpScan, Table: "a", EstRows: 10},
|
||||
{ID: "n3", OpType: connection.ExplainOpScan, Table: "b", EstRows: 50000},
|
||||
},
|
||||
Edges: []connection.ExplainEdge{
|
||||
{From: "n1", To: "n2"},
|
||||
{From: "n1", To: "n3"},
|
||||
},
|
||||
}
|
||||
suggestions := runExplainRules(result)
|
||||
found := false
|
||||
for _, s := range suggestions {
|
||||
if s.Rule == "nested_loop_high_fanout" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("Nested Loop 被驱动表 > 10000 行应触发 nested_loop_high_fanout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExplainRules_SortBySeverity(t *testing.T) {
|
||||
result := connection.ExplainResult{
|
||||
DBType: "mysql",
|
||||
SourceSQL: "SELECT * FROM t1 JOIN t2 ON t1.id = t2.id ORDER BY t1.name",
|
||||
Nodes: []connection.ExplainNode{
|
||||
{
|
||||
ID: "n1",
|
||||
OpType: connection.ExplainOpScan,
|
||||
Table: "t1",
|
||||
EstRows: 50000,
|
||||
Flags: []string{connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex},
|
||||
},
|
||||
{
|
||||
ID: "n2",
|
||||
OpType: connection.ExplainOpSort,
|
||||
EstRows: 100,
|
||||
Flags: []string{connection.ExplainFlagFilesort},
|
||||
},
|
||||
},
|
||||
}
|
||||
suggestions := runExplainRules(result)
|
||||
if len(suggestions) < 2 {
|
||||
t.Fatalf("应触发至少 2 条建议,got=%d", len(suggestions))
|
||||
}
|
||||
// 第一条应是 critical(全表扫描)
|
||||
if suggestions[0].Severity != connection.SeverityCritical {
|
||||
t.Fatalf("首条建议应为 critical,got=%s(rule=%s)", suggestions[0].Severity, suggestions[0].Rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExplainRules_EmptyResultNoSuggestions(t *testing.T) {
|
||||
result := connection.ExplainResult{
|
||||
DBType: "mysql",
|
||||
SourceSQL: "SELECT 1",
|
||||
}
|
||||
suggestions := runExplainRules(result)
|
||||
if len(suggestions) != 0 {
|
||||
t.Fatalf("空 ExplainResult 不应产生建议,got=%d", len(suggestions))
|
||||
}
|
||||
}
|
||||
|
||||
// contains 检查字符串包含(避免和 strings.Contains 冲突,这里独立实现)。
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || indexOfContains(s, substr) >= 0)
|
||||
}
|
||||
|
||||
func indexOfContains(s, substr string) int {
|
||||
for i := 0; i+len(substr) <= len(s); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -85,7 +85,9 @@ func (a *App) DiagnoseQuery(config connection.ConnectionConfig, dbName, query st
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
report := connection.DiagnoseReport{Plan: plan}
|
||||
suggestions := runExplainRules(plan)
|
||||
report := connection.DiagnoseReport{Plan: plan, Suggestions: suggestions}
|
||||
logger.Infof("DiagnoseQuery 完成:type=%s nodes=%d suggestions=%d", dbType, len(plan.Nodes), len(suggestions))
|
||||
return connection.QueryResult{Success: true, Message: "诊断完成", Data: report}
|
||||
}
|
||||
|
||||
@@ -260,32 +262,11 @@ func parseExplainRaw(dbType, sourceSQL, raw string, format connection.ExplainFor
|
||||
case "sqlite":
|
||||
return parseSQLiteExplain(sourceSQL, raw, format)
|
||||
case "clickhouse":
|
||||
// PR2 实现
|
||||
return connection.ExplainResult{
|
||||
DBType: dbType,
|
||||
SourceSQL: sourceSQL,
|
||||
RawFormat: format,
|
||||
RawPayload: raw,
|
||||
Warnings: []string{"ClickHouse 解析器在 PR2 实现,先返回原文"},
|
||||
}, nil
|
||||
return parseClickHouseExplain(sourceSQL, raw, format)
|
||||
case "oracle":
|
||||
// PR2 实现
|
||||
return connection.ExplainResult{
|
||||
DBType: dbType,
|
||||
SourceSQL: sourceSQL,
|
||||
RawFormat: format,
|
||||
RawPayload: raw,
|
||||
Warnings: []string{"Oracle 解析器在 PR2 实现,先返回原文"},
|
||||
}, nil
|
||||
return parseOracleExplain(sourceSQL, raw, format)
|
||||
case "sqlserver":
|
||||
// PR2 实现
|
||||
return connection.ExplainResult{
|
||||
DBType: dbType,
|
||||
SourceSQL: sourceSQL,
|
||||
RawFormat: format,
|
||||
RawPayload: raw,
|
||||
Warnings: []string{"SQLServer 解析器在 PR2 实现,先返回原文"},
|
||||
}, nil
|
||||
return parseSQLServerExplain(sourceSQL, raw, format)
|
||||
default:
|
||||
return connection.ExplainResult{}, fmt.Errorf("不支持的 EXPLAIN 方言:%s", dbType)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user