Files
MyGoNavi/internal/app/methods_explain_test.go
Syngnat 034e968888 feat(sql-analysis): 完善慢 SQL 监控与执行计划诊断
- 完善慢查询采集、聚合、迁移与跨进程持久化

- 加固只读诊断边界并补齐多数据库执行计划解析

- 优化慢 SQL 面板、计划图、响应式布局与交互体验

- 补充后端、前端及 SQL Server 会话回归测试
2026-07-11 16:25:07 +08:00

445 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"context"
"errors"
"strings"
"testing"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
)
// 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_PostgresUsesSafeJSONPlan(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") {
t.Fatalf("默认诊断不应实际执行查询got=%q", wrapped)
}
if !strings.Contains(wrapped, "FORMAT JSON") {
t.Fatalf("postgres SQL 应使用 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 PLAN json = 1, description = 1, indexes = 1 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 的安全 JSON 计划路径。
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 strings.Contains(wrapped, "ANALYZE") || strings.Contains(wrapped, "BUFFERS") {
t.Errorf("%s 默认诊断不应实际执行查询got=%q", dbType, wrapped)
}
if format != connection.ExplainFormatJSON {
t.Errorf("%s format got=%v want=json", dbType, format)
}
}
}
func TestIsSafeExplainQueryRejectsWriteCTEAndMultipleStatements(t *testing.T) {
tests := []struct {
name string
query string
}{
{
name: "data changing CTE",
query: "WITH moved AS (DELETE FROM audit_logs RETURNING id) SELECT * FROM moved",
},
{
name: "select into",
query: "SELECT * INTO archived_users FROM users",
},
{
name: "trailing write statement",
query: "SELECT 1; DELETE FROM users",
},
{
name: "multiple read statements",
query: "SELECT 1; SELECT 2",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if isSafeExplainQuery("postgres", tt.query) {
t.Fatalf("unsafe query should be rejected: %q", tt.query)
}
})
}
if !isSafeExplainQuery("postgres", "/* inspect */ WITH target AS (SELECT id FROM users) SELECT * FROM target;") {
t.Fatal("single read-only CTE should be accepted")
}
if !isSafeExplainQuery("postgres", "SELECT 1; -- keep this note") {
t.Fatal("trailing comments should not be treated as another statement")
}
}
func TestIsSafeExplainQueryRejectsExecutableMySQLComments(t *testing.T) {
unsafeQueries := []struct {
dbType string
query string
}{
{dbType: "mysql", query: "SELECT 1 /*! ; DELETE FROM users */"},
{dbType: "mariadb", query: "SELECT 1 /*M! ; DELETE FROM users */"},
{dbType: "oceanbase", query: "SELECT /*!50700 SQL_NO_CACHE */ 1"},
{dbType: "diros", query: "SELECT 1 /*! UNION SELECT secret FROM credentials */"},
{dbType: "starrocks", query: "SELECT 1 /*m! UNION SELECT secret FROM credentials */"},
{dbType: "mysql", query: "SELECT 1--1 /*! ; DELETE FROM users */"},
}
for _, tt := range unsafeQueries {
if isSafeExplainQuery(tt.dbType, tt.query) {
t.Fatalf("%s executable comment should be rejected: %q", tt.dbType, tt.query)
}
}
if !isSafeExplainQuery("mysql", "SELECT /*+ MAX_EXECUTION_TIME(1000) */ 1") {
t.Fatal("ordinary optimizer hints should remain supported")
}
if !isSafeExplainQuery("postgres", "SELECT '/*! literal text */'") {
t.Fatal("executable-comment markers inside literals must not be rejected")
}
}
func TestIsSafeExplainQueryUsesDialectAwareStatementSplitting(t *testing.T) {
tests := []struct {
name string
dbType string
query string
}{
{
name: "mysql double dash without following whitespace is arithmetic",
dbType: "mysql",
query: "SELECT 1--1; DELETE FROM users",
},
{
name: "mariadb double dash without following whitespace is arithmetic",
dbType: "mariadb",
query: "SELECT 1--1; UPDATE users SET active = 0",
},
{
name: "postgres hash is not a line comment",
dbType: "postgres",
query: "SELECT 1 # 1; DELETE FROM users",
},
{
name: "mysql dollar-tag lookalike is an identifier",
dbType: "mysql",
query: "SELECT 1 AS a$tag$; DELETE FROM users; SELECT 1 AS a$tag$",
},
{
name: "postgres dollar tag inside identifier is not a quote",
dbType: "postgres",
query: "SELECT 1 AS a$tag$; DELETE FROM users; SELECT 1 AS a$tag$",
},
{
name: "postgres backslash quote ambiguity fails closed",
dbType: "postgres",
query: `SELECT '\' AS x; DELETE FROM users; SELECT '\' AS y`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if isSafeExplainQuery(tt.dbType, tt.query) {
t.Fatalf("expected dialect-aware splitter to reject %q", tt.query)
}
})
}
if !isSafeExplainQuery("mysql", "SELECT 1 -- harmless comment\n") {
t.Fatal("expected a valid MySQL double-dash comment to remain allowed")
}
if !isSafeExplainQuery("clickhouse", "SELECT 1 # harmless comment") {
t.Fatal("expected a ClickHouse hash comment to remain allowed")
}
if isSafeExplainQuery("postgres", "SELECT ';' AS delimiter") {
t.Fatal("expected embedded semicolons to fail closed at the diagnostic security boundary")
}
}
func TestGetDiagnoseTimeoutHonorsConnectionTimeout(t *testing.T) {
if got, want := getDiagnoseTimeout(connection.ConnectionConfig{Timeout: 2}), 2*time.Second; got != want {
t.Fatalf("configured timeout got=%v want=%v", got, want)
}
if got, want := getDiagnoseTimeout(connection.ConnectionConfig{}), defaultExplainStatementTimeout; got != want {
t.Fatalf("default timeout got=%v want=%v", got, want)
}
}
type fakePinnedExplainDatabase struct {
session *fakePinnedExplainSession
queryCalled bool
queryContextCalled bool
}
func (database *fakePinnedExplainDatabase) Connect(connection.ConnectionConfig) error { return nil }
func (database *fakePinnedExplainDatabase) Close() error { return nil }
func (database *fakePinnedExplainDatabase) Ping() error { return nil }
func (database *fakePinnedExplainDatabase) Query(string) ([]map[string]interface{}, []string, error) {
database.queryCalled = true
return []map[string]interface{}{{"detail": "legacy"}}, []string{"detail"}, nil
}
func (database *fakePinnedExplainDatabase) QueryContext(ctx context.Context, _ string) ([]map[string]interface{}, []string, error) {
database.queryContextCalled = true
if err := ctx.Err(); err != nil {
return nil, nil, err
}
return []map[string]interface{}{{"detail": "context"}}, []string{"detail"}, nil
}
func (database *fakePinnedExplainDatabase) Exec(string) (int64, error) { return 0, nil }
func (database *fakePinnedExplainDatabase) GetDatabases() ([]string, error) {
return nil, nil
}
func (database *fakePinnedExplainDatabase) GetTables(string) ([]string, error) {
return nil, nil
}
func (database *fakePinnedExplainDatabase) GetCreateStatement(string, string) (string, error) {
return "", nil
}
func (database *fakePinnedExplainDatabase) GetColumns(string, string) ([]connection.ColumnDefinition, error) {
return nil, nil
}
func (database *fakePinnedExplainDatabase) GetAllColumns(string) ([]connection.ColumnDefinitionWithTable, error) {
return nil, nil
}
func (database *fakePinnedExplainDatabase) GetIndexes(string, string) ([]connection.IndexDefinition, error) {
return nil, nil
}
func (database *fakePinnedExplainDatabase) GetForeignKeys(string, string) ([]connection.ForeignKeyDefinition, error) {
return nil, nil
}
func (database *fakePinnedExplainDatabase) GetTriggers(string, string) ([]connection.TriggerDefinition, error) {
return nil, nil
}
func (database *fakePinnedExplainDatabase) OpenSessionExecer(context.Context) (db.StatementExecer, error) {
return database.session, nil
}
type fakePinnedExplainSession struct {
operations []string
failExecContains string
discarded bool
}
func (session *fakePinnedExplainSession) Exec(query string) (int64, error) {
return session.ExecContext(context.Background(), query)
}
func (session *fakePinnedExplainSession) ExecContext(_ context.Context, query string) (int64, error) {
session.operations = append(session.operations, "exec:"+query)
if session.failExecContains != "" && strings.Contains(query, session.failExecContains) {
return 0, errors.New("forced cleanup failure")
}
return 0, nil
}
func (session *fakePinnedExplainSession) Query(query string) ([]map[string]interface{}, []string, error) {
return session.QueryContext(context.Background(), query)
}
func (session *fakePinnedExplainSession) QueryContext(_ context.Context, query string) ([]map[string]interface{}, []string, error) {
session.operations = append(session.operations, "query:"+query)
if strings.Contains(query, "DBMS_XPLAN.DISPLAY") {
return []map[string]interface{}{{"PLAN_TABLE_OUTPUT": "Plan hash value: 123"}}, []string{"PLAN_TABLE_OUTPUT"}, nil
}
return []map[string]interface{}{{"Microsoft SQL Server 2005 XML Showplan": "<ShowPlanXML/>"}}, []string{"Microsoft SQL Server 2005 XML Showplan"}, nil
}
func (session *fakePinnedExplainSession) Close() error {
session.operations = append(session.operations, "close")
return nil
}
func (session *fakePinnedExplainSession) Discard() error {
session.discarded = true
session.operations = append(session.operations, "discard")
return nil
}
func TestExecutePinnedExplainStatements_SQLServerUsesSeparateBatches(t *testing.T) {
session := &fakePinnedExplainSession{}
database := &fakePinnedExplainDatabase{session: session}
raw, format, err := executePinnedExplainStatements(context.Background(), database, "sqlserver", "SELECT * FROM users", defaultExplainBackendText)
if err != nil {
t.Fatalf("SQL Server pinned explain failed: %v", err)
}
if format != connection.ExplainFormatXML || raw != "<ShowPlanXML/>" {
t.Fatalf("unexpected SQL Server explain result: format=%q raw=%q", format, raw)
}
want := []string{
"exec:SET SHOWPLAN_XML ON",
"query:SELECT * FROM users",
"exec:SET SHOWPLAN_XML OFF",
"close",
}
if strings.Join(session.operations, "\n") != strings.Join(want, "\n") {
t.Fatalf("SQL Server SHOWPLAN must use separate batches on one session:\n got=%q\nwant=%q", session.operations, want)
}
}
func TestExecutePinnedExplainStatements_SQLServerDiscardsSessionWhenCleanupFails(t *testing.T) {
session := &fakePinnedExplainSession{failExecContains: "SHOWPLAN_XML OFF"}
database := &fakePinnedExplainDatabase{session: session}
if _, _, err := executePinnedExplainStatements(context.Background(), database, "sqlserver", "SELECT * FROM users", defaultExplainBackendText); err != nil {
t.Fatalf("plan collection should still succeed before cleanup: %v", err)
}
if !session.discarded {
t.Fatalf("cleanup failure must discard the pinned physical session, operations=%q", session.operations)
}
}
func TestExecutePinnedExplainStatements_OracleReadsAndCleansPlanOnSameSession(t *testing.T) {
session := &fakePinnedExplainSession{}
database := &fakePinnedExplainDatabase{session: session}
raw, format, err := executePinnedExplainStatements(context.Background(), database, "oracle", "SELECT * FROM users", defaultExplainBackendText)
if err != nil {
t.Fatalf("Oracle pinned explain failed: %v", err)
}
if format != connection.ExplainFormatTable || !strings.Contains(raw, "Plan hash value: 123") {
t.Fatalf("unexpected Oracle explain result: format=%q raw=%q", format, raw)
}
if len(session.operations) != 4 ||
!strings.HasPrefix(session.operations[0], "exec:EXPLAIN PLAN SET STATEMENT_ID") ||
!strings.Contains(session.operations[1], "DBMS_XPLAN.DISPLAY") ||
!strings.HasPrefix(session.operations[2], "exec:DELETE FROM plan_table") ||
session.operations[3] != "close" {
t.Fatalf("Oracle explain must execute/read/cleanup on one session, got=%q", session.operations)
}
}
func TestExecuteExplainStatementsSingleResultFallbackHonorsContext(t *testing.T) {
database := &fakePinnedExplainDatabase{}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, _, err := executeExplainStatementsWithText(
ctx,
database,
"sqlite",
"EXPLAIN QUERY PLAN SELECT 1",
nil,
connection.ExplainFormatTable,
defaultExplainBackendText,
)
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected canceled context, got %v", err)
}
if !database.queryContextCalled || database.queryCalled {
t.Fatalf("expected QueryContext fallback only, context=%v legacy=%v", database.queryContextCalled, database.queryCalled)
}
}
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]
}