mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-24 23:43:50 +08:00
370 lines
16 KiB
Go
370 lines
16 KiB
Go
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 实现 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) {
|
||
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()
|
||
}
|
||
|
||
// 优先:驱动自带 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("%s", text("sql_analysis.backend.error.driver_explain_failed", map[string]any{"detail": err.Error()}))
|
||
}
|
||
return parseExplainRawWithText(dbType, query, raw, format, text)
|
||
}
|
||
|
||
// Fallback:app 层构造 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_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) {
|
||
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}))
|
||
}
|
||
}
|