mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-14 00:42:17 +08:00
- 存储层:JSONL 单连接单文件,5MB 自动滚动,TopN 排序 + SQL 指纹去重 - 执行埋点:DBQueryMulti 用 named return + defer 异步记录,成功后自动写入历史 - 阈值过滤:默认 500ms 以下查询跳过记录,避免历史爆炸 - 查询入口:GetSlowQueries 按 duration/rowsRead/recent 排序,ClearSlowQueries 支持清空 - SQL 指纹:字面量/大小写归一化后 sha256,同模板不同参数视为同一查询 - 测试覆盖:新增 13 个单元测试覆盖存储/滚动/排序/去重/指纹
277 lines
7.6 KiB
Go
277 lines
7.6 KiB
Go
package app
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
"time"
|
||
|
||
"GoNavi-Wails/internal/connection"
|
||
)
|
||
|
||
func TestQueryHistoryStore_AppendAndLoad(t *testing.T) {
|
||
dir := t.TempDir()
|
||
store := newQueryHistoryStore(dir, "test-conn-fp")
|
||
|
||
store.Append(connection.QueryExecutionRecord{
|
||
ID: "r1",
|
||
ConnectionFP: "test-conn-fp",
|
||
SQLFingerprint: "fp-select-1",
|
||
SQLPreview: "SELECT * FROM t",
|
||
DBType: "mysql",
|
||
DurationMs: 1000,
|
||
ExecutedAt: time.Now(),
|
||
})
|
||
store.Append(connection.QueryExecutionRecord{
|
||
ID: "r2",
|
||
ConnectionFP: "test-conn-fp",
|
||
SQLFingerprint: "fp-select-2",
|
||
SQLPreview: "SELECT * FROM u WHERE id = 1",
|
||
DBType: "mysql",
|
||
DurationMs: 2000,
|
||
ExecutedAt: time.Now().Add(time.Second),
|
||
})
|
||
|
||
records, err := store.LoadTopN("duration", 10, false)
|
||
if err != nil {
|
||
t.Fatalf("LoadTopN 失败:%v", err)
|
||
}
|
||
if len(records) != 2 {
|
||
t.Fatalf("应有 2 条记录,got=%d", len(records))
|
||
}
|
||
// duration 排序:r2 (2000ms) 应在前面
|
||
if records[0].ID != "r2" {
|
||
t.Fatalf("按 duration 排序后首条应为 r2,got=%s", records[0].ID)
|
||
}
|
||
}
|
||
|
||
func TestQueryHistoryStore_SkipBelowThreshold(t *testing.T) {
|
||
dir := t.TempDir()
|
||
store := newQueryHistoryStore(dir, "test-conn-fp")
|
||
|
||
// 低于 500ms 阈值应被跳过
|
||
store.Append(connection.QueryExecutionRecord{
|
||
ID: "fast",
|
||
DurationMs: 100,
|
||
SQLPreview: "SELECT 1",
|
||
ExecutedAt: time.Now(),
|
||
})
|
||
records, _ := store.LoadTopN("duration", 10, false)
|
||
if len(records) != 0 {
|
||
t.Fatalf("低于阈值的查询不应被记录,got=%d", len(records))
|
||
}
|
||
}
|
||
|
||
func TestQueryHistoryStore_DedupeBySQLFingerprint(t *testing.T) {
|
||
dir := t.TempDir()
|
||
store := newQueryHistoryStore(dir, "test-conn-fp")
|
||
|
||
base := time.Now()
|
||
// 同一 SQL 指纹,3 次执行(不同时间)
|
||
for i := 0; i < 3; i++ {
|
||
store.Append(connection.QueryExecutionRecord{
|
||
ID: "r" + string(rune('1'+i)),
|
||
SQLFingerprint: "same-fp",
|
||
DurationMs: int64(1000 + i*500),
|
||
ExecutedAt: base.Add(time.Duration(i) * time.Second),
|
||
})
|
||
}
|
||
records, _ := store.LoadTopN("duration", 10, true)
|
||
if len(records) != 1 {
|
||
t.Fatalf("去重后应剩 1 条,got=%d", len(records))
|
||
}
|
||
// 应保留最新一条(ExecutedAt 最大)
|
||
if records[0].ID != "r3" {
|
||
t.Fatalf("去重应保留最新,got ID=%s", records[0].ID)
|
||
}
|
||
}
|
||
|
||
func TestQueryHistoryStore_RotationAtThreshold(t *testing.T) {
|
||
dir := t.TempDir()
|
||
store := newQueryHistoryStore(dir, "test-conn-fp")
|
||
|
||
// 写入大量记录触发 rotate(5MB 阈值)
|
||
for i := 0; i < 50000; i++ {
|
||
store.Append(connection.QueryExecutionRecord{
|
||
ID: "r",
|
||
SQLFingerprint: "fp",
|
||
SQLPreview: "SELECT * FROM some_very_large_table WHERE col = 'long string to fill up space quickly'",
|
||
DurationMs: 1000,
|
||
ExecutedAt: time.Now(),
|
||
})
|
||
}
|
||
|
||
// 主文件存在 + rotate 文件存在
|
||
if _, err := os.Stat(store.filePath); err != nil {
|
||
t.Fatalf("主文件应存在:%v", err)
|
||
}
|
||
if _, err := os.Stat(store.filePath + ".1"); err != nil {
|
||
t.Fatalf("rotate 文件 .1 应存在:%v", err)
|
||
}
|
||
|
||
records, _ := store.LoadTopN("duration", 1000, false)
|
||
if len(records) == 0 {
|
||
t.Fatal("rotate 后应仍能加载历史")
|
||
}
|
||
}
|
||
|
||
func TestQueryHistoryStore_SortByRecent(t *testing.T) {
|
||
dir := t.TempDir()
|
||
store := newQueryHistoryStore(dir, "test-conn-fp")
|
||
|
||
base := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
|
||
times := []time.Time{
|
||
base.Add(2 * time.Second),
|
||
base.Add(0 * time.Second),
|
||
base.Add(1 * time.Second),
|
||
}
|
||
for i, ts := range times {
|
||
store.Append(connection.QueryExecutionRecord{
|
||
ID: "r" + string(rune('1'+i)),
|
||
SQLFingerprint: "fp-" + string(rune('1'+i)),
|
||
DurationMs: 1000,
|
||
ExecutedAt: ts,
|
||
})
|
||
}
|
||
|
||
records, _ := store.LoadTopN("recent", 10, false)
|
||
if len(records) != 3 {
|
||
t.Fatalf("应有 3 条,got=%d", len(records))
|
||
}
|
||
// recent 排序:最新(time[0])应在前面
|
||
if records[0].ID != "r1" {
|
||
t.Fatalf("recent 排序后首条应为 r1(最新),got=%s", records[0].ID)
|
||
}
|
||
}
|
||
|
||
func TestQueryHistoryStore_Clear(t *testing.T) {
|
||
dir := t.TempDir()
|
||
store := newQueryHistoryStore(dir, "test-conn-fp")
|
||
|
||
store.Append(connection.QueryExecutionRecord{
|
||
ID: "r1",
|
||
DurationMs: 1000,
|
||
SQLPreview: "SELECT 1",
|
||
ExecutedAt: time.Now(),
|
||
})
|
||
if err := store.Clear(); err != nil {
|
||
t.Fatalf("Clear 失败:%v", err)
|
||
}
|
||
records, _ := store.LoadTopN("duration", 10, false)
|
||
if len(records) != 0 {
|
||
t.Fatalf("清空后应无记录,got=%d", len(records))
|
||
}
|
||
}
|
||
|
||
func TestQueryHistoryStore_EmptyReturnsEmpty(t *testing.T) {
|
||
dir := t.TempDir()
|
||
store := newQueryHistoryStore(dir, "missing-fp")
|
||
records, err := store.LoadTopN("duration", 10, false)
|
||
if err != nil {
|
||
t.Fatalf("不存在的文件应返回空而非 error:%v", err)
|
||
}
|
||
if len(records) != 0 {
|
||
t.Fatalf("空历史应返回 0 条,got=%d", len(records))
|
||
}
|
||
}
|
||
|
||
func TestBuildSQLFingerprint_NormalizesLiterals(t *testing.T) {
|
||
sql1 := "SELECT * FROM users WHERE id = 1 AND name = 'alice'"
|
||
sql2 := "SELECT * FROM users WHERE id = 999 AND name = 'bob'"
|
||
fp1 := buildSQLFingerprint(sql1)
|
||
fp2 := buildSQLFingerprint(sql2)
|
||
if fp1 != fp2 {
|
||
t.Fatalf("字面量不同应归一化为同一指纹:fp1=%s fp2=%s", fp1, fp2)
|
||
}
|
||
if fp1 == "" {
|
||
t.Fatal("指纹不应为空")
|
||
}
|
||
}
|
||
|
||
func TestBuildSQLFingerprint_DifferentSQLDifferentFingerprint(t *testing.T) {
|
||
sql1 := "SELECT * FROM users WHERE id = 1"
|
||
sql2 := "SELECT * FROM orders WHERE id = 1"
|
||
fp1 := buildSQLFingerprint(sql1)
|
||
fp2 := buildSQLFingerprint(sql2)
|
||
if fp1 == fp2 {
|
||
t.Fatal("不同 SQL 应有不同指纹")
|
||
}
|
||
}
|
||
|
||
func TestBuildSQLFingerprint_CaseInsensitiveKeywords(t *testing.T) {
|
||
sql1 := "SELECT * FROM users"
|
||
sql2 := "select * from users"
|
||
if buildSQLFingerprint(sql1) != buildSQLFingerprint(sql2) {
|
||
t.Fatal("大小写不同的关键字应归一化为同一指纹")
|
||
}
|
||
}
|
||
|
||
func TestBuildQueryPreview_TruncatesLongSQL(t *testing.T) {
|
||
longSQL := ""
|
||
for i := 0; i < 500; i++ {
|
||
longSQL += "a"
|
||
}
|
||
preview := buildQueryPreview(longSQL)
|
||
if len([]rune(preview)) > queryHistoryPreviewRunes {
|
||
t.Fatalf("预览应不超过 %d 字符,got=%d", queryHistoryPreviewRunes, len([]rune(preview)))
|
||
}
|
||
}
|
||
|
||
func TestBuildQueryPreview_FoldsWhitespace(t *testing.T) {
|
||
multiLine := "SELECT *\n FROM\tusers\nWHERE id = 1"
|
||
preview := buildQueryPreview(multiLine)
|
||
if containsNewline(preview) {
|
||
t.Fatalf("预览不应含换行符:%q", preview)
|
||
}
|
||
if !containsStr(preview, "SELECT * FROM users WHERE id = 1") {
|
||
t.Fatalf("预览应折叠空白:%q", preview)
|
||
}
|
||
}
|
||
|
||
func TestSanitizeFingerprintForFilename(t *testing.T) {
|
||
if got := sanitizeFingerprintForFilename("abc123_-"); got != "abc123_-" {
|
||
t.Fatalf("合法字符应保留:got=%s", got)
|
||
}
|
||
if got := sanitizeFingerprintForFilename("a/b\\c:d"); got != "abcd" {
|
||
t.Fatalf("非法字符应被过滤:got=%s", got)
|
||
}
|
||
if got := sanitizeFingerprintForFilename(""); got != "default" {
|
||
t.Fatalf("空指纹应回退为 default,got=%s", got)
|
||
}
|
||
}
|
||
|
||
func TestNewQueryHistoryStore_CreatesDir(t *testing.T) {
|
||
dir := filepath.Join(t.TempDir(), "nested", "deep")
|
||
store := newQueryHistoryStore(dir, "fp")
|
||
store.Append(connection.QueryExecutionRecord{
|
||
ID: "r1",
|
||
DurationMs: 1000,
|
||
ExecutedAt: time.Now(),
|
||
})
|
||
if _, err := os.Stat(store.filePath); err != nil {
|
||
t.Fatalf("Append 应创建嵌套目录并写入:%v", err)
|
||
}
|
||
}
|
||
|
||
func containsNewline(s string) bool {
|
||
for _, ch := range s {
|
||
if ch == '\n' || ch == '\r' {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func containsStr(s, substr string) bool {
|
||
return len(s) >= len(substr) && indexOfSubstr(s, substr) >= 0
|
||
}
|
||
|
||
func indexOfSubstr(s, substr string) int {
|
||
for i := 0; i+len(substr) <= len(s); i++ {
|
||
if s[i:i+len(substr)] == substr {
|
||
return i
|
||
}
|
||
}
|
||
return -1
|
||
}
|