Files
MyGoNavi/internal/app/explain_parse_common.go
Syngnat b997788437 feat(explain): 新增 SQL 诊断工作台后端 EXPLAIN 基建
- 数据结构:新增 ExplainResult/Node/Stats/IndexSuggestion/DiagnoseReport 等归一化模型,跨方言通用
- 接口扩展:Database 接口新增 ExplainExecer 可选能力,支持驱动自带 EXPLAIN 实现
- 核心入口:DiagnoseQuery 支持 SELECT/WITH 白名单校验、方言调度、原生与 fallback 两条执行路径
- 方言适配:buildExplainQuery 覆盖 MySQL/PostgreSQL/SQLite/Oracle/SQLServer/ClickHouse 7 大主流
- 解析器:MySQL FORMAT=JSON 含表格 fallback、PostgreSQL ANALYZE BUFFERS JSON、SQLite EQP 层级解析
- 测试覆盖:新增 27 个单元测试覆盖 SQL 构造与三方言解析器
2026-06-19 12:30:56 +08:00

165 lines
4.6 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"
"strings"
"sync/atomic"
"GoNavi-Wails/internal/connection"
)
// SQL 诊断工作台:方言解析器公共工具。
//
// 本文件只放跨方言共享的辅助函数;每方言解析器在 explain_parse_<db>.go。
// explainNodeIDCounter 是单次解析内的递增节点 ID 生成器。
// 通过 resetExplainNodeID() 在解析开始时归零;并发安全(同一 query 串行解析)。
var explainNodeIDCounter uint64
func resetExplainNodeID() {
atomic.StoreUint64(&explainNodeIDCounter, 0)
}
// nextExplainNodeID 返回下一个节点 ID"n1"、"n2"……)。
func nextExplainNodeID() string {
id := atomic.AddUint64(&explainNodeIDCounter, 1)
return fmt.Sprintf("n%d", id)
}
// appendExplainChild 把子节点追加到 result.Nodes并生成对应的 ExplainEdge。
// parentID 为空时不生成 Edge根节点
func appendExplainChild(result *connection.ExplainResult, parentID string, node connection.ExplainNode) (nodeID string) {
if node.ID == "" {
node.ID = nextExplainNodeID()
}
if parentID != "" {
node.ParentID = parentID
result.Edges = append(result.Edges, connection.ExplainEdge{From: parentID, To: node.ID})
}
result.Nodes = append(result.Nodes, node)
return node.ID
}
// finalizeExplainStats 遍历所有节点,计算聚合统计并写入 Stats 字段。
// 在解析器返回前调用。
//
// 注意TotalDurationMs 在 PG/MySQL 8.0 中由解析器直接从 Execution Time 写入,
// 这里只在解析器未设置时(=0才用节点累加值兜底避免覆盖更精确的实例值。
func finalizeExplainStats(result *connection.ExplainResult) {
if result == nil || len(result.Nodes) == 0 {
return
}
var totalCost, accumulatedDuration float64
var rowsRead, maxRows int64
var bufferHitSum float64
var bufferHitCount int
for _, n := range result.Nodes {
if n.Cost > 0 {
totalCost += n.Cost
}
if n.DurationMs > 0 {
accumulatedDuration += n.DurationMs
}
if n.OpType == connection.ExplainOpScan || n.OpType == connection.ExplainOpIndexScan || n.OpType == connection.ExplainOpIndexOnly {
rowsRead += n.EstRows
}
if n.EstRows > maxRows {
maxRows = n.EstRows
}
if n.BufferHit > 0 {
bufferHitSum += n.BufferHit
bufferHitCount++
}
for _, flag := range n.Flags {
switch flag {
case connection.ExplainFlagFullScan:
result.Stats.HasFullScan = true
case connection.ExplainFlagFilesort:
result.Stats.HasFilesort = true
case connection.ExplainFlagTempTable:
result.Stats.HasTempTable = true
}
}
}
result.Stats.TotalCost = totalCost
if result.Stats.TotalDurationMs == 0 && accumulatedDuration > 0 {
result.Stats.TotalDurationMs = accumulatedDuration
}
result.Stats.RowsRead = rowsRead
result.Stats.MaxEstRows = maxRows
if bufferHitCount > 0 {
result.Stats.BufferHitRate = bufferHitSum / float64(bufferHitCount)
}
}
// parseExplainTSVRows 把 collectExplainRaw 生成的 TSV 原文重新切分为行(每行 []string 按列拆)。
// 第一行视为列头;空行跳过。
func parseExplainTSVRows(raw string) (header []string, rows [][]string) {
lines := strings.Split(strings.TrimSpace(raw), "\n")
if len(lines) == 0 {
return nil, nil
}
header = strings.Split(lines[0], "\t")
for i := 1; i < len(lines); i++ {
line := strings.TrimRight(lines[i], "\r")
if strings.TrimSpace(line) == "" {
continue
}
rows = append(rows, strings.Split(line, "\t"))
}
return header, rows
}
// lookupTSVColumn 在 header 中按列名查找索引(大小写不敏感);未找到返回 -1。
func lookupTSVColumn(header []string, names ...string) int {
if len(header) == 0 || len(names) == 0 {
return -1
}
for _, name := range names {
target := strings.ToLower(strings.TrimSpace(name))
if target == "" {
continue
}
for i, h := range header {
if strings.ToLower(strings.TrimSpace(h)) == target {
return i
}
}
}
return -1
}
// parseExplainInt64 容错地把字符串解析为 int64空/非法返回 0
func parseExplainInt64(s string) int64 {
s = strings.TrimSpace(s)
if s == "" || s == "NULL" || s == "<nil>" || s == "null" {
return 0
}
var n int64
for _, ch := range s {
if ch < '0' || ch > '9' {
if ch == '-' || ch == '+' {
continue
}
break
}
n = n*10 + int64(ch-'0')
}
return n
}
// parseExplainFloat64 容错地把字符串解析为 float64空/非法返回 0
// 支持形如 "100.00"、"1.5e3" 的简单浮点格式。
func parseExplainFloat64(s string) float64 {
s = strings.TrimSpace(s)
if s == "" || s == "NULL" || s == "<nil>" || s == "null" {
return 0
}
var f float64
_, err := fmt.Sscanf(s, "%f", &f)
if err != nil {
return 0
}
return f
}