Files
MyGoNavi/internal/app/explain_parse_oracle.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

457 lines
14 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 (
"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,
}
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")
}