mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-30 07:21:25 +08:00
✨ 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 构造与三方言解析器
This commit is contained in:
164
internal/app/explain_parse_common.go
Normal file
164
internal/app/explain_parse_common.go
Normal file
@@ -0,0 +1,164 @@
|
||||
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
|
||||
}
|
||||
332
internal/app/explain_parse_mysql.go
Normal file
332
internal/app/explain_parse_mysql.go
Normal file
@@ -0,0 +1,332 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
// MySQL FORMAT=JSON 解析。
|
||||
//
|
||||
// 典型结构(8.0+):
|
||||
//
|
||||
// {
|
||||
// "query_block": {
|
||||
// "select_id": 1,
|
||||
// "cost_info": {"query_cost": "100.00"},
|
||||
// "table": { ... }, // 单表
|
||||
// "nested_loop": [{"table": {...}}], // 多表 JOIN
|
||||
// "ordering_operation": { ... }, // ORDER BY 包装
|
||||
// "grouping_operation": { ... }, // GROUP BY 包装
|
||||
// "duplicates_removal": { ... }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// 单个 table 节点字段:
|
||||
// - table_name / alias
|
||||
// - access_type:system/const/eq_ref/ref/range/index/ALL
|
||||
// - rows_examined_per_scan / rows_produced_per_join / filtered
|
||||
// - possible_keys / key / used_key_parts / key_length
|
||||
// - attached_condition / used_columns
|
||||
//
|
||||
// OceanBase MySQL 协议输出与 MySQL 8.0 几乎一致(可能多 range_info 列)。
|
||||
//
|
||||
// 5.7 不支持 FORMAT=JSON 时走 vanilla EXPLAIN,返回 8 列表格:id/select_type/table/type/
|
||||
// possible_keys/key/key_len/ref/rows/Extra(OceanBase 可能多 range_info),由 parseMySQLTableExplain 处理。
|
||||
|
||||
func parseMySQLExplain(dbType, sourceSQL, raw string, format connection.ExplainFormat) (connection.ExplainResult, error) {
|
||||
result := connection.ExplainResult{
|
||||
DBType: dbType,
|
||||
SourceSQL: sourceSQL,
|
||||
}
|
||||
resetExplainNodeID()
|
||||
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return result, fmt.Errorf("MySQL EXPLAIN 返回空内容")
|
||||
}
|
||||
|
||||
// FORMAT=JSON 模式
|
||||
if format == connection.ExplainFormatJSON || strings.HasPrefix(trimmed, "{") {
|
||||
plan, warnings, err := parseMySQLJSONExplain(trimmed)
|
||||
if err != nil {
|
||||
// JSON 解析失败但确实是 JSON 开头:报错让上层决定降级
|
||||
return result, fmt.Errorf("解析 MySQL FORMAT=JSON 失败:%w", err)
|
||||
}
|
||||
result.Nodes = plan.Nodes
|
||||
result.Edges = plan.Edges
|
||||
result.Warnings = warnings
|
||||
result.RawFormat = connection.ExplainFormatJSON
|
||||
result.RawPayload = raw
|
||||
finalizeExplainStats(&result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 表格模式(5.7 fallback 或 Doris/StarRocks)
|
||||
parsed, err := parseMySQLTableExplain(raw)
|
||||
if err != nil {
|
||||
result.RawFormat = connection.ExplainFormatText
|
||||
result.RawPayload = raw
|
||||
result.Warnings = []string{fmt.Sprintf("表格解析失败:%v;保留原文供调试", err)}
|
||||
return result, nil
|
||||
}
|
||||
result.Nodes = parsed.Nodes
|
||||
result.Edges = parsed.Edges
|
||||
result.RawFormat = connection.ExplainFormatTable
|
||||
result.RawPayload = raw
|
||||
finalizeExplainStats(&result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// mysqlQueryBlock 对应 MySQL FORMAT=JSON 顶层 query_block。
|
||||
type mysqlQueryBlock struct {
|
||||
SelectID json.Number `json:"select_id"`
|
||||
CostInfo map[string]string `json:"cost_info"`
|
||||
Table *mysqlTableNode `json:"table"`
|
||||
NestedLoop []map[string]json.RawMessage `json:"nested_loop"`
|
||||
OrderingOperation *map[string]any `json:"ordering_operation"`
|
||||
GroupingOperation *map[string]any `json:"grouping_operation"`
|
||||
DuplicatesRemoval *map[string]any `json:"duplicates_removal"`
|
||||
Windowing *map[string]any `json:"windowing"`
|
||||
Distinct *map[string]any `json:"distinct"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type mysqlTableNode struct {
|
||||
TableName string `json:"table_name"`
|
||||
Alias string `json:"alias"`
|
||||
AccessType string `json:"access_type"`
|
||||
RowsExaminedPerScan json.Number `json:"rows_examined_per_scan"`
|
||||
RowsProducedPerJoin json.Number `json:"rows_produced_per_join"`
|
||||
Filtered string `json:"filtered"`
|
||||
PossibleKeys []string `json:"possible_keys"`
|
||||
Key string `json:"key"`
|
||||
UsedKeyParts []string `json:"used_key_parts"`
|
||||
KeyLength json.Number `json:"key_length"`
|
||||
Ref []string `json:"ref"`
|
||||
RowsExaminedPerJoin json.Number `json:"rows_examined_per_join"`
|
||||
CostInfo map[string]string `json:"cost_info"`
|
||||
AttachedCondition string `json:"attached_condition"`
|
||||
AttachedSubqueries []map[string]any `json:"attached_subqueries"`
|
||||
UsingIntersection []map[string]any `json:"using_intersect"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// parseMySQLJSONExplain 递归解析 MySQL FORMAT=JSON 输出。
|
||||
// 返回扁平的节点列表 + 解析过程中的警告(用于前端提示不识别的字段)。
|
||||
func parseMySQLJSONExplain(raw string) (*connection.ExplainResult, []string, error) {
|
||||
var top map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(raw), &top); err != nil {
|
||||
return nil, nil, fmt.Errorf("顶层 JSON 解析失败:%w", err)
|
||||
}
|
||||
|
||||
result := &connection.ExplainResult{}
|
||||
var warnings []string
|
||||
|
||||
qbRaw, ok := top["query_block"]
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("缺少 query_block 字段")
|
||||
}
|
||||
|
||||
// query_block 总成本
|
||||
var qb map[string]json.RawMessage
|
||||
if err := json.Unmarshal(qbRaw, &qb); err != nil {
|
||||
return nil, nil, fmt.Errorf("query_block 解析失败:%w", err)
|
||||
}
|
||||
if costRaw, ok := qb["cost_info"]; ok {
|
||||
var ci map[string]string
|
||||
if err := json.Unmarshal(costRaw, &ci); err == nil {
|
||||
result.Stats.TotalCost = parseExplainFloat64(ci["query_cost"])
|
||||
}
|
||||
}
|
||||
|
||||
// 递归 query_block(可能套 ordering/grouping/distinct 等操作层)
|
||||
parseMySQLQueryBlock(qbRaw, "", result, &warnings)
|
||||
|
||||
return result, warnings, nil
|
||||
}
|
||||
|
||||
// parseMySQLQueryBlock 递归解析 query_block 内部结构。
|
||||
// MySQL FORMAT=JSON 是深度嵌套的"操作层"结构,每层可能包含 table、nested_loop、ordering_operation 等。
|
||||
func parseMySQLQueryBlock(qbRaw json.RawMessage, parentID string, result *connection.ExplainResult, warnings *[]string) {
|
||||
var qb mysqlQueryBlock
|
||||
if err := json.Unmarshal(qbRaw, &qb); err != nil {
|
||||
*warnings = append(*warnings, fmt.Sprintf("query_block JSON 反序列化失败:%v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 单表:直接挂一个 table 节点
|
||||
if qb.Table != nil {
|
||||
node := buildMySQLTableNode(qb.Table)
|
||||
appendExplainChild(result, parentID, node)
|
||||
}
|
||||
|
||||
// nested_loop:每个元素含 table,作为 parent 的子节点
|
||||
for _, item := range qb.NestedLoop {
|
||||
if tableRaw, ok := item["table"]; ok {
|
||||
var t mysqlTableNode
|
||||
if err := json.Unmarshal(tableRaw, &t); err == nil {
|
||||
node := buildMySQLTableNode(&t)
|
||||
appendExplainChild(result, parentID, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 递归操作层:ordering_operation / grouping_operation / duplicates_removal / windowing
|
||||
type opLayer struct {
|
||||
raw json.RawMessage
|
||||
opType string
|
||||
}
|
||||
layers := []opLayer{}
|
||||
if qb.OrderingOperation != nil {
|
||||
// 反向取原始 JSON(结构体已 unmarshal,但用 raw 更通用)
|
||||
}
|
||||
// 直接遍历原始 qb map 更省事
|
||||
var qbMap map[string]json.RawMessage
|
||||
_ = json.Unmarshal(qbRaw, &qbMap)
|
||||
for key, val := range qbMap {
|
||||
switch key {
|
||||
case "ordering_operation":
|
||||
layers = append(layers, opLayer{raw: val, opType: connection.ExplainOpSort})
|
||||
case "grouping_operation":
|
||||
layers = append(layers, opLayer{raw: val, opType: connection.ExplainOpAggregate})
|
||||
case "duplicates_removal":
|
||||
layers = append(layers, opLayer{raw: val, opType: connection.ExplainOpOther})
|
||||
case "windowing":
|
||||
layers = append(layers, opLayer{raw: val, opType: connection.ExplainOpWindow})
|
||||
case "distinct":
|
||||
layers = append(layers, opLayer{raw: val, opType: connection.ExplainOpAggregate})
|
||||
}
|
||||
}
|
||||
for _, layer := range layers {
|
||||
// 操作层本身作为一个节点(供前端展示层次)
|
||||
layerNode := connection.ExplainNode{
|
||||
OpType: layer.opType,
|
||||
OpDetail: strings.Title(strings.ReplaceAll(layer.opType, "_", " ")),
|
||||
}
|
||||
layerID := appendExplainChild(result, parentID, layerNode)
|
||||
// 递归:操作层可能含 table、nested_loop、子操作层
|
||||
parseMySQLQueryBlock(layer.raw, layerID, result, warnings)
|
||||
}
|
||||
}
|
||||
|
||||
// buildMySQLTableNode 把 mysqlTableNode 转成归一化的 ExplainNode,并探测 Flags。
|
||||
func buildMySQLTableNode(t *mysqlTableNode) connection.ExplainNode {
|
||||
node := connection.ExplainNode{
|
||||
OpType: classifyMySQLAccessType(t.AccessType),
|
||||
OpDetail: fmt.Sprintf("access_type=%s", strings.ToLower(strings.TrimSpace(t.AccessType))),
|
||||
Table: t.TableName,
|
||||
Index: t.Key,
|
||||
EstRows: parseExplainInt64(string(t.RowsExaminedPerScan)),
|
||||
Cost: parseExplainFloat64(t.CostInfo["read_cost"]),
|
||||
}
|
||||
if t.Alias != "" && t.Alias != t.TableName {
|
||||
node.Extra = map[string]any{"alias": t.Alias}
|
||||
}
|
||||
if t.AttachedCondition != "" {
|
||||
if node.Extra == nil {
|
||||
node.Extra = map[string]any{}
|
||||
}
|
||||
node.Extra["attachedCondition"] = t.AttachedCondition
|
||||
}
|
||||
if len(t.UsedKeyParts) > 0 {
|
||||
if node.Extra == nil {
|
||||
node.Extra = map[string]any{}
|
||||
}
|
||||
node.Extra["usedKeyParts"] = t.UsedKeyParts
|
||||
}
|
||||
// 探测 Flags
|
||||
if node.OpType == connection.ExplainOpScan {
|
||||
node.Flags = append(node.Flags, connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
// classifyMySQLAccessType 把 MySQL access_type 归一化到通用 OpType。
|
||||
// ALL → SCAN,range/eq_ref/ref/index → INDEX_SCAN 或 INDEX_ONLY,其他 → OTHER。
|
||||
func classifyMySQLAccessType(accessType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(accessType)) {
|
||||
case "all":
|
||||
return connection.ExplainOpScan
|
||||
case "index":
|
||||
return connection.ExplainOpIndexOnly // 仅扫索引不回表
|
||||
case "range":
|
||||
return connection.ExplainOpIndexScan
|
||||
case "eq_ref", "ref", "ref_or_null", "unique_subquery", "index_subquery":
|
||||
return connection.ExplainOpIndexScan
|
||||
case "const", "system":
|
||||
return connection.ExplainOpOther // 单行命中,性能极佳
|
||||
default:
|
||||
return connection.ExplainOpOther
|
||||
}
|
||||
}
|
||||
|
||||
// parseMySQLTableExplain 解析 MySQL 5.7 表格 / Doris / StarRocks 的 EXPLAIN 输出。
|
||||
// 标准 MySQL 表格列:id|select_type|table|type|possible_keys|key|key_len|ref|rows|Extra
|
||||
// OceanBase 可能多 range_info;Doris/StarRocks 是完全不同的结构化文本(PR2 优化)。
|
||||
func parseMySQLTableExplain(raw string) (*connection.ExplainResult, error) {
|
||||
header, rows := parseExplainTSVRows(raw)
|
||||
if len(header) == 0 || len(rows) == 0 {
|
||||
return nil, fmt.Errorf("MySQL 表格 EXPLAIN 无有效行")
|
||||
}
|
||||
|
||||
result := &connection.ExplainResult{}
|
||||
colID := lookupTSVColumn(header, "id")
|
||||
colType := lookupTSVColumn(header, "type")
|
||||
colTable := lookupTSVColumn(header, "table")
|
||||
colKey := lookupTSVColumn(header, "key")
|
||||
colRows := lookupTSVColumn(header, "rows")
|
||||
colExtra := lookupTSVColumn(header, "extra")
|
||||
|
||||
// MySQL 的 id 字段表达父子:相同 id 是同一 SELECT 内的 join,id 不同代表子查询
|
||||
// 简化处理:每行作为独立节点,无父子(PR2 增强)
|
||||
var lastID string
|
||||
for _, row := range rows {
|
||||
var idStr string
|
||||
if colID >= 0 && colID < len(row) {
|
||||
idStr = strings.TrimSpace(row[colID])
|
||||
}
|
||||
if idStr == "" {
|
||||
idStr = lastID
|
||||
}
|
||||
lastID = idStr
|
||||
|
||||
var accessType string
|
||||
if colType >= 0 && colType < len(row) {
|
||||
accessType = strings.TrimSpace(row[colType])
|
||||
}
|
||||
node := connection.ExplainNode{
|
||||
OpType: classifyMySQLAccessType(accessType),
|
||||
OpDetail: fmt.Sprintf("id=%s type=%s", idStr, strings.ToLower(accessType)),
|
||||
}
|
||||
if colTable >= 0 && colTable < len(row) {
|
||||
node.Table = strings.TrimSpace(row[colTable])
|
||||
}
|
||||
if colKey >= 0 && colKey < len(row) {
|
||||
node.Index = strings.TrimSpace(row[colKey])
|
||||
}
|
||||
if colRows >= 0 && colRows < len(row) {
|
||||
node.EstRows = parseExplainInt64(row[colRows])
|
||||
}
|
||||
if colExtra >= 0 && colExtra < len(row) {
|
||||
extra := strings.TrimSpace(row[colExtra])
|
||||
if extra != "" {
|
||||
node.Extra = map[string]any{"extra": extra}
|
||||
lower := strings.ToLower(extra)
|
||||
if strings.Contains(lower, "using filesort") {
|
||||
node.Flags = append(node.Flags, connection.ExplainFlagFilesort)
|
||||
}
|
||||
if strings.Contains(lower, "using temporary") {
|
||||
node.Flags = append(node.Flags, connection.ExplainFlagTempTable)
|
||||
}
|
||||
}
|
||||
}
|
||||
if node.OpType == connection.ExplainOpScan {
|
||||
node.Flags = append(node.Flags, connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex)
|
||||
}
|
||||
appendExplainChild(result, "", node)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
210
internal/app/explain_parse_mysql_test.go
Normal file
210
internal/app/explain_parse_mysql_test.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
// MySQL FORMAT=JSON fixture:单表全表扫描。
|
||||
const mySQLFormatJSONSingleTableFullScan = `{
|
||||
"query_block": {
|
||||
"select_id": 1,
|
||||
"cost_info": {"query_cost": "100.00"},
|
||||
"table": {
|
||||
"table_name": "users",
|
||||
"access_type": "ALL",
|
||||
"rows_examined_per_scan": 10000,
|
||||
"rows_produced_per_join": 1000,
|
||||
"filtered": "10.00",
|
||||
"cost_info": {"read_cost": "100.00"},
|
||||
"used_columns": ["id", "name", "email"]
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
func TestParseMySQLExplain_SingleTableFullScan(t *testing.T) {
|
||||
result, err := parseMySQLExplain("mysql", "SELECT * FROM users", mySQLFormatJSONSingleTableFullScan, connection.ExplainFormatJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败:%v", err)
|
||||
}
|
||||
if len(result.Nodes) != 1 {
|
||||
t.Fatalf("应有 1 个节点,got=%d", len(result.Nodes))
|
||||
}
|
||||
node := result.Nodes[0]
|
||||
if node.OpType != connection.ExplainOpScan {
|
||||
t.Fatalf("access_type=ALL 应归一化为 SCAN,got=%s", node.OpType)
|
||||
}
|
||||
if node.Table != "users" {
|
||||
t.Fatalf("table got=%q want=users", node.Table)
|
||||
}
|
||||
if node.EstRows != 10000 {
|
||||
t.Fatalf("EstRows got=%d want=10000", node.EstRows)
|
||||
}
|
||||
if !containsFlag(node.Flags, connection.ExplainFlagFullScan) {
|
||||
t.Fatalf("全表扫描节点应有 FULL_SCAN flag,got=%v", node.Flags)
|
||||
}
|
||||
if !containsFlag(node.Flags, connection.ExplainFlagNoIndex) {
|
||||
t.Fatalf("全表扫描节点应有 NO_INDEX flag,got=%v", node.Flags)
|
||||
}
|
||||
if !result.Stats.HasFullScan {
|
||||
t.Fatalf("Stats.HasFullScan 应为 true")
|
||||
}
|
||||
if result.Stats.TotalCost != 100.0 {
|
||||
t.Fatalf("TotalCost got=%v want=100", result.Stats.TotalCost)
|
||||
}
|
||||
if result.Stats.RowsRead != 10000 {
|
||||
t.Fatalf("RowsRead got=%d want=10000", result.Stats.RowsRead)
|
||||
}
|
||||
}
|
||||
|
||||
// MySQL FORMAT=JSON fixture:两表 JOIN(一个走索引,一个走全表)。
|
||||
const mySQLFormatJSONJoinScanAndIndex = `{
|
||||
"query_block": {
|
||||
"select_id": 1,
|
||||
"cost_info": {"query_cost": "250.00"},
|
||||
"nested_loop": [
|
||||
{
|
||||
"table": {
|
||||
"table_name": "orders",
|
||||
"access_type": "ALL",
|
||||
"rows_examined_per_scan": 5000,
|
||||
"cost_info": {"read_cost": "100.00"}
|
||||
}
|
||||
},
|
||||
{
|
||||
"table": {
|
||||
"table_name": "users",
|
||||
"access_type": "eq_ref",
|
||||
"possible_keys": ["PRIMARY"],
|
||||
"key": "PRIMARY",
|
||||
"used_key_parts": ["id"],
|
||||
"rows_examined_per_scan": 1,
|
||||
"cost_info": {"read_cost": "150.00"}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}`
|
||||
|
||||
func TestParseMySQLExplain_JoinScanAndIndex(t *testing.T) {
|
||||
result, err := parseMySQLExplain("mysql", "SELECT * FROM orders o JOIN users u ON o.user_id = u.id", mySQLFormatJSONJoinScanAndIndex, connection.ExplainFormatJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败:%v", err)
|
||||
}
|
||||
if len(result.Nodes) != 2 {
|
||||
t.Fatalf("应有 2 个节点(nested_loop 内 2 个 table),got=%d", len(result.Nodes))
|
||||
}
|
||||
if result.Nodes[0].Table != "orders" {
|
||||
t.Fatalf("第一个表应是 orders,got=%s", result.Nodes[0].Table)
|
||||
}
|
||||
if result.Nodes[0].OpType != connection.ExplainOpScan {
|
||||
t.Fatalf("orders access_type=ALL 应为 SCAN,got=%s", result.Nodes[0].OpType)
|
||||
}
|
||||
if result.Nodes[1].Table != "users" {
|
||||
t.Fatalf("第二个表应是 users,got=%s", result.Nodes[1].Table)
|
||||
}
|
||||
if result.Nodes[1].OpType != connection.ExplainOpIndexScan {
|
||||
t.Fatalf("users access_type=eq_ref 应为 INDEX_SCAN,got=%s", result.Nodes[1].OpType)
|
||||
}
|
||||
if result.Nodes[1].Index != "PRIMARY" {
|
||||
t.Fatalf("users 使用 PRIMARY key,got=%s", result.Nodes[1].Index)
|
||||
}
|
||||
// stats:orders 估算 5000 行
|
||||
if result.Stats.RowsRead != 5000+1 {
|
||||
t.Fatalf("RowsRead 应为两表 EstRows 之和 (5000+1),got=%d", result.Stats.RowsRead)
|
||||
}
|
||||
}
|
||||
|
||||
// MySQL FORMAT=JSON fixture:含 ordering_operation 包装层。
|
||||
const mySQLFormatJSONWithOrder = `{
|
||||
"query_block": {
|
||||
"select_id": 1,
|
||||
"ordering_operation": {
|
||||
"table": {
|
||||
"table_name": "t",
|
||||
"access_type": "ALL",
|
||||
"rows_examined_per_scan": 100,
|
||||
"cost_info": {"read_cost": "10.00"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
func TestParseMySQLExplain_WithOrderingOperation(t *testing.T) {
|
||||
result, err := parseMySQLExplain("mysql", "SELECT * FROM t ORDER BY id", mySQLFormatJSONWithOrder, connection.ExplainFormatJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败:%v", err)
|
||||
}
|
||||
// 应该有 2 个节点:ordering 层 + table 层
|
||||
if len(result.Nodes) != 2 {
|
||||
t.Fatalf("ordering_operation 应展开为 2 个节点,got=%d", len(result.Nodes))
|
||||
}
|
||||
if result.Nodes[0].OpType != connection.ExplainOpSort {
|
||||
t.Fatalf("ordering_operation 顶层节点应为 SORT,got=%s", result.Nodes[0].OpType)
|
||||
}
|
||||
if result.Nodes[1].OpType != connection.ExplainOpScan {
|
||||
t.Fatalf("内层 table 应为 SCAN,got=%s", result.Nodes[1].OpType)
|
||||
}
|
||||
// 验证父子边
|
||||
if len(result.Edges) != 1 {
|
||||
t.Fatalf("应有 1 条边,got=%d", len(result.Edges))
|
||||
}
|
||||
if result.Edges[0].From != result.Nodes[0].ID || result.Edges[0].To != result.Nodes[1].ID {
|
||||
t.Fatalf("边应连接 SORT -> SCAN")
|
||||
}
|
||||
}
|
||||
|
||||
// MySQL 5.7 表格模式 fallback。
|
||||
const mySQLTableExplainOutput = `id select_type table type possible_keys key key_len ref rows Extra
|
||||
1 SIMPLE users ALL NULL NULL NULL NULL 10000 Using where
|
||||
1 SIMPLE orders ref idx_uid idx_uid 4 const 5 Using filesort`
|
||||
|
||||
func TestParseMySQLExplain_TableFormatFallback(t *testing.T) {
|
||||
result, err := parseMySQLExplain("mysql", "SELECT * FROM users", mySQLTableExplainOutput, connection.ExplainFormatTable)
|
||||
if err != nil {
|
||||
t.Fatalf("表格解析失败:%v", err)
|
||||
}
|
||||
if len(result.Nodes) != 2 {
|
||||
t.Fatalf("应有 2 个节点,got=%d", len(result.Nodes))
|
||||
}
|
||||
if result.Nodes[0].OpType != connection.ExplainOpScan {
|
||||
t.Fatalf("users type=ALL 应为 SCAN,got=%s", result.Nodes[0].OpType)
|
||||
}
|
||||
if !containsFlag(result.Nodes[0].Flags, connection.ExplainFlagFullScan) {
|
||||
t.Fatalf("users 应有 FULL_SCAN flag")
|
||||
}
|
||||
if result.Nodes[1].Index != "idx_uid" {
|
||||
t.Fatalf("orders 使用 idx_uid,got=%s", result.Nodes[1].Index)
|
||||
}
|
||||
if !containsFlag(result.Nodes[1].Flags, connection.ExplainFlagFilesort) {
|
||||
t.Fatalf("orders Extra 含 Using filesort,应有 FILESORT flag")
|
||||
}
|
||||
if result.RawFormat != connection.ExplainFormatTable {
|
||||
t.Fatalf("RawFormat got=%v want=table", result.RawFormat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMySQLExplain_EmptyRawReturnsError(t *testing.T) {
|
||||
_, err := parseMySQLExplain("mysql", "SELECT 1", " ", connection.ExplainFormatJSON)
|
||||
if err == nil {
|
||||
t.Fatal("空输入应返回 error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMySQLExplain_InvalidJSONReturnsError(t *testing.T) {
|
||||
_, err := parseMySQLExplain("mysql", "SELECT 1", "{ this is not valid json", connection.ExplainFormatJSON)
|
||||
if err == nil {
|
||||
t.Fatal("非法 JSON 应返回 error")
|
||||
}
|
||||
}
|
||||
|
||||
// containsFlag 检查 flags 列表是否包含目标值。
|
||||
func containsFlag(flags []string, target string) bool {
|
||||
for _, f := range flags {
|
||||
if f == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
241
internal/app/explain_parse_postgres.go
Normal file
241
internal/app/explain_parse_postgres.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
// PostgreSQL EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) 解析。
|
||||
//
|
||||
// 典型结构(PG 13+):
|
||||
//
|
||||
// [
|
||||
// {
|
||||
// "Plan": {
|
||||
// "Node Type": "Seq Scan",
|
||||
// "Relation Name": "t",
|
||||
// "Alias": "t",
|
||||
// "Startup Cost": 0.00,
|
||||
// "Total Cost": 100.00,
|
||||
// "Plan Rows": 1000,
|
||||
// "Plan Width": 4,
|
||||
// "Actual Startup Time": 0.01,
|
||||
// "Actual Total Time": 1.23,
|
||||
// "Actual Rows": 1000,
|
||||
// "Actual Loops": 1,
|
||||
// "Filter": "(id > 100)",
|
||||
// "Rows Removed by Filter": 100,
|
||||
// "Shared Hit Blocks": 50,
|
||||
// "Shared Read Blocks": 0,
|
||||
// "Plans": [...] // 递归子节点
|
||||
// },
|
||||
// "Planning Time": 0.15,
|
||||
// "Execution Time": 1.30,
|
||||
// "Triggers": [],
|
||||
// "Execution Buffers": {...}
|
||||
// }
|
||||
// ]
|
||||
//
|
||||
// 多语句时数组可能有多个元素,但 EXPLAIN 单条 SQL 时通常是 1 个。
|
||||
|
||||
func parsePostgresExplain(dbType, sourceSQL, raw string, format connection.ExplainFormat) (connection.ExplainResult, error) {
|
||||
result := connection.ExplainResult{
|
||||
DBType: dbType,
|
||||
SourceSQL: sourceSQL,
|
||||
}
|
||||
resetExplainNodeID()
|
||||
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return result, fmt.Errorf("PostgreSQL EXPLAIN 返回空内容")
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(trimmed, "[") && !strings.HasPrefix(trimmed, "{") {
|
||||
// 老版本 PG 无 FORMAT JSON 时返回文本表格——PR2 增强
|
||||
result.RawFormat = connection.ExplainFormatText
|
||||
result.RawPayload = raw
|
||||
result.Warnings = []string{"PostgreSQL 返回非 JSON 格式(可能未启用 FORMAT JSON),原文保留"}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var top []map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(trimmed), &top); err != nil {
|
||||
// 单对象(无外层数组)兼容
|
||||
var single map[string]json.RawMessage
|
||||
if err2 := json.Unmarshal([]byte(trimmed), &single); err2 == nil {
|
||||
top = []map[string]json.RawMessage{single}
|
||||
} else {
|
||||
return result, fmt.Errorf("PostgreSQL JSON 解析失败:%w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(top) == 0 {
|
||||
return result, fmt.Errorf("PostgreSQL EXPLAIN 数组为空")
|
||||
}
|
||||
|
||||
var warnings []string
|
||||
for _, item := range top {
|
||||
// 顶层 Execution Time / Planning Time
|
||||
if etRaw, ok := item["Execution Time"]; ok {
|
||||
var et float64
|
||||
if err := json.Unmarshal(etRaw, &et); err == nil {
|
||||
result.Stats.TotalDurationMs = et
|
||||
}
|
||||
}
|
||||
planRaw, ok := item["Plan"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
parsePostgresPlanNode(planRaw, "", &result, &warnings)
|
||||
}
|
||||
|
||||
result.RawFormat = connection.ExplainFormatJSON
|
||||
result.RawPayload = raw
|
||||
result.Warnings = warnings
|
||||
finalizeExplainStats(&result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// pgPlanNode 映射 PG FORMAT JSON 的 Plan 结构(部分字段,未识别字段保留在 raw 中备用)。
|
||||
type pgPlanNode struct {
|
||||
NodeType string `json:"Node Type"`
|
||||
RelationName string `json:"Relation Name"`
|
||||
Alias string `json:"Alias"`
|
||||
Schema string `json:"Schema"`
|
||||
StartupCost float64 `json:"Startup Cost"`
|
||||
TotalCost float64 `json:"Total Cost"`
|
||||
PlanRows json.Number `json:"Plan Rows"`
|
||||
PlanWidth json.Number `json:"Plan Width"`
|
||||
ActualStartup float64 `json:"Actual Startup Time"`
|
||||
ActualTotal float64 `json:"Actual Total Time"`
|
||||
ActualRows json.Number `json:"Actual Rows"`
|
||||
ActualLoops json.Number `json:"Actual Loops"`
|
||||
IndexName string `json:"Index Name"`
|
||||
Filter string `json:"Filter"`
|
||||
HashCond string `json:"Hash Cond"`
|
||||
JoinType string `json:"Join Type"`
|
||||
Strategy string `json:"Strategy"`
|
||||
SharedHit json.Number `json:"Shared Hit Blocks"`
|
||||
SharedRead json.Number `json:"Shared Read Blocks"`
|
||||
Output []string `json:"Output"`
|
||||
Plans []json.RawMessage `json:"Plans"`
|
||||
}
|
||||
|
||||
// parsePostgresPlanNode 递归解析 PG Plan 节点。
|
||||
func parsePostgresPlanNode(planRaw json.RawMessage, parentID string, result *connection.ExplainResult, warnings *[]string) {
|
||||
var node pgPlanNode
|
||||
if err := json.Unmarshal(planRaw, &node); err != nil {
|
||||
*warnings = append(*warnings, fmt.Sprintf("PG Plan 节点反序列化失败:%v", err))
|
||||
return
|
||||
}
|
||||
|
||||
en := connection.ExplainNode{
|
||||
OpType: classifyPostgresNodeType(node.NodeType, node.IndexName),
|
||||
OpDetail: node.NodeType,
|
||||
Table: pickPostgresTableName(node),
|
||||
Index: node.IndexName,
|
||||
EstRows: parseExplainInt64(string(node.PlanRows)),
|
||||
ActualRows: parseExplainInt64(string(node.ActualRows)),
|
||||
Loops: parseExplainInt64(string(node.ActualLoops)),
|
||||
Cost: node.StartupCost + node.TotalCost,
|
||||
DurationMs: node.ActualTotal,
|
||||
}
|
||||
if node.Strategy != "" {
|
||||
en.Extra = map[string]any{"strategy": node.Strategy}
|
||||
}
|
||||
if node.Filter != "" {
|
||||
if en.Extra == nil {
|
||||
en.Extra = map[string]any{}
|
||||
}
|
||||
en.Extra["filter"] = node.Filter
|
||||
}
|
||||
if node.HashCond != "" {
|
||||
if en.Extra == nil {
|
||||
en.Extra = map[string]any{}
|
||||
}
|
||||
en.Extra["hashCond"] = node.HashCond
|
||||
}
|
||||
if node.JoinType != "" {
|
||||
if en.Extra == nil {
|
||||
en.Extra = map[string]any{}
|
||||
}
|
||||
en.Extra["joinType"] = node.JoinType
|
||||
}
|
||||
|
||||
// BufferHit 命中率:Shared Hit / (Shared Hit + Shared Read)
|
||||
hit := parseExplainInt64(string(node.SharedHit))
|
||||
read := parseExplainInt64(string(node.SharedRead))
|
||||
if hit+read > 0 {
|
||||
en.BufferHit = float64(hit) / float64(hit+read)
|
||||
if en.BufferHit < 0.5 {
|
||||
en.Flags = append(en.Flags, connection.ExplainFlagLowBufferHit)
|
||||
}
|
||||
}
|
||||
|
||||
if en.OpType == connection.ExplainOpScan {
|
||||
en.Flags = append(en.Flags, connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex)
|
||||
}
|
||||
|
||||
// Sort/Hash Join 等可能用临时表
|
||||
ntLower := strings.ToLower(node.NodeType)
|
||||
if strings.Contains(ntLower, "sort") {
|
||||
en.Flags = append(en.Flags, connection.ExplainFlagFilesort)
|
||||
}
|
||||
if strings.Contains(ntLower, "materialize") || strings.Contains(ntLower, "hash") {
|
||||
en.Flags = append(en.Flags, connection.ExplainFlagTempTable)
|
||||
}
|
||||
|
||||
nodeID := appendExplainChild(result, parentID, en)
|
||||
for _, childRaw := range node.Plans {
|
||||
parsePostgresPlanNode(childRaw, nodeID, result, warnings)
|
||||
}
|
||||
}
|
||||
|
||||
// classifyPostgresNodeType 把 PG Node Type 归一化到通用 OpType。
|
||||
// 例如 Seq Scan → SCAN;Index Scan/Index Only Scan → INDEX_SCAN/INDEX_ONLY;
|
||||
// Hash Join/Nested Loop/Merge Join → JOIN;Aggregate/GroupAggregate → AGGREGATE;Sort → SORT。
|
||||
func classifyPostgresNodeType(nodeType, indexName string) string {
|
||||
nt := strings.ToLower(strings.TrimSpace(nodeType))
|
||||
switch {
|
||||
case strings.Contains(nt, "seq scan"):
|
||||
return connection.ExplainOpScan
|
||||
case strings.Contains(nt, "index only scan"):
|
||||
return connection.ExplainOpIndexOnly
|
||||
case strings.Contains(nt, "index scan"), strings.Contains(nt, "bitmap index"):
|
||||
return connection.ExplainOpIndexScan
|
||||
case strings.Contains(nt, "join"):
|
||||
return connection.ExplainOpJoin
|
||||
case strings.Contains(nt, "aggregate"), strings.Contains(nt, "group"):
|
||||
return connection.ExplainOpAggregate
|
||||
case strings.Contains(nt, "sort"):
|
||||
return connection.ExplainOpSort
|
||||
case strings.Contains(nt, "limit"):
|
||||
return connection.ExplainOpLimit
|
||||
case strings.Contains(nt, "subquery"), strings.Contains(nt, "subplan"):
|
||||
return connection.ExplainOpSubquery
|
||||
case strings.Contains(nt, "union"):
|
||||
return connection.ExplainOpUnion
|
||||
case strings.Contains(nt, "window"):
|
||||
return connection.ExplainOpWindow
|
||||
case strings.Contains(nt, "materialize"):
|
||||
return connection.ExplainOpMaterialize
|
||||
case strings.Contains(nt, "result"), strings.Contains(nt, "filter"):
|
||||
return connection.ExplainOpFilter
|
||||
default:
|
||||
return connection.ExplainOpOther
|
||||
}
|
||||
}
|
||||
|
||||
// pickPostgresTableName 提取 PG Plan 中的表名(Schema.RelationName 或仅 RelationName)。
|
||||
func pickPostgresTableName(node pgPlanNode) string {
|
||||
if node.RelationName == "" {
|
||||
return ""
|
||||
}
|
||||
if node.Schema != "" {
|
||||
return node.Schema + "." + node.RelationName
|
||||
}
|
||||
return node.RelationName
|
||||
}
|
||||
194
internal/app/explain_parse_postgres_test.go
Normal file
194
internal/app/explain_parse_postgres_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
// PG FORMAT JSON fixture:单 Seq Scan + 低缓冲命中。
|
||||
const postgresFormatJSONSeqScan = `[
|
||||
{
|
||||
"Plan": {
|
||||
"Node Type": "Seq Scan",
|
||||
"Relation Name": "users",
|
||||
"Schema": "public",
|
||||
"Alias": "users",
|
||||
"Startup Cost": 0.00,
|
||||
"Total Cost": 154.00,
|
||||
"Plan Rows": 1540,
|
||||
"Plan Width": 36,
|
||||
"Actual Startup Time": 0.012,
|
||||
"Actual Total Time": 1.234,
|
||||
"Actual Rows": 1500,
|
||||
"Actual Loops": 1,
|
||||
"Filter": "(age > 18)",
|
||||
"Rows Removed by Filter": 40,
|
||||
"Shared Hit Blocks": 10,
|
||||
"Shared Read Blocks": 50
|
||||
},
|
||||
"Planning Time": 0.123,
|
||||
"Execution Time": 1.456
|
||||
}
|
||||
]`
|
||||
|
||||
func TestParsePostgresExplain_SeqScan(t *testing.T) {
|
||||
result, err := parsePostgresExplain("postgres", "SELECT * FROM users WHERE age > 18", postgresFormatJSONSeqScan, connection.ExplainFormatJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败:%v", err)
|
||||
}
|
||||
if len(result.Nodes) != 1 {
|
||||
t.Fatalf("应有 1 个节点,got=%d", len(result.Nodes))
|
||||
}
|
||||
node := result.Nodes[0]
|
||||
if node.OpType != connection.ExplainOpScan {
|
||||
t.Fatalf("Seq Scan 应为 SCAN,got=%s", node.OpType)
|
||||
}
|
||||
if node.Table != "public.users" {
|
||||
t.Fatalf("Table 应含 schema,got=%q", node.Table)
|
||||
}
|
||||
if node.EstRows != 1540 {
|
||||
t.Fatalf("EstRows got=%d want=1540", node.EstRows)
|
||||
}
|
||||
if node.ActualRows != 1500 {
|
||||
t.Fatalf("ActualRows got=%d want=1500", node.ActualRows)
|
||||
}
|
||||
if node.Loops != 1 {
|
||||
t.Fatalf("Loops got=%d want=1", node.Loops)
|
||||
}
|
||||
// BufferHit = 10 / (10+50) = 0.166...
|
||||
if node.BufferHit < 0.16 || node.BufferHit > 0.17 {
|
||||
t.Fatalf("BufferHit 应约 0.167,got=%v", node.BufferHit)
|
||||
}
|
||||
if !containsFlag(node.Flags, connection.ExplainFlagLowBufferHit) {
|
||||
t.Fatalf("缓冲命中率低应有 LOW_BUFFER_HIT flag")
|
||||
}
|
||||
if !containsFlag(node.Flags, connection.ExplainFlagFullScan) {
|
||||
t.Fatalf("Seq Scan 应有 FULL_SCAN flag")
|
||||
}
|
||||
if result.Stats.TotalDurationMs != 1.456 {
|
||||
t.Fatalf("Execution Time 应写到 Stats.TotalDurationMs,got=%v", result.Stats.TotalDurationMs)
|
||||
}
|
||||
}
|
||||
|
||||
// PG FORMAT JSON fixture:Hash Join + 子节点(Seq Scan + Index Scan)。
|
||||
const postgresFormatJSONHashJoin = `[
|
||||
{
|
||||
"Plan": {
|
||||
"Node Type": "Hash Join",
|
||||
"Join Type": "Inner",
|
||||
"Hash Cond": "(o.user_id = u.id)",
|
||||
"Startup Cost": 50.00,
|
||||
"Total Cost": 200.00,
|
||||
"Plan Rows": 1000,
|
||||
"Actual Rows": 950,
|
||||
"Actual Loops": 1,
|
||||
"Plans": [
|
||||
{
|
||||
"Node Type": "Seq Scan",
|
||||
"Relation Name": "orders",
|
||||
"Alias": "o",
|
||||
"Startup Cost": 0.00,
|
||||
"Total Cost": 100.00,
|
||||
"Plan Rows": 5000,
|
||||
"Actual Rows": 5000,
|
||||
"Actual Loops": 1
|
||||
},
|
||||
{
|
||||
"Node Type": "Hash",
|
||||
"Startup Cost": 25.00,
|
||||
"Total Cost": 25.00,
|
||||
"Plan Rows": 100,
|
||||
"Plans": [
|
||||
{
|
||||
"Node Type": "Index Scan",
|
||||
"Relation Name": "users",
|
||||
"Alias": "u",
|
||||
"Index Name": "users_pkey",
|
||||
"Startup Cost": 0.15,
|
||||
"Total Cost": 25.00,
|
||||
"Plan Rows": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Execution Time": 5.5
|
||||
}
|
||||
]`
|
||||
|
||||
func TestParsePostgresExplain_HashJoinWithChildren(t *testing.T) {
|
||||
result, err := parsePostgresExplain("postgres", "SELECT * FROM orders o JOIN users u ON o.user_id = u.id", postgresFormatJSONHashJoin, connection.ExplainFormatJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败:%v", err)
|
||||
}
|
||||
// 应该有 4 个节点:Hash Join + Seq Scan + Hash + Index Scan
|
||||
if len(result.Nodes) != 4 {
|
||||
t.Fatalf("应有 4 个节点,got=%d(nodes=%+v)", len(result.Nodes), result.Nodes)
|
||||
}
|
||||
join := result.Nodes[0]
|
||||
if join.OpType != connection.ExplainOpJoin {
|
||||
t.Fatalf("顶层应为 JOIN,got=%s", join.OpType)
|
||||
}
|
||||
if join.Extra["hashCond"] != "(o.user_id = u.id)" {
|
||||
t.Fatalf("HashCond 应保留,got=%v", join.Extra["hashCond"])
|
||||
}
|
||||
if join.Extra["joinType"] != "Inner" {
|
||||
t.Fatalf("JoinType 应保留,got=%v", join.Extra["joinType"])
|
||||
}
|
||||
if !containsFlag(join.Flags, connection.ExplainFlagTempTable) {
|
||||
t.Fatalf("Hash 节点应有 TEMP_TABLE flag")
|
||||
}
|
||||
// 找到 orders Seq Scan
|
||||
var seqScanNode *connection.ExplainNode
|
||||
var indexScanNode *connection.ExplainNode
|
||||
for i := range result.Nodes {
|
||||
switch result.Nodes[i].OpType {
|
||||
case connection.ExplainOpScan:
|
||||
seqScanNode = &result.Nodes[i]
|
||||
case connection.ExplainOpIndexScan:
|
||||
indexScanNode = &result.Nodes[i]
|
||||
}
|
||||
}
|
||||
if seqScanNode == nil {
|
||||
t.Fatal("应有一个 Seq Scan 节点")
|
||||
}
|
||||
if seqScanNode.Table != "orders" {
|
||||
t.Fatalf("Seq Scan 应为 orders 表,got=%s", seqScanNode.Table)
|
||||
}
|
||||
if indexScanNode == nil {
|
||||
t.Fatal("应有一个 Index Scan 节点")
|
||||
}
|
||||
if indexScanNode.Index != "users_pkey" {
|
||||
t.Fatalf("Index Scan 应使用 users_pkey,got=%s", indexScanNode.Index)
|
||||
}
|
||||
// Edges:3 条(顶层无父;Seq Scan + Hash 是顶层子;Index Scan 是 Hash 子)
|
||||
if len(result.Edges) != 3 {
|
||||
t.Fatalf("应有 3 条边,got=%d", len(result.Edges))
|
||||
}
|
||||
}
|
||||
|
||||
// PG 老版本无 FORMAT JSON 时返回文本。
|
||||
func TestParsePostgresExplain_TextFallbackKeepsRaw(t *testing.T) {
|
||||
raw := "Seq Scan on users (cost=0.00..154.00 rows=1540)"
|
||||
result, err := parsePostgresExplain("postgres", "SELECT * FROM users", raw, connection.ExplainFormatText)
|
||||
if err != nil {
|
||||
t.Fatalf("非 JSON 输入应降级返回原文而非 error:%v", err)
|
||||
}
|
||||
if len(result.Warnings) == 0 {
|
||||
t.Fatal("应有降级 warning")
|
||||
}
|
||||
if result.RawPayload != raw {
|
||||
t.Fatalf("RawPayload 应保留原文")
|
||||
}
|
||||
if result.RawFormat != connection.ExplainFormatText {
|
||||
t.Fatalf("RawFormat got=%v want=text", result.RawFormat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePostgresExplain_EmptyRawReturnsError(t *testing.T) {
|
||||
_, err := parsePostgresExplain("postgres", "SELECT 1", " ", connection.ExplainFormatJSON)
|
||||
if err == nil {
|
||||
t.Fatal("空输入应返回 error")
|
||||
}
|
||||
}
|
||||
241
internal/app/explain_parse_sqlite.go
Normal file
241
internal/app/explain_parse_sqlite.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
// SQLite EXPLAIN QUERY PLAN 解析。
|
||||
//
|
||||
// SQLite EQP 输出是 4 列表格:
|
||||
//
|
||||
// id | parent | notused | detail
|
||||
// 2 | 0 | 0 | SCAN TABLE t
|
||||
// 3 | 0 | 0 | SEARCH TABLE t USING INDEX idx_x (col=?)
|
||||
// 7 | 0 | 0 | USE TEMP B-TREE FOR ORDER BY
|
||||
// 21 | 0 | 0 | COMPOUND QUERY
|
||||
// 22 | 0 | 0 | USE TEMP B-TREE FOR LAST DISTINCT
|
||||
//
|
||||
// id 字段语义:
|
||||
// - 同一 id 多行:同一节点的多个细节行(如"SCAN" + "USE TEMP B-TREE")
|
||||
// - 不同 id:不同节点;parent 字段指向父节点 id
|
||||
//
|
||||
// detail 文本模式:
|
||||
// - "SCAN TABLE <name>" 或 "SCAN <name>":全表扫描
|
||||
// - "SEARCH TABLE <name> USING INDEX <idx> (<cols>)":索引扫描
|
||||
// - "SEARCH TABLE <name> USING PRIMARY KEY (<cols>)":主键扫描
|
||||
// - "USE TEMP B-TREE FOR ORDER BY":filesort
|
||||
// - "USE TEMP B-TREE FOR DISTINCT":临时表
|
||||
// - "COMPOUND QUERY":UNION/INTERSECT 等
|
||||
// - "CORRELATED SCALAR SUBQUERY":子查询
|
||||
// - "CO-ROUTINE <name>":协程
|
||||
|
||||
func parseSQLiteExplain(sourceSQL, raw string, format connection.ExplainFormat) (connection.ExplainResult, error) {
|
||||
result := connection.ExplainResult{
|
||||
DBType: "sqlite",
|
||||
SourceSQL: sourceSQL,
|
||||
}
|
||||
resetExplainNodeID()
|
||||
|
||||
header, rows := parseExplainTSVRows(raw)
|
||||
if len(header) == 0 || len(rows) == 0 {
|
||||
return result, fmt.Errorf("SQLite EQP 输出无有效行")
|
||||
}
|
||||
|
||||
colID := lookupTSVColumn(header, "id")
|
||||
colParent := lookupTSVColumn(header, "parent")
|
||||
colDetail := lookupTSVColumn(header, "detail")
|
||||
if colID < 0 || colDetail < 0 {
|
||||
return result, fmt.Errorf("SQLite EQP 输出缺少 id 或 detail 列")
|
||||
}
|
||||
|
||||
// 同一 id 多行:合并 detail 后作为单节点
|
||||
// 不同 id 的父子通过 parent 关联
|
||||
type eqpEntry struct {
|
||||
ID string
|
||||
ParentID string
|
||||
Details []string
|
||||
NodeID string // 归一化后的 ExplainNode.ID
|
||||
}
|
||||
entries := make(map[string]*eqpEntry)
|
||||
var order []string // 保持 id 出现顺序
|
||||
|
||||
for _, row := range rows {
|
||||
var id, parent, detail string
|
||||
if colID < len(row) {
|
||||
id = strings.TrimSpace(row[colID])
|
||||
}
|
||||
if colParent >= 0 && colParent < len(row) {
|
||||
parent = strings.TrimSpace(row[colParent])
|
||||
}
|
||||
if colDetail < len(row) {
|
||||
detail = strings.TrimSpace(row[colDetail])
|
||||
}
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
entry, exists := entries[id]
|
||||
if !exists {
|
||||
entry = &eqpEntry{ID: id, ParentID: parent}
|
||||
entries[id] = entry
|
||||
order = append(order, id)
|
||||
}
|
||||
if detail != "" {
|
||||
entry.Details = append(entry.Details, detail)
|
||||
}
|
||||
}
|
||||
|
||||
// 按 id 出现顺序生成节点(SQLite 保证父先于子)
|
||||
for _, id := range order {
|
||||
entry := entries[id]
|
||||
node := buildSQLiteNodeFromDetails(entry.Details)
|
||||
parentNodeID := ""
|
||||
if entry.ParentID != "" && entry.ParentID != "0" {
|
||||
if parent, ok := entries[entry.ParentID]; ok && parent.NodeID != "" {
|
||||
parentNodeID = parent.NodeID
|
||||
}
|
||||
}
|
||||
entry.NodeID = appendExplainChild(&result, parentNodeID, node)
|
||||
}
|
||||
|
||||
result.RawFormat = connection.ExplainFormatTable
|
||||
result.RawPayload = raw
|
||||
finalizeExplainStats(&result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// buildSQLiteNodeFromDetails 把 SQLite EQP 的多个 detail 行合并为单节点。
|
||||
// 第一行通常是主操作(SCAN/SEARCH),后续行是附加标志(USE TEMP B-TREE 等)。
|
||||
//
|
||||
// 注意:SQLite 在某些场景下 "USE TEMP B-TREE ..." 会作为独立 id 出现(不是 SCAN 的附加行),
|
||||
// 此时主操作本身就是 USE TEMP B-TREE,需要识别为附加 flag 节点(OpType 保持 OTHER)。
|
||||
func buildSQLiteNodeFromDetails(details []string) connection.ExplainNode {
|
||||
node := connection.ExplainNode{OpType: connection.ExplainOpOther}
|
||||
if len(details) == 0 {
|
||||
return node
|
||||
}
|
||||
|
||||
// 主操作从第一行解析
|
||||
primary := details[0]
|
||||
node.OpDetail = primary
|
||||
lower := strings.ToLower(primary)
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "scan"):
|
||||
node.OpType = connection.ExplainOpScan
|
||||
node.Table = extractSQLiteTableName(primary)
|
||||
node.Flags = append(node.Flags, connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex)
|
||||
case strings.HasPrefix(lower, "search"):
|
||||
node.OpType = classifySQLiteSearchOp(primary)
|
||||
node.Table = extractSQLiteTableName(primary)
|
||||
node.Index = extractSQLiteIndexName(primary)
|
||||
case strings.HasPrefix(lower, "compound"):
|
||||
node.OpType = connection.ExplainOpUnion
|
||||
case strings.HasPrefix(lower, "correlated"), strings.HasPrefix(lower, "scalar subquery"):
|
||||
node.OpType = connection.ExplainOpSubquery
|
||||
case strings.HasPrefix(lower, "co-routine"):
|
||||
node.OpType = connection.ExplainOpOther
|
||||
case strings.HasPrefix(lower, "use temp b-tree"):
|
||||
// 独立 id 形式的附加 flag 节点:直接打 flag,OpType 保持 OTHER
|
||||
if strings.Contains(lower, "order by") {
|
||||
node.Flags = append(node.Flags, connection.ExplainFlagFilesort)
|
||||
} else {
|
||||
node.Flags = append(node.Flags, connection.ExplainFlagTempTable)
|
||||
}
|
||||
}
|
||||
|
||||
// 后续行是附加 flag(仅当主行不是 USE TEMP B-TREE 时才处理,避免重复)
|
||||
if !strings.HasPrefix(lower, "use temp b-tree") {
|
||||
for _, d := range details[1:] {
|
||||
dl := strings.ToLower(d)
|
||||
switch {
|
||||
case strings.Contains(dl, "temp b-tree"):
|
||||
if strings.Contains(dl, "order by") {
|
||||
node.Flags = append(node.Flags, connection.ExplainFlagFilesort)
|
||||
} else {
|
||||
node.Flags = append(node.Flags, connection.ExplainFlagTempTable)
|
||||
}
|
||||
case strings.Contains(dl, "subquery"):
|
||||
node.Flags = append(node.Flags, "SUBQUERY")
|
||||
}
|
||||
if node.Extra == nil {
|
||||
node.Extra = map[string]any{}
|
||||
}
|
||||
node.Extra["extra"] = d
|
||||
}
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
// classifySQLiteSearchOp 区分 SQLite SEARCH 的索引类型。
|
||||
// USING INDEX → INDEX_SCAN;USING PRIMARY KEY → INDEX_SCAN;USING ROWID → SCAN(伪索引扫描)。
|
||||
func classifySQLiteSearchOp(detail string) string {
|
||||
lower := strings.ToLower(detail)
|
||||
if strings.Contains(lower, "using covering index") {
|
||||
return connection.ExplainOpIndexOnly
|
||||
}
|
||||
if strings.Contains(lower, "using index") || strings.Contains(lower, "using primary key") {
|
||||
return connection.ExplainOpIndexScan
|
||||
}
|
||||
if strings.Contains(lower, "using rowid") {
|
||||
// ROWID 扫描本质还是按物理位置顺序访问
|
||||
return connection.ExplainOpScan
|
||||
}
|
||||
return connection.ExplainOpIndexScan
|
||||
}
|
||||
|
||||
// extractSQLiteTableName 从 detail 文本中提取表名。
|
||||
// 形如 "SCAN TABLE users" → "users";"SEARCH TABLE users USING INDEX idx_x (id)" → "users"。
|
||||
func extractSQLiteTableName(detail string) string {
|
||||
upper := strings.ToUpper(detail)
|
||||
for _, marker := range []string{"TABLE ", "VIEW "} {
|
||||
idx := strings.Index(upper, marker)
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
rest := detail[idx+len(marker):]
|
||||
// 截到下一个空格或 USING 之前
|
||||
for i, ch := range rest {
|
||||
if ch == ' ' || ch == '\t' {
|
||||
return strings.TrimSpace(rest[:i])
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(rest)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractSQLiteIndexName 从 detail 中提取使用的索引名。
|
||||
// 形如 "USING INDEX idx_x (id)" → "idx_x";"USING PRIMARY KEY" → "PRIMARY"。
|
||||
func extractSQLiteIndexName(detail string) string {
|
||||
upper := strings.ToUpper(detail)
|
||||
for _, marker := range []string{"USING INDEX ", "USING PRIMARY KEY", "USING COVERING INDEX "} {
|
||||
idx := strings.Index(upper, marker)
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
rest := detail[idx+len(marker):]
|
||||
if marker == "USING PRIMARY KEY" {
|
||||
return "PRIMARY"
|
||||
}
|
||||
// 截到下一个空格或左括号
|
||||
for i, ch := range rest {
|
||||
if ch == ' ' || ch == '\t' || ch == '(' {
|
||||
if i == 0 {
|
||||
return ""
|
||||
}
|
||||
name := strings.TrimSpace(rest[:i])
|
||||
if _, err := strconv.Atoi(name); err == nil {
|
||||
continue
|
||||
}
|
||||
return name
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(rest)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
138
internal/app/explain_parse_sqlite_test.go
Normal file
138
internal/app/explain_parse_sqlite_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
// SQLite EQP fixture:单表全表扫描 + filesort。
|
||||
const sqliteEQPFullScanWithSort = `id parent notused detail
|
||||
2 0 0 SCAN TABLE users
|
||||
5 0 0 USE TEMP B-TREE FOR ORDER BY`
|
||||
|
||||
func TestParseSQLiteExplain_FullScanWithFileSort(t *testing.T) {
|
||||
result, err := parseSQLiteExplain("SELECT * FROM users ORDER BY name", sqliteEQPFullScanWithSort, connection.ExplainFormatTable)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败:%v", err)
|
||||
}
|
||||
// 2 个独立节点(id 不同,parent 都是 0,无父子关系)
|
||||
if len(result.Nodes) != 2 {
|
||||
t.Fatalf("应有 2 个节点,got=%d", len(result.Nodes))
|
||||
}
|
||||
scan := result.Nodes[0]
|
||||
if scan.OpType != connection.ExplainOpScan {
|
||||
t.Fatalf("第一个应为 SCAN,got=%s", scan.OpType)
|
||||
}
|
||||
if scan.Table != "users" {
|
||||
t.Fatalf("table got=%s want=users", scan.Table)
|
||||
}
|
||||
if !containsFlag(scan.Flags, connection.ExplainFlagFullScan) {
|
||||
t.Fatalf("SCAN 应有 FULL_SCAN flag")
|
||||
}
|
||||
if result.Stats.HasFullScan != true {
|
||||
t.Fatalf("Stats.HasFullScan 应为 true")
|
||||
}
|
||||
if result.Stats.HasFilesort != true {
|
||||
t.Fatalf("Stats.HasFilesort 应为 true")
|
||||
}
|
||||
}
|
||||
|
||||
// SQLite EQP fixture:索引扫描。
|
||||
const sqliteEQPIndexScan = `id parent notused detail
|
||||
3 0 0 SEARCH TABLE users USING INDEX idx_email (email=?)`
|
||||
|
||||
func TestParseSQLiteExplain_IndexScanExtractsIndex(t *testing.T) {
|
||||
result, err := parseSQLiteExplain("SELECT * FROM users WHERE email = 'x'", sqliteEQPIndexScan, connection.ExplainFormatTable)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败:%v", err)
|
||||
}
|
||||
if len(result.Nodes) != 1 {
|
||||
t.Fatalf("应有 1 个节点,got=%d", len(result.Nodes))
|
||||
}
|
||||
node := result.Nodes[0]
|
||||
if node.OpType != connection.ExplainOpIndexScan {
|
||||
t.Fatalf("USING INDEX 应为 INDEX_SCAN,got=%s", node.OpType)
|
||||
}
|
||||
if node.Table != "users" {
|
||||
t.Fatalf("table got=%s want=users", node.Table)
|
||||
}
|
||||
if node.Index != "idx_email" {
|
||||
t.Fatalf("index got=%s want=idx_email", node.Index)
|
||||
}
|
||||
}
|
||||
|
||||
// SQLite EQP fixture:主键扫描 + 临时表(distinct)。
|
||||
const sqliteEQPPrimaryKeyWithDistinct = `id parent notused detail
|
||||
3 0 0 SEARCH TABLE users USING PRIMARY KEY (id=?)
|
||||
7 0 0 USE TEMP B-TREE FOR DISTINCT`
|
||||
|
||||
func TestParseSQLiteExplain_PrimaryKeyAndDistinct(t *testing.T) {
|
||||
result, err := parseSQLiteExplain("SELECT DISTINCT name FROM users WHERE id = 1", sqliteEQPPrimaryKeyWithDistinct, connection.ExplainFormatTable)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败:%v", err)
|
||||
}
|
||||
if len(result.Nodes) != 2 {
|
||||
t.Fatalf("应有 2 个节点,got=%d", len(result.Nodes))
|
||||
}
|
||||
pk := result.Nodes[0]
|
||||
if pk.OpType != connection.ExplainOpIndexScan {
|
||||
t.Fatalf("PRIMARY KEY 应为 INDEX_SCAN,got=%s", pk.OpType)
|
||||
}
|
||||
if pk.Index != "PRIMARY" {
|
||||
t.Fatalf("index got=%s want=PRIMARY", pk.Index)
|
||||
}
|
||||
if result.Stats.HasTempTable != true {
|
||||
t.Fatalf("FOR DISTINCT 应触发 TEMP_TABLE flag")
|
||||
}
|
||||
}
|
||||
|
||||
// SQLite EQP fixture:父子关系(子查询)。
|
||||
const sqliteEQPCorrelatedSubquery = `id parent notused detail
|
||||
2 0 0 SCAN TABLE orders
|
||||
6 2 0 CORRELATED SCALAR SUBQUERY 1
|
||||
8 6 0 SEARCH TABLE users USING INDEX idx_id (id=?)`
|
||||
|
||||
func TestParseSQLiteExplain_HierarchicalRelationShips(t *testing.T) {
|
||||
result, err := parseSQLiteExplain("SELECT *, (SELECT name FROM users WHERE id = o.user_id) FROM orders o", sqliteEQPCorrelatedSubquery, connection.ExplainFormatTable)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败:%v", err)
|
||||
}
|
||||
if len(result.Nodes) != 3 {
|
||||
t.Fatalf("应有 3 个节点,got=%d", len(result.Nodes))
|
||||
}
|
||||
// orders 是根(parent=0)
|
||||
// CORRELATED SCALAR SUBQUERY 的 parent=2 → orders
|
||||
// SEARCH 的 parent=6 → subquery
|
||||
if result.Nodes[0].ParentID != "" {
|
||||
t.Fatalf("根节点 ParentID 应为空,got=%q", result.Nodes[0].ParentID)
|
||||
}
|
||||
if result.Nodes[1].ParentID != result.Nodes[0].ID {
|
||||
t.Fatalf("subquery 节点的 ParentID 应指向 orders")
|
||||
}
|
||||
if result.Nodes[2].ParentID != result.Nodes[1].ID {
|
||||
t.Fatalf("SEARCH 节点的 ParentID 应指向 subquery")
|
||||
}
|
||||
if len(result.Edges) != 2 {
|
||||
t.Fatalf("应有 2 条边,got=%d", len(result.Edges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSQLiteExplain_CoveringIndex(t *testing.T) {
|
||||
raw := `id parent notused detail
|
||||
3 0 0 SEARCH TABLE users USING COVERING INDEX idx_name_email (name=?)`
|
||||
result, err := parseSQLiteExplain("SELECT name FROM users WHERE name = 'x'", raw, connection.ExplainFormatTable)
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败:%v", err)
|
||||
}
|
||||
if result.Nodes[0].OpType != connection.ExplainOpIndexOnly {
|
||||
t.Fatalf("COVERING INDEX 应为 INDEX_ONLY,got=%s", result.Nodes[0].OpType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSQLiteExplain_MissingColumnsReturnsError(t *testing.T) {
|
||||
_, err := parseSQLiteExplain("SELECT 1", "id parent\n1 0", connection.ExplainFormatTable)
|
||||
if err == nil {
|
||||
t.Fatal("缺少 detail 列应返回 error")
|
||||
}
|
||||
}
|
||||
353
internal/app/methods_explain.go
Normal file
353
internal/app/methods_explain.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
"GoNavi-Wails/internal/utils"
|
||||
)
|
||||
|
||||
// SQL 诊断工作台后端入口。
|
||||
//
|
||||
// 数据流:
|
||||
// 用户 SQL
|
||||
// → DiagnoseQuery(白名单校验 + 调度)
|
||||
// → executeExplain(决定走 ExplainExecer 还是 fallback 包装)
|
||||
// → buildExplainQuery(方言特定的 EXPLAIN 语句构造)
|
||||
// → dbInst.QueryMultiContextWithMessages(实际执行)
|
||||
// → collectExplainRaw(合并结果集为原文)
|
||||
// → parseExplainRaw(路由到方言解析器)
|
||||
// → ExplainResult(归一化节点树 + Stats)
|
||||
//
|
||||
// 解析器实现在 explain_parse_<dbtype>.go。
|
||||
|
||||
// explainSupportedDBTypes 是一期支持的 EXPLAIN 数据源白名单。
|
||||
// 不在白名单内的数据源(MongoDB/Redis/TDengine 等)调用 DiagnoseQuery 时直接返回不支持。
|
||||
var explainSupportedDBTypes = map[string]bool{
|
||||
"mysql": true,
|
||||
"mariadb": true,
|
||||
"diros": true, // Doris 走 MySQL 协议,EXPLAIN 语法兼容
|
||||
"starrocks": true, // 同上
|
||||
"postgres": true,
|
||||
"gaussdb": true,
|
||||
"opengauss": true,
|
||||
"kingbase": true,
|
||||
"highgo": true,
|
||||
"vastbase": true,
|
||||
"sqlite": true,
|
||||
"clickhouse": true,
|
||||
"oracle": true, // 含 OceanBase Oracle 协议(resolveDDLDBType 已归一化)
|
||||
"sqlserver": true,
|
||||
"oceanbase": true, // MySQL 协议走 MySQL 语法
|
||||
}
|
||||
|
||||
// explainStatementTimeoutFloor 是诊断的最小超时下限。
|
||||
// EXPLAIN 本身通常很快,但 ANALYZE 模式(PG/Oracle)会真实执行 SQL,
|
||||
// 需要给足时间避免大查询超时。
|
||||
const explainStatementTimeoutFloor = 5 * time.Minute
|
||||
|
||||
// DiagnoseQuery 是 SQL 诊断工作台对外暴露的入口。
|
||||
// 输入用户 SQL(仅允许 SELECT/WITH),返回执行计划归一化结果。
|
||||
// PR1 仅返回 ExplainResult;索引建议(Suggestions)在 PR2 规则引擎接入后填充。
|
||||
//
|
||||
// Wails 绑定:前端通过 DiagnoseQuery(config, dbName, sql) 调用,返回 QueryResult.Data 为 DiagnoseReport。
|
||||
func (a *App) DiagnoseQuery(config connection.ConnectionConfig, dbName, query string) connection.QueryResult {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return connection.QueryResult{Success: false, Message: "查询语句不能为空"}
|
||||
}
|
||||
if !looksLikeSelectOrWith(query) {
|
||||
return connection.QueryResult{Success: false, Message: "诊断仅支持 SELECT / WITH 查询;写操作请使用 EXPLAIN PLAN 模式(PR2 支持)"}
|
||||
}
|
||||
|
||||
runConfig := normalizeRunConfig(config, dbName)
|
||||
dbType := resolveDDLDBType(runConfig)
|
||||
if !explainSupportedDBTypes[dbType] {
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("当前数据源(%s)暂不支持 SQL 诊断;一期支持 MySQL/PostgreSQL/SQLite/ClickHouse/Oracle/SQLServer/OceanBase", dbType),
|
||||
}
|
||||
}
|
||||
|
||||
dbInst, err := a.getDatabase(runConfig)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
plan, err := a.executeExplain(dbInst, runConfig, dbType, query)
|
||||
if err != nil {
|
||||
logger.Warnf("DiagnoseQuery 执行 EXPLAIN 失败:type=%s err=%v sql=%q", dbType, err, sqlSnippet(query))
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
report := connection.DiagnoseReport{Plan: plan}
|
||||
return connection.QueryResult{Success: true, Message: "诊断完成", Data: report}
|
||||
}
|
||||
|
||||
// executeExplain 决定走哪条 EXPLAIN 执行路径:
|
||||
// 1. 若 dbInst 实现 ExplainExecer(driver-agent 在 PR2 接入),优先用驱动原生实现
|
||||
// 2. 否则走 app 层 fallback:buildExplainQuery 构造 EXPLAIN 语句,通过 QueryMulti 执行
|
||||
func (a *App) executeExplain(dbInst db.Database, config connection.ConnectionConfig, dbType, query string) (connection.ExplainResult, error) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if timeout := getDiagnoseTimeout(config); timeout > 0 {
|
||||
var cancelFn context.CancelFunc
|
||||
ctx, cancelFn = utils.ContextWithTimeout(timeout)
|
||||
defer cancelFn()
|
||||
}
|
||||
|
||||
// 优先:驱动自带 Explain(OceanBase driver-agent 走此路径)
|
||||
if explainer, ok := dbInst.(db.ExplainExecer); ok {
|
||||
logger.Infof("DiagnoseQuery 走 ExplainExecer 路径:type=%s", dbType)
|
||||
raw, format, err := explainer.Explain(ctx, query)
|
||||
if err != nil {
|
||||
return connection.ExplainResult{}, fmt.Errorf("驱动 EXPLAIN 执行失败:%w", err)
|
||||
}
|
||||
return parseExplainRaw(dbType, query, raw, format)
|
||||
}
|
||||
|
||||
// Fallback:app 层构造 EXPLAIN 语句
|
||||
wrappedSQL, postQueries, preferFormat, cleanupQueries, err := buildExplainQuery(dbType, query)
|
||||
if err != nil {
|
||||
return connection.ExplainResult{}, err
|
||||
}
|
||||
defer runExplainCleanup(dbInst, cleanupQueries)
|
||||
|
||||
raw, actualFormat, execErr := executeExplainStatements(ctx, dbInst, dbType, wrappedSQL, postQueries, preferFormat)
|
||||
if execErr != nil {
|
||||
return connection.ExplainResult{}, fmt.Errorf("执行 EXPLAIN 失败:%w", execErr)
|
||||
}
|
||||
return parseExplainRaw(dbType, query, raw, actualFormat)
|
||||
}
|
||||
|
||||
// runExplainCleanup 执行清理语句(如 Oracle DELETE FROM plan_table),失败仅记日志不阻塞主流程。
|
||||
// 在 defer 中调用,确保主 EXPLAIN 失败时也能尝试清理。
|
||||
func runExplainCleanup(dbInst db.Database, cleanupQueries []string) {
|
||||
for _, q := range cleanupQueries {
|
||||
if strings.TrimSpace(q) == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := dbInst.Exec(q); err != nil {
|
||||
logger.Warnf("EXPLAIN 清理失败(可忽略):sql=%q err=%v", sqlSnippet(q), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// executeExplainStatements 执行 EXPLAIN 主语句和后置查询(Oracle 的 DBMS_XPLAN.DISPLAY)。
|
||||
// 返回拼接后的原文 + 实际格式(可能与 preferFormat 不同,比如 MySQL 5.7 不支持 FORMAT=JSON 时降级)。
|
||||
func executeExplainStatements(ctx context.Context, dbInst db.Database, dbType, wrappedSQL string, postQueries []string, preferFormat connection.ExplainFormat) (string, connection.ExplainFormat, error) {
|
||||
statements := []string{wrappedSQL}
|
||||
statements = append(statements, postQueries...)
|
||||
fullSQL := strings.Join(statements, ";\n")
|
||||
|
||||
// 优先使用带 context 的多结果接口,便于取消
|
||||
if multi, ok := dbInst.(db.MultiResultQueryMessageExecer); ok {
|
||||
results, _, err := multi.QueryMultiContextWithMessages(ctx, fullSQL)
|
||||
if err != nil {
|
||||
return "", preferFormat, err
|
||||
}
|
||||
return collectExplainRaw(results, preferFormat)
|
||||
}
|
||||
if multi, ok := dbInst.(db.MultiResultQuerierContext); ok {
|
||||
results, err := multi.QueryMultiContext(ctx, fullSQL)
|
||||
if err != nil {
|
||||
return "", preferFormat, err
|
||||
}
|
||||
return collectExplainRaw(results, preferFormat)
|
||||
}
|
||||
if multi, ok := dbInst.(db.MultiResultQuerier); ok {
|
||||
results, err := multi.QueryMulti(fullSQL)
|
||||
if err != nil {
|
||||
return "", preferFormat, err
|
||||
}
|
||||
return collectExplainRaw(results, preferFormat)
|
||||
}
|
||||
|
||||
// 单结果 fallback:只执行第一条 EXPLAIN,忽略 postQueries(不适合 Oracle/SQLServer)
|
||||
data, _, err := dbInst.Query(wrappedSQL)
|
||||
if err != nil {
|
||||
return "", preferFormat, err
|
||||
}
|
||||
return collectExplainRaw([]connection.ResultSetData{{Rows: data}}, preferFormat)
|
||||
}
|
||||
|
||||
// collectExplainRaw 把多个结果集合并为单个原文,并探测实际格式。
|
||||
// MySQL FORMAT=JSON 返回 1 行 1 列包含完整 JSON 文本;表格模式返回多行多列。
|
||||
func collectExplainRaw(results []connection.ResultSetData, preferFormat connection.ExplainFormat) (string, connection.ExplainFormat, error) {
|
||||
if len(results) == 0 {
|
||||
return "", preferFormat, fmt.Errorf("EXPLAIN 未返回结果")
|
||||
}
|
||||
|
||||
// 大多数方言只有 1 个结果集;Oracle 有 2 个(EXPLAIN PLAN 影响 + DBMS_XPLAN.DISPLAY 查询)
|
||||
// 取最后一个非空结果集作为 EXPLAIN 输出(DISPLAY 在 post 查询中)
|
||||
last := pickLastNonEmptyResult(results)
|
||||
if last == nil {
|
||||
return "", preferFormat, fmt.Errorf("EXPLAIN 返回空结果集")
|
||||
}
|
||||
|
||||
// 单列单行 + 值是 JSON/XML 字符串 → 直接当原文
|
||||
if len(last.Columns) == 1 && len(last.Rows) == 1 {
|
||||
for _, v := range last.Rows[0] {
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", v))
|
||||
if text != "" && text != "<nil>" {
|
||||
return text, detectExplainFormat(text, preferFormat), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 表格模式:把行重组成 TSV,解析器按列定位
|
||||
var builder strings.Builder
|
||||
builder.WriteString(strings.Join(last.Columns, "\t"))
|
||||
builder.WriteByte('\n')
|
||||
for _, row := range last.Rows {
|
||||
values := make([]string, 0, len(last.Columns))
|
||||
for _, col := range last.Columns {
|
||||
val := row[col]
|
||||
if val == nil {
|
||||
values = append(values, "")
|
||||
continue
|
||||
}
|
||||
values = append(values, fmt.Sprintf("%v", val))
|
||||
}
|
||||
builder.WriteString(strings.Join(values, "\t"))
|
||||
builder.WriteByte('\n')
|
||||
}
|
||||
return builder.String(), connection.ExplainFormatTable, nil
|
||||
}
|
||||
|
||||
// pickLastNonEmptyResult 找最后一个有行数据的结果集(Oracle 的 EXPLAIN PLAN 影响 0 行,DISPLAY 才有数据)。
|
||||
func pickLastNonEmptyResult(results []connection.ResultSetData) *connection.ResultSetData {
|
||||
for i := len(results) - 1; i >= 0; i-- {
|
||||
r := results[i]
|
||||
if len(r.Rows) > 0 {
|
||||
return &r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// detectExplainFormat 探测原文实际格式(当驱动返回的是单字符串时)。
|
||||
// 优先信任 preferFormat;不可识别时按内容启发式判断。
|
||||
func detectExplainFormat(text string, preferFormat connection.ExplainFormat) connection.ExplainFormat {
|
||||
trimmed := strings.TrimLeft(text, " \t\r\n")
|
||||
switch {
|
||||
case strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "["):
|
||||
return connection.ExplainFormatJSON
|
||||
case strings.HasPrefix(trimmed, "<?xml") || strings.HasPrefix(trimmed, "<"):
|
||||
return connection.ExplainFormatXML
|
||||
case preferFormat != "":
|
||||
return preferFormat
|
||||
default:
|
||||
return connection.ExplainFormatText
|
||||
}
|
||||
}
|
||||
|
||||
// parseExplainRaw 是方言解析器的总路由。
|
||||
// 每方言在 explain_parse_<dbtype>.go 中实现 parseXxxExplain,这里按 dbType 分发。
|
||||
// 未实现的方言返回原文 + 警告,保证主流程不阻塞。
|
||||
func parseExplainRaw(dbType, sourceSQL, raw string, format connection.ExplainFormat) (connection.ExplainResult, error) {
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "diros", "starrocks", "oceanbase":
|
||||
return parseMySQLExplain(dbType, sourceSQL, raw, format)
|
||||
case "postgres", "gaussdb", "opengauss", "kingbase", "highgo", "vastbase":
|
||||
return parsePostgresExplain(dbType, sourceSQL, raw, format)
|
||||
case "sqlite":
|
||||
return parseSQLiteExplain(sourceSQL, raw, format)
|
||||
case "clickhouse":
|
||||
// PR2 实现
|
||||
return connection.ExplainResult{
|
||||
DBType: dbType,
|
||||
SourceSQL: sourceSQL,
|
||||
RawFormat: format,
|
||||
RawPayload: raw,
|
||||
Warnings: []string{"ClickHouse 解析器在 PR2 实现,先返回原文"},
|
||||
}, nil
|
||||
case "oracle":
|
||||
// PR2 实现
|
||||
return connection.ExplainResult{
|
||||
DBType: dbType,
|
||||
SourceSQL: sourceSQL,
|
||||
RawFormat: format,
|
||||
RawPayload: raw,
|
||||
Warnings: []string{"Oracle 解析器在 PR2 实现,先返回原文"},
|
||||
}, nil
|
||||
case "sqlserver":
|
||||
// PR2 实现
|
||||
return connection.ExplainResult{
|
||||
DBType: dbType,
|
||||
SourceSQL: sourceSQL,
|
||||
RawFormat: format,
|
||||
RawPayload: raw,
|
||||
Warnings: []string{"SQLServer 解析器在 PR2 实现,先返回原文"},
|
||||
}, nil
|
||||
default:
|
||||
return connection.ExplainResult{}, fmt.Errorf("不支持的 EXPLAIN 方言:%s", dbType)
|
||||
}
|
||||
}
|
||||
|
||||
// getDiagnoseTimeout 取诊断超时:优先 config.Timeout,否则默认 5 分钟。
|
||||
// EXPLAIN ANALYZE 会真实执行 SQL,超时太短会让大查询被误判失败。
|
||||
func getDiagnoseTimeout(config connection.ConnectionConfig) time.Duration {
|
||||
if config.Timeout > 0 {
|
||||
timeout := time.Duration(config.Timeout) * time.Second
|
||||
if timeout < explainStatementTimeoutFloor {
|
||||
return explainStatementTimeoutFloor
|
||||
}
|
||||
return timeout
|
||||
}
|
||||
return explainStatementTimeoutFloor
|
||||
}
|
||||
|
||||
// buildExplainQuery 按方言构造 EXPLAIN 语句。
|
||||
// 返回:
|
||||
// - wrappedSQL:主 EXPLAIN 语句(可能含 prelude 如 SQLServer 的 SET SHOWPLAN_XML ON)
|
||||
// - postQueries:后置查询(如 Oracle 的 SELECT ... FROM DBMS_XPLAN.DISPLAY)
|
||||
// - preferFormat:期望的输出格式(用于解析器调度;实际格式由 collectExplainRaw 探测后确定)
|
||||
// - cleanupQueries:清理语句(Oracle DELETE FROM plan_table),defer 中执行
|
||||
// - err:方言不支持时返回
|
||||
//
|
||||
// 参考现有风格:buildListViewQueries (methods_file.go:3102) 的 switch-case 模式。
|
||||
func buildExplainQuery(dbType, query string) (wrappedSQL string, postQueries []string, preferFormat connection.ExplainFormat, cleanupQueries []string, err error) {
|
||||
sql := strings.TrimRight(strings.TrimSpace(query), ";")
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "oceanbase":
|
||||
// MySQL 8.0+ 和 OceanBase 都支持 FORMAT=JSON
|
||||
// 5.7 在 collectExplainRaw 阶段会拿到语法错误,由调用方降级处理(PR2 加重试逻辑)
|
||||
return fmt.Sprintf("EXPLAIN FORMAT=JSON %s", sql), nil, connection.ExplainFormatJSON, nil, nil
|
||||
case "diros", "starrocks":
|
||||
// Doris/StarRocks 不支持 FORMAT=JSON,使用原生 EXPLAIN(返回表格 + 一些文本块)
|
||||
return fmt.Sprintf("EXPLAIN %s", sql), nil, connection.ExplainFormatTable, nil, nil
|
||||
case "postgres", "gaussdb", "opengauss", "kingbase", "highgo", "vastbase":
|
||||
// ANALYZE 真实执行 SQL,但 looksLikeSelectOrWith 已校验只读;BUFFERS 在 PG14+ 自动忽略不支持的选项
|
||||
return fmt.Sprintf("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) %s", sql), nil, connection.ExplainFormatJSON, nil, nil
|
||||
case "sqlite":
|
||||
return fmt.Sprintf("EXPLAIN QUERY PLAN %s", sql), nil, connection.ExplainFormatTable, nil, nil
|
||||
case "clickhouse":
|
||||
return fmt.Sprintf("EXPLAIN JSON %s", sql), nil, connection.ExplainFormatJSON, nil, nil
|
||||
case "oracle":
|
||||
// OceanBase Oracle 协议也走此分支(resolveDDLDBType 已归一化)
|
||||
// 用 STATEMENT_ID 隔离,避免多用户共享 plan_table 时互相覆盖
|
||||
stmtID := fmt.Sprintf("gonavi_%d", time.Now().UnixNano())
|
||||
wrapped := fmt.Sprintf("EXPLAIN PLAN SET STATEMENT_ID = '%s' FOR %s", stmtID, sql)
|
||||
post := []string{
|
||||
fmt.Sprintf("SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY(NULL, '%s', 'ALL'))", stmtID),
|
||||
}
|
||||
cleanup := []string{
|
||||
fmt.Sprintf("DELETE FROM plan_table WHERE statement_id = '%s'", stmtID),
|
||||
}
|
||||
return wrapped, post, connection.ExplainFormatTable, cleanup, nil
|
||||
case "sqlserver":
|
||||
// SET SHOWPLAN_XML ON 后整个会话只返回计划不执行;必须 SET OFF 清理,否则连接污染
|
||||
wrapped := fmt.Sprintf("SET SHOWPLAN_XML ON;\n%s", sql)
|
||||
post := []string{"SET SHOWPLAN_XML OFF;"}
|
||||
return wrapped, post, connection.ExplainFormatXML, nil, nil
|
||||
default:
|
||||
return "", nil, "", nil, fmt.Errorf("方言 %s 的 EXPLAIN 构造未实现", dbType)
|
||||
}
|
||||
}
|
||||
144
internal/app/methods_explain_test.go
Normal file
144
internal/app/methods_explain_test.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
// buildExplainQuery 测试:验证各方言生成的 SQL 是否符合预期。
|
||||
func TestBuildExplainQuery_MySQLUsesFormatJSON(t *testing.T) {
|
||||
wrapped, post, format, cleanup, err := buildExplainQuery("mysql", "SELECT * FROM t")
|
||||
if err != nil {
|
||||
t.Fatalf("mysql 构造失败:%v", err)
|
||||
}
|
||||
if want := "EXPLAIN FORMAT=JSON SELECT * FROM t"; wrapped != want {
|
||||
t.Fatalf("got=%q want=%q", wrapped, want)
|
||||
}
|
||||
if len(post) != 0 {
|
||||
t.Fatalf("mysql 不应有 post 查询,got=%v", post)
|
||||
}
|
||||
if len(cleanup) != 0 {
|
||||
t.Fatalf("mysql 不应有 cleanup,got=%v", cleanup)
|
||||
}
|
||||
if format != connection.ExplainFormatJSON {
|
||||
t.Fatalf("format got=%v want=json", format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExplainQuery_PostgresUsesAnalyzeBuffersJSON(t *testing.T) {
|
||||
wrapped, _, format, _, err := buildExplainQuery("postgres", "SELECT * FROM t WHERE id = 1")
|
||||
if err != nil {
|
||||
t.Fatalf("postgres 构造失败:%v", err)
|
||||
}
|
||||
if !strings.Contains(wrapped, "ANALYZE") || !strings.Contains(wrapped, "BUFFERS") || !strings.Contains(wrapped, "FORMAT JSON") {
|
||||
t.Fatalf("postgres SQL 应含 ANALYZE BUFFERS FORMAT JSON,got=%q", wrapped)
|
||||
}
|
||||
if format != connection.ExplainFormatJSON {
|
||||
t.Fatalf("format got=%v want=json", format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExplainQuery_SQLiteUsesEQP(t *testing.T) {
|
||||
wrapped, _, format, _, err := buildExplainQuery("sqlite", "SELECT * FROM t")
|
||||
if err != nil {
|
||||
t.Fatalf("sqlite 构造失败:%v", err)
|
||||
}
|
||||
if want := "EXPLAIN QUERY PLAN SELECT * FROM t"; wrapped != want {
|
||||
t.Fatalf("got=%q want=%q", wrapped, want)
|
||||
}
|
||||
if format != connection.ExplainFormatTable {
|
||||
t.Fatalf("format got=%v want=table", format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExplainQuery_OracleReturnsStatementIDAndCleanup(t *testing.T) {
|
||||
wrapped, post, _, cleanup, err := buildExplainQuery("oracle", "SELECT * FROM t")
|
||||
if err != nil {
|
||||
t.Fatalf("oracle 构造失败:%v", err)
|
||||
}
|
||||
if !strings.Contains(wrapped, "EXPLAIN PLAN SET STATEMENT_ID") {
|
||||
t.Fatalf("oracle 主语句应含 STATEMENT_ID,got=%q", wrapped)
|
||||
}
|
||||
if len(post) != 1 || !strings.Contains(post[0], "DBMS_XPLAN.DISPLAY") {
|
||||
t.Fatalf("oracle post 应含 DBMS_XPLAN.DISPLAY,got=%v", post)
|
||||
}
|
||||
if len(cleanup) != 1 || !strings.Contains(cleanup[0], "DELETE FROM plan_table") {
|
||||
t.Fatalf("oracle cleanup 应含 DELETE FROM plan_table,got=%v", cleanup)
|
||||
}
|
||||
// 验证 statement_id 在三条 SQL 中一致
|
||||
idInWrapped := extractBetween(wrapped, "STATEMENT_ID = '", "' FOR")
|
||||
idInPost := extractBetween(post[0], "NULL, '", "'")
|
||||
idInCleanup := extractBetween(cleanup[0], "statement_id = '", "'")
|
||||
if idInWrapped == "" || idInWrapped != idInPost || idInWrapped != idInCleanup {
|
||||
t.Fatalf("statement_id 不一致:wrapped=%q post=%q cleanup=%q", idInWrapped, idInPost, idInCleanup)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExplainQuery_SQLServerSetsShowplanXML(t *testing.T) {
|
||||
wrapped, post, _, _, err := buildExplainQuery("sqlserver", "SELECT * FROM t")
|
||||
if err != nil {
|
||||
t.Fatalf("sqlserver 构造失败:%v", err)
|
||||
}
|
||||
if !strings.Contains(wrapped, "SET SHOWPLAN_XML ON") {
|
||||
t.Fatalf("sqlserver 应 SET SHOWPLAN_XML ON,got=%q", wrapped)
|
||||
}
|
||||
if !strings.Contains(wrapped, "SELECT * FROM t") {
|
||||
t.Fatalf("sqlserver 应保留原 SQL,got=%q", wrapped)
|
||||
}
|
||||
if len(post) != 1 || !strings.Contains(post[0], "SET SHOWPLAN_XML OFF") {
|
||||
t.Fatalf("sqlserver post 应 SET SHOWPLAN_XML OFF,got=%v", post)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExplainQuery_ClickHouseUsesExplainJSON(t *testing.T) {
|
||||
wrapped, _, format, _, err := buildExplainQuery("clickhouse", "SELECT * FROM t")
|
||||
if err != nil {
|
||||
t.Fatalf("clickhouse 构造失败:%v", err)
|
||||
}
|
||||
if want := "EXPLAIN JSON SELECT * FROM t"; wrapped != want {
|
||||
t.Fatalf("got=%q want=%q", wrapped, want)
|
||||
}
|
||||
if format != connection.ExplainFormatJSON {
|
||||
t.Fatalf("format got=%v want=json", format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExplainQuery_PGLikeDialectsSharePath(t *testing.T) {
|
||||
// gaussdb/opengauss/kingbase/highgo/vastbase 应该复用 PG 的 ANALYZE BUFFERS 路径
|
||||
for _, dbType := range []string{"gaussdb", "opengauss", "kingbase", "highgo", "vastbase"} {
|
||||
wrapped, _, format, _, err := buildExplainQuery(dbType, "SELECT 1")
|
||||
if err != nil {
|
||||
t.Errorf("%s 构造失败:%v", dbType, err)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(wrapped, "FORMAT JSON") {
|
||||
t.Errorf("%s 应使用 FORMAT JSON 路径,got=%q", dbType, wrapped)
|
||||
}
|
||||
if format != connection.ExplainFormatJSON {
|
||||
t.Errorf("%s format got=%v want=json", dbType, format)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExplainQuery_UnsupportedDialectReturnsError(t *testing.T) {
|
||||
_, _, _, _, err := buildExplainQuery("mongodb", "db.t.find()")
|
||||
if err == nil {
|
||||
t.Fatal("未支持方言应返回 error")
|
||||
}
|
||||
}
|
||||
|
||||
// extractBetween 取 s 中 between start 和 end 的第一个匹配子串(测试辅助)。
|
||||
func extractBetween(s, start, end string) string {
|
||||
startIdx := strings.Index(s, start)
|
||||
if startIdx < 0 {
|
||||
return ""
|
||||
}
|
||||
startIdx += len(start)
|
||||
endIdx := strings.Index(s[startIdx:], end)
|
||||
if endIdx < 0 {
|
||||
return ""
|
||||
}
|
||||
return s[startIdx : startIdx+endIdx]
|
||||
}
|
||||
139
internal/connection/explain.go
Normal file
139
internal/connection/explain.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package connection
|
||||
|
||||
import "time"
|
||||
|
||||
// SQL 诊断工作台数据结构。
|
||||
//
|
||||
// 设计要点:
|
||||
// - 节点用扁平数组 + ParentID 表达父子(不用嵌套树),便于前端 react-flow 渲染和按 ID 检索
|
||||
// - 跨方言归一化:不论 MySQL/PG/Oracle 输出,统一映射到 ExplainNode.OpType + Flags
|
||||
// - 原文保留(RawPayload)用于调试和前端展开查看
|
||||
// - 与 ResultSetData 同包,便于 Wails 绑定自动生成 TS 类型
|
||||
|
||||
// ExplainFormat 标识 EXPLAIN 原始输出的格式,决定解析器路径。
|
||||
type ExplainFormat string
|
||||
|
||||
const (
|
||||
ExplainFormatJSON ExplainFormat = "json" // MySQL 8.0 FORMAT=JSON / PG FORMAT JSON / ClickHouse JSON
|
||||
ExplainFormatTable ExplainFormat = "table" // MySQL 5.7 表格 / SQLite EQP / Oracle DBMS_XPLAN
|
||||
ExplainFormatXML ExplainFormat = "xml" // SQLServer SHOWPLAN_XML
|
||||
ExplainFormatText ExplainFormat = "text" // 兜底,无法归类时
|
||||
)
|
||||
|
||||
// 节点操作类型(归一化后跨方言通用)。
|
||||
const (
|
||||
ExplainOpScan = "SCAN" // 全表扫描 / 顺序扫描
|
||||
ExplainOpIndexScan = "INDEX_SCAN" // 索引扫描(ref/eq_ref/range)
|
||||
ExplainOpIndexOnly = "INDEX_ONLY" // Using index 覆盖索引
|
||||
ExplainOpJoin = "JOIN" // 任意 JOIN 类型(Nested Loop / Hash / Merge)
|
||||
ExplainOpAggregate = "AGGREGATE" // GROUP BY / DISTINCT / 聚合函数
|
||||
ExplainOpSort = "SORT" // filesort / ORDER BY
|
||||
ExplainOpLimit = "LIMIT" // LIMIT 截断
|
||||
ExplainOpFilter = "FILTER" // WHERE/HAVING 过滤
|
||||
ExplainOpSubquery = "SUBQUERY" // 子查询
|
||||
ExplainOpUnion = "UNION" // UNION 合并
|
||||
ExplainOpWindow = "WINDOW" // 窗口函数
|
||||
ExplainOpMaterialize = "MATERIALIZE" // 物化临时表
|
||||
ExplainOpInsert = "INSERT" // INSERT 操作(EXPLAIN INSERT)
|
||||
ExplainOpUpdate = "UPDATE"
|
||||
ExplainOpDelete = "DELETE"
|
||||
ExplainOpOther = "OTHER" // 无法归类
|
||||
)
|
||||
|
||||
// 节点警告标志(用于规则匹配和前端高亮)。
|
||||
const (
|
||||
ExplainFlagFullScan = "FULL_SCAN" // 全表扫描
|
||||
ExplainFlagFilesort = "FILESORT" // 额外排序
|
||||
ExplainFlagTempTable = "TEMP_TABLE" // 使用临时表
|
||||
ExplainFlagNoIndex = "NO_INDEX" // 未命中索引
|
||||
ExplainFlagHighCost = "HIGH_COST" // 成本显著高于其他节点
|
||||
ExplainFlagLowBufferHit = "LOW_BUFFER_HIT" // 缓冲命中率低(PG BUFFERS)
|
||||
ExplainFlagUccWarn = "UNCERTAIN_ROWS" // 估算行数不确定(rows=0 或巨大偏差)
|
||||
)
|
||||
|
||||
// 索引建议严重度。
|
||||
const (
|
||||
SeverityCritical = "critical" // 严重影响性能(如大表全表扫描)
|
||||
SeverityWarning = "warning" // 有改进空间
|
||||
SeverityInfo = "info" // 优化建议
|
||||
)
|
||||
|
||||
// ExplainNode 表示执行计划中的一个节点(归一化后跨方言通用)。
|
||||
type ExplainNode struct {
|
||||
ID string `json:"id"`
|
||||
ParentID string `json:"parentId,omitempty"`
|
||||
OpType string `json:"opType"`
|
||||
OpDetail string `json:"opDetail,omitempty"` // 原始操作符文本,如 "Hash Join" / "Using where"
|
||||
Table string `json:"table,omitempty"` // 涉及的表名
|
||||
Index string `json:"index,omitempty"` // 使用的索引名
|
||||
EstRows int64 `json:"estRows,omitempty"` // 估算扫描行数
|
||||
ActualRows int64 `json:"actualRows,omitempty"` // 实际返回行数(需 ANALYZE)
|
||||
Loops int64 `json:"loops,omitempty"` // 循环执行次数
|
||||
Cost float64 `json:"cost,omitempty"` // 估算成本
|
||||
DurationMs float64 `json:"durationMs,omitempty"` // 实际耗时毫秒(需 ANALYZE)
|
||||
BufferHit float64 `json:"bufferHit,omitempty"` // 缓冲命中率 0-1
|
||||
Flags []string `json:"flags,omitempty"` // 警告标志
|
||||
Extra map[string]any `json:"extra,omitempty"` // 方言特定字段,前端按需展示
|
||||
}
|
||||
|
||||
// ExplainEdge 表示执行计划节点间的父子关系,前端 react-flow 用于绘制连线。
|
||||
type ExplainEdge struct {
|
||||
From string `json:"from"` // 父节点 ID
|
||||
To string `json:"to"` // 子节点 ID
|
||||
Label string `json:"label,omitempty"` // 边的标注(如 JOIN 类型 "INNER"/"LEFT")
|
||||
}
|
||||
|
||||
// ExplainStats 是整个执行计划的聚合统计。
|
||||
type ExplainStats struct {
|
||||
TotalCost float64 `json:"totalCost,omitempty"`
|
||||
TotalDurationMs float64 `json:"totalDurationMs,omitempty"`
|
||||
RowsRead int64 `json:"rowsRead,omitempty"` // 所有 SCAN 节点估算行数之和
|
||||
BufferHitRate float64 `json:"bufferHitRate,omitempty"` // 平均缓冲命中率
|
||||
HasFullScan bool `json:"hasFullScan"`
|
||||
HasFilesort bool `json:"hasFilesort"`
|
||||
HasTempTable bool `json:"hasTempTable"`
|
||||
MaxEstRows int64 `json:"maxEstRows,omitempty"` // 单节点最大估算行数(用于规则匹配)
|
||||
}
|
||||
|
||||
// ExplainResult 是一次 EXPLAIN 解析后的归一化结果。
|
||||
type ExplainResult struct {
|
||||
DBType string `json:"dbType"`
|
||||
SourceSQL string `json:"sourceSql"`
|
||||
Nodes []ExplainNode `json:"nodes"`
|
||||
Edges []ExplainEdge `json:"edges,omitempty"`
|
||||
Stats ExplainStats `json:"stats"`
|
||||
Warnings []string `json:"warnings,omitempty"` // 解析/降级过程中的提示
|
||||
RawFormat ExplainFormat `json:"rawFormat"`
|
||||
RawPayload string `json:"rawPayload,omitempty"` // 原始 EXPLAIN 输出,前端调试用
|
||||
}
|
||||
|
||||
// IndexSuggestion 是规则引擎针对某个节点产生的索引建议。
|
||||
type IndexSuggestion struct {
|
||||
Severity string `json:"severity"` // critical/warning/info
|
||||
Rule string `json:"rule"` // 规则 ID,如 "full_scan_on_large_table"
|
||||
Reason string `json:"reason"` // 人类可读的触发原因
|
||||
SuggestedIndex string `json:"suggestedIndex,omitempty"` // 建议的 CREATE INDEX 语句(如有)
|
||||
AffectedNodeID string `json:"affectedNodeId,omitempty"` // 关联的 ExplainNode.ID
|
||||
AffectedTable string `json:"affectedTable,omitempty"`
|
||||
EstRows int64 `json:"estRows,omitempty"` // 触发节点的估算行数,便于排序
|
||||
}
|
||||
|
||||
// DiagnoseReport 是 DiagnoseQuery 的最终返回值,前端诊断面板消费此结构。
|
||||
type DiagnoseReport struct {
|
||||
Plan ExplainResult `json:"plan"`
|
||||
Suggestions []IndexSuggestion `json:"suggestions"`
|
||||
}
|
||||
|
||||
// QueryExecutionRecord 是慢 SQL 历史的一条记录(PR5 慢 SQL 摘要用,提前定义便于 PR1 数据流贯通)。
|
||||
type QueryExecutionRecord struct {
|
||||
ID string `json:"id"`
|
||||
ConnectionFP string `json:"connectionFp"` // 连接指纹,复用 saved_query_fingerprint
|
||||
SQLFingerprint string `json:"sqlFp"` // SQL 文本指纹(归一化后 sha256 取前 16)
|
||||
SQLPreview string `json:"sqlPreview"` // 截断后的 SQL 预览(前 200 字符)
|
||||
DBType string `json:"dbType"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
RowsRead int64 `json:"rowsRead,omitempty"`
|
||||
RowsReturned int64 `json:"rowsReturned,omitempty"`
|
||||
PlanHash string `json:"planHash,omitempty"` // 同一 SQL 不同计划的区分(PR5 实现)
|
||||
ExecutedAt time.Time `json:"executedAt"`
|
||||
}
|
||||
@@ -98,6 +98,26 @@ type StreamQueryExecer interface {
|
||||
StreamQueryContext(ctx context.Context, query string, consumer QueryStreamConsumer) error
|
||||
}
|
||||
|
||||
// ExplainExecer is an optional interface for drivers that can run EXPLAIN and
|
||||
// return the dialect-native output (JSON text, table rows as JSON, or XML).
|
||||
//
|
||||
// Drivers that implement this interface own the full EXPLAIN lifecycle:
|
||||
// - MySQL: prefer EXPLAIN FORMAT=JSON, fallback to vanilla EXPLAIN on 5.7
|
||||
// - PostgreSQL: EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
|
||||
// - Oracle: EXPLAIN PLAN SET STATEMENT_ID ... + DBMS_XPLAN.DISPLAY + cleanup
|
||||
// - SQLServer: SET SHOWPLAN_XML ON + sql + SET OFF (defer cleanup mandatory)
|
||||
// - SQLite: EXPLAIN QUERY PLAN
|
||||
// - ClickHouse: EXPLAIN JSON
|
||||
//
|
||||
// The driver decides which format to use and returns the raw payload plus the
|
||||
// detected format tag; the app layer parses via the corresponding parser.
|
||||
//
|
||||
// Drivers that do NOT implement this interface fall back to the generic path
|
||||
// in app.DiagnoseQuery: wrap the SQL as "EXPLAIN <sql>" and run via QueryMulti.
|
||||
type ExplainExecer interface {
|
||||
Explain(ctx context.Context, query string) (raw string, format connection.ExplainFormat, err error)
|
||||
}
|
||||
|
||||
// StatementQueryMessageExecer can run queries on a pinned session and return
|
||||
// extra server messages/notices alongside rows.
|
||||
type StatementQueryMessageExecer interface {
|
||||
|
||||
Reference in New Issue
Block a user