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:
Syngnat
2026-06-19 12:45:15 +08:00
parent 85648b1e5a
commit 8e24e40fdd
9 changed files with 2100 additions and 25 deletions

View 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
}