Files
MyGoNavi/internal/app/explain_parse_sqlserver.go
Syngnat 1a8effb51f 🐛 fix(export,ssh,secret,explain): 修复 SQL 导出注入与六处静默数据丢失/资源泄漏
- SQL 导出:formatSQLValue 按方言转义反斜杠,修复 MySQL 系 dump 还原时静默改写数据、
  且以反斜杠结尾的值吞掉闭合引号致源库恶意行可执行任意 SQL 的问题;非 MySQL 方言保持原样
- 明文凭据:daily_secrets.json 由 0o644 改为 0o600、目录改 0o700,并对历史文件显式 Chmod
- SSH 隧道:RegisterSSHNetwork 改为确定性 network 名并复用缓存客户端,消除驱动全局 dialer
  表随重连线性增长、永久钉住 ssh.Client 的连接与 goroutine 泄漏
- xlsx 导入:单元格 r 属性列号增加 OOXML 16384 上限并提前熔断,避免篡改文件放大分配致 OOM
- 数据导出:三处非 SQL 导出入口捕获 file.Close 错误,不再在落盘失败时返回“导出成功”
- 数据根迁移:copyFile 捕获 Close 错误并补 Sync,避免被截断的副本被判定为迁移成功
- 执行计划:节点 ID 改为按 ExplainResult 派生,移除进程级全局计数器与 reset,
  修复并发诊断互相踩踏编号导致的重复 node ID 与错挂父子边
- 补充 5 个回归测试文件,含“导出→切分”闭环断言与未转义时的反向对照
2026-07-26 19:40:33 +08:00

306 lines
11 KiB
Go
Raw 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/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,
}
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
}