Files
MyGoNavi/internal/app/methods_explain.go
2026-06-23 18:26:20 +08:00

370 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"context"
"fmt"
"strings"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
"GoNavi-Wails/internal/logger"
"GoNavi-Wails/internal/utils"
"GoNavi-Wails/shared/i18n"
)
// 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
func defaultExplainBackendText(key string, params map[string]any) string {
localizer, err := i18n.NewLocalizer(i18n.LanguageZhCN)
if err != nil {
return key
}
return localizer.T(key, params)
}
// 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: a.appText("sql_analysis.backend.error.query_required", nil)}
}
if !looksLikeSelectOrWith(query) {
return connection.QueryResult{Success: false, Message: a.appText("sql_analysis.backend.error.select_only", nil)}
}
runConfig := normalizeRunConfig(config, dbName)
dbType := resolveDDLDBType(runConfig)
if !explainSupportedDBTypes[dbType] {
return connection.QueryResult{
Success: false,
Message: a.appText("sql_analysis.backend.error.unsupported_db_type", map[string]any{"dbType": 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()}
}
suggestions := runExplainRules(plan)
report := connection.DiagnoseReport{Plan: plan, Suggestions: suggestions}
logger.Infof("DiagnoseQuery 完成type=%s nodes=%d suggestions=%d", dbType, len(plan.Nodes), len(suggestions))
return connection.QueryResult{Success: true, Message: a.appText("sql_analysis.backend.message.completed", nil), 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) {
text := a.appText
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("%s", text("sql_analysis.backend.error.driver_explain_failed", map[string]any{"detail": err.Error()}))
}
return parseExplainRawWithText(dbType, query, raw, format, text)
}
// Fallbackapp 层构造 EXPLAIN 语句
wrappedSQL, postQueries, preferFormat, cleanupQueries, err := buildExplainQueryWithText(dbType, query, text)
if err != nil {
return connection.ExplainResult{}, err
}
defer runExplainCleanup(dbInst, cleanupQueries)
raw, actualFormat, execErr := executeExplainStatementsWithText(ctx, dbInst, dbType, wrappedSQL, postQueries, preferFormat, text)
if execErr != nil {
return connection.ExplainResult{}, fmt.Errorf("%s", text("sql_analysis.backend.error.explain_execution_failed", map[string]any{"detail": execErr.Error()}))
}
return parseExplainRawWithText(dbType, query, raw, actualFormat, text)
}
// 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) {
return executeExplainStatementsWithText(ctx, dbInst, dbType, wrappedSQL, postQueries, preferFormat, defaultExplainBackendText)
}
func executeExplainStatementsWithText(ctx context.Context, dbInst db.Database, dbType, wrappedSQL string, postQueries []string, preferFormat connection.ExplainFormat, text func(string, map[string]any) string) (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 collectExplainRawWithText(results, preferFormat, text)
}
if multi, ok := dbInst.(db.MultiResultQuerierContext); ok {
results, err := multi.QueryMultiContext(ctx, fullSQL)
if err != nil {
return "", preferFormat, err
}
return collectExplainRawWithText(results, preferFormat, text)
}
if multi, ok := dbInst.(db.MultiResultQuerier); ok {
results, err := multi.QueryMulti(fullSQL)
if err != nil {
return "", preferFormat, err
}
return collectExplainRawWithText(results, preferFormat, text)
}
// 单结果 fallback只执行第一条 EXPLAIN忽略 postQueries不适合 Oracle/SQLServer
data, _, err := dbInst.Query(wrappedSQL)
if err != nil {
return "", preferFormat, err
}
return collectExplainRawWithText([]connection.ResultSetData{{Rows: data}}, preferFormat, text)
}
// collectExplainRaw 把多个结果集合并为单个原文,并探测实际格式。
// MySQL FORMAT=JSON 返回 1 行 1 列包含完整 JSON 文本;表格模式返回多行多列。
func collectExplainRaw(results []connection.ResultSetData, preferFormat connection.ExplainFormat) (string, connection.ExplainFormat, error) {
return collectExplainRawWithText(results, preferFormat, defaultExplainBackendText)
}
func collectExplainRawWithText(results []connection.ResultSetData, preferFormat connection.ExplainFormat, text func(string, map[string]any) string) (string, connection.ExplainFormat, error) {
if text == nil {
text = defaultExplainBackendText
}
if len(results) == 0 {
return "", preferFormat, fmt.Errorf("%s", text("sql_analysis.backend.error.explain_result_missing", nil))
}
// 大多数方言只有 1 个结果集Oracle 有 2 个EXPLAIN PLAN 影响 + DBMS_XPLAN.DISPLAY 查询)
// 取最后一个非空结果集作为 EXPLAIN 输出DISPLAY 在 post 查询中)
last := pickLastNonEmptyResult(results)
if last == nil {
return "", preferFormat, fmt.Errorf("%s", text("sql_analysis.backend.error.explain_result_empty", nil))
}
// 单列单行 + 值是 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) {
return parseExplainRawWithText(dbType, sourceSQL, raw, format, defaultExplainBackendText)
}
func parseExplainRawWithText(dbType, sourceSQL, raw string, format connection.ExplainFormat, text func(string, map[string]any) string) (connection.ExplainResult, error) {
if text == nil {
text = defaultExplainBackendText
}
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":
return parseClickHouseExplain(sourceSQL, raw, format)
case "oracle":
return parseOracleExplain(sourceSQL, raw, format)
case "sqlserver":
return parseSQLServerExplain(sourceSQL, raw, format)
default:
return connection.ExplainResult{}, fmt.Errorf("%s", text("sql_analysis.backend.error.explain_dialect_unsupported", map[string]any{"dbType": 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) {
return buildExplainQueryWithText(dbType, query, defaultExplainBackendText)
}
func buildExplainQueryWithText(dbType, query string, text func(string, map[string]any) string) (wrappedSQL string, postQueries []string, preferFormat connection.ExplainFormat, cleanupQueries []string, err error) {
if text == nil {
text = defaultExplainBackendText
}
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", text("sql_analysis.backend.error.explain_query_not_implemented", map[string]any{"dbType": dbType}))
}
}