feat(saved-query): 支持已存查询后端持久化与连接重绑

- 后端新增 saved_queries.json 仓库,保存、导入、删除和重绑统一走 Wails 方法
- 启动时导入旧 lite-db-storage 中的 savedQueries 和连接快照,成功后清理旧字段
- 新增连接指纹匹配,唯一强匹配自动重绑,歧义场景保留为未匹配
- 侧边栏新增未匹配已存查询分组,并支持手动绑定到目标连接
- 前端保存、重命名、删除查询改为后端持久化,并补充浏览器 mock
- 补充后端与前端迁移回归测试
This commit is contained in:
Syngnat
2026-06-15 12:20:57 +08:00
parent 0b9f0448c8
commit eca9601ab0
20 changed files with 1870 additions and 66 deletions

View 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
}

View 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
}

View 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)
}
}

View 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
}