mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-25 16:04:02 +08:00
- 方言解析:新增 Oracle DBMS_XPLAN 表格、SQLServer SHOWPLAN_XML、ClickHouse EXPLAIN JSON 解析器 - 规则引擎:新增 10 条跨方言规则(全表扫描、缺索引 JOIN、filesort、估算偏差、缓冲命中、Nested Loop 高扇出等) - 入口接入:DiagnoseQuery 返回的 Suggestions 自动填充规则匹配结果 - 容错增强:SQLServer strip 默认命名空间与 XML 声明;Oracle 表格列与独立 Predicate 段双源融合 - 测试覆盖:新增 27 个用例覆盖三方言解析与规则触发场景
458 lines
14 KiB
Go
458 lines
14 KiB
Go
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")
|
||
}
|