mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-12 16:02:48 +08:00
✨ feat(saved-query): 支持已存查询后端持久化与连接重绑
- 后端新增 saved_queries.json 仓库,保存、导入、删除和重绑统一走 Wails 方法 - 启动时导入旧 lite-db-storage 中的 savedQueries 和连接快照,成功后清理旧字段 - 新增连接指纹匹配,唯一强匹配自动重绑,歧义场景保留为未匹配 - 侧边栏新增未匹配已存查询分组,并支持手动绑定到目标连接 - 前端保存、重命名、删除查询改为后端持久化,并补充浏览器 mock - 补充后端与前端迁移回归测试
This commit is contained in:
64
internal/app/methods_saved_queries.go
Normal file
64
internal/app/methods_saved_queries.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package app
|
||||
|
||||
import "GoNavi-Wails/internal/connection"
|
||||
|
||||
func (a *App) savedQueryRepository() *savedQueryRepository {
|
||||
return newSavedQueryRepository(a.configDir)
|
||||
}
|
||||
|
||||
func (a *App) GetSavedQueries() ([]connection.SavedQuery, error) {
|
||||
savedQueriesMu.Lock()
|
||||
defer savedQueriesMu.Unlock()
|
||||
|
||||
queries, err := a.savedQueryRepository().load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
currentConnections, err := a.savedConnectionRepository().List()
|
||||
if err != nil {
|
||||
return queries, nil
|
||||
}
|
||||
return resolveSavedQueryBindings(queries, currentConnections, nil), nil
|
||||
}
|
||||
|
||||
func (a *App) SaveQuery(input connection.SavedQuery) (connection.SavedQuery, error) {
|
||||
currentConnections, err := a.savedConnectionRepository().List()
|
||||
if err == nil {
|
||||
input = resolveSavedQueryBindings([]connection.SavedQuery{input}, currentConnections, nil)[0]
|
||||
}
|
||||
return a.savedQueryRepository().Save(input)
|
||||
}
|
||||
|
||||
func (a *App) ImportSavedQueries(payload connection.SavedQueryImportPayload) ([]connection.SavedQuery, error) {
|
||||
currentConnections, err := a.savedConnectionRepository().List()
|
||||
if err != nil {
|
||||
currentConnections = nil
|
||||
}
|
||||
return a.savedQueryRepository().Import(payload, currentConnections)
|
||||
}
|
||||
|
||||
func (a *App) DeleteQuery(id string) error {
|
||||
return a.savedQueryRepository().Delete(id)
|
||||
}
|
||||
|
||||
func (a *App) RebindSavedQuery(id string, connectionID string) (connection.SavedQuery, error) {
|
||||
target, err := a.savedConnectionRepository().Find(connectionID)
|
||||
if err != nil {
|
||||
return connection.SavedQuery{}, err
|
||||
}
|
||||
return a.savedQueryRepository().Rebind(id, target)
|
||||
}
|
||||
|
||||
func (a *App) GetUnboundSavedQueries() ([]connection.SavedQuery, error) {
|
||||
queries, err := a.GetSavedQueries()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]connection.SavedQuery, 0)
|
||||
for _, query := range queries {
|
||||
if query.BindingStatus == savedQueryBindingOrphan {
|
||||
result = append(result, query)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
287
internal/app/saved_queries.go
Normal file
287
internal/app/saved_queries.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const savedQueriesFileName = "saved_queries.json"
|
||||
|
||||
var savedQueriesMu sync.Mutex
|
||||
|
||||
type savedQueriesFile struct {
|
||||
Queries []connection.SavedQuery `json:"queries"`
|
||||
}
|
||||
|
||||
type savedQueryRepository struct {
|
||||
configDir string
|
||||
}
|
||||
|
||||
func newSavedQueryRepository(configDir string) *savedQueryRepository {
|
||||
if strings.TrimSpace(configDir) == "" {
|
||||
configDir = resolveAppConfigDir()
|
||||
}
|
||||
return &savedQueryRepository{configDir: configDir}
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) queriesPath() string {
|
||||
return filepath.Join(r.configDir, savedQueriesFileName)
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) load() ([]connection.SavedQuery, error) {
|
||||
data, err := os.ReadFile(r.queriesPath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []connection.SavedQuery{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var file savedQueriesFile
|
||||
if err := json.Unmarshal(data, &file); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if file.Queries == nil {
|
||||
return []connection.SavedQuery{}, nil
|
||||
}
|
||||
return sanitizeSavedQueries(file.Queries), nil
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) saveAll(queries []connection.SavedQuery) error {
|
||||
if err := os.MkdirAll(r.configDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := json.MarshalIndent(savedQueriesFile{Queries: sanitizeSavedQueries(queries)}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeSavedQueriesFileAtomic(r.queriesPath(), payload)
|
||||
}
|
||||
|
||||
func writeSavedQueriesFileAtomic(targetPath string, payload []byte) error {
|
||||
dir := filepath.Dir(targetPath)
|
||||
temp, err := os.CreateTemp(dir, ".saved_queries_*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempPath := temp.Name()
|
||||
cleanup := true
|
||||
defer func() {
|
||||
if cleanup {
|
||||
_ = os.Remove(tempPath)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := temp.Write(payload); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Sync(); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tempPath, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tempPath, targetPath); err != nil {
|
||||
if removeErr := os.Remove(targetPath); removeErr != nil && !os.IsNotExist(removeErr) {
|
||||
return err
|
||||
}
|
||||
if retryErr := os.Rename(tempPath, targetPath); retryErr != nil {
|
||||
return retryErr
|
||||
}
|
||||
}
|
||||
cleanup = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) Save(input connection.SavedQuery) (connection.SavedQuery, error) {
|
||||
savedQueriesMu.Lock()
|
||||
defer savedQueriesMu.Unlock()
|
||||
|
||||
query, ok := sanitizeSavedQuery(input, 0, true)
|
||||
if !ok {
|
||||
return connection.SavedQuery{}, fmt.Errorf("saved query requires sql, connectionId and dbName")
|
||||
}
|
||||
|
||||
queries, err := r.load()
|
||||
if err != nil {
|
||||
return connection.SavedQuery{}, err
|
||||
}
|
||||
|
||||
replaced := false
|
||||
for index, item := range queries {
|
||||
if item.ID == query.ID {
|
||||
queries[index] = query
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
queries = append(queries, query)
|
||||
}
|
||||
if err := r.saveAll(queries); err != nil {
|
||||
return connection.SavedQuery{}, err
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) Import(payload connection.SavedQueryImportPayload, currentConnections []connection.SavedConnectionView) ([]connection.SavedQuery, error) {
|
||||
savedQueriesMu.Lock()
|
||||
defer savedQueriesMu.Unlock()
|
||||
|
||||
existing, err := r.load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
byID := make(map[string]int, len(existing)+len(payload.Queries))
|
||||
for index, item := range existing {
|
||||
byID[item.ID] = index
|
||||
}
|
||||
|
||||
imported := resolveSavedQueryBindings(payload.Queries, currentConnections, payload.LegacyConnections)
|
||||
for index, item := range imported {
|
||||
query, ok := sanitizeSavedQuery(item, index, true)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if existingIndex, found := byID[query.ID]; found {
|
||||
existing[existingIndex] = query
|
||||
continue
|
||||
}
|
||||
byID[query.ID] = len(existing)
|
||||
existing = append(existing, query)
|
||||
}
|
||||
|
||||
if err := r.saveAll(existing); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) Rebind(id string, target connection.SavedConnectionView) (connection.SavedQuery, error) {
|
||||
savedQueriesMu.Lock()
|
||||
defer savedQueriesMu.Unlock()
|
||||
|
||||
targetID := strings.TrimSpace(id)
|
||||
if targetID == "" || strings.TrimSpace(target.ID) == "" {
|
||||
return connection.SavedQuery{}, fmt.Errorf("saved query and target connection are required")
|
||||
}
|
||||
|
||||
queries, err := r.load()
|
||||
if err != nil {
|
||||
return connection.SavedQuery{}, err
|
||||
}
|
||||
|
||||
for index, item := range queries {
|
||||
if item.ID != targetID {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(item.OriginalConnectionID) == "" && strings.TrimSpace(item.ConnectionID) != strings.TrimSpace(target.ID) {
|
||||
item.OriginalConnectionID = item.ConnectionID
|
||||
}
|
||||
item.ConnectionID = target.ID
|
||||
item = applySavedQueryActiveBinding(item, target)
|
||||
query, ok := sanitizeSavedQuery(item, index, false)
|
||||
if !ok {
|
||||
return connection.SavedQuery{}, fmt.Errorf("saved query is invalid: %s", targetID)
|
||||
}
|
||||
queries[index] = query
|
||||
if err := r.saveAll(queries); err != nil {
|
||||
return connection.SavedQuery{}, err
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
return connection.SavedQuery{}, fmt.Errorf("saved query not found: %s", targetID)
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) Delete(id string) error {
|
||||
savedQueriesMu.Lock()
|
||||
defer savedQueriesMu.Unlock()
|
||||
|
||||
targetID := strings.TrimSpace(id)
|
||||
if targetID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
queries, err := r.load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filtered := queries[:0]
|
||||
for _, item := range queries {
|
||||
if item.ID != targetID {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
return r.saveAll(filtered)
|
||||
}
|
||||
|
||||
func sanitizeSavedQueries(items []connection.SavedQuery) []connection.SavedQuery {
|
||||
result := make([]connection.SavedQuery, 0, len(items))
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
for index, item := range items {
|
||||
query, ok := sanitizeSavedQuery(item, index, false)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[query.ID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[query.ID] = struct{}{}
|
||||
result = append(result, query)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func sanitizeSavedQuery(input connection.SavedQuery, index int, allowGeneratedID bool) (connection.SavedQuery, bool) {
|
||||
id := strings.TrimSpace(input.ID)
|
||||
if id == "" && allowGeneratedID {
|
||||
id = "saved-" + uuid.NewString()
|
||||
}
|
||||
if id == "" {
|
||||
return connection.SavedQuery{}, false
|
||||
}
|
||||
|
||||
sqlText := input.SQL
|
||||
connectionID := strings.TrimSpace(input.ConnectionID)
|
||||
dbName := strings.TrimSpace(input.DBName)
|
||||
if strings.TrimSpace(sqlText) == "" || connectionID == "" || dbName == "" {
|
||||
return connection.SavedQuery{}, false
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(input.Name)
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("查询-%d", index+1)
|
||||
}
|
||||
createdAt := input.CreatedAt
|
||||
if createdAt <= 0 {
|
||||
createdAt = time.Now().UnixMilli()
|
||||
}
|
||||
|
||||
return connection.SavedQuery{
|
||||
ID: id,
|
||||
Name: name,
|
||||
SQL: sqlText,
|
||||
ConnectionID: connectionID,
|
||||
DBName: dbName,
|
||||
CreatedAt: createdAt,
|
||||
ConnectionFingerprint: strings.TrimSpace(input.ConnectionFingerprint),
|
||||
FingerprintVersion: strings.TrimSpace(input.FingerprintVersion),
|
||||
BindingStatus: strings.TrimSpace(input.BindingStatus),
|
||||
OriginalConnectionID: strings.TrimSpace(input.OriginalConnectionID),
|
||||
}, true
|
||||
}
|
||||
416
internal/app/saved_queries_test.go
Normal file
416
internal/app/saved_queries_test.go
Normal file
@@ -0,0 +1,416 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/secretstore"
|
||||
)
|
||||
|
||||
func TestSavedQueryRepositorySaveUpdateAndDelete(t *testing.T) {
|
||||
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
|
||||
app.configDir = t.TempDir()
|
||||
|
||||
query := connection.SavedQuery{
|
||||
ID: "saved-1",
|
||||
Name: "Orders",
|
||||
SQL: "select * from orders",
|
||||
ConnectionID: "conn-1",
|
||||
DBName: "app",
|
||||
CreatedAt: 100,
|
||||
}
|
||||
if _, err := app.SaveQuery(query); err != nil {
|
||||
t.Fatalf("SaveQuery returned error: %v", err)
|
||||
}
|
||||
|
||||
saved, err := app.GetSavedQueries()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSavedQueries returned error: %v", err)
|
||||
}
|
||||
if len(saved) != 1 || saved[0].Name != "Orders" {
|
||||
t.Fatalf("expected saved query to be persisted, got %#v", saved)
|
||||
}
|
||||
|
||||
query.Name = "Orders Updated"
|
||||
query.SQL = "select id from orders"
|
||||
if _, err := app.SaveQuery(query); err != nil {
|
||||
t.Fatalf("SaveQuery update returned error: %v", err)
|
||||
}
|
||||
saved, err = app.GetSavedQueries()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSavedQueries after update returned error: %v", err)
|
||||
}
|
||||
if len(saved) != 1 || saved[0].Name != "Orders Updated" || saved[0].SQL != "select id from orders" {
|
||||
t.Fatalf("expected saved query to be updated in place, got %#v", saved)
|
||||
}
|
||||
|
||||
if err := app.DeleteQuery("saved-1"); err != nil {
|
||||
t.Fatalf("DeleteQuery returned error: %v", err)
|
||||
}
|
||||
saved, err = app.GetSavedQueries()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSavedQueries after delete returned error: %v", err)
|
||||
}
|
||||
if len(saved) != 0 {
|
||||
t.Fatalf("expected saved query to be deleted, got %#v", saved)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(app.configDir, savedQueriesFileName)); err != nil {
|
||||
t.Fatalf("expected saved query file to remain readable after delete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSavedQueriesUpsertsAndSkipsInvalidItems(t *testing.T) {
|
||||
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
|
||||
app.configDir = t.TempDir()
|
||||
|
||||
if _, err := app.SaveQuery(connection.SavedQuery{
|
||||
ID: "saved-1",
|
||||
Name: "Old",
|
||||
SQL: "select 1",
|
||||
ConnectionID: "conn-1",
|
||||
DBName: "app",
|
||||
CreatedAt: 100,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed SaveQuery returned error: %v", err)
|
||||
}
|
||||
|
||||
imported, err := app.ImportSavedQueries(connection.SavedQueryImportPayload{
|
||||
Queries: []connection.SavedQuery{
|
||||
{
|
||||
ID: "saved-1",
|
||||
Name: "New",
|
||||
SQL: "select 2",
|
||||
ConnectionID: "conn-1",
|
||||
DBName: "app",
|
||||
CreatedAt: 100,
|
||||
},
|
||||
{
|
||||
ID: "saved-2",
|
||||
Name: "Second",
|
||||
SQL: "select 3",
|
||||
ConnectionID: "conn-2",
|
||||
DBName: "analytics",
|
||||
CreatedAt: 200,
|
||||
},
|
||||
{
|
||||
ID: "invalid",
|
||||
Name: "Missing SQL",
|
||||
ConnectionID: "conn-3",
|
||||
DBName: "app",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ImportSavedQueries returned error: %v", err)
|
||||
}
|
||||
if len(imported) != 2 {
|
||||
t.Fatalf("expected 2 valid saved queries after import, got %#v", imported)
|
||||
}
|
||||
if imported[0].Name != "New" || imported[0].SQL != "select 2" {
|
||||
t.Fatalf("expected import to upsert saved-1, got %#v", imported[0])
|
||||
}
|
||||
if imported[1].ID != "saved-2" {
|
||||
t.Fatalf("expected import to append saved-2, got %#v", imported[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedQueryRepositoryPreservesSQLWhitespace(t *testing.T) {
|
||||
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
|
||||
app.configDir = t.TempDir()
|
||||
|
||||
query := connection.SavedQuery{
|
||||
ID: "saved-whitespace",
|
||||
Name: "Formatted",
|
||||
SQL: "\n select * from orders;\n",
|
||||
ConnectionID: "conn-1",
|
||||
DBName: "app",
|
||||
CreatedAt: 100,
|
||||
}
|
||||
persisted, err := app.SaveQuery(query)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveQuery returned error: %v", err)
|
||||
}
|
||||
if persisted.SQL != query.SQL {
|
||||
t.Fatalf("expected saved SQL whitespace to be preserved, got %q", persisted.SQL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedQueryRepositorySerializesConcurrentWrites(t *testing.T) {
|
||||
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
|
||||
app.configDir = t.TempDir()
|
||||
|
||||
const count = 24
|
||||
var wg sync.WaitGroup
|
||||
errCh := make(chan error, count)
|
||||
for index := 0; index < count; index++ {
|
||||
index := index
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, err := app.SaveQuery(connection.SavedQuery{
|
||||
ID: fmt.Sprintf("saved-%02d", index),
|
||||
Name: fmt.Sprintf("Query %02d", index),
|
||||
SQL: fmt.Sprintf("select %d", index),
|
||||
ConnectionID: "conn-1",
|
||||
DBName: "app",
|
||||
CreatedAt: int64(index + 1),
|
||||
})
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
|
||||
for err := range errCh {
|
||||
t.Fatalf("SaveQuery returned error during concurrent write: %v", err)
|
||||
}
|
||||
|
||||
saved, err := app.GetSavedQueries()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSavedQueries returned error: %v", err)
|
||||
}
|
||||
if len(saved) != count {
|
||||
t.Fatalf("expected %d saved queries after concurrent writes, got %d: %#v", count, len(saved), saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSavedQueriesRebindsLegacyConnectionByUniqueFingerprint(t *testing.T) {
|
||||
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
|
||||
app.configDir = t.TempDir()
|
||||
|
||||
current, err := app.SaveConnection(connection.SavedConnectionInput{
|
||||
ID: "conn-current",
|
||||
Name: "Current",
|
||||
Config: connection.ConnectionConfig{
|
||||
ID: "conn-current",
|
||||
Type: "postgres",
|
||||
Host: "db.local",
|
||||
Port: 5432,
|
||||
User: "app",
|
||||
Database: "app",
|
||||
Password: "new-secret",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConnection returned error: %v", err)
|
||||
}
|
||||
|
||||
imported, err := app.ImportSavedQueries(connection.SavedQueryImportPayload{
|
||||
Queries: []connection.SavedQuery{
|
||||
{
|
||||
ID: "saved-legacy",
|
||||
Name: "Legacy",
|
||||
SQL: "select 1",
|
||||
ConnectionID: "conn-old",
|
||||
DBName: "app",
|
||||
CreatedAt: 100,
|
||||
},
|
||||
},
|
||||
LegacyConnections: []connection.SavedConnectionInput{
|
||||
{
|
||||
ID: "conn-old",
|
||||
Name: "Old",
|
||||
Config: connection.ConnectionConfig{
|
||||
ID: "conn-old",
|
||||
Type: "postgres",
|
||||
Host: "DB.LOCAL",
|
||||
Port: 5432,
|
||||
User: "app",
|
||||
Database: "app",
|
||||
Password: "old-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ImportSavedQueries returned error: %v", err)
|
||||
}
|
||||
if len(imported) != 1 {
|
||||
t.Fatalf("expected 1 imported query, got %#v", imported)
|
||||
}
|
||||
query := imported[0]
|
||||
if query.ConnectionID != current.ID {
|
||||
t.Fatalf("expected query to rebind to %s, got %#v", current.ID, query)
|
||||
}
|
||||
if query.OriginalConnectionID != "conn-old" {
|
||||
t.Fatalf("expected original connection id to be preserved, got %#v", query)
|
||||
}
|
||||
if query.BindingStatus != savedQueryBindingRebound {
|
||||
t.Fatalf("expected rebound binding status, got %#v", query)
|
||||
}
|
||||
if query.ConnectionFingerprint == "" || query.FingerprintVersion != savedQueryFingerprintVersion {
|
||||
t.Fatalf("expected fingerprint metadata, got %#v", query)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSavedQueriesDoesNotRebindAmbiguousFingerprint(t *testing.T) {
|
||||
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
|
||||
app.configDir = t.TempDir()
|
||||
|
||||
for _, id := range []string{"conn-a", "conn-b"} {
|
||||
if _, err := app.SaveConnection(connection.SavedConnectionInput{
|
||||
ID: id,
|
||||
Name: id,
|
||||
Config: connection.ConnectionConfig{
|
||||
ID: id,
|
||||
Type: "mysql",
|
||||
Host: "db.local",
|
||||
Port: 3306,
|
||||
User: "app",
|
||||
Database: "app",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveConnection %s returned error: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
imported, err := app.ImportSavedQueries(connection.SavedQueryImportPayload{
|
||||
Queries: []connection.SavedQuery{
|
||||
{
|
||||
ID: "saved-ambiguous",
|
||||
Name: "Ambiguous",
|
||||
SQL: "select 1",
|
||||
ConnectionID: "conn-old",
|
||||
DBName: "app",
|
||||
CreatedAt: 100,
|
||||
},
|
||||
},
|
||||
LegacyConnections: []connection.SavedConnectionInput{
|
||||
{
|
||||
ID: "conn-old",
|
||||
Name: "Old",
|
||||
Config: connection.ConnectionConfig{
|
||||
ID: "conn-old",
|
||||
Type: "mysql",
|
||||
Host: "db.local",
|
||||
Port: 3306,
|
||||
User: "app",
|
||||
Database: "app",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ImportSavedQueries returned error: %v", err)
|
||||
}
|
||||
if len(imported) != 1 {
|
||||
t.Fatalf("expected 1 imported query, got %#v", imported)
|
||||
}
|
||||
query := imported[0]
|
||||
if query.ConnectionID != "conn-old" {
|
||||
t.Fatalf("expected ambiguous query to keep old connection id, got %#v", query)
|
||||
}
|
||||
if query.BindingStatus != savedQueryBindingOrphan {
|
||||
t.Fatalf("expected orphan binding status, got %#v", query)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSavedQueriesRebindsStoredFingerprint(t *testing.T) {
|
||||
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
|
||||
app.configDir = t.TempDir()
|
||||
|
||||
current, err := app.SaveConnection(connection.SavedConnectionInput{
|
||||
ID: "conn-current",
|
||||
Name: "Current",
|
||||
Config: connection.ConnectionConfig{
|
||||
ID: "conn-current",
|
||||
Type: "postgres",
|
||||
Host: "db.local",
|
||||
Port: 5432,
|
||||
User: "app",
|
||||
Database: "app",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConnection returned error: %v", err)
|
||||
}
|
||||
fingerprint, ok := buildSavedConnectionFingerprint(current)
|
||||
if !ok {
|
||||
t.Fatal("expected current connection fingerprint to be available")
|
||||
}
|
||||
if err := app.savedQueryRepository().saveAll([]connection.SavedQuery{
|
||||
{
|
||||
ID: "saved-stored-fingerprint",
|
||||
Name: "Stored Fingerprint",
|
||||
SQL: "select 1",
|
||||
ConnectionID: "conn-old",
|
||||
DBName: "app",
|
||||
CreatedAt: 100,
|
||||
ConnectionFingerprint: fingerprint,
|
||||
FingerprintVersion: savedQueryFingerprintVersion,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("saveAll returned error: %v", err)
|
||||
}
|
||||
|
||||
queries, err := app.GetSavedQueries()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSavedQueries returned error: %v", err)
|
||||
}
|
||||
if len(queries) != 1 {
|
||||
t.Fatalf("expected 1 query, got %#v", queries)
|
||||
}
|
||||
if queries[0].ConnectionID != current.ID {
|
||||
t.Fatalf("expected query to rebind to %s, got %#v", current.ID, queries[0])
|
||||
}
|
||||
if queries[0].OriginalConnectionID != "conn-old" {
|
||||
t.Fatalf("expected original connection id to be preserved, got %#v", queries[0])
|
||||
}
|
||||
if queries[0].BindingStatus != savedQueryBindingRebound {
|
||||
t.Fatalf("expected rebound binding status, got %#v", queries[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebindSavedQueryUpdatesConnectionAndFingerprint(t *testing.T) {
|
||||
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
|
||||
app.configDir = t.TempDir()
|
||||
|
||||
target, err := app.SaveConnection(connection.SavedConnectionInput{
|
||||
ID: "conn-target",
|
||||
Name: "Target",
|
||||
Config: connection.ConnectionConfig{
|
||||
ID: "conn-target",
|
||||
Type: "postgres",
|
||||
Host: "db.local",
|
||||
Port: 5432,
|
||||
User: "app",
|
||||
Database: "app",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConnection returned error: %v", err)
|
||||
}
|
||||
if _, err := app.SaveQuery(connection.SavedQuery{
|
||||
ID: "saved-orphan",
|
||||
Name: "Orphan",
|
||||
SQL: "select 1",
|
||||
ConnectionID: "conn-old",
|
||||
DBName: "app",
|
||||
CreatedAt: 100,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveQuery returned error: %v", err)
|
||||
}
|
||||
|
||||
rebound, err := app.RebindSavedQuery("saved-orphan", target.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("RebindSavedQuery returned error: %v", err)
|
||||
}
|
||||
if rebound.ConnectionID != target.ID {
|
||||
t.Fatalf("expected query to bind to target connection, got %#v", rebound)
|
||||
}
|
||||
if rebound.OriginalConnectionID != "conn-old" {
|
||||
t.Fatalf("expected original connection id to be retained, got %#v", rebound)
|
||||
}
|
||||
if rebound.BindingStatus != savedQueryBindingActive {
|
||||
t.Fatalf("expected active binding status, got %#v", rebound)
|
||||
}
|
||||
if rebound.ConnectionFingerprint == "" {
|
||||
t.Fatalf("expected fingerprint to be stored, got %#v", rebound)
|
||||
}
|
||||
}
|
||||
271
internal/app/saved_query_fingerprint.go
Normal file
271
internal/app/saved_query_fingerprint.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
const (
|
||||
savedQueryFingerprintVersion = "connection-v1"
|
||||
savedQueryBindingActive = "active"
|
||||
savedQueryBindingRebound = "rebound"
|
||||
savedQueryBindingOrphan = "orphan"
|
||||
)
|
||||
|
||||
type savedQueryConnectionFingerprintPayload struct {
|
||||
Version string `json:"version"`
|
||||
Type string `json:"type"`
|
||||
Driver string `json:"driver,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Hosts []string `json:"hosts,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
Database string `json:"database,omitempty"`
|
||||
UseSSL bool `json:"useSSL,omitempty"`
|
||||
SSLMode string `json:"sslMode,omitempty"`
|
||||
UseSSH bool `json:"useSSH,omitempty"`
|
||||
SSHHost string `json:"sshHost,omitempty"`
|
||||
SSHPort int `json:"sshPort,omitempty"`
|
||||
SSHUser string `json:"sshUser,omitempty"`
|
||||
UseHTTPTunnel bool `json:"useHttpTunnel,omitempty"`
|
||||
HTTPTunnelHost string `json:"httpTunnelHost,omitempty"`
|
||||
HTTPTunnelPort int `json:"httpTunnelPort,omitempty"`
|
||||
HTTPTunnelUser string `json:"httpTunnelUser,omitempty"`
|
||||
ClickHouseProtocol string `json:"clickHouseProtocol,omitempty"`
|
||||
OceanBaseProtocol string `json:"oceanBaseProtocol,omitempty"`
|
||||
Topology string `json:"topology,omitempty"`
|
||||
ReplicaSet string `json:"replicaSet,omitempty"`
|
||||
AuthSource string `json:"authSource,omitempty"`
|
||||
ReadPreference string `json:"readPreference,omitempty"`
|
||||
MongoSRV bool `json:"mongoSrv,omitempty"`
|
||||
MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"`
|
||||
RedisMaster string `json:"redisMaster,omitempty"`
|
||||
RedisUser string `json:"redisUser,omitempty"`
|
||||
MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"`
|
||||
MongoReplicaUser string `json:"mongoReplicaUser,omitempty"`
|
||||
}
|
||||
|
||||
func normalizeFingerprintText(value string) string {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func normalizeFingerprintLower(value string) string {
|
||||
return strings.ToLower(normalizeFingerprintText(value))
|
||||
}
|
||||
|
||||
func normalizeFingerprintHosts(values []string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
host := normalizeFingerprintLower(value)
|
||||
if host == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[host]; exists {
|
||||
continue
|
||||
}
|
||||
seen[host] = struct{}{}
|
||||
result = append(result, host)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func buildConnectionFingerprint(config connection.ConnectionConfig) (string, bool) {
|
||||
payload := savedQueryConnectionFingerprintPayload{
|
||||
Version: savedQueryFingerprintVersion,
|
||||
Type: normalizeFingerprintLower(config.Type),
|
||||
Driver: normalizeFingerprintLower(config.Driver),
|
||||
Host: normalizeFingerprintLower(config.Host),
|
||||
Port: config.Port,
|
||||
Hosts: normalizeFingerprintHosts(config.Hosts),
|
||||
User: normalizeFingerprintText(config.User),
|
||||
Database: normalizeFingerprintText(config.Database),
|
||||
UseSSL: config.UseSSL,
|
||||
SSLMode: normalizeFingerprintLower(config.SSLMode),
|
||||
UseSSH: config.UseSSH,
|
||||
SSHHost: normalizeFingerprintLower(config.SSH.Host),
|
||||
SSHPort: config.SSH.Port,
|
||||
SSHUser: normalizeFingerprintText(config.SSH.User),
|
||||
UseHTTPTunnel: config.UseHTTPTunnel,
|
||||
HTTPTunnelHost: normalizeFingerprintLower(config.HTTPTunnel.Host),
|
||||
HTTPTunnelPort: config.HTTPTunnel.Port,
|
||||
HTTPTunnelUser: normalizeFingerprintText(config.HTTPTunnel.User),
|
||||
ClickHouseProtocol: normalizeFingerprintLower(config.ClickHouseProtocol),
|
||||
OceanBaseProtocol: normalizeFingerprintLower(config.OceanBaseProtocol),
|
||||
Topology: normalizeFingerprintLower(config.Topology),
|
||||
ReplicaSet: normalizeFingerprintText(config.ReplicaSet),
|
||||
AuthSource: normalizeFingerprintText(config.AuthSource),
|
||||
ReadPreference: normalizeFingerprintLower(config.ReadPreference),
|
||||
MongoSRV: config.MongoSRV,
|
||||
MongoAuthMechanism: normalizeFingerprintLower(config.MongoAuthMechanism),
|
||||
RedisMaster: normalizeFingerprintText(config.RedisSentinelMaster),
|
||||
RedisUser: normalizeFingerprintText(config.RedisSentinelUser),
|
||||
MySQLReplicaUser: normalizeFingerprintText(config.MySQLReplicaUser),
|
||||
MongoReplicaUser: normalizeFingerprintText(config.MongoReplicaUser),
|
||||
}
|
||||
if payload.Type == "" {
|
||||
return "", false
|
||||
}
|
||||
if payload.Host == "" && len(payload.Hosts) == 0 && payload.Database == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
return savedQueryFingerprintVersion + ":" + hex.EncodeToString(sum[:]), true
|
||||
}
|
||||
|
||||
func buildSavedConnectionFingerprint(view connection.SavedConnectionView) (string, bool) {
|
||||
return buildConnectionFingerprint(view.Config)
|
||||
}
|
||||
|
||||
func buildLegacyConnectionFingerprint(input connection.SavedConnectionInput) (string, bool) {
|
||||
config := input.Config
|
||||
if strings.TrimSpace(config.ID) == "" {
|
||||
config.ID = strings.TrimSpace(input.ID)
|
||||
}
|
||||
return buildConnectionFingerprint(config)
|
||||
}
|
||||
|
||||
func indexSavedConnectionsByID(connections []connection.SavedConnectionView) map[string]connection.SavedConnectionView {
|
||||
result := make(map[string]connection.SavedConnectionView, len(connections))
|
||||
for _, item := range connections {
|
||||
id := strings.TrimSpace(item.ID)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
result[id] = item
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func indexLegacyConnectionsByID(connections []connection.SavedConnectionInput) map[string]connection.SavedConnectionInput {
|
||||
result := make(map[string]connection.SavedConnectionInput, len(connections))
|
||||
for _, item := range connections {
|
||||
id := strings.TrimSpace(item.ID)
|
||||
if id == "" {
|
||||
id = strings.TrimSpace(item.Config.ID)
|
||||
}
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
result[id] = item
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func indexSavedConnectionsByFingerprint(connections []connection.SavedConnectionView) map[string][]connection.SavedConnectionView {
|
||||
result := make(map[string][]connection.SavedConnectionView)
|
||||
for _, item := range connections {
|
||||
fingerprint, ok := buildSavedConnectionFingerprint(item)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
result[fingerprint] = append(result[fingerprint], item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func applySavedQueryActiveBinding(query connection.SavedQuery, current connection.SavedConnectionView) connection.SavedQuery {
|
||||
if fingerprint, ok := buildSavedConnectionFingerprint(current); ok {
|
||||
query.ConnectionFingerprint = fingerprint
|
||||
query.FingerprintVersion = savedQueryFingerprintVersion
|
||||
}
|
||||
query.BindingStatus = savedQueryBindingActive
|
||||
if strings.TrimSpace(query.OriginalConnectionID) == strings.TrimSpace(query.ConnectionID) {
|
||||
query.OriginalConnectionID = ""
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func applySavedQueryOrphanBinding(query connection.SavedQuery, originalConnectionID string, fingerprint string) connection.SavedQuery {
|
||||
if strings.TrimSpace(query.OriginalConnectionID) == "" {
|
||||
query.OriginalConnectionID = strings.TrimSpace(originalConnectionID)
|
||||
}
|
||||
if strings.TrimSpace(fingerprint) != "" {
|
||||
query.ConnectionFingerprint = fingerprint
|
||||
query.FingerprintVersion = savedQueryFingerprintVersion
|
||||
}
|
||||
query.BindingStatus = savedQueryBindingOrphan
|
||||
return query
|
||||
}
|
||||
|
||||
func rebindSavedQueryByFingerprint(
|
||||
query connection.SavedQuery,
|
||||
connectionID string,
|
||||
fingerprint string,
|
||||
currentByFingerprint map[string][]connection.SavedConnectionView,
|
||||
) (connection.SavedQuery, bool) {
|
||||
fingerprint = strings.TrimSpace(fingerprint)
|
||||
if fingerprint == "" {
|
||||
return query, false
|
||||
}
|
||||
|
||||
matches := currentByFingerprint[fingerprint]
|
||||
if len(matches) != 1 {
|
||||
return applySavedQueryOrphanBinding(query, connectionID, fingerprint), true
|
||||
}
|
||||
|
||||
if strings.TrimSpace(query.OriginalConnectionID) == "" {
|
||||
query.OriginalConnectionID = connectionID
|
||||
}
|
||||
query.ConnectionID = matches[0].ID
|
||||
query.ConnectionFingerprint = fingerprint
|
||||
query.FingerprintVersion = savedQueryFingerprintVersion
|
||||
query.BindingStatus = savedQueryBindingRebound
|
||||
return query, true
|
||||
}
|
||||
|
||||
func resolveSavedQueryBinding(
|
||||
query connection.SavedQuery,
|
||||
currentByID map[string]connection.SavedConnectionView,
|
||||
currentByFingerprint map[string][]connection.SavedConnectionView,
|
||||
legacyByID map[string]connection.SavedConnectionInput,
|
||||
) connection.SavedQuery {
|
||||
connectionID := strings.TrimSpace(query.ConnectionID)
|
||||
if current, found := currentByID[connectionID]; found {
|
||||
return applySavedQueryActiveBinding(query, current)
|
||||
}
|
||||
|
||||
if rebound, resolved := rebindSavedQueryByFingerprint(query, connectionID, query.ConnectionFingerprint, currentByFingerprint); resolved {
|
||||
return rebound
|
||||
}
|
||||
|
||||
legacy, found := legacyByID[connectionID]
|
||||
if !found {
|
||||
return applySavedQueryOrphanBinding(query, connectionID, query.ConnectionFingerprint)
|
||||
}
|
||||
|
||||
fingerprint, ok := buildLegacyConnectionFingerprint(legacy)
|
||||
if !ok {
|
||||
return applySavedQueryOrphanBinding(query, connectionID, query.ConnectionFingerprint)
|
||||
}
|
||||
|
||||
rebound, _ := rebindSavedQueryByFingerprint(query, connectionID, fingerprint, currentByFingerprint)
|
||||
return rebound
|
||||
}
|
||||
|
||||
func resolveSavedQueryBindings(
|
||||
queries []connection.SavedQuery,
|
||||
currentConnections []connection.SavedConnectionView,
|
||||
legacyConnections []connection.SavedConnectionInput,
|
||||
) []connection.SavedQuery {
|
||||
currentByID := indexSavedConnectionsByID(currentConnections)
|
||||
currentByFingerprint := indexSavedConnectionsByFingerprint(currentConnections)
|
||||
legacyByID := indexLegacyConnectionsByID(legacyConnections)
|
||||
|
||||
result := make([]connection.SavedQuery, 0, len(queries))
|
||||
for _, query := range queries {
|
||||
result = append(result, resolveSavedQueryBinding(query, currentByID, currentByFingerprint, legacyByID))
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user