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:
Syngnat
2026-06-19 12:30:56 +08:00
parent 542bafe6c4
commit b997788437
11 changed files with 2176 additions and 0 deletions

View 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
}

View 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_typesystem/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/ExtraOceanBase 可能多 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 → SCANrange/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_infoDoris/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 内的 joinid 不同代表子查询
// 简化处理每行作为独立节点无父子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
}

View 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 应归一化为 SCANgot=%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 flaggot=%v", node.Flags)
}
if !containsFlag(node.Flags, connection.ExplainFlagNoIndex) {
t.Fatalf("全表扫描节点应有 NO_INDEX flaggot=%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 个 tablegot=%d", len(result.Nodes))
}
if result.Nodes[0].Table != "orders" {
t.Fatalf("第一个表应是 ordersgot=%s", result.Nodes[0].Table)
}
if result.Nodes[0].OpType != connection.ExplainOpScan {
t.Fatalf("orders access_type=ALL 应为 SCANgot=%s", result.Nodes[0].OpType)
}
if result.Nodes[1].Table != "users" {
t.Fatalf("第二个表应是 usersgot=%s", result.Nodes[1].Table)
}
if result.Nodes[1].OpType != connection.ExplainOpIndexScan {
t.Fatalf("users access_type=eq_ref 应为 INDEX_SCANgot=%s", result.Nodes[1].OpType)
}
if result.Nodes[1].Index != "PRIMARY" {
t.Fatalf("users 使用 PRIMARY keygot=%s", result.Nodes[1].Index)
}
// statsorders 估算 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 顶层节点应为 SORTgot=%s", result.Nodes[0].OpType)
}
if result.Nodes[1].OpType != connection.ExplainOpScan {
t.Fatalf("内层 table 应为 SCANgot=%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 应为 SCANgot=%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_uidgot=%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
}

View 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 → SCANIndex Scan/Index Only Scan → INDEX_SCAN/INDEX_ONLY
// Hash Join/Nested Loop/Merge Join → JOINAggregate/GroupAggregate → AGGREGATESort → 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
}

View 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 应为 SCANgot=%s", node.OpType)
}
if node.Table != "public.users" {
t.Fatalf("Table 应含 schemagot=%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.167got=%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.TotalDurationMsgot=%v", result.Stats.TotalDurationMs)
}
}
// PG FORMAT JSON fixtureHash 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=%dnodes=%+v", len(result.Nodes), result.Nodes)
}
join := result.Nodes[0]
if join.OpType != connection.ExplainOpJoin {
t.Fatalf("顶层应为 JOINgot=%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_pkeygot=%s", indexScanNode.Index)
}
// Edges3 条顶层无父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")
}
}

View 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 节点:直接打 flagOpType 保持 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_SCANUSING PRIMARY KEY → INDEX_SCANUSING 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 ""
}

View 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("第一个应为 SCANgot=%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_SCANgot=%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_SCANgot=%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_ONLYgot=%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")
}
}

View 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 实现 ExplainExecerdriver-agent 在 PR2 接入),优先用驱动原生实现
// 2. 否则走 app 层 fallbackbuildExplainQuery 构造 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()
}
// 优先:驱动自带 ExplainOceanBase 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)
}
// Fallbackapp 层构造 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_tabledefer 中执行
// - 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)
}
}

View 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 不应有 cleanupgot=%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 JSONgot=%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_IDgot=%q", wrapped)
}
if len(post) != 1 || !strings.Contains(post[0], "DBMS_XPLAN.DISPLAY") {
t.Fatalf("oracle post 应含 DBMS_XPLAN.DISPLAYgot=%v", post)
}
if len(cleanup) != 1 || !strings.Contains(cleanup[0], "DELETE FROM plan_table") {
t.Fatalf("oracle cleanup 应含 DELETE FROM plan_tablegot=%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 ONgot=%q", wrapped)
}
if !strings.Contains(wrapped, "SELECT * FROM t") {
t.Fatalf("sqlserver 应保留原 SQLgot=%q", wrapped)
}
if len(post) != 1 || !strings.Contains(post[0], "SET SHOWPLAN_XML OFF") {
t.Fatalf("sqlserver post 应 SET SHOWPLAN_XML OFFgot=%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]
}

View 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"`
}

View File

@@ -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 {