mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-22 00:42:47 +08:00
✨ feat(saved-query): 支持独立 SQL 文件与自定义存储目录
- 将已存查询内容从 JSON 拆分为独立 .sql 文件,并采用可读名称和数字后缀处理冲突 - 自动迁移旧版内联 SQL 与摘要文件名,保留外部编辑并在失败时回滚 - 支持选择、迁移、打开及恢复默认存储目录,补充数据根切换串行保护 - 新增已存查询右键在文件夹中打开,并同步处理查询重命名后的文件路径 - 补齐 Wails、浏览器模拟、六语言文案及后端和前端回归测试
This commit is contained in:
@@ -424,12 +424,24 @@ func dataRootInfoPayload(activeRoot string) map[string]interface{} {
|
||||
if currentRoot == "" {
|
||||
currentRoot = appdata.MustResolveActiveRoot()
|
||||
}
|
||||
defaultSavedQueryDirectory := appdata.DefaultSavedQueryDirectory(currentRoot)
|
||||
savedQueryDirectory, err := appdata.ResolveSavedQueryDirectory(currentRoot)
|
||||
if err != nil || strings.TrimSpace(savedQueryDirectory) == "" {
|
||||
savedQueryDirectory = defaultSavedQueryDirectory
|
||||
}
|
||||
savedQueryDirectorySource := "custom"
|
||||
if directoriesEqual(savedQueryDirectory, defaultSavedQueryDirectory) {
|
||||
savedQueryDirectorySource = "default"
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"path": currentRoot,
|
||||
"defaultPath": defaultRoot,
|
||||
"driverPath": appdata.DriverRoot(currentRoot),
|
||||
"isDefaultPath": filepath.Clean(currentRoot) == filepath.Clean(defaultRoot),
|
||||
"bootstrapPath": appdata.BootstrapPath(),
|
||||
"path": currentRoot,
|
||||
"defaultPath": defaultRoot,
|
||||
"driverPath": appdata.DriverRoot(currentRoot),
|
||||
"isDefaultPath": filepath.Clean(currentRoot) == filepath.Clean(defaultRoot),
|
||||
"bootstrapPath": appdata.BootstrapPath(),
|
||||
"savedQueryDirectory": savedQueryDirectory,
|
||||
"defaultSavedQueryDirectory": defaultSavedQueryDirectory,
|
||||
"savedQueryDirectorySource": savedQueryDirectorySource,
|
||||
}
|
||||
for key, value := range logDirectoryInfoPayload() {
|
||||
payload[key] = value
|
||||
|
||||
@@ -68,6 +68,10 @@ func (a *App) DeleteQuery(id string) error {
|
||||
return a.savedQueryRepository().Delete(id)
|
||||
}
|
||||
|
||||
func (a *App) RenameSavedQuery(id string, name string) (connection.SavedQuery, error) {
|
||||
return a.savedQueryRepository().Rename(id, name)
|
||||
}
|
||||
|
||||
// SaveSavedQueryGroup creates or fully replaces a saved SQL group. Callers
|
||||
// must submit the current parent, query IDs, and child order; query IDs in the
|
||||
// submitted group become owned by that direct group only.
|
||||
|
||||
303
internal/app/methods_saved_query_directory.go
Normal file
303
internal/app/methods_saved_query_directory.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
stdRuntime "runtime"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/appdata"
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
func (a *App) SelectSavedQueryDirectory(currentDirectory string) connection.QueryResult {
|
||||
if a.webRuntime {
|
||||
return connection.QueryResult{Success: false, Message: a.appText("app.data_root.saved_query_directory.backend.error.desktop_only", nil)}
|
||||
}
|
||||
|
||||
defaultDirectory := strings.TrimSpace(currentDirectory)
|
||||
if defaultDirectory == "" {
|
||||
resolved, err := appdata.ResolveSavedQueryDirectory(a.configDir)
|
||||
if err == nil {
|
||||
defaultDirectory = resolved
|
||||
}
|
||||
}
|
||||
if defaultDirectory == "" {
|
||||
defaultDirectory = appdata.DefaultSavedQueryDirectory(a.configDir)
|
||||
}
|
||||
if !filepath.IsAbs(defaultDirectory) {
|
||||
if abs, err := filepath.Abs(defaultDirectory); err == nil {
|
||||
defaultDirectory = abs
|
||||
}
|
||||
}
|
||||
|
||||
selection, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
|
||||
Title: a.appText("app.data_root.saved_query_directory.backend.dialog.select_directory", nil),
|
||||
DefaultDirectory: defaultDirectory,
|
||||
CanCreateDirectories: true,
|
||||
})
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if strings.TrimSpace(selection) == "" {
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
Data: map[string]interface{}{"cancelled": true},
|
||||
}
|
||||
}
|
||||
resolved, err := filepath.Abs(strings.TrimSpace(selection))
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
return connection.QueryResult{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"directory": filepath.Clean(resolved),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) ApplySavedQueryDirectory(directory string) connection.QueryResult {
|
||||
a.dataRootApplyMu.Lock()
|
||||
defer a.dataRootApplyMu.Unlock()
|
||||
|
||||
if a.webRuntime {
|
||||
return connection.QueryResult{Success: false, Message: a.appText("app.data_root.saved_query_directory.backend.error.desktop_only", nil)}
|
||||
}
|
||||
|
||||
defaultDirectory := appdata.DefaultSavedQueryDirectory(a.configDir)
|
||||
target := strings.TrimSpace(directory)
|
||||
if target == "" {
|
||||
target = defaultDirectory
|
||||
}
|
||||
abs, err := filepath.Abs(target)
|
||||
if err != nil {
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
Message: a.appText("app.data_root.saved_query_directory.backend.error.save_failed", map[string]any{
|
||||
"detail": err.Error(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
target = filepath.Clean(abs)
|
||||
|
||||
currentDirectory, err := appdata.ResolveSavedQueryDirectory(a.configDir)
|
||||
if err != nil {
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
Message: a.appText("app.data_root.saved_query_directory.backend.error.save_failed", map[string]any{
|
||||
"detail": err.Error(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
changed := !directoriesEqual(currentDirectory, target)
|
||||
configuredTarget := target
|
||||
if directoriesEqual(target, defaultDirectory) {
|
||||
configuredTarget = ""
|
||||
}
|
||||
var migrateErr error
|
||||
var saveErr error
|
||||
func() {
|
||||
savedQueriesMu.Lock()
|
||||
defer savedQueriesMu.Unlock()
|
||||
if changed {
|
||||
migrateErr = a.savedQueryRepository().migrateSQLDirectoryLocked(target)
|
||||
if migrateErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
_, saveErr = appdata.SetConfiguredSavedQueryDirectory(configuredTarget)
|
||||
}()
|
||||
if migrateErr != nil {
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
Message: a.appText("app.data_root.saved_query_directory.backend.error.migrate_failed", map[string]any{
|
||||
"detail": migrateErr.Error(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
if saveErr != nil {
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
Message: a.appText("app.data_root.saved_query_directory.backend.error.save_failed", map[string]any{
|
||||
"detail": saveErr.Error(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
messageKey := "app.data_root.saved_query_directory.backend.message.updated"
|
||||
if !changed {
|
||||
messageKey = "app.data_root.saved_query_directory.backend.message.unchanged"
|
||||
}
|
||||
return connection.QueryResult{
|
||||
Success: true,
|
||||
Message: a.appText(messageKey, nil),
|
||||
Data: dataRootInfoPayload(a.configDir),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) OpenSavedQueryDirectory() connection.QueryResult {
|
||||
if a.webRuntime {
|
||||
return connection.QueryResult{Success: false, Message: a.appText("app.data_root.saved_query_directory.backend.error.desktop_only", nil)}
|
||||
}
|
||||
|
||||
directory, err := appdata.ResolveSavedQueryDirectory(a.configDir)
|
||||
if err != nil {
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
Message: a.appText("app.data_root.saved_query_directory.backend.error.open_directory_failed", map[string]any{
|
||||
"detail": err.Error(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
Message: a.appText("app.data_root.saved_query_directory.backend.error.open_directory_failed", map[string]any{
|
||||
"detail": err.Error(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
if stat, err := os.Stat(directory); err != nil || !stat.IsDir() {
|
||||
return connection.QueryResult{Success: false, Message: a.appText("app.data_root.saved_query_directory.backend.error.directory_unavailable", nil)}
|
||||
}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch stdRuntime.GOOS {
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", directory)
|
||||
case "windows":
|
||||
cmd = exec.Command("explorer", directory)
|
||||
case "linux":
|
||||
cmd = exec.Command("xdg-open", directory)
|
||||
default:
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
Message: a.appText("app.data_root.saved_query_directory.backend.error.open_directory_unsupported", map[string]any{
|
||||
"platform": stdRuntime.GOOS,
|
||||
}),
|
||||
}
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
logger.Errorf("打开已存查询目录失败:%v", err)
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
Message: a.appText("app.data_root.saved_query_directory.backend.error.open_directory_failed", map[string]any{
|
||||
"detail": err.Error(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
return connection.QueryResult{
|
||||
Success: true,
|
||||
Message: a.appText("app.data_root.saved_query_directory.backend.message.opened", nil),
|
||||
Data: dataRootInfoPayload(a.configDir),
|
||||
}
|
||||
}
|
||||
|
||||
func savedQueryRevealCommand(platform string, filePath string) *exec.Cmd {
|
||||
switch platform {
|
||||
case "darwin":
|
||||
return exec.Command("open", "-R", filePath)
|
||||
case "windows":
|
||||
return exec.Command("explorer.exe", "/select,"+filePath)
|
||||
case "linux":
|
||||
return exec.Command("xdg-open", filepath.Dir(filePath))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var startSavedQueryRevealCommand = func(cmd *exec.Cmd) error {
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
_ = cmd.Wait()
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) RevealSavedQueryInFolder(id string) connection.QueryResult {
|
||||
if a.webRuntime {
|
||||
return connection.QueryResult{Success: false, Message: a.appText("app.data_root.saved_query_directory.backend.error.desktop_only", nil)}
|
||||
}
|
||||
|
||||
targetID := strings.TrimSpace(id)
|
||||
if targetID == "" {
|
||||
return connection.QueryResult{Success: false, Message: a.appText("app.data_root.saved_query_directory.backend.error.query_id_required", nil)}
|
||||
}
|
||||
|
||||
a.dataRootApplyMu.Lock()
|
||||
defer a.dataRootApplyMu.Unlock()
|
||||
|
||||
savedQueriesMu.Lock()
|
||||
defer savedQueriesMu.Unlock()
|
||||
|
||||
filePath, found, err := a.savedQueryRepository().findSQLPath(targetID)
|
||||
if err != nil {
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
Message: a.appText("app.data_root.saved_query_directory.backend.error.reveal_failed", map[string]any{
|
||||
"detail": err.Error(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
Message: a.appText("app.data_root.saved_query_directory.backend.error.query_not_found", map[string]any{
|
||||
"id": targetID,
|
||||
}),
|
||||
}
|
||||
}
|
||||
filePath, err = filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
Message: a.appText("app.data_root.saved_query_directory.backend.error.reveal_failed", map[string]any{
|
||||
"detail": err.Error(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
if stat, statErr := os.Stat(filePath); statErr != nil || !stat.Mode().IsRegular() {
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
Message: a.appText("app.data_root.saved_query_directory.backend.error.query_file_unavailable", map[string]any{
|
||||
"path": filePath,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
cmd := savedQueryRevealCommand(stdRuntime.GOOS, filePath)
|
||||
if cmd == nil {
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
Message: a.appText("app.data_root.saved_query_directory.backend.error.reveal_unsupported", map[string]any{
|
||||
"platform": stdRuntime.GOOS,
|
||||
}),
|
||||
}
|
||||
}
|
||||
if err := startSavedQueryRevealCommand(cmd); err != nil {
|
||||
logger.Errorf("在文件夹中显示已存查询失败:%v", err)
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
Message: a.appText("app.data_root.saved_query_directory.backend.error.reveal_failed", map[string]any{
|
||||
"detail": err.Error(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
return connection.QueryResult{
|
||||
Success: true,
|
||||
Message: a.appText("app.data_root.saved_query_directory.backend.message.revealed", map[string]any{
|
||||
"path": filePath,
|
||||
}),
|
||||
Data: map[string]any{
|
||||
"id": targetID,
|
||||
"path": filePath,
|
||||
},
|
||||
}
|
||||
}
|
||||
354
internal/app/methods_saved_query_directory_test.go
Normal file
354
internal/app/methods_saved_query_directory_test.go
Normal file
@@ -0,0 +1,354 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
stdRuntime "runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/appdata"
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/secretstore"
|
||||
)
|
||||
|
||||
func TestDataRootInfoPayloadIncludesSavedQueryDirectory(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
t.Setenv("USERPROFILE", homeDir)
|
||||
|
||||
activeRoot := filepath.Join(t.TempDir(), "gonavi-data")
|
||||
defaultDirectory := appdata.DefaultSavedQueryDirectory(activeRoot)
|
||||
payload := dataRootInfoPayload(activeRoot)
|
||||
if payload["savedQueryDirectory"] != defaultDirectory {
|
||||
t.Fatalf("savedQueryDirectory = %#v, want %q", payload["savedQueryDirectory"], defaultDirectory)
|
||||
}
|
||||
if payload["defaultSavedQueryDirectory"] != defaultDirectory {
|
||||
t.Fatalf("defaultSavedQueryDirectory = %#v, want %q", payload["defaultSavedQueryDirectory"], defaultDirectory)
|
||||
}
|
||||
if payload["savedQueryDirectorySource"] != "default" {
|
||||
t.Fatalf("savedQueryDirectorySource = %#v, want default", payload["savedQueryDirectorySource"])
|
||||
}
|
||||
|
||||
customDirectory := filepath.Join(t.TempDir(), "saved-queries")
|
||||
if _, err := appdata.SetConfiguredSavedQueryDirectory(customDirectory); err != nil {
|
||||
t.Fatalf("SetConfiguredSavedQueryDirectory returned error: %v", err)
|
||||
}
|
||||
payload = dataRootInfoPayload(activeRoot)
|
||||
if payload["savedQueryDirectory"] != customDirectory {
|
||||
t.Fatalf("custom savedQueryDirectory = %#v, want %q", payload["savedQueryDirectory"], customDirectory)
|
||||
}
|
||||
if payload["savedQueryDirectorySource"] != "custom" {
|
||||
t.Fatalf("custom savedQueryDirectorySource = %#v, want custom", payload["savedQueryDirectorySource"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySavedQueryDirectoryMigratesBeforeSwitchingConfiguration(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
t.Setenv("USERPROFILE", homeDir)
|
||||
|
||||
application := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
|
||||
application.configDir = filepath.Join(t.TempDir(), "gonavi-data")
|
||||
query := connection.SavedQuery{
|
||||
ID: "saved-directory-migration",
|
||||
Name: "Directory migration",
|
||||
SQL: "select 42;",
|
||||
ConnectionID: "conn-1",
|
||||
DBName: "app",
|
||||
CreatedAt: 100,
|
||||
}
|
||||
if _, err := application.SaveQuery(query); err != nil {
|
||||
t.Fatalf("SaveQuery returned error: %v", err)
|
||||
}
|
||||
|
||||
targetDirectory := filepath.Join(t.TempDir(), "custom-saved-queries")
|
||||
result := application.ApplySavedQueryDirectory(targetDirectory)
|
||||
if !result.Success {
|
||||
t.Fatalf("ApplySavedQueryDirectory returned failure: %s", result.Message)
|
||||
}
|
||||
configuredDirectory, err := appdata.ResolveConfiguredSavedQueryDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConfiguredSavedQueryDirectory returned error: %v", err)
|
||||
}
|
||||
if configuredDirectory != targetDirectory {
|
||||
t.Fatalf("configured saved query directory = %q, want %q", configuredDirectory, targetDirectory)
|
||||
}
|
||||
payload, ok := result.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("ApplySavedQueryDirectory data = %#v, want data-root payload", result.Data)
|
||||
}
|
||||
for _, key := range []string{
|
||||
"path", "defaultPath", "driverPath", "isDefaultPath", "bootstrapPath",
|
||||
"logDirectory", "activeLogDirectory", "logFilePath", "defaultLogDirectory",
|
||||
"logDirectorySource", "logDirectoryEditable", "logDirectoryRestartRequired",
|
||||
"savedQueryDirectory", "defaultSavedQueryDirectory", "savedQueryDirectorySource",
|
||||
} {
|
||||
if _, exists := payload[key]; !exists {
|
||||
t.Fatalf("ApplySavedQueryDirectory payload missing key %q: %#v", key, payload)
|
||||
}
|
||||
}
|
||||
if payload["savedQueryDirectory"] != targetDirectory || payload["savedQueryDirectorySource"] != "custom" {
|
||||
t.Fatalf("ApplySavedQueryDirectory payload has unexpected saved query directory: %#v", payload)
|
||||
}
|
||||
|
||||
queries, err := application.GetSavedQueries()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSavedQueries after directory migration returned error: %v", err)
|
||||
}
|
||||
if len(queries) != 1 || queries[0].ID != query.ID || queries[0].SQL != query.SQL {
|
||||
t.Fatalf("migrated saved queries = %#v, want query %#v", queries, query)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySavedQueryDirectoryRestoresDefaultDirectory(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
t.Setenv("USERPROFILE", homeDir)
|
||||
|
||||
application := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
|
||||
application.configDir = filepath.Join(t.TempDir(), "gonavi-data")
|
||||
customDirectory := filepath.Join(t.TempDir(), "custom-saved-queries")
|
||||
if _, err := appdata.SetConfiguredSavedQueryDirectory(customDirectory); err != nil {
|
||||
t.Fatalf("SetConfiguredSavedQueryDirectory returned error: %v", err)
|
||||
}
|
||||
query := connection.SavedQuery{
|
||||
ID: "saved-directory-restore",
|
||||
Name: "Directory restore",
|
||||
SQL: "select 84;",
|
||||
ConnectionID: "conn-1",
|
||||
DBName: "app",
|
||||
CreatedAt: 100,
|
||||
}
|
||||
if _, err := application.SaveQuery(query); err != nil {
|
||||
t.Fatalf("SaveQuery returned error: %v", err)
|
||||
}
|
||||
|
||||
defaultDirectory := appdata.DefaultSavedQueryDirectory(application.configDir)
|
||||
result := application.ApplySavedQueryDirectory(defaultDirectory)
|
||||
if !result.Success {
|
||||
t.Fatalf("ApplySavedQueryDirectory default returned failure: %s", result.Message)
|
||||
}
|
||||
configuredDirectory, err := appdata.ResolveConfiguredSavedQueryDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConfiguredSavedQueryDirectory returned error: %v", err)
|
||||
}
|
||||
if configuredDirectory != "" {
|
||||
t.Fatalf("configured saved query directory = %q, want default override cleared", configuredDirectory)
|
||||
}
|
||||
queries, err := application.GetSavedQueries()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSavedQueries after restoring default returned error: %v", err)
|
||||
}
|
||||
if len(queries) != 1 || queries[0].ID != query.ID || queries[0].SQL != query.SQL {
|
||||
t.Fatalf("restored saved queries = %#v, want query %#v", queries, query)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySavedQueryDirectoryDoesNotSwitchConfigurationWhenMigrationFails(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
t.Setenv("USERPROFILE", homeDir)
|
||||
|
||||
application := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
|
||||
application.configDir = filepath.Join(t.TempDir(), "gonavi-data")
|
||||
if _, err := application.SaveQuery(connection.SavedQuery{
|
||||
ID: "saved-directory-failed-migration",
|
||||
Name: "Failed directory migration",
|
||||
SQL: "select 126;",
|
||||
ConnectionID: "conn-1",
|
||||
DBName: "app",
|
||||
CreatedAt: 100,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveQuery returned error: %v", err)
|
||||
}
|
||||
|
||||
blockingPath := filepath.Join(t.TempDir(), "not-a-directory")
|
||||
if err := os.WriteFile(blockingPath, []byte("blocked"), 0o644); err != nil {
|
||||
t.Fatalf("write blocking path: %v", err)
|
||||
}
|
||||
result := application.ApplySavedQueryDirectory(blockingPath)
|
||||
if result.Success {
|
||||
t.Fatalf("ApplySavedQueryDirectory should fail when target is a file: %+v", result)
|
||||
}
|
||||
configuredDirectory, err := appdata.ResolveConfiguredSavedQueryDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConfiguredSavedQueryDirectory returned error: %v", err)
|
||||
}
|
||||
if configuredDirectory != "" {
|
||||
t.Fatalf("failed migration changed configured directory to %q", configuredDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySavedQueryDirectoryRejectsWebRuntime(t *testing.T) {
|
||||
application := NewApp()
|
||||
application.webRuntime = true
|
||||
result := application.ApplySavedQueryDirectory(filepath.Join(t.TempDir(), "saved-queries"))
|
||||
if result.Success {
|
||||
t.Fatalf("ApplySavedQueryDirectory should reject web runtime: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSavedQueryDirectoryRejectsWebRuntime(t *testing.T) {
|
||||
application := NewApp()
|
||||
application.webRuntime = true
|
||||
result := application.OpenSavedQueryDirectory()
|
||||
if result.Success {
|
||||
t.Fatalf("OpenSavedQueryDirectory should reject web runtime: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectSavedQueryDirectoryRejectsWebRuntime(t *testing.T) {
|
||||
application := NewApp()
|
||||
application.webRuntime = true
|
||||
result := application.SelectSavedQueryDirectory("")
|
||||
if result.Success {
|
||||
t.Fatalf("SelectSavedQueryDirectory should reject web runtime: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevealSavedQueryInFolderRejectsWebRuntime(t *testing.T) {
|
||||
application := NewApp()
|
||||
application.webRuntime = true
|
||||
result := application.RevealSavedQueryInFolder("saved-query")
|
||||
if result.Success {
|
||||
t.Fatalf("RevealSavedQueryInFolder should reject web runtime: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevealSavedQueryInFolderValidatesQueryID(t *testing.T) {
|
||||
application := NewApp()
|
||||
result := application.RevealSavedQueryInFolder(" ")
|
||||
if result.Success {
|
||||
t.Fatalf("RevealSavedQueryInFolder should reject an empty query id: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevealSavedQueryInFolderRejectsMissingQuery(t *testing.T) {
|
||||
application := newSavedQueryTestApp(t)
|
||||
result := application.RevealSavedQueryInFolder("missing-query")
|
||||
if result.Success {
|
||||
t.Fatalf("RevealSavedQueryInFolder should reject a missing query: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevealSavedQueryInFolderIgnoresUnrelatedMissingSQLFile(t *testing.T) {
|
||||
application := newSavedQueryTestApp(t)
|
||||
for _, query := range []connection.SavedQuery{
|
||||
{ID: "healthy-query", Name: "Healthy", SQL: "select 1;", ConnectionID: "conn-1", DBName: "app", CreatedAt: 100},
|
||||
{ID: "missing-file-query", Name: "Missing", SQL: "select 2;", ConnectionID: "conn-1", DBName: "app", CreatedAt: 101},
|
||||
} {
|
||||
if _, err := application.SaveQuery(query); err != nil {
|
||||
t.Fatalf("SaveQuery(%s) returned error: %v", query.ID, err)
|
||||
}
|
||||
}
|
||||
repository := application.savedQueryRepository()
|
||||
healthyPath, found, err := repository.findSQLPath("healthy-query")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("findSQLPath(healthy-query) = %q, %v, %v", healthyPath, found, err)
|
||||
}
|
||||
missingPath, found, err := repository.findSQLPath("missing-file-query")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("findSQLPath(missing-file-query) = %q, %v, %v", missingPath, found, err)
|
||||
}
|
||||
if err := os.Remove(missingPath); err != nil {
|
||||
t.Fatalf("remove unrelated sql file: %v", err)
|
||||
}
|
||||
|
||||
previousStart := startSavedQueryRevealCommand
|
||||
var startedArgs []string
|
||||
startSavedQueryRevealCommand = func(command *exec.Cmd) error {
|
||||
startedArgs = append([]string(nil), command.Args...)
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
startSavedQueryRevealCommand = previousStart
|
||||
})
|
||||
|
||||
result := application.RevealSavedQueryInFolder("healthy-query")
|
||||
if !result.Success {
|
||||
t.Fatalf("RevealSavedQueryInFolder healthy query returned failure: %+v", result)
|
||||
}
|
||||
expectedCommand := savedQueryRevealCommand(stdRuntime.GOOS, healthyPath)
|
||||
if expectedCommand == nil {
|
||||
t.Fatalf("savedQueryRevealCommand does not support test platform %q", stdRuntime.GOOS)
|
||||
}
|
||||
if !reflect.DeepEqual(startedArgs, expectedCommand.Args) {
|
||||
t.Fatalf("reveal command args = %#v, want %#v", startedArgs, expectedCommand.Args)
|
||||
}
|
||||
|
||||
startedArgs = nil
|
||||
result = application.RevealSavedQueryInFolder("missing-file-query")
|
||||
if result.Success {
|
||||
t.Fatalf("RevealSavedQueryInFolder should reject the missing target file: %+v", result)
|
||||
}
|
||||
if len(startedArgs) != 0 {
|
||||
t.Fatalf("missing target unexpectedly started file manager: %#v", startedArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevealSavedQueryInFolderSerializesWithDataRootRequests(t *testing.T) {
|
||||
application := newSavedQueryTestApp(t)
|
||||
if _, err := application.SaveQuery(connection.SavedQuery{
|
||||
ID: "serialized-reveal", Name: "Serialized", SQL: "select 1;", ConnectionID: "conn-1", DBName: "app", CreatedAt: 100,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveQuery returned error: %v", err)
|
||||
}
|
||||
|
||||
previousStart := startSavedQueryRevealCommand
|
||||
startSavedQueryRevealCommand = func(*exec.Cmd) error { return nil }
|
||||
t.Cleanup(func() {
|
||||
startSavedQueryRevealCommand = previousStart
|
||||
})
|
||||
|
||||
application.dataRootApplyMu.Lock()
|
||||
done := make(chan connection.QueryResult, 1)
|
||||
go func() {
|
||||
done <- application.RevealSavedQueryInFolder("serialized-reveal")
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
application.dataRootApplyMu.Unlock()
|
||||
t.Fatal("RevealSavedQueryInFolder bypassed the shared data-root serialization lock")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
application.dataRootApplyMu.Unlock()
|
||||
|
||||
select {
|
||||
case result := <-done:
|
||||
if !result.Success {
|
||||
t.Fatalf("serialized RevealSavedQueryInFolder returned failure: %+v", result)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("serialized RevealSavedQueryInFolder did not resume")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedQueryRevealCommandUsesPlatformFileManager(t *testing.T) {
|
||||
filePath := filepath.Join(t.TempDir(), "saved query.sql")
|
||||
tests := []struct {
|
||||
platform string
|
||||
want []string
|
||||
}{
|
||||
{platform: "darwin", want: []string{"open", "-R", filePath}},
|
||||
{platform: "windows", want: []string{"explorer.exe", "/select," + filePath}},
|
||||
{platform: "linux", want: []string{"xdg-open", filepath.Dir(filePath)}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.platform, func(t *testing.T) {
|
||||
command := savedQueryRevealCommand(test.platform, filePath)
|
||||
if command == nil {
|
||||
t.Fatalf("savedQueryRevealCommand(%q) returned nil", test.platform)
|
||||
}
|
||||
if !reflect.DeepEqual(command.Args, test.want) {
|
||||
t.Fatalf("savedQueryRevealCommand(%q) args = %#v, want %#v", test.platform, command.Args, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if command := savedQueryRevealCommand("plan9", filePath); command != nil {
|
||||
t.Fatalf("unsupported platform command = %#v, want nil", command.Args)
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,24 @@ package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"GoNavi-Wails/internal/appdata"
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const savedQueriesFileName = "saved_queries.json"
|
||||
const (
|
||||
savedQueriesFileName = "saved_queries.json"
|
||||
savedQueriesFormatVersion = 3
|
||||
)
|
||||
|
||||
const (
|
||||
savedQueryGroupTokenPrefix = "group:"
|
||||
@@ -22,8 +28,34 @@ const (
|
||||
|
||||
var savedQueriesMu sync.Mutex
|
||||
|
||||
var writeSavedQueriesMetadataAtomic = writeSavedQueriesFileAtomic
|
||||
|
||||
type savedQueriesFile struct {
|
||||
Queries []connection.SavedQuery `json:"queries"`
|
||||
Queries []connection.SavedQuery
|
||||
Groups []connection.SavedQueryGroup
|
||||
FileNames map[string]string
|
||||
}
|
||||
|
||||
// savedQueryDiskRecord deliberately excludes SQL from the current on-disk
|
||||
// format. LegacySQL is read only so older saved_queries.json files can be
|
||||
// migrated without losing content.
|
||||
type savedQueryDiskRecord struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
FileName string `json:"fileName,omitempty"`
|
||||
LegacySQL string `json:"sql,omitempty"`
|
||||
ConnectionID string `json:"connectionId"`
|
||||
DBName string `json:"dbName"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
ConnectionFingerprint string `json:"connectionFingerprint,omitempty"`
|
||||
FingerprintVersion string `json:"fingerprintVersion,omitempty"`
|
||||
BindingStatus string `json:"bindingStatus,omitempty"`
|
||||
OriginalConnectionID string `json:"originalConnectionId,omitempty"`
|
||||
}
|
||||
|
||||
type savedQueriesDiskFile struct {
|
||||
Version int `json:"version,omitempty"`
|
||||
Queries []savedQueryDiskRecord `json:"queries"`
|
||||
Groups []connection.SavedQueryGroup `json:"groups,omitempty"`
|
||||
}
|
||||
|
||||
@@ -42,20 +74,24 @@ func (r *savedQueryRepository) queriesPath() string {
|
||||
return filepath.Join(r.configDir, savedQueriesFileName)
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) sqlDirectory() (string, error) {
|
||||
return appdata.ResolveSavedQueryDirectory(r.configDir)
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) loadFile() (savedQueriesFile, error) {
|
||||
data, err := os.ReadFile(r.queriesPath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return savedQueriesFile{Queries: []connection.SavedQuery{}, Groups: []connection.SavedQueryGroup{}}, nil
|
||||
return emptySavedQueriesFile(), nil
|
||||
}
|
||||
return savedQueriesFile{}, err
|
||||
}
|
||||
|
||||
var file savedQueriesFile
|
||||
if err := json.Unmarshal(data, &file); err != nil {
|
||||
var diskFile savedQueriesDiskFile
|
||||
if err := json.Unmarshal(data, &diskFile); err != nil {
|
||||
return savedQueriesFile{}, err
|
||||
}
|
||||
return normalizeSavedQueriesFile(file), nil
|
||||
return r.hydrateDiskFile(diskFile)
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) load() ([]connection.SavedQuery, error) {
|
||||
@@ -74,27 +110,559 @@ func (r *savedQueryRepository) loadGroups() ([]connection.SavedQueryGroup, error
|
||||
return file.Groups, nil
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) saveFile(file savedQueriesFile) error {
|
||||
func (r *savedQueryRepository) findSQLPath(id string) (string, bool, error) {
|
||||
targetID := strings.TrimSpace(id)
|
||||
if targetID == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
payload, err := os.ReadFile(r.queriesPath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
var diskFile savedQueriesDiskFile
|
||||
if err := json.Unmarshal(payload, &diskFile); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
fileName := ""
|
||||
for _, record := range diskFile.Queries {
|
||||
if strings.TrimSpace(record.ID) != targetID {
|
||||
continue
|
||||
}
|
||||
fileName = strings.TrimSpace(record.FileName)
|
||||
if diskFile.Version < savedQueriesFormatVersion || fileName == "" || record.LegacySQL != "" {
|
||||
hydrated, err := r.hydrateDiskFile(diskFile)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
fileName = hydrated.FileNames[targetID]
|
||||
}
|
||||
break
|
||||
}
|
||||
if fileName == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
fileName, err = normalizeSavedQueryDiskFileName(fileName)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
directory, err := r.sqlDirectory()
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return filepath.Join(directory, fileName), true, nil
|
||||
}
|
||||
|
||||
func emptySavedQueriesFile() savedQueriesFile {
|
||||
return savedQueriesFile{
|
||||
Queries: []connection.SavedQuery{},
|
||||
Groups: []connection.SavedQueryGroup{},
|
||||
FileNames: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func savedQueryFromDiskRecord(record savedQueryDiskRecord, sqlText string) connection.SavedQuery {
|
||||
return connection.SavedQuery{
|
||||
ID: record.ID,
|
||||
Name: record.Name,
|
||||
SQL: sqlText,
|
||||
ConnectionID: record.ConnectionID,
|
||||
DBName: record.DBName,
|
||||
CreatedAt: record.CreatedAt,
|
||||
ConnectionFingerprint: record.ConnectionFingerprint,
|
||||
FingerprintVersion: record.FingerprintVersion,
|
||||
BindingStatus: record.BindingStatus,
|
||||
OriginalConnectionID: record.OriginalConnectionID,
|
||||
}
|
||||
}
|
||||
|
||||
func savedQueryToDiskRecord(query connection.SavedQuery, fileName string) savedQueryDiskRecord {
|
||||
return savedQueryDiskRecord{
|
||||
ID: query.ID,
|
||||
Name: query.Name,
|
||||
FileName: fileName,
|
||||
ConnectionID: query.ConnectionID,
|
||||
DBName: query.DBName,
|
||||
CreatedAt: query.CreatedAt,
|
||||
ConnectionFingerprint: query.ConnectionFingerprint,
|
||||
FingerprintVersion: query.FingerprintVersion,
|
||||
BindingStatus: query.BindingStatus,
|
||||
OriginalConnectionID: query.OriginalConnectionID,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSavedQueryDiskFileName(fileName string) (string, error) {
|
||||
name := strings.TrimSpace(fileName)
|
||||
if name == "" || filepath.Base(name) != name || strings.ContainsAny(name, `/\`) {
|
||||
return "", fmt.Errorf("saved query has an invalid sql file name: %q", fileName)
|
||||
}
|
||||
if !strings.EqualFold(filepath.Ext(name), ".sql") {
|
||||
return "", fmt.Errorf("saved query sql file must use the .sql extension: %q", fileName)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func trimSavedQueryFileBase(value string, maxBytes int) string {
|
||||
if len(value) <= maxBytes {
|
||||
return value
|
||||
}
|
||||
end := 0
|
||||
for index := range value {
|
||||
if index > maxBytes {
|
||||
break
|
||||
}
|
||||
end = index
|
||||
}
|
||||
if end == 0 {
|
||||
_, size := utf8.DecodeRuneInString(value)
|
||||
if size <= maxBytes {
|
||||
end = size
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(value[:end])
|
||||
}
|
||||
|
||||
func buildSavedQuerySQLFileBase(name string) string {
|
||||
base := strings.TrimSpace(name)
|
||||
if strings.EqualFold(filepath.Ext(base), ".sql") {
|
||||
base = strings.TrimSpace(base[:len(base)-len(filepath.Ext(base))])
|
||||
}
|
||||
base = strings.Map(func(value rune) rune {
|
||||
if value < 0x20 || strings.ContainsRune(`<>:"/\|?*`, value) {
|
||||
return '_'
|
||||
}
|
||||
return value
|
||||
}, base)
|
||||
base = strings.Trim(base, " .")
|
||||
base = trimSavedQueryFileBase(base, 120)
|
||||
if base == "" {
|
||||
base = "query"
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func savedQuerySQLFileNameKey(fileName string) string {
|
||||
return strings.ToLower(strings.TrimSpace(fileName))
|
||||
}
|
||||
|
||||
func allocateSavedQuerySQLFileName(name string, unavailable map[string]struct{}) string {
|
||||
base := buildSavedQuerySQLFileBase(name)
|
||||
for suffix := 1; ; suffix++ {
|
||||
candidate := base + ".sql"
|
||||
if suffix > 1 {
|
||||
candidate = fmt.Sprintf("%s (%d).sql", base, suffix)
|
||||
}
|
||||
key := savedQuerySQLFileNameKey(candidate)
|
||||
if _, exists := unavailable[key]; exists {
|
||||
continue
|
||||
}
|
||||
unavailable[key] = struct{}{}
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
func listSavedQueryDirectoryFileNames(directory string) (map[string]struct{}, error) {
|
||||
fileNames := make(map[string]struct{})
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fileNames, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
fileNames[savedQuerySQLFileNameKey(entry.Name())] = struct{}{}
|
||||
}
|
||||
return fileNames, nil
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) hydrateDiskFile(diskFile savedQueriesDiskFile) (savedQueriesFile, error) {
|
||||
directory, err := r.sqlDirectory()
|
||||
if err != nil {
|
||||
return savedQueriesFile{}, err
|
||||
}
|
||||
file := emptySavedQueriesFile()
|
||||
file.Groups = diskFile.Groups
|
||||
migrated := diskFile.Version < savedQueriesFormatVersion
|
||||
unavailable, err := listSavedQueryDirectoryFileNames(directory)
|
||||
if err != nil {
|
||||
return savedQueriesFile{}, err
|
||||
}
|
||||
mutations := make([]savedQuerySQLMutation, 0)
|
||||
oldPaths := make([]string, 0, len(diskFile.Queries))
|
||||
seenQueryIDs := make(map[string]struct{}, len(diskFile.Queries))
|
||||
rollback := func(cause error) (savedQueriesFile, error) {
|
||||
return savedQueriesFile{}, errors.Join(cause, rollbackSavedQuerySQLMutations(mutations))
|
||||
}
|
||||
|
||||
for index, record := range diskFile.Queries {
|
||||
recordID := strings.TrimSpace(record.ID)
|
||||
if recordID == "" {
|
||||
migrated = true
|
||||
continue
|
||||
}
|
||||
if _, exists := seenQueryIDs[recordID]; exists {
|
||||
migrated = true
|
||||
continue
|
||||
}
|
||||
seenQueryIDs[recordID] = struct{}{}
|
||||
if record.LegacySQL != "" {
|
||||
migrated = true
|
||||
}
|
||||
oldFileName := strings.TrimSpace(record.FileName)
|
||||
content := []byte(record.LegacySQL)
|
||||
if oldFileName != "" {
|
||||
oldFileName, err = normalizeSavedQueryDiskFileName(oldFileName)
|
||||
if err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
oldPath := filepath.Join(directory, oldFileName)
|
||||
content, err = os.ReadFile(oldPath)
|
||||
if err != nil {
|
||||
return rollback(fmt.Errorf("read saved query sql file %s: %w", oldFileName, err))
|
||||
}
|
||||
oldPaths = append(oldPaths, oldPath)
|
||||
}
|
||||
query, ok := sanitizeSavedQuery(savedQueryFromDiskRecord(record, string(content)), index, false)
|
||||
if !ok {
|
||||
if oldFileName == "" {
|
||||
migrated = true
|
||||
continue
|
||||
}
|
||||
return rollback(fmt.Errorf("saved query is invalid: %s", strings.TrimSpace(record.ID)))
|
||||
}
|
||||
|
||||
fileName := oldFileName
|
||||
if diskFile.Version < savedQueriesFormatVersion || fileName == "" {
|
||||
oldKey := savedQuerySQLFileNameKey(oldFileName)
|
||||
if oldKey != "" {
|
||||
delete(unavailable, oldKey)
|
||||
}
|
||||
fileName = allocateSavedQuerySQLFileName(query.Name, unavailable)
|
||||
if oldKey != "" && savedQuerySQLFileNameKey(fileName) != oldKey {
|
||||
unavailable[oldKey] = struct{}{}
|
||||
}
|
||||
migrated = true
|
||||
} else {
|
||||
fileName, err = normalizeSavedQueryDiskFileName(fileName)
|
||||
if err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
}
|
||||
|
||||
targetPath := filepath.Join(directory, fileName)
|
||||
if oldFileName == "" || !savedQueryPathsReferToSameFile(filepath.Join(directory, oldFileName), targetPath) {
|
||||
previous, readErr := os.ReadFile(targetPath)
|
||||
if readErr != nil && !os.IsNotExist(readErr) {
|
||||
return rollback(readErr)
|
||||
}
|
||||
if readErr == nil && string(previous) != string(content) {
|
||||
return rollback(fmt.Errorf("saved query migration target already exists: %s", targetPath))
|
||||
}
|
||||
if os.IsNotExist(readErr) {
|
||||
mutation := savedQuerySQLMutation{path: targetPath}
|
||||
if err := writeSavedQuerySQLFileAtomic(targetPath, content); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
mutations = append(mutations, mutation)
|
||||
}
|
||||
}
|
||||
record.FileName = fileName
|
||||
record.LegacySQL = ""
|
||||
diskFile.Queries[index] = record
|
||||
file.Queries = append(file.Queries, query)
|
||||
file.FileNames[query.ID] = fileName
|
||||
}
|
||||
|
||||
file = normalizeSavedQueriesFile(file)
|
||||
if migrated {
|
||||
diskFile = buildSavedQueriesDiskFile(file)
|
||||
if err := r.saveDiskFile(diskFile); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
referencedPaths := make([]string, 0, len(file.FileNames))
|
||||
for _, fileName := range file.FileNames {
|
||||
referencedPaths = append(referencedPaths, filepath.Join(directory, fileName))
|
||||
}
|
||||
for _, oldPath := range oldPaths {
|
||||
referenced := false
|
||||
for _, targetPath := range referencedPaths {
|
||||
if savedQueryPathsReferToSameFile(oldPath, targetPath) {
|
||||
referenced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !referenced {
|
||||
_ = os.Remove(oldPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func buildSavedQueriesDiskFile(file savedQueriesFile) savedQueriesDiskFile {
|
||||
file = normalizeSavedQueriesFile(file)
|
||||
records := make([]savedQueryDiskRecord, 0, len(file.Queries))
|
||||
for _, query := range file.Queries {
|
||||
records = append(records, savedQueryToDiskRecord(query, file.FileNames[query.ID]))
|
||||
}
|
||||
return savedQueriesDiskFile{
|
||||
Version: savedQueriesFormatVersion,
|
||||
Queries: records,
|
||||
Groups: file.Groups,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) saveDiskFile(file savedQueriesDiskFile) error {
|
||||
if err := os.MkdirAll(r.configDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := json.MarshalIndent(normalizeSavedQueriesFile(file), "", " ")
|
||||
payload, err := json.MarshalIndent(file, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeSavedQueriesFileAtomic(r.queriesPath(), payload)
|
||||
return writeSavedQueriesMetadataAtomic(r.queriesPath(), payload)
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) saveMetadataFile(file savedQueriesFile) error {
|
||||
return r.saveDiskFile(buildSavedQueriesDiskFile(file))
|
||||
}
|
||||
|
||||
type savedQuerySQLMutation struct {
|
||||
path string
|
||||
previous []byte
|
||||
existed bool
|
||||
}
|
||||
|
||||
func rollbackSavedQuerySQLMutations(mutations []savedQuerySQLMutation) error {
|
||||
var rollbackErr error
|
||||
for index := len(mutations) - 1; index >= 0; index-- {
|
||||
mutation := mutations[index]
|
||||
if mutation.existed {
|
||||
rollbackErr = errors.Join(rollbackErr, writeSavedQuerySQLFileAtomic(mutation.path, mutation.previous))
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(mutation.path); err != nil && !os.IsNotExist(err) {
|
||||
rollbackErr = errors.Join(rollbackErr, err)
|
||||
}
|
||||
}
|
||||
return rollbackErr
|
||||
}
|
||||
|
||||
func savedQueryPathsReferToSameFile(left string, right string) bool {
|
||||
if filepath.Clean(left) == filepath.Clean(right) {
|
||||
return true
|
||||
}
|
||||
leftInfo, leftErr := os.Stat(left)
|
||||
rightInfo, rightErr := os.Stat(right)
|
||||
return leftErr == nil && rightErr == nil && os.SameFile(leftInfo, rightInfo)
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) replaceQueries(current savedQueriesFile, queries []connection.SavedQuery) error {
|
||||
directory, err := r.sqlDirectory()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
next := savedQueriesFile{
|
||||
Queries: sanitizeSavedQueries(queries),
|
||||
Groups: append([]connection.SavedQueryGroup(nil), current.Groups...),
|
||||
FileNames: make(map[string]string, len(queries)),
|
||||
}
|
||||
currentByID := make(map[string]connection.SavedQuery, len(current.Queries))
|
||||
for _, query := range current.Queries {
|
||||
currentByID[query.ID] = query
|
||||
}
|
||||
unavailable, err := listSavedQueryDirectoryFileNames(directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, query := range next.Queries {
|
||||
previous, existed := currentByID[query.ID]
|
||||
if existed && previous.Name == query.Name {
|
||||
fileName, normalizeErr := normalizeSavedQueryDiskFileName(current.FileNames[query.ID])
|
||||
if normalizeErr != nil {
|
||||
return normalizeErr
|
||||
}
|
||||
next.FileNames[query.ID] = fileName
|
||||
continue
|
||||
}
|
||||
|
||||
oldFileName := ""
|
||||
if existed {
|
||||
oldFileName = current.FileNames[query.ID]
|
||||
delete(unavailable, savedQuerySQLFileNameKey(oldFileName))
|
||||
}
|
||||
fileName := allocateSavedQuerySQLFileName(query.Name, unavailable)
|
||||
next.FileNames[query.ID] = fileName
|
||||
if oldFileName != "" && savedQuerySQLFileNameKey(fileName) != savedQuerySQLFileNameKey(oldFileName) {
|
||||
unavailable[savedQuerySQLFileNameKey(oldFileName)] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
mutations := make([]savedQuerySQLMutation, 0, len(next.Queries))
|
||||
rollback := func(cause error) error {
|
||||
return errors.Join(cause, rollbackSavedQuerySQLMutations(mutations))
|
||||
}
|
||||
for _, query := range next.Queries {
|
||||
previous, existed := currentByID[query.ID]
|
||||
fileName := next.FileNames[query.ID]
|
||||
fileName, err = normalizeSavedQueryDiskFileName(fileName)
|
||||
if err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
next.FileNames[query.ID] = fileName
|
||||
targetPath := filepath.Join(directory, fileName)
|
||||
previousPath := ""
|
||||
if existed {
|
||||
previousPath = filepath.Join(directory, current.FileNames[query.ID])
|
||||
}
|
||||
|
||||
if existed && savedQueryPathsReferToSameFile(previousPath, targetPath) && previous.SQL == query.SQL {
|
||||
continue
|
||||
}
|
||||
priorContent, readErr := os.ReadFile(targetPath)
|
||||
if readErr != nil && !os.IsNotExist(readErr) {
|
||||
return rollback(readErr)
|
||||
}
|
||||
if !existed || !savedQueryPathsReferToSameFile(previousPath, targetPath) {
|
||||
if readErr == nil && string(priorContent) != query.SQL {
|
||||
return rollback(fmt.Errorf("saved query sql target already exists: %s", targetPath))
|
||||
}
|
||||
}
|
||||
if readErr == nil && string(priorContent) == query.SQL {
|
||||
continue
|
||||
}
|
||||
mutation := savedQuerySQLMutation{path: targetPath}
|
||||
if readErr == nil {
|
||||
mutation.existed = true
|
||||
mutation.previous = priorContent
|
||||
}
|
||||
if err := writeSavedQuerySQLFileAtomic(targetPath, []byte(query.SQL)); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
mutations = append(mutations, mutation)
|
||||
}
|
||||
|
||||
next = normalizeSavedQueriesFile(next)
|
||||
if err := r.saveMetadataFile(next); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
|
||||
referencedPaths := make(map[string]struct{}, len(next.FileNames))
|
||||
for _, fileName := range next.FileNames {
|
||||
referencedPaths[filepath.Clean(filepath.Join(directory, fileName))] = struct{}{}
|
||||
}
|
||||
for queryID, fileName := range current.FileNames {
|
||||
oldPath := filepath.Clean(filepath.Join(directory, fileName))
|
||||
if _, stillReferenced := referencedPaths[oldPath]; stillReferenced {
|
||||
continue
|
||||
}
|
||||
if nextName, exists := next.FileNames[queryID]; exists {
|
||||
newPath := filepath.Join(directory, nextName)
|
||||
if savedQueryPathsReferToSameFile(oldPath, newPath) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
_ = os.Remove(oldPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveAll remains available to callers that only replace query content. It
|
||||
// loads and carries forward saved-query groups instead of silently dropping
|
||||
// the new metadata.
|
||||
func (r *savedQueryRepository) saveAll(queries []connection.SavedQuery) error {
|
||||
savedQueriesMu.Lock()
|
||||
defer savedQueriesMu.Unlock()
|
||||
|
||||
file, err := r.loadFile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file.Queries = queries
|
||||
return r.saveFile(file)
|
||||
return r.replaceQueries(file, queries)
|
||||
}
|
||||
|
||||
func writeSavedQuerySQLFileAtomic(targetPath string, payload []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
mode := os.FileMode(0o644)
|
||||
if info, err := os.Stat(targetPath); err == nil {
|
||||
if info.IsDir() {
|
||||
return fmt.Errorf("saved query sql path is a directory: %s", targetPath)
|
||||
}
|
||||
mode = info.Mode().Perm()
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
temp, err := os.CreateTemp(filepath.Dir(targetPath), ".saved_query_*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempPath := temp.Name()
|
||||
cleanup := true
|
||||
defer func() {
|
||||
if cleanup {
|
||||
_ = os.Remove(tempPath)
|
||||
}
|
||||
}()
|
||||
if err := temp.Chmod(mode); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
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 := replaceSavedQueryTempFile(tempPath, targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
cleanup = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func replaceSavedQueryTempFile(tempPath string, targetPath string) error {
|
||||
renameErr := os.Rename(tempPath, targetPath)
|
||||
if renameErr == nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(targetPath); err != nil {
|
||||
return renameErr
|
||||
}
|
||||
backup, err := os.CreateTemp(filepath.Dir(targetPath), ".saved_query_backup_*.tmp")
|
||||
if err != nil {
|
||||
return errors.Join(renameErr, err)
|
||||
}
|
||||
backupPath := backup.Name()
|
||||
if err := backup.Close(); err != nil {
|
||||
_ = os.Remove(backupPath)
|
||||
return errors.Join(renameErr, err)
|
||||
}
|
||||
if err := os.Remove(backupPath); err != nil {
|
||||
return errors.Join(renameErr, err)
|
||||
}
|
||||
if err := os.Rename(targetPath, backupPath); err != nil {
|
||||
return errors.Join(renameErr, err)
|
||||
}
|
||||
if err := os.Rename(tempPath, targetPath); err != nil {
|
||||
return errors.Join(err, os.Rename(backupPath, targetPath))
|
||||
}
|
||||
_ = os.Remove(backupPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeSavedQueriesFileAtomic(targetPath string, payload []byte) error {
|
||||
@@ -125,13 +693,8 @@ func writeSavedQueriesFileAtomic(targetPath string, payload []byte) error {
|
||||
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
|
||||
}
|
||||
if err := replaceSavedQueryTempFile(tempPath, targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
cleanup = false
|
||||
return nil
|
||||
@@ -150,7 +713,7 @@ func (r *savedQueryRepository) Save(input connection.SavedQuery) (connection.Sav
|
||||
if err != nil {
|
||||
return connection.SavedQuery{}, err
|
||||
}
|
||||
queries := file.Queries
|
||||
queries := append([]connection.SavedQuery(nil), file.Queries...)
|
||||
|
||||
replaced := false
|
||||
for index, item := range queries {
|
||||
@@ -163,8 +726,7 @@ func (r *savedQueryRepository) Save(input connection.SavedQuery) (connection.Sav
|
||||
if !replaced {
|
||||
queries = append(queries, query)
|
||||
}
|
||||
file.Queries = queries
|
||||
if err := r.saveFile(file); err != nil {
|
||||
if err := r.replaceQueries(file, queries); err != nil {
|
||||
return connection.SavedQuery{}, err
|
||||
}
|
||||
return query, nil
|
||||
@@ -199,17 +761,16 @@ func (r *savedQueryRepository) Import(payload connection.SavedQueryImportPayload
|
||||
existing = append(existing, query)
|
||||
}
|
||||
|
||||
file.Queries = existing
|
||||
if payload.Groups != nil {
|
||||
if err := validateSavedQueryGroupsQueryIDs(payload.Groups, existing); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file.Groups = mergeSavedQueryGroups(file.Groups, payload.Groups)
|
||||
}
|
||||
if err := r.saveFile(file); err != nil {
|
||||
if err := r.replaceQueries(file, existing); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return existing, nil
|
||||
return sanitizeSavedQueries(existing), nil
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) Rebind(id string, target connection.SavedConnectionView) (connection.SavedQuery, error) {
|
||||
@@ -242,7 +803,7 @@ func (r *savedQueryRepository) Rebind(id string, target connection.SavedConnecti
|
||||
}
|
||||
queries[index] = query
|
||||
file.Queries = queries
|
||||
if err := r.saveFile(file); err != nil {
|
||||
if err := r.saveMetadataFile(file); err != nil {
|
||||
return connection.SavedQuery{}, err
|
||||
}
|
||||
return query, nil
|
||||
@@ -271,8 +832,106 @@ func (r *savedQueryRepository) Delete(id string) error {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
file.Queries = filtered
|
||||
return r.saveFile(file)
|
||||
return r.replaceQueries(file, filtered)
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) Rename(id string, name string) (connection.SavedQuery, error) {
|
||||
savedQueriesMu.Lock()
|
||||
defer savedQueriesMu.Unlock()
|
||||
|
||||
targetID := strings.TrimSpace(id)
|
||||
nextName := strings.TrimSpace(name)
|
||||
if targetID == "" || nextName == "" {
|
||||
return connection.SavedQuery{}, fmt.Errorf("saved query and name are required")
|
||||
}
|
||||
file, err := r.loadFile()
|
||||
if err != nil {
|
||||
return connection.SavedQuery{}, err
|
||||
}
|
||||
for index, query := range file.Queries {
|
||||
if query.ID != targetID {
|
||||
continue
|
||||
}
|
||||
if query.Name == nextName {
|
||||
return query, nil
|
||||
}
|
||||
queries := append([]connection.SavedQuery(nil), file.Queries...)
|
||||
query.Name = nextName
|
||||
queries[index] = query
|
||||
if err := r.replaceQueries(file, queries); err != nil {
|
||||
return connection.SavedQuery{}, err
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
return connection.SavedQuery{}, fmt.Errorf("saved query not found: %s", targetID)
|
||||
}
|
||||
|
||||
// migrateSQLDirectory copies all managed SQL files to target atomically as a
|
||||
// batch. Source files remain in place until the directory setting is switched,
|
||||
// so a later configuration write failure cannot disconnect saved queries from
|
||||
// their content.
|
||||
func (r *savedQueryRepository) migrateSQLDirectory(target string) error {
|
||||
savedQueriesMu.Lock()
|
||||
defer savedQueriesMu.Unlock()
|
||||
return r.migrateSQLDirectoryLocked(target)
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) migrateSQLDirectoryLocked(target string) error {
|
||||
targetDirectory := strings.TrimSpace(target)
|
||||
if targetDirectory == "" {
|
||||
targetDirectory = appdata.DefaultSavedQueryDirectory(r.configDir)
|
||||
}
|
||||
absTarget, err := filepath.Abs(targetDirectory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetDirectory = filepath.Clean(absTarget)
|
||||
if err := os.MkdirAll(targetDirectory, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
currentDirectory, err := r.sqlDirectory()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if savedQueryPathsReferToSameFile(currentDirectory, targetDirectory) {
|
||||
return nil
|
||||
}
|
||||
file, err := r.loadFile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mutations := make([]savedQuerySQLMutation, 0, len(file.Queries))
|
||||
rollback := func(cause error) error {
|
||||
return errors.Join(cause, rollbackSavedQuerySQLMutations(mutations))
|
||||
}
|
||||
for _, query := range file.Queries {
|
||||
fileName := file.FileNames[query.ID]
|
||||
sourcePath := filepath.Join(currentDirectory, fileName)
|
||||
content, err := os.ReadFile(sourcePath)
|
||||
if err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
targetPath := filepath.Join(targetDirectory, fileName)
|
||||
previous, readErr := os.ReadFile(targetPath)
|
||||
if readErr == nil && string(previous) == string(content) {
|
||||
continue
|
||||
}
|
||||
if readErr != nil && !os.IsNotExist(readErr) {
|
||||
return rollback(readErr)
|
||||
}
|
||||
mutation := savedQuerySQLMutation{path: targetPath}
|
||||
if readErr == nil {
|
||||
mutation.existed = true
|
||||
mutation.previous = previous
|
||||
}
|
||||
if err := writeSavedQuerySQLFileAtomic(targetPath, content); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
mutations = append(mutations, mutation)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) SaveGroup(input connection.SavedQueryGroup) (connection.SavedQueryGroup, error) {
|
||||
@@ -317,7 +976,7 @@ func (r *savedQueryRepository) SaveGroup(input connection.SavedQueryGroup) (conn
|
||||
|
||||
nextGroups = removeSavedQueryIDsFromOtherGroups(nextGroups, groupID, group.QueryIDs)
|
||||
file.Groups = normalizeSavedQueryGroups(nextGroups, file.Queries)
|
||||
if err := r.saveFile(file); err != nil {
|
||||
if err := r.saveMetadataFile(file); err != nil {
|
||||
return connection.SavedQueryGroup{}, err
|
||||
}
|
||||
|
||||
@@ -370,7 +1029,7 @@ func (r *savedQueryRepository) DeleteGroup(id string) error {
|
||||
}
|
||||
|
||||
file.Groups = normalizeSavedQueryGroups(nextGroups, file.Queries)
|
||||
return r.saveFile(file)
|
||||
return r.saveMetadataFile(file)
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) MoveQueryToGroup(queryID string, groupID string) error {
|
||||
@@ -414,7 +1073,7 @@ func (r *savedQueryRepository) MoveQueryToGroup(queryID string, groupID string)
|
||||
}
|
||||
|
||||
file.Groups = normalizeSavedQueryGroups(nextGroups, file.Queries)
|
||||
return r.saveFile(file)
|
||||
return r.saveMetadataFile(file)
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) MoveGroup(groupID string, parentGroupID string) error {
|
||||
@@ -454,14 +1113,21 @@ func (r *savedQueryRepository) MoveGroup(groupID string, parentGroupID string) e
|
||||
}
|
||||
|
||||
file.Groups = normalizeSavedQueryGroups(nextGroups, file.Queries)
|
||||
return r.saveFile(file)
|
||||
return r.saveMetadataFile(file)
|
||||
}
|
||||
|
||||
func normalizeSavedQueriesFile(file savedQueriesFile) savedQueriesFile {
|
||||
queries := sanitizeSavedQueries(file.Queries)
|
||||
fileNames := make(map[string]string, len(queries))
|
||||
for _, query := range queries {
|
||||
if fileName := strings.TrimSpace(file.FileNames[query.ID]); fileName != "" {
|
||||
fileNames[query.ID] = fileName
|
||||
}
|
||||
}
|
||||
return savedQueriesFile{
|
||||
Queries: queries,
|
||||
Groups: normalizeSavedQueryGroups(file.Groups, queries),
|
||||
Queries: queries,
|
||||
Groups: normalizeSavedQueryGroups(file.Groups, queries),
|
||||
FileNames: fileNames,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -12,6 +14,455 @@ import (
|
||||
"GoNavi-Wails/internal/secretstore"
|
||||
)
|
||||
|
||||
func TestSavedQueryRepositoryStoresSQLInIndependentFile(t *testing.T) {
|
||||
app := newSavedQueryTestApp(t)
|
||||
query := connection.SavedQuery{
|
||||
ID: "saved-file-backed",
|
||||
Name: "Orders / latest",
|
||||
SQL: "\n select * from orders;\n",
|
||||
ConnectionID: "conn-1",
|
||||
DBName: "app",
|
||||
CreatedAt: 100,
|
||||
}
|
||||
|
||||
if _, err := app.SaveQuery(query); err != nil {
|
||||
t.Fatalf("SaveQuery returned error: %v", err)
|
||||
}
|
||||
diskFile, payload := readSavedQueriesDiskFile(t, app)
|
||||
if bytes.Contains(payload, []byte(`"sql"`)) {
|
||||
t.Fatalf("saved query metadata still contains inline sql: %s", payload)
|
||||
}
|
||||
if diskFile.Version != savedQueriesFormatVersion || len(diskFile.Queries) != 1 {
|
||||
t.Fatalf("unexpected saved query metadata: %#v", diskFile)
|
||||
}
|
||||
record := diskFile.Queries[0]
|
||||
if record.FileName != "Orders _ latest.sql" {
|
||||
t.Fatalf("managed sql file name = %q, want %q", record.FileName, "Orders _ latest.sql")
|
||||
}
|
||||
if strings.ContainsAny(record.FileName, `/\`) {
|
||||
t.Fatalf("managed sql file name contains a path separator: %q", record.FileName)
|
||||
}
|
||||
content, err := os.ReadFile(savedQuerySQLPath(t, app, record.FileName))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile saved query sql: %v", err)
|
||||
}
|
||||
if string(content) != query.SQL {
|
||||
t.Fatalf("saved query sql = %q, want %q", content, query.SQL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedQueryRepositoryAllocatesReadableDuplicateFileNames(t *testing.T) {
|
||||
app := newSavedQueryTestApp(t)
|
||||
for index, id := range []string{"orders-1", "orders-2", "orders-3"} {
|
||||
if _, err := app.SaveQuery(connection.SavedQuery{
|
||||
ID: id, Name: "Orders", SQL: fmt.Sprintf("select %d", index+1), ConnectionID: "conn-1", DBName: "app", CreatedAt: int64(index + 1),
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveQuery(%s) returned error: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
diskFile, _ := readSavedQueriesDiskFile(t, app)
|
||||
got := make([]string, 0, len(diskFile.Queries))
|
||||
for _, record := range diskFile.Queries {
|
||||
got = append(got, record.FileName)
|
||||
}
|
||||
want := []string{"Orders.sql", "Orders (2).sql", "Orders (3).sql"}
|
||||
if !sameStringSlice(got, want) {
|
||||
t.Fatalf("managed sql file names = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedQueryRepositoryAvoidsCaseInsensitiveExternalFileCollision(t *testing.T) {
|
||||
app := newSavedQueryTestApp(t)
|
||||
directory, err := app.savedQueryRepository().sqlDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("resolve sql directory: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll sql directory: %v", err)
|
||||
}
|
||||
externalPath := filepath.Join(directory, "orders.SQL")
|
||||
if err := os.WriteFile(externalPath, []byte("external sql"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile external sql: %v", err)
|
||||
}
|
||||
|
||||
if _, err := app.SaveQuery(connection.SavedQuery{
|
||||
ID: "orders-managed", Name: "Orders", SQL: "select 1", ConnectionID: "conn-1", DBName: "app", CreatedAt: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveQuery returned error: %v", err)
|
||||
}
|
||||
diskFile, _ := readSavedQueriesDiskFile(t, app)
|
||||
if got := diskFile.Queries[0].FileName; got != "Orders (2).sql" {
|
||||
t.Fatalf("managed sql file name = %q, want %q", got, "Orders (2).sql")
|
||||
}
|
||||
content, err := os.ReadFile(externalPath)
|
||||
if err != nil || string(content) != "external sql" {
|
||||
t.Fatalf("external sql content = %q, %v", content, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameSavedQueryAllocatesNextReadableFileName(t *testing.T) {
|
||||
app := newSavedQueryTestApp(t)
|
||||
for _, query := range []connection.SavedQuery{
|
||||
{ID: "rename-source", Name: "Source", SQL: "select 1", ConnectionID: "conn-1", DBName: "app", CreatedAt: 1},
|
||||
{ID: "rename-target", Name: "Target", SQL: "select 2", ConnectionID: "conn-1", DBName: "app", CreatedAt: 2},
|
||||
} {
|
||||
if _, err := app.SaveQuery(query); err != nil {
|
||||
t.Fatalf("SaveQuery(%s) returned error: %v", query.ID, err)
|
||||
}
|
||||
}
|
||||
directory, err := app.savedQueryRepository().sqlDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("resolve sql directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(directory, "target (2).SQL"), []byte("external sql"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile external sql: %v", err)
|
||||
}
|
||||
beforeDisk, _ := readSavedQueriesDiskFile(t, app)
|
||||
oldPath := savedQuerySQLPath(t, app, beforeDisk.Queries[0].FileName)
|
||||
latestSQL := "select 3 -- edited outside GoNavi"
|
||||
if err := os.WriteFile(oldPath, []byte(latestSQL), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile external edit: %v", err)
|
||||
}
|
||||
|
||||
renamed, err := app.RenameSavedQuery("rename-source", "Target")
|
||||
if err != nil {
|
||||
t.Fatalf("RenameSavedQuery returned error: %v", err)
|
||||
}
|
||||
if renamed.SQL != latestSQL {
|
||||
t.Fatalf("renamed query sql = %q, want latest disk content %q", renamed.SQL, latestSQL)
|
||||
}
|
||||
afterDisk, _ := readSavedQueriesDiskFile(t, app)
|
||||
if got := afterDisk.Queries[0].FileName; got != "Target (3).sql" {
|
||||
t.Fatalf("renamed sql file name = %q, want %q", got, "Target (3).sql")
|
||||
}
|
||||
content, err := os.ReadFile(savedQuerySQLPath(t, app, afterDisk.Queries[0].FileName))
|
||||
if err != nil || string(content) != latestSQL {
|
||||
t.Fatalf("renamed sql content = %q, %v", content, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedQueryRepositoryMigratesVersionTwoHashedFileNames(t *testing.T) {
|
||||
app := newSavedQueryTestApp(t)
|
||||
directory, err := app.savedQueryRepository().sqlDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("resolve sql directory: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll sql directory: %v", err)
|
||||
}
|
||||
externalPath := filepath.Join(directory, "orders.SQL")
|
||||
if err := os.WriteFile(externalPath, []byte("external sql"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile external sql: %v", err)
|
||||
}
|
||||
oldFileNames := []string{
|
||||
"Orders--11111111111111111111111111111111.sql",
|
||||
"Orders--22222222222222222222222222222222.sql",
|
||||
}
|
||||
for index, fileName := range oldFileNames {
|
||||
if err := os.WriteFile(filepath.Join(directory, fileName), []byte(fmt.Sprintf("select %d", index+1)), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile old managed sql: %v", err)
|
||||
}
|
||||
}
|
||||
diskFile := savedQueriesDiskFile{
|
||||
Version: 2,
|
||||
Queries: []savedQueryDiskRecord{
|
||||
{ID: "migrate-v2-1", Name: "Orders", FileName: oldFileNames[0], ConnectionID: "conn-1", DBName: "app", CreatedAt: 1},
|
||||
{ID: "migrate-v2-2", Name: "Orders", FileName: oldFileNames[1], ConnectionID: "conn-1", DBName: "app", CreatedAt: 2},
|
||||
},
|
||||
}
|
||||
payload, err := json.Marshal(diskFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal v2 metadata: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(app.configDir, savedQueriesFileName), payload, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile v2 metadata: %v", err)
|
||||
}
|
||||
|
||||
queries, err := app.GetSavedQueries()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSavedQueries returned error: %v", err)
|
||||
}
|
||||
if len(queries) != 2 || queries[0].SQL != "select 1" || queries[1].SQL != "select 2" {
|
||||
t.Fatalf("migrated queries = %#v", queries)
|
||||
}
|
||||
migratedDisk, _ := readSavedQueriesDiskFile(t, app)
|
||||
if migratedDisk.Version != savedQueriesFormatVersion {
|
||||
t.Fatalf("migrated metadata version = %d, want %d", migratedDisk.Version, savedQueriesFormatVersion)
|
||||
}
|
||||
wantFileNames := []string{"Orders (2).sql", "Orders (3).sql"}
|
||||
for index, record := range migratedDisk.Queries {
|
||||
if record.FileName != wantFileNames[index] {
|
||||
t.Fatalf("migrated file name %d = %q, want %q", index, record.FileName, wantFileNames[index])
|
||||
}
|
||||
content, readErr := os.ReadFile(savedQuerySQLPath(t, app, record.FileName))
|
||||
if readErr != nil || string(content) != fmt.Sprintf("select %d", index+1) {
|
||||
t.Fatalf("migrated sql %d = %q, %v", index, content, readErr)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(directory, oldFileNames[index])); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("old hashed sql file still exists: %s, err=%v", oldFileNames[index], statErr)
|
||||
}
|
||||
}
|
||||
content, err := os.ReadFile(externalPath)
|
||||
if err != nil || string(content) != "external sql" {
|
||||
t.Fatalf("external sql content = %q, %v", content, err)
|
||||
}
|
||||
path, found, err := app.savedQueryRepository().findSQLPath("migrate-v2-2")
|
||||
if err != nil || !found || filepath.Base(path) != "Orders (3).sql" {
|
||||
t.Fatalf("findSQLPath after migration = %q, %v, %v", path, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedQueryRepositoryVersionTwoMigrationRollsBackWhenMetadataWriteFails(t *testing.T) {
|
||||
app := newSavedQueryTestApp(t)
|
||||
directory, err := app.savedQueryRepository().sqlDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("resolve sql directory: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll sql directory: %v", err)
|
||||
}
|
||||
oldFileName := "Rollback--11111111111111111111111111111111.sql"
|
||||
oldPath := filepath.Join(directory, oldFileName)
|
||||
if err := os.WriteFile(oldPath, []byte("select 1"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile old managed sql: %v", err)
|
||||
}
|
||||
diskFile := savedQueriesDiskFile{
|
||||
Version: 2,
|
||||
Queries: []savedQueryDiskRecord{{
|
||||
ID: "migrate-rollback", Name: "Rollback", FileName: oldFileName, ConnectionID: "conn-1", DBName: "app", CreatedAt: 1,
|
||||
}},
|
||||
}
|
||||
payload, err := json.Marshal(diskFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal v2 metadata: %v", err)
|
||||
}
|
||||
metadataPath := filepath.Join(app.configDir, savedQueriesFileName)
|
||||
if err := os.WriteFile(metadataPath, payload, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile v2 metadata: %v", err)
|
||||
}
|
||||
|
||||
previousWrite := writeSavedQueriesMetadataAtomic
|
||||
writeSavedQueriesMetadataAtomic = func(string, []byte) error {
|
||||
return fmt.Errorf("forced metadata write failure")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
writeSavedQueriesMetadataAtomic = previousWrite
|
||||
})
|
||||
|
||||
if _, err := app.GetSavedQueries(); err == nil {
|
||||
t.Fatal("expected migration to fail when metadata cannot be written")
|
||||
}
|
||||
afterMetadata, err := os.ReadFile(metadataPath)
|
||||
if err != nil || !bytes.Equal(afterMetadata, payload) {
|
||||
t.Fatalf("metadata after failed migration = %q, %v", afterMetadata, err)
|
||||
}
|
||||
oldContent, err := os.ReadFile(oldPath)
|
||||
if err != nil || string(oldContent) != "select 1" {
|
||||
t.Fatalf("old managed sql after failed migration = %q, %v", oldContent, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(directory, "Rollback.sql")); !os.IsNotExist(err) {
|
||||
t.Fatalf("new readable sql should be rolled back, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedQueryRepositoryFindSQLPathTracksManagedFileName(t *testing.T) {
|
||||
app := newSavedQueryTestApp(t)
|
||||
query := connection.SavedQuery{
|
||||
ID: "saved-reveal-path",
|
||||
Name: "Before reveal",
|
||||
SQL: "select 1;",
|
||||
ConnectionID: "conn-1",
|
||||
DBName: "app",
|
||||
CreatedAt: 100,
|
||||
}
|
||||
if _, err := app.SaveQuery(query); err != nil {
|
||||
t.Fatalf("SaveQuery returned error: %v", err)
|
||||
}
|
||||
|
||||
repository := app.savedQueryRepository()
|
||||
beforePath, found, err := repository.findSQLPath(query.ID)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("findSQLPath before rename = %q, %v, %v", beforePath, found, err)
|
||||
}
|
||||
if _, err := os.Stat(beforePath); err != nil {
|
||||
t.Fatalf("managed sql path before rename is unavailable: %v", err)
|
||||
}
|
||||
|
||||
if _, err := app.RenameSavedQuery(query.ID, "After reveal"); err != nil {
|
||||
t.Fatalf("RenameSavedQuery returned error: %v", err)
|
||||
}
|
||||
afterPath, found, err := repository.findSQLPath(query.ID)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("findSQLPath after rename = %q, %v, %v", afterPath, found, err)
|
||||
}
|
||||
if afterPath == beforePath {
|
||||
t.Fatalf("findSQLPath still returns the pre-rename path: %s", afterPath)
|
||||
}
|
||||
if _, err := os.Stat(afterPath); err != nil {
|
||||
t.Fatalf("managed sql path after rename is unavailable: %v", err)
|
||||
}
|
||||
if _, found, err := repository.findSQLPath("missing-query"); err != nil || found {
|
||||
t.Fatalf("findSQLPath for a missing query returned found=%v, err=%v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedQueryRepositoryMigratesLegacyInlineSQLIdempotently(t *testing.T) {
|
||||
app := newSavedQueryTestApp(t)
|
||||
legacyPayload := []byte(`{
|
||||
"queries": [{
|
||||
"id": "saved-legacy-file",
|
||||
"name": "Legacy",
|
||||
"sql": "select 1;\n",
|
||||
"connectionId": "conn-1",
|
||||
"dbName": "app",
|
||||
"createdAt": 100
|
||||
}]
|
||||
}`)
|
||||
if err := os.WriteFile(filepath.Join(app.configDir, savedQueriesFileName), legacyPayload, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile legacy metadata: %v", err)
|
||||
}
|
||||
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
queries, err := app.GetSavedQueries()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSavedQueries attempt %d returned error: %v", attempt+1, err)
|
||||
}
|
||||
if len(queries) != 1 || queries[0].SQL != "select 1;\n" {
|
||||
t.Fatalf("unexpected migrated query on attempt %d: %#v", attempt+1, queries)
|
||||
}
|
||||
}
|
||||
diskFile, payload := readSavedQueriesDiskFile(t, app)
|
||||
if bytes.Contains(payload, []byte(`"sql"`)) {
|
||||
t.Fatalf("migrated metadata still contains inline sql: %s", payload)
|
||||
}
|
||||
if len(diskFile.Queries) != 1 {
|
||||
t.Fatalf("unexpected migrated metadata: %#v", diskFile)
|
||||
}
|
||||
content, err := os.ReadFile(savedQuerySQLPath(t, app, diskFile.Queries[0].FileName))
|
||||
if err != nil || string(content) != "select 1;\n" {
|
||||
t.Fatalf("migrated sql content = %q, %v", content, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedQueryRepositoryLegacyMigrationFailureLeavesJSONUntouched(t *testing.T) {
|
||||
app := newSavedQueryTestApp(t)
|
||||
legacyPayload := []byte(`{"queries":[{"id":"saved-blocked","name":"Blocked","sql":"select 1","connectionId":"conn-1","dbName":"app","createdAt":100}]}`)
|
||||
metadataPath := filepath.Join(app.configDir, savedQueriesFileName)
|
||||
if err := os.WriteFile(metadataPath, legacyPayload, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile legacy metadata: %v", err)
|
||||
}
|
||||
directory, err := app.savedQueryRepository().sqlDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("resolve sql directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(directory, []byte("blocks directory creation"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile blocking path: %v", err)
|
||||
}
|
||||
|
||||
if _, err := app.GetSavedQueries(); err == nil {
|
||||
t.Fatal("expected legacy migration to fail when the sql directory is blocked")
|
||||
}
|
||||
after, err := os.ReadFile(metadataPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile metadata after failure: %v", err)
|
||||
}
|
||||
if !bytes.Equal(after, legacyPayload) {
|
||||
t.Fatalf("legacy metadata changed after failed migration:\n%s", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedQueryRepositoryReloadsExternallyModifiedSQL(t *testing.T) {
|
||||
app := newSavedQueryTestApp(t)
|
||||
if _, err := app.SaveQuery(connection.SavedQuery{
|
||||
ID: "saved-external-edit", Name: "External", SQL: "select 1", ConnectionID: "conn-1", DBName: "app", CreatedAt: 100,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveQuery returned error: %v", err)
|
||||
}
|
||||
diskFile, _ := readSavedQueriesDiskFile(t, app)
|
||||
path := savedQuerySQLPath(t, app, diskFile.Queries[0].FileName)
|
||||
if err := os.WriteFile(path, []byte("select 2 -- edited externally"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile external edit: %v", err)
|
||||
}
|
||||
|
||||
queries, err := app.GetSavedQueries()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSavedQueries returned error: %v", err)
|
||||
}
|
||||
if len(queries) != 1 || queries[0].SQL != "select 2 -- edited externally" {
|
||||
t.Fatalf("expected latest disk content, got %#v", queries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameSavedQueryPreservesLatestDiskContent(t *testing.T) {
|
||||
app := newSavedQueryTestApp(t)
|
||||
if _, err := app.SaveQuery(connection.SavedQuery{
|
||||
ID: "saved-rename-file", Name: "Before", SQL: "select 1", ConnectionID: "conn-1", DBName: "app", CreatedAt: 100,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveQuery returned error: %v", err)
|
||||
}
|
||||
beforeDisk, _ := readSavedQueriesDiskFile(t, app)
|
||||
oldPath := savedQuerySQLPath(t, app, beforeDisk.Queries[0].FileName)
|
||||
latestSQL := "select 2 -- edited outside GoNavi"
|
||||
if err := os.WriteFile(oldPath, []byte(latestSQL), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile external edit: %v", err)
|
||||
}
|
||||
|
||||
renamed, err := app.RenameSavedQuery("saved-rename-file", "After")
|
||||
if err != nil {
|
||||
t.Fatalf("RenameSavedQuery returned error: %v", err)
|
||||
}
|
||||
if renamed.Name != "After" || renamed.SQL != latestSQL {
|
||||
t.Fatalf("unexpected renamed query: %#v", renamed)
|
||||
}
|
||||
afterDisk, payload := readSavedQueriesDiskFile(t, app)
|
||||
if bytes.Contains(payload, []byte(`"sql"`)) {
|
||||
t.Fatalf("renamed metadata contains inline sql: %s", payload)
|
||||
}
|
||||
newPath := savedQuerySQLPath(t, app, afterDisk.Queries[0].FileName)
|
||||
if newPath == oldPath {
|
||||
t.Fatalf("expected query rename to rename the managed sql file: %s", newPath)
|
||||
}
|
||||
if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("old sql path should be gone, got err=%v", err)
|
||||
}
|
||||
content, err := os.ReadFile(newPath)
|
||||
if err != nil || string(content) != latestSQL {
|
||||
t.Fatalf("renamed sql content = %q, %v", content, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSavedQueryRemovesSQLFileAndGroupMembership(t *testing.T) {
|
||||
app := newSavedQueryTestApp(t)
|
||||
if _, err := app.SaveQuery(connection.SavedQuery{
|
||||
ID: "saved-delete-file", Name: "Delete", SQL: "select 1", ConnectionID: "conn-1", DBName: "app", CreatedAt: 100,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveQuery returned error: %v", err)
|
||||
}
|
||||
if _, err := app.SaveSavedQueryGroup(connection.SavedQueryGroup{
|
||||
ID: "group-delete-file", Name: "Delete", QueryIDs: []string{"saved-delete-file"},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveSavedQueryGroup returned error: %v", err)
|
||||
}
|
||||
diskFile, _ := readSavedQueriesDiskFile(t, app)
|
||||
queryPath := savedQuerySQLPath(t, app, diskFile.Queries[0].FileName)
|
||||
|
||||
if err := app.DeleteQuery("saved-delete-file"); err != nil {
|
||||
t.Fatalf("DeleteQuery returned error: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(queryPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("deleted query sql file still exists, err=%v", err)
|
||||
}
|
||||
groups, err := app.GetSavedQueryGroups()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSavedQueryGroups returned error: %v", err)
|
||||
}
|
||||
group := findSavedQueryGroup(groups, "group-delete-file")
|
||||
if group == nil || len(group.QueryIDs) != 0 || len(group.ChildOrder) != 0 {
|
||||
t.Fatalf("deleted query still belongs to a group: %#v", group)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedQueryRepositorySaveUpdateAndDelete(t *testing.T) {
|
||||
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
|
||||
app.configDir = t.TempDir()
|
||||
@@ -865,3 +1316,35 @@ func containsString(values []string, target string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func newSavedQueryTestApp(t *testing.T) *App {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
application := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
|
||||
application.configDir = t.TempDir()
|
||||
return application
|
||||
}
|
||||
|
||||
func readSavedQueriesDiskFile(t *testing.T, application *App) (savedQueriesDiskFile, []byte) {
|
||||
t.Helper()
|
||||
payload, err := os.ReadFile(filepath.Join(application.configDir, savedQueriesFileName))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile saved query metadata: %v", err)
|
||||
}
|
||||
var file savedQueriesDiskFile
|
||||
if err := json.Unmarshal(payload, &file); err != nil {
|
||||
t.Fatalf("Unmarshal saved query metadata: %v", err)
|
||||
}
|
||||
return file, payload
|
||||
}
|
||||
|
||||
func savedQuerySQLPath(t *testing.T, application *App, fileName string) string {
|
||||
t.Helper()
|
||||
directory, err := application.savedQueryRepository().sqlDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("resolve saved query sql directory: %v", err)
|
||||
}
|
||||
return filepath.Join(directory, fileName)
|
||||
}
|
||||
|
||||
@@ -10,9 +10,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
bootstrapFileName = "storage_root.json"
|
||||
bootstrapLockFileName = bootstrapFileName + ".lock"
|
||||
configuredLogFileName = "gonavi.log"
|
||||
bootstrapFileName = "storage_root.json"
|
||||
bootstrapLockFileName = bootstrapFileName + ".lock"
|
||||
configuredLogFileName = "gonavi.log"
|
||||
savedQueryDirectoryName = "saved_queries"
|
||||
savedQueryDirectoryProbePrefix = ".gonavi-saved-query-"
|
||||
)
|
||||
const dataRootEnvName = "GONAVI_DATA_ROOT"
|
||||
|
||||
@@ -63,8 +65,9 @@ func SetActiveRootErrorDetail(err error) error {
|
||||
}
|
||||
|
||||
type bootstrapConfig struct {
|
||||
DataRoot string `json:"dataRoot,omitempty"`
|
||||
LogDirectory string `json:"logDirectory,omitempty"`
|
||||
DataRoot string `json:"dataRoot,omitempty"`
|
||||
LogDirectory string `json:"logDirectory,omitempty"`
|
||||
SavedQueryDirectory string `json:"savedQueryDirectory,omitempty"`
|
||||
}
|
||||
|
||||
func readBootstrapConfig() (bootstrapConfig, error) {
|
||||
@@ -85,7 +88,8 @@ func readBootstrapConfig() (bootstrapConfig, error) {
|
||||
func writeBootstrapConfig(cfg bootstrapConfig) error {
|
||||
cfg.DataRoot = strings.TrimSpace(cfg.DataRoot)
|
||||
cfg.LogDirectory = strings.TrimSpace(cfg.LogDirectory)
|
||||
if cfg.DataRoot == "" && cfg.LogDirectory == "" {
|
||||
cfg.SavedQueryDirectory = strings.TrimSpace(cfg.SavedQueryDirectory)
|
||||
if cfg.DataRoot == "" && cfg.LogDirectory == "" && cfg.SavedQueryDirectory == "" {
|
||||
if err := os.Remove(BootstrapPath()); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
@@ -228,6 +232,17 @@ func DriverRoot(activeRoot string) string {
|
||||
return filepath.Join(root, "drivers")
|
||||
}
|
||||
|
||||
func DefaultSavedQueryDirectory(activeRoot string) string {
|
||||
root := strings.TrimSpace(activeRoot)
|
||||
if root == "" {
|
||||
root = MustResolveActiveRoot()
|
||||
}
|
||||
if abs, err := filepath.Abs(root); err == nil {
|
||||
root = abs
|
||||
}
|
||||
return filepath.Join(filepath.Clean(root), savedQueryDirectoryName)
|
||||
}
|
||||
|
||||
func SetActiveRoot(root string) (string, error) {
|
||||
targetRoot, err := normalizeRoot(root)
|
||||
if err != nil {
|
||||
@@ -303,3 +318,66 @@ func SetConfiguredLogDirectory(directory string) (string, error) {
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func ResolveConfiguredSavedQueryDirectory() (string, error) {
|
||||
bootstrapConfigMu.Lock()
|
||||
cfg, err := readBootstrapConfig()
|
||||
bootstrapConfigMu.Unlock()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
directory := strings.TrimSpace(cfg.SavedQueryDirectory)
|
||||
if directory == "" {
|
||||
return "", nil
|
||||
}
|
||||
abs, err := filepath.Abs(directory)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Clean(abs), nil
|
||||
}
|
||||
|
||||
func ResolveSavedQueryDirectory(activeRoot string) (string, error) {
|
||||
directory, err := ResolveConfiguredSavedQueryDirectory()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if directory != "" {
|
||||
return directory, nil
|
||||
}
|
||||
return DefaultSavedQueryDirectory(activeRoot), nil
|
||||
}
|
||||
|
||||
func SetConfiguredSavedQueryDirectory(directory string) (string, error) {
|
||||
target := strings.TrimSpace(directory)
|
||||
if target != "" {
|
||||
abs, resolveErr := filepath.Abs(target)
|
||||
if resolveErr != nil {
|
||||
return "", resolveErr
|
||||
}
|
||||
target = filepath.Clean(abs)
|
||||
if mkdirErr := os.MkdirAll(target, 0o755); mkdirErr != nil {
|
||||
return "", mkdirErr
|
||||
}
|
||||
|
||||
probe, createErr := os.CreateTemp(target, savedQueryDirectoryProbePrefix)
|
||||
if createErr != nil {
|
||||
return "", createErr
|
||||
}
|
||||
probePath := probe.Name()
|
||||
if closeErr := probe.Close(); closeErr != nil {
|
||||
_ = os.Remove(probePath)
|
||||
return "", closeErr
|
||||
}
|
||||
if removeErr := os.Remove(probePath); removeErr != nil {
|
||||
return "", removeErr
|
||||
}
|
||||
}
|
||||
|
||||
if err := updateBootstrapConfig(func(cfg *bootstrapConfig) {
|
||||
cfg.SavedQueryDirectory = target
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
@@ -146,6 +146,120 @@ func TestDataRootAndLogDirectoryPreserveEachOtherInBootstrap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSavedQueryDirectoryUsesActiveRoot(t *testing.T) {
|
||||
activeRoot := filepath.Join(t.TempDir(), "gonavi-data")
|
||||
want := filepath.Join(activeRoot, savedQueryDirectoryName)
|
||||
if got := DefaultSavedQueryDirectory(activeRoot); got != want {
|
||||
t.Fatalf("DefaultSavedQueryDirectory = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedQueryDirectoryFollowsDataRootOnlyWhileUsingDefault(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
t.Setenv("USERPROFILE", homeDir)
|
||||
|
||||
firstRoot := filepath.Join(t.TempDir(), "first-root")
|
||||
secondRoot := filepath.Join(t.TempDir(), "second-root")
|
||||
if _, err := SetActiveRoot(firstRoot); err != nil {
|
||||
t.Fatalf("SetActiveRoot first returned error: %v", err)
|
||||
}
|
||||
resolved, err := ResolveSavedQueryDirectory(firstRoot)
|
||||
if err != nil || resolved != filepath.Join(firstRoot, savedQueryDirectoryName) {
|
||||
t.Fatalf("first default saved query directory = %q, %v", resolved, err)
|
||||
}
|
||||
if _, err := SetActiveRoot(secondRoot); err != nil {
|
||||
t.Fatalf("SetActiveRoot second returned error: %v", err)
|
||||
}
|
||||
resolved, err = ResolveSavedQueryDirectory(secondRoot)
|
||||
if err != nil || resolved != filepath.Join(secondRoot, savedQueryDirectoryName) {
|
||||
t.Fatalf("second default saved query directory = %q, %v", resolved, err)
|
||||
}
|
||||
|
||||
customDirectory := filepath.Join(t.TempDir(), "custom-saved-queries")
|
||||
if _, err := SetConfiguredSavedQueryDirectory(customDirectory); err != nil {
|
||||
t.Fatalf("SetConfiguredSavedQueryDirectory returned error: %v", err)
|
||||
}
|
||||
if _, err := SetActiveRoot(firstRoot); err != nil {
|
||||
t.Fatalf("restore SetActiveRoot first returned error: %v", err)
|
||||
}
|
||||
resolved, err = ResolveSavedQueryDirectory(firstRoot)
|
||||
if err != nil || resolved != customDirectory {
|
||||
t.Fatalf("custom saved query directory after data-root switch = %q, %v; want %q", resolved, err, customDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedQueryDirectoryAndOtherSettingsPreserveEachOtherInBootstrap(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
t.Setenv("USERPROFILE", homeDir)
|
||||
|
||||
customDataRoot := filepath.Join(t.TempDir(), "gonavi-data")
|
||||
customLogDirectory := filepath.Join(t.TempDir(), "gonavi-logs")
|
||||
customSavedQueryDirectory := filepath.Join(t.TempDir(), "saved-queries")
|
||||
if _, err := SetActiveRoot(customDataRoot); err != nil {
|
||||
t.Fatalf("SetActiveRoot returned error: %v", err)
|
||||
}
|
||||
if _, err := SetConfiguredLogDirectory(customLogDirectory); err != nil {
|
||||
t.Fatalf("SetConfiguredLogDirectory returned error: %v", err)
|
||||
}
|
||||
if _, err := SetConfiguredSavedQueryDirectory(customSavedQueryDirectory); err != nil {
|
||||
t.Fatalf("SetConfiguredSavedQueryDirectory returned error: %v", err)
|
||||
}
|
||||
|
||||
resolvedSavedQueryDirectory, err := ResolveSavedQueryDirectory(customDataRoot)
|
||||
if err != nil || resolvedSavedQueryDirectory != customSavedQueryDirectory {
|
||||
t.Fatalf("saved query directory = %q, %v; want %q", resolvedSavedQueryDirectory, err, customSavedQueryDirectory)
|
||||
}
|
||||
|
||||
if _, err := SetActiveRoot(""); err != nil {
|
||||
t.Fatalf("reset SetActiveRoot returned error: %v", err)
|
||||
}
|
||||
if _, err := SetConfiguredLogDirectory(""); err != nil {
|
||||
t.Fatalf("reset SetConfiguredLogDirectory returned error: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(BootstrapPath()); err != nil {
|
||||
t.Fatalf("bootstrap should remain while saved query directory is customized: %v", err)
|
||||
}
|
||||
resolvedSavedQueryDirectory, err = ResolveConfiguredSavedQueryDirectory()
|
||||
if err != nil || resolvedSavedQueryDirectory != customSavedQueryDirectory {
|
||||
t.Fatalf("configured saved query directory = %q, %v; want %q", resolvedSavedQueryDirectory, err, customSavedQueryDirectory)
|
||||
}
|
||||
|
||||
if _, err := SetConfiguredSavedQueryDirectory(""); err != nil {
|
||||
t.Fatalf("reset SetConfiguredSavedQueryDirectory returned error: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(BootstrapPath()); !os.IsNotExist(err) {
|
||||
t.Fatalf("bootstrap should be removed when all settings use defaults, got err=%v", err)
|
||||
}
|
||||
defaultSavedQueryDirectory := filepath.Join(homeDir, ".gonavi", savedQueryDirectoryName)
|
||||
resolvedSavedQueryDirectory, err = ResolveSavedQueryDirectory("")
|
||||
if err != nil || resolvedSavedQueryDirectory != defaultSavedQueryDirectory {
|
||||
t.Fatalf("default saved query directory = %q, %v; want %q", resolvedSavedQueryDirectory, err, defaultSavedQueryDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetConfiguredSavedQueryDirectoryRejectsFilePathWithoutChangingConfig(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
t.Setenv("USERPROFILE", homeDir)
|
||||
|
||||
blockingPath := filepath.Join(t.TempDir(), "not-a-directory")
|
||||
if err := os.WriteFile(blockingPath, []byte("blocked"), 0o644); err != nil {
|
||||
t.Fatalf("write blocking file: %v", err)
|
||||
}
|
||||
if _, err := SetConfiguredSavedQueryDirectory(blockingPath); err == nil {
|
||||
t.Fatal("expected file path to be rejected as a saved query directory")
|
||||
}
|
||||
configured, err := ResolveConfiguredSavedQueryDirectory()
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConfiguredSavedQueryDirectory returned error: %v", err)
|
||||
}
|
||||
if configured != "" {
|
||||
t.Fatalf("failed update changed configured saved query directory to %q", configured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetConfiguredLogDirectoryRejectsFilePathWithoutChangingConfig(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
|
||||
@@ -46,6 +46,10 @@ var desktopOnlyAppMethods = map[string]struct{}{
|
||||
"SelectLogDirectory": {},
|
||||
"ApplyLogDirectory": {},
|
||||
"OpenLogDirectory": {},
|
||||
"SelectSavedQueryDirectory": {},
|
||||
"ApplySavedQueryDirectory": {},
|
||||
"OpenSavedQueryDirectory": {},
|
||||
"RevealSavedQueryInFolder": {},
|
||||
"SelectDriverDownloadDirectory": {},
|
||||
"SelectDriverPackageFile": {},
|
||||
"SelectDriverPackageDirectory": {},
|
||||
|
||||
@@ -106,7 +106,8 @@ func TestMethodInvokerRejectsDesktopOnlyAppMethodsBeforeReflection(t *testing.T)
|
||||
"Shutdown", "ExportSQLAuditFile", "OpenSQLFile", "ExecuteSQLFile", "ReadSQLFile",
|
||||
"PreviewImportFile", "ImportDatabaseSQL", "ImportDataWithProgress", "ImportDataWithProgressOptions", "GetDataRootDirectoryInfo",
|
||||
"ExportDatabaseSQLWithOptions", "ExportSchemaSQLWithOptions",
|
||||
"ApplyDataRootDirectory", "OpenDataRootDirectory", "SelectLogDirectory", "ApplyLogDirectory", "OpenLogDirectory", "SetApplicationBrandIcon",
|
||||
"ApplyDataRootDirectory", "OpenDataRootDirectory", "SelectLogDirectory", "ApplyLogDirectory", "OpenLogDirectory",
|
||||
"SelectSavedQueryDirectory", "ApplySavedQueryDirectory", "OpenSavedQueryDirectory", "RevealSavedQueryInFolder", "SetApplicationBrandIcon",
|
||||
} {
|
||||
_, err := invoker.Invoke(invokeRequest{Namespace: "app", Receiver: "app", Method: method})
|
||||
if err == nil || !strings.Contains(err.Error(), "unavailable in web runtime") {
|
||||
|
||||
Reference in New Issue
Block a user