mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-21 16:34:21 +08:00
✨ feat(saved-query): 支持多级已存查询分组
- 新增已存 SQL 分组的持久化模型与后端管理接口 - 支持创建、编辑、嵌套、排序、移动和删除分组 - 在侧栏树和菜单中提供分组管理与未分组查询视图 - 同步 Wails 绑定、六种语言文案及前后端回归测试
This commit is contained in:
@@ -25,6 +25,13 @@ func (a *App) GetSavedQueries() ([]connection.SavedQuery, error) {
|
||||
return resolveSavedQueryBindings(queries, currentConnections, nil), nil
|
||||
}
|
||||
|
||||
func (a *App) GetSavedQueryGroups() ([]connection.SavedQueryGroup, error) {
|
||||
savedQueriesMu.Lock()
|
||||
defer savedQueriesMu.Unlock()
|
||||
|
||||
return a.savedQueryRepository().loadGroups()
|
||||
}
|
||||
|
||||
func (a *App) SaveQuery(input connection.SavedQuery) (connection.SavedQuery, error) {
|
||||
if strings.TrimSpace(input.Name) == "" {
|
||||
input.Name = a.localizedSavedQueryDefaultName(0)
|
||||
@@ -61,6 +68,31 @@ func (a *App) DeleteQuery(id string) error {
|
||||
return a.savedQueryRepository().Delete(id)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (a *App) SaveSavedQueryGroup(input connection.SavedQueryGroup) (connection.SavedQueryGroup, error) {
|
||||
return a.savedQueryRepository().SaveGroup(input)
|
||||
}
|
||||
|
||||
// DeleteSavedQueryGroup removes a group and promotes its direct queries and
|
||||
// child groups to the deleted group's parent when it has one.
|
||||
func (a *App) DeleteSavedQueryGroup(id string) error {
|
||||
return a.savedQueryRepository().DeleteGroup(id)
|
||||
}
|
||||
|
||||
// MoveSavedQueryToGroup moves a saved query to a direct group. An empty group
|
||||
// id makes the query ungrouped.
|
||||
func (a *App) MoveSavedQueryToGroup(queryID string, groupID string) error {
|
||||
return a.savedQueryRepository().MoveQueryToGroup(queryID, groupID)
|
||||
}
|
||||
|
||||
// MoveSavedQueryGroup reparents a saved-query group. An empty parent id moves
|
||||
// it to the root level.
|
||||
func (a *App) MoveSavedQueryGroup(groupID string, parentGroupID string) error {
|
||||
return a.savedQueryRepository().MoveGroup(groupID, parentGroupID)
|
||||
}
|
||||
|
||||
func (a *App) RebindSavedQuery(id string, connectionID string) (connection.SavedQuery, error) {
|
||||
target, err := a.savedConnectionRepository().Find(connectionID)
|
||||
if err != nil {
|
||||
|
||||
@@ -15,10 +15,16 @@ import (
|
||||
|
||||
const savedQueriesFileName = "saved_queries.json"
|
||||
|
||||
const (
|
||||
savedQueryGroupTokenPrefix = "group:"
|
||||
savedQueryTokenPrefix = "query:"
|
||||
)
|
||||
|
||||
var savedQueriesMu sync.Mutex
|
||||
|
||||
type savedQueriesFile struct {
|
||||
Queries []connection.SavedQuery `json:"queries"`
|
||||
Queries []connection.SavedQuery `json:"queries"`
|
||||
Groups []connection.SavedQueryGroup `json:"groups,omitempty"`
|
||||
}
|
||||
|
||||
type savedQueryRepository struct {
|
||||
@@ -36,36 +42,61 @@ func (r *savedQueryRepository) queriesPath() string {
|
||||
return filepath.Join(r.configDir, savedQueriesFileName)
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) load() ([]connection.SavedQuery, error) {
|
||||
func (r *savedQueryRepository) loadFile() (savedQueriesFile, error) {
|
||||
data, err := os.ReadFile(r.queriesPath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []connection.SavedQuery{}, nil
|
||||
return savedQueriesFile{Queries: []connection.SavedQuery{}, Groups: []connection.SavedQueryGroup{}}, nil
|
||||
}
|
||||
return nil, err
|
||||
return savedQueriesFile{}, err
|
||||
}
|
||||
|
||||
var file savedQueriesFile
|
||||
if err := json.Unmarshal(data, &file); err != nil {
|
||||
return nil, err
|
||||
return savedQueriesFile{}, err
|
||||
}
|
||||
if file.Queries == nil {
|
||||
return []connection.SavedQuery{}, nil
|
||||
}
|
||||
return sanitizeSavedQueries(file.Queries), nil
|
||||
return normalizeSavedQueriesFile(file), nil
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) saveAll(queries []connection.SavedQuery) error {
|
||||
func (r *savedQueryRepository) load() ([]connection.SavedQuery, error) {
|
||||
file, err := r.loadFile()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return file.Queries, nil
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) loadGroups() ([]connection.SavedQueryGroup, error) {
|
||||
file, err := r.loadFile()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return file.Groups, nil
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) saveFile(file savedQueriesFile) error {
|
||||
if err := os.MkdirAll(r.configDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := json.MarshalIndent(savedQueriesFile{Queries: sanitizeSavedQueries(queries)}, "", " ")
|
||||
payload, err := json.MarshalIndent(normalizeSavedQueriesFile(file), "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeSavedQueriesFileAtomic(r.queriesPath(), payload)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
file, err := r.loadFile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file.Queries = queries
|
||||
return r.saveFile(file)
|
||||
}
|
||||
|
||||
func writeSavedQueriesFileAtomic(targetPath string, payload []byte) error {
|
||||
dir := filepath.Dir(targetPath)
|
||||
temp, err := os.CreateTemp(dir, ".saved_queries_*.tmp")
|
||||
@@ -115,10 +146,11 @@ func (r *savedQueryRepository) Save(input connection.SavedQuery) (connection.Sav
|
||||
return connection.SavedQuery{}, fmt.Errorf("saved query requires sql, connectionId and dbName")
|
||||
}
|
||||
|
||||
queries, err := r.load()
|
||||
file, err := r.loadFile()
|
||||
if err != nil {
|
||||
return connection.SavedQuery{}, err
|
||||
}
|
||||
queries := file.Queries
|
||||
|
||||
replaced := false
|
||||
for index, item := range queries {
|
||||
@@ -131,7 +163,8 @@ func (r *savedQueryRepository) Save(input connection.SavedQuery) (connection.Sav
|
||||
if !replaced {
|
||||
queries = append(queries, query)
|
||||
}
|
||||
if err := r.saveAll(queries); err != nil {
|
||||
file.Queries = queries
|
||||
if err := r.saveFile(file); err != nil {
|
||||
return connection.SavedQuery{}, err
|
||||
}
|
||||
return query, nil
|
||||
@@ -141,10 +174,11 @@ func (r *savedQueryRepository) Import(payload connection.SavedQueryImportPayload
|
||||
savedQueriesMu.Lock()
|
||||
defer savedQueriesMu.Unlock()
|
||||
|
||||
existing, err := r.load()
|
||||
file, err := r.loadFile()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing := file.Queries
|
||||
|
||||
byID := make(map[string]int, len(existing)+len(payload.Queries))
|
||||
for index, item := range existing {
|
||||
@@ -165,7 +199,14 @@ func (r *savedQueryRepository) Import(payload connection.SavedQueryImportPayload
|
||||
existing = append(existing, query)
|
||||
}
|
||||
|
||||
if err := r.saveAll(existing); err != nil {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
return existing, nil
|
||||
@@ -180,10 +221,11 @@ func (r *savedQueryRepository) Rebind(id string, target connection.SavedConnecti
|
||||
return connection.SavedQuery{}, fmt.Errorf("saved query and target connection are required")
|
||||
}
|
||||
|
||||
queries, err := r.load()
|
||||
file, err := r.loadFile()
|
||||
if err != nil {
|
||||
return connection.SavedQuery{}, err
|
||||
}
|
||||
queries := file.Queries
|
||||
|
||||
for index, item := range queries {
|
||||
if item.ID != targetID {
|
||||
@@ -199,7 +241,8 @@ func (r *savedQueryRepository) Rebind(id string, target connection.SavedConnecti
|
||||
return connection.SavedQuery{}, fmt.Errorf("saved query is invalid: %s", targetID)
|
||||
}
|
||||
queries[index] = query
|
||||
if err := r.saveAll(queries); err != nil {
|
||||
file.Queries = queries
|
||||
if err := r.saveFile(file); err != nil {
|
||||
return connection.SavedQuery{}, err
|
||||
}
|
||||
return query, nil
|
||||
@@ -217,17 +260,629 @@ func (r *savedQueryRepository) Delete(id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
queries, err := r.load()
|
||||
file, err := r.loadFile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
queries := file.Queries
|
||||
filtered := queries[:0]
|
||||
for _, item := range queries {
|
||||
if item.ID != targetID {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
return r.saveAll(filtered)
|
||||
file.Queries = filtered
|
||||
return r.saveFile(file)
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) SaveGroup(input connection.SavedQueryGroup) (connection.SavedQueryGroup, error) {
|
||||
savedQueriesMu.Lock()
|
||||
defer savedQueriesMu.Unlock()
|
||||
|
||||
file, err := r.loadFile()
|
||||
if err != nil {
|
||||
return connection.SavedQueryGroup{}, err
|
||||
}
|
||||
|
||||
groupID := strings.TrimSpace(input.ID)
|
||||
if groupID == "" {
|
||||
groupID = "saved-query-group-" + uuid.NewString()
|
||||
}
|
||||
name := strings.TrimSpace(input.Name)
|
||||
if name == "" {
|
||||
return connection.SavedQueryGroup{}, fmt.Errorf("saved query group requires a name")
|
||||
}
|
||||
|
||||
group := connection.SavedQueryGroup{
|
||||
ID: groupID,
|
||||
Name: name,
|
||||
ParentGroupID: strings.TrimSpace(input.ParentGroupID),
|
||||
QueryIDs: sanitizeSavedQueryIDs(input.QueryIDs),
|
||||
ChildOrder: sanitizeSavedQueryGroupChildOrder(input.ChildOrder),
|
||||
}
|
||||
if err := validateSavedQueryGroupQueryIDs(input.QueryIDs, file.Queries); err != nil {
|
||||
return connection.SavedQueryGroup{}, err
|
||||
}
|
||||
groupIndex := findSavedQueryGroupIndex(file.Groups, groupID)
|
||||
|
||||
nextGroups := append([]connection.SavedQueryGroup(nil), file.Groups...)
|
||||
if groupIndex >= 0 {
|
||||
nextGroups[groupIndex] = group
|
||||
} else {
|
||||
nextGroups = append(nextGroups, group)
|
||||
}
|
||||
if err := validateSavedQueryGroupParent(nextGroups, groupID, group.ParentGroupID); err != nil {
|
||||
return connection.SavedQueryGroup{}, err
|
||||
}
|
||||
|
||||
nextGroups = removeSavedQueryIDsFromOtherGroups(nextGroups, groupID, group.QueryIDs)
|
||||
file.Groups = normalizeSavedQueryGroups(nextGroups, file.Queries)
|
||||
if err := r.saveFile(file); err != nil {
|
||||
return connection.SavedQueryGroup{}, err
|
||||
}
|
||||
|
||||
persistedIndex := findSavedQueryGroupIndex(file.Groups, groupID)
|
||||
if persistedIndex < 0 {
|
||||
return connection.SavedQueryGroup{}, fmt.Errorf("saved query group could not be persisted: %s", groupID)
|
||||
}
|
||||
return file.Groups[persistedIndex], nil
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) DeleteGroup(id string) error {
|
||||
savedQueriesMu.Lock()
|
||||
defer savedQueriesMu.Unlock()
|
||||
|
||||
groupID := strings.TrimSpace(id)
|
||||
if groupID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
file, err := r.loadFile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groupIndex := findSavedQueryGroupIndex(file.Groups, groupID)
|
||||
if groupIndex < 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
removed := file.Groups[groupIndex]
|
||||
promotedOrder := resolveSavedQueryGroupChildOrder(groupID, file.Groups)
|
||||
removedToken := buildSavedQueryGroupToken(groupID)
|
||||
nextGroups := make([]connection.SavedQueryGroup, 0, len(file.Groups)-1)
|
||||
for _, candidate := range file.Groups {
|
||||
if candidate.ID == groupID {
|
||||
continue
|
||||
}
|
||||
next := candidate
|
||||
if next.ParentGroupID == groupID {
|
||||
next.ParentGroupID = removed.ParentGroupID
|
||||
}
|
||||
if next.ID == removed.ParentGroupID {
|
||||
next.QueryIDs = append(next.QueryIDs, removed.QueryIDs...)
|
||||
next.ChildOrder = replaceSavedQueryGroupChildOrderToken(
|
||||
next.ChildOrder,
|
||||
removedToken,
|
||||
promotedOrder,
|
||||
)
|
||||
}
|
||||
nextGroups = append(nextGroups, next)
|
||||
}
|
||||
|
||||
file.Groups = normalizeSavedQueryGroups(nextGroups, file.Queries)
|
||||
return r.saveFile(file)
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) MoveQueryToGroup(queryID string, groupID string) error {
|
||||
savedQueriesMu.Lock()
|
||||
defer savedQueriesMu.Unlock()
|
||||
|
||||
targetQueryID := strings.TrimSpace(queryID)
|
||||
if targetQueryID == "" {
|
||||
return fmt.Errorf("saved query is required")
|
||||
}
|
||||
|
||||
file, err := r.loadFile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !savedQueryExists(file.Queries, targetQueryID) {
|
||||
return fmt.Errorf("saved query not found: %s", targetQueryID)
|
||||
}
|
||||
|
||||
targetGroupID := strings.TrimSpace(groupID)
|
||||
targetGroupIndex := -1
|
||||
if targetGroupID != "" {
|
||||
targetGroupIndex = findSavedQueryGroupIndex(file.Groups, targetGroupID)
|
||||
if targetGroupIndex < 0 {
|
||||
return fmt.Errorf("saved query group not found: %s", targetGroupID)
|
||||
}
|
||||
}
|
||||
|
||||
queryToken := buildSavedQueryToken(targetQueryID)
|
||||
nextGroups := append([]connection.SavedQueryGroup(nil), file.Groups...)
|
||||
for index := range nextGroups {
|
||||
nextGroups[index].QueryIDs = removeSavedQueryID(nextGroups[index].QueryIDs, targetQueryID)
|
||||
nextGroups[index].ChildOrder = removeSavedQueryGroupChildOrderToken(
|
||||
nextGroups[index].ChildOrder,
|
||||
queryToken,
|
||||
)
|
||||
}
|
||||
if targetGroupIndex >= 0 {
|
||||
nextGroups[targetGroupIndex].QueryIDs = append(nextGroups[targetGroupIndex].QueryIDs, targetQueryID)
|
||||
nextGroups[targetGroupIndex].ChildOrder = append(nextGroups[targetGroupIndex].ChildOrder, queryToken)
|
||||
}
|
||||
|
||||
file.Groups = normalizeSavedQueryGroups(nextGroups, file.Queries)
|
||||
return r.saveFile(file)
|
||||
}
|
||||
|
||||
func (r *savedQueryRepository) MoveGroup(groupID string, parentGroupID string) error {
|
||||
savedQueriesMu.Lock()
|
||||
defer savedQueriesMu.Unlock()
|
||||
|
||||
targetGroupID := strings.TrimSpace(groupID)
|
||||
if targetGroupID == "" {
|
||||
return fmt.Errorf("saved query group is required")
|
||||
}
|
||||
|
||||
file, err := r.loadFile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groupIndex := findSavedQueryGroupIndex(file.Groups, targetGroupID)
|
||||
if groupIndex < 0 {
|
||||
return fmt.Errorf("saved query group not found: %s", targetGroupID)
|
||||
}
|
||||
nextParentGroupID := strings.TrimSpace(parentGroupID)
|
||||
if err := validateSavedQueryGroupParent(file.Groups, targetGroupID, nextParentGroupID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
groupToken := buildSavedQueryGroupToken(targetGroupID)
|
||||
nextGroups := append([]connection.SavedQueryGroup(nil), file.Groups...)
|
||||
for index := range nextGroups {
|
||||
nextGroups[index].ChildOrder = removeSavedQueryGroupChildOrderToken(
|
||||
nextGroups[index].ChildOrder,
|
||||
groupToken,
|
||||
)
|
||||
}
|
||||
nextGroups[groupIndex].ParentGroupID = nextParentGroupID
|
||||
if nextParentGroupID != "" {
|
||||
parentIndex := findSavedQueryGroupIndex(nextGroups, nextParentGroupID)
|
||||
nextGroups[parentIndex].ChildOrder = append(nextGroups[parentIndex].ChildOrder, groupToken)
|
||||
}
|
||||
|
||||
file.Groups = normalizeSavedQueryGroups(nextGroups, file.Queries)
|
||||
return r.saveFile(file)
|
||||
}
|
||||
|
||||
func normalizeSavedQueriesFile(file savedQueriesFile) savedQueriesFile {
|
||||
queries := sanitizeSavedQueries(file.Queries)
|
||||
return savedQueriesFile{
|
||||
Queries: queries,
|
||||
Groups: normalizeSavedQueryGroups(file.Groups, queries),
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSavedQueryGroups(
|
||||
input []connection.SavedQueryGroup,
|
||||
queries []connection.SavedQuery,
|
||||
) []connection.SavedQueryGroup {
|
||||
validQueryIDs := make(map[string]struct{}, len(queries))
|
||||
for _, query := range queries {
|
||||
validQueryIDs[query.ID] = struct{}{}
|
||||
}
|
||||
|
||||
groups := make([]connection.SavedQueryGroup, 0, len(input))
|
||||
seenGroupIDs := make(map[string]struct{}, len(input))
|
||||
for index, item := range input {
|
||||
id := strings.TrimSpace(item.ID)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seenGroupIDs[id]; exists {
|
||||
continue
|
||||
}
|
||||
seenGroupIDs[id] = struct{}{}
|
||||
name := strings.TrimSpace(item.Name)
|
||||
if name == "" {
|
||||
name = defaultSavedQueryGroupName(index)
|
||||
}
|
||||
groups = append(groups, connection.SavedQueryGroup{
|
||||
ID: id,
|
||||
Name: name,
|
||||
ParentGroupID: strings.TrimSpace(item.ParentGroupID),
|
||||
QueryIDs: sanitizeSavedQueryIDs(item.QueryIDs),
|
||||
ChildOrder: sanitizeSavedQueryGroupChildOrder(item.ChildOrder),
|
||||
})
|
||||
}
|
||||
if len(groups) == 0 {
|
||||
return []connection.SavedQueryGroup{}
|
||||
}
|
||||
|
||||
groupIndexByID := make(map[string]int, len(groups))
|
||||
for index, group := range groups {
|
||||
groupIndexByID[group.ID] = index
|
||||
}
|
||||
for index := range groups {
|
||||
parentID := groups[index].ParentGroupID
|
||||
if parentID == "" || parentID == groups[index].ID {
|
||||
groups[index].ParentGroupID = ""
|
||||
continue
|
||||
}
|
||||
if _, exists := groupIndexByID[parentID]; !exists {
|
||||
groups[index].ParentGroupID = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Corrupt persisted parent cycles must not make the sidebar recurse forever.
|
||||
for _, group := range groups {
|
||||
path := make([]string, 0, len(groups))
|
||||
pathIndex := make(map[string]int, len(groups))
|
||||
currentID := group.ID
|
||||
for currentID != "" {
|
||||
currentIndex, exists := groupIndexByID[currentID]
|
||||
if !exists || groups[currentIndex].ParentGroupID == "" {
|
||||
break
|
||||
}
|
||||
if cycleStart, found := pathIndex[currentID]; found {
|
||||
for _, cycleID := range path[cycleStart:] {
|
||||
groups[groupIndexByID[cycleID]].ParentGroupID = ""
|
||||
}
|
||||
break
|
||||
}
|
||||
pathIndex[currentID] = len(path)
|
||||
path = append(path, currentID)
|
||||
currentID = groups[currentIndex].ParentGroupID
|
||||
}
|
||||
}
|
||||
|
||||
assignedQueryIDs := make(map[string]struct{}, len(validQueryIDs))
|
||||
for index := range groups {
|
||||
filtered := make([]string, 0, len(groups[index].QueryIDs))
|
||||
for _, queryID := range groups[index].QueryIDs {
|
||||
if _, exists := validQueryIDs[queryID]; !exists {
|
||||
continue
|
||||
}
|
||||
if _, alreadyAssigned := assignedQueryIDs[queryID]; alreadyAssigned {
|
||||
continue
|
||||
}
|
||||
assignedQueryIDs[queryID] = struct{}{}
|
||||
filtered = append(filtered, queryID)
|
||||
}
|
||||
groups[index].QueryIDs = filtered
|
||||
}
|
||||
|
||||
for index := range groups {
|
||||
childOrder := resolveSavedQueryGroupChildOrder(groups[index].ID, groups)
|
||||
groups[index].ChildOrder = childOrder
|
||||
queryIDs := make([]string, 0, len(groups[index].QueryIDs))
|
||||
for _, token := range childOrder {
|
||||
if queryID, ok := parseSavedQueryToken(token); ok {
|
||||
queryIDs = append(queryIDs, queryID)
|
||||
}
|
||||
}
|
||||
groups[index].QueryIDs = queryIDs
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
func mergeSavedQueryGroups(
|
||||
existing []connection.SavedQueryGroup,
|
||||
imported []connection.SavedQueryGroup,
|
||||
) []connection.SavedQueryGroup {
|
||||
groups := append([]connection.SavedQueryGroup(nil), existing...)
|
||||
for _, item := range imported {
|
||||
groupID := strings.TrimSpace(item.ID)
|
||||
if groupID == "" {
|
||||
continue
|
||||
}
|
||||
item.ID = groupID
|
||||
index := findSavedQueryGroupIndex(groups, groupID)
|
||||
if index >= 0 {
|
||||
current := groups[index]
|
||||
if item.QueryIDs == nil {
|
||||
item.QueryIDs = append([]string(nil), current.QueryIDs...)
|
||||
}
|
||||
if item.ChildOrder == nil {
|
||||
item.ChildOrder = append([]string(nil), current.ChildOrder...)
|
||||
}
|
||||
groups[index] = item
|
||||
} else {
|
||||
groups = append(groups, item)
|
||||
}
|
||||
groups = removeSavedQueryIDsFromOtherGroups(groups, groupID, item.QueryIDs)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func resolveSavedQueryGroupChildOrder(
|
||||
groupID string,
|
||||
groups []connection.SavedQueryGroup,
|
||||
) []string {
|
||||
groupIndex := findSavedQueryGroupIndex(groups, groupID)
|
||||
if groupIndex < 0 {
|
||||
return []string{}
|
||||
}
|
||||
group := groups[groupIndex]
|
||||
defaultOrder := make([]string, 0, len(group.QueryIDs)+len(groups))
|
||||
for _, queryID := range sanitizeSavedQueryIDs(group.QueryIDs) {
|
||||
defaultOrder = append(defaultOrder, buildSavedQueryToken(queryID))
|
||||
}
|
||||
for _, candidate := range groups {
|
||||
if candidate.ParentGroupID == groupID {
|
||||
defaultOrder = append(defaultOrder, buildSavedQueryGroupToken(candidate.ID))
|
||||
}
|
||||
}
|
||||
|
||||
validTokens := make(map[string]struct{}, len(defaultOrder))
|
||||
for _, token := range defaultOrder {
|
||||
validTokens[token] = struct{}{}
|
||||
}
|
||||
result := make([]string, 0, len(defaultOrder))
|
||||
seen := make(map[string]struct{}, len(defaultOrder))
|
||||
for _, token := range sanitizeSavedQueryGroupChildOrder(group.ChildOrder) {
|
||||
if _, valid := validTokens[token]; !valid {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[token]; exists {
|
||||
continue
|
||||
}
|
||||
seen[token] = struct{}{}
|
||||
result = append(result, token)
|
||||
}
|
||||
for _, token := range defaultOrder {
|
||||
if _, exists := seen[token]; exists {
|
||||
continue
|
||||
}
|
||||
seen[token] = struct{}{}
|
||||
result = append(result, token)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func validateSavedQueryGroupParent(
|
||||
groups []connection.SavedQueryGroup,
|
||||
groupID string,
|
||||
parentGroupID string,
|
||||
) error {
|
||||
parentID := strings.TrimSpace(parentGroupID)
|
||||
if parentID == "" {
|
||||
return nil
|
||||
}
|
||||
if parentID == groupID {
|
||||
return fmt.Errorf("saved query group cannot be its own parent")
|
||||
}
|
||||
groupIndexByID := make(map[string]int, len(groups))
|
||||
for index, group := range groups {
|
||||
groupIndexByID[group.ID] = index
|
||||
}
|
||||
if _, exists := groupIndexByID[parentID]; !exists {
|
||||
return fmt.Errorf("saved query group parent not found: %s", parentID)
|
||||
}
|
||||
|
||||
visited := make(map[string]struct{}, len(groups))
|
||||
currentID := parentID
|
||||
for currentID != "" {
|
||||
if currentID == groupID {
|
||||
return fmt.Errorf("saved query group cannot be moved below its descendant")
|
||||
}
|
||||
if _, exists := visited[currentID]; exists {
|
||||
return fmt.Errorf("saved query group hierarchy contains a cycle")
|
||||
}
|
||||
visited[currentID] = struct{}{}
|
||||
currentIndex, exists := groupIndexByID[currentID]
|
||||
if !exists {
|
||||
break
|
||||
}
|
||||
currentID = strings.TrimSpace(groups[currentIndex].ParentGroupID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSavedQueryGroupsQueryIDs(
|
||||
groups []connection.SavedQueryGroup,
|
||||
queries []connection.SavedQuery,
|
||||
) error {
|
||||
for _, group := range groups {
|
||||
if err := validateSavedQueryGroupQueryIDs(group.QueryIDs, queries); err != nil {
|
||||
groupID := strings.TrimSpace(group.ID)
|
||||
if groupID == "" {
|
||||
groupID = strings.TrimSpace(group.Name)
|
||||
}
|
||||
if groupID == "" {
|
||||
groupID = "unknown"
|
||||
}
|
||||
return fmt.Errorf("saved query group %s: %w", groupID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSavedQueryGroupQueryIDs(
|
||||
queryIDs []string,
|
||||
queries []connection.SavedQuery,
|
||||
) error {
|
||||
validQueryIDs := make(map[string]struct{}, len(queries))
|
||||
for _, query := range queries {
|
||||
validQueryIDs[query.ID] = struct{}{}
|
||||
}
|
||||
for _, rawID := range queryIDs {
|
||||
queryID := strings.TrimSpace(rawID)
|
||||
if queryID == "" {
|
||||
return fmt.Errorf("saved query group contains an empty query id")
|
||||
}
|
||||
if _, exists := validQueryIDs[queryID]; !exists {
|
||||
return fmt.Errorf("saved query not found: %s", queryID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeSavedQueryIDsFromOtherGroups(
|
||||
groups []connection.SavedQueryGroup,
|
||||
targetGroupID string,
|
||||
queryIDs []string,
|
||||
) []connection.SavedQueryGroup {
|
||||
if len(queryIDs) == 0 {
|
||||
return groups
|
||||
}
|
||||
requested := make(map[string]struct{}, len(queryIDs))
|
||||
for _, queryID := range queryIDs {
|
||||
if queryID = strings.TrimSpace(queryID); queryID != "" {
|
||||
requested[queryID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(requested) == 0 {
|
||||
return groups
|
||||
}
|
||||
for index := range groups {
|
||||
if groups[index].ID == targetGroupID {
|
||||
continue
|
||||
}
|
||||
filtered := make([]string, 0, len(groups[index].QueryIDs))
|
||||
for _, queryID := range groups[index].QueryIDs {
|
||||
if _, remove := requested[queryID]; !remove {
|
||||
filtered = append(filtered, queryID)
|
||||
}
|
||||
}
|
||||
groups[index].QueryIDs = filtered
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func findSavedQueryGroupIndex(groups []connection.SavedQueryGroup, groupID string) int {
|
||||
targetID := strings.TrimSpace(groupID)
|
||||
for index, group := range groups {
|
||||
if group.ID == targetID {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func savedQueryExists(queries []connection.SavedQuery, queryID string) bool {
|
||||
for _, query := range queries {
|
||||
if query.ID == queryID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sanitizeSavedQueryIDs(value []string) []string {
|
||||
result := make([]string, 0, len(value))
|
||||
seen := make(map[string]struct{}, len(value))
|
||||
for _, item := range value {
|
||||
id := strings.TrimSpace(item)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
result = append(result, id)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func removeSavedQueryID(ids []string, queryID string) []string {
|
||||
result := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id != queryID {
|
||||
result = append(result, id)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func buildSavedQueryGroupToken(groupID string) string {
|
||||
return savedQueryGroupTokenPrefix + groupID
|
||||
}
|
||||
|
||||
func buildSavedQueryToken(queryID string) string {
|
||||
return savedQueryTokenPrefix + queryID
|
||||
}
|
||||
|
||||
func parseSavedQueryToken(token string) (string, bool) {
|
||||
if !strings.HasPrefix(token, savedQueryTokenPrefix) {
|
||||
return "", false
|
||||
}
|
||||
queryID := strings.TrimSpace(strings.TrimPrefix(token, savedQueryTokenPrefix))
|
||||
return queryID, queryID != ""
|
||||
}
|
||||
|
||||
func isSavedQueryGroupChildOrderToken(token string) bool {
|
||||
if queryID, ok := parseSavedQueryToken(token); ok && queryID != "" {
|
||||
return true
|
||||
}
|
||||
groupID := strings.TrimSpace(strings.TrimPrefix(token, savedQueryGroupTokenPrefix))
|
||||
return strings.HasPrefix(token, savedQueryGroupTokenPrefix) && groupID != ""
|
||||
}
|
||||
|
||||
func sanitizeSavedQueryGroupChildOrder(value []string) []string {
|
||||
result := make([]string, 0, len(value))
|
||||
seen := make(map[string]struct{}, len(value))
|
||||
for _, item := range value {
|
||||
token := strings.TrimSpace(item)
|
||||
if !isSavedQueryGroupChildOrderToken(token) {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[token]; exists {
|
||||
continue
|
||||
}
|
||||
seen[token] = struct{}{}
|
||||
result = append(result, token)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func removeSavedQueryGroupChildOrderToken(order []string, token string) []string {
|
||||
result := make([]string, 0, len(order))
|
||||
for _, item := range sanitizeSavedQueryGroupChildOrder(order) {
|
||||
if item != token {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func replaceSavedQueryGroupChildOrderToken(
|
||||
order []string,
|
||||
token string,
|
||||
replacements []string,
|
||||
) []string {
|
||||
replacementTokens := sanitizeSavedQueryGroupChildOrder(replacements)
|
||||
replacementSet := make(map[string]struct{}, len(replacementTokens))
|
||||
for _, item := range replacementTokens {
|
||||
replacementSet[item] = struct{}{}
|
||||
}
|
||||
result := make([]string, 0, len(order)+len(replacementTokens))
|
||||
inserted := false
|
||||
for _, item := range sanitizeSavedQueryGroupChildOrder(order) {
|
||||
if item == token {
|
||||
if !inserted {
|
||||
result = append(result, replacementTokens...)
|
||||
inserted = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, isReplacement := replacementSet[item]; isReplacement {
|
||||
continue
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
if !inserted {
|
||||
result = append(result, replacementTokens...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func defaultSavedQueryGroupName(index int) string {
|
||||
return fmt.Sprintf("Group %d", index+1)
|
||||
}
|
||||
|
||||
func sanitizeSavedQueries(items []connection.SavedQuery) []connection.SavedQuery {
|
||||
|
||||
@@ -445,3 +445,423 @@ func TestRebindSavedQueryUpdatesConnectionAndFingerprint(t *testing.T) {
|
||||
t.Fatalf("expected fingerprint to be stored, got %#v", rebound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedQueryGroupsReadLegacyQueriesOnlyFile(t *testing.T) {
|
||||
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
|
||||
app.configDir = t.TempDir()
|
||||
|
||||
legacyPayload := `{
|
||||
"queries": [
|
||||
{
|
||||
"id": "saved-legacy",
|
||||
"name": "Legacy",
|
||||
"sql": "select 1",
|
||||
"connectionId": "conn-1",
|
||||
"dbName": "app",
|
||||
"createdAt": 100
|
||||
}
|
||||
]
|
||||
}`
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(app.configDir, savedQueriesFileName),
|
||||
[]byte(legacyPayload),
|
||||
0o644,
|
||||
); err != nil {
|
||||
t.Fatalf("WriteFile legacy saved queries: %v", err)
|
||||
}
|
||||
|
||||
queries, err := app.GetSavedQueries()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSavedQueries returned error: %v", err)
|
||||
}
|
||||
if len(queries) != 1 || queries[0].ID != "saved-legacy" {
|
||||
t.Fatalf("expected legacy query to load, got %#v", queries)
|
||||
}
|
||||
groups, err := app.GetSavedQueryGroups()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSavedQueryGroups returned error: %v", err)
|
||||
}
|
||||
if len(groups) != 0 {
|
||||
t.Fatalf("expected legacy file without groups to load an empty group list, got %#v", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedQueryGroupsEnforceSingleMembershipAndValidateHierarchy(t *testing.T) {
|
||||
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
|
||||
app.configDir = t.TempDir()
|
||||
seedSavedQueryGroupQueries(t, app, "saved-1", "saved-2", "saved-3")
|
||||
|
||||
root, err := app.SaveSavedQueryGroup(connection.SavedQueryGroup{
|
||||
ID: "group-root",
|
||||
Name: "Root",
|
||||
QueryIDs: []string{"saved-1", "saved-2"},
|
||||
ChildOrder: []string{"query:saved-2", "query:saved-1"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveSavedQueryGroup root: %v", err)
|
||||
}
|
||||
if got, want := root.QueryIDs, []string{"saved-2", "saved-1"}; !sameStringSlice(got, want) {
|
||||
t.Fatalf("expected child order to order direct query ids, got %#v want %#v", got, want)
|
||||
}
|
||||
if _, err := app.SaveSavedQueryGroup(connection.SavedQueryGroup{
|
||||
ID: "group-child",
|
||||
Name: "Child",
|
||||
ParentGroupID: "group-root",
|
||||
QueryIDs: []string{"saved-3"},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveSavedQueryGroup child: %v", err)
|
||||
}
|
||||
if _, err := app.SaveSavedQueryGroup(connection.SavedQueryGroup{
|
||||
ID: "group-latest",
|
||||
Name: "Latest",
|
||||
QueryIDs: []string{"saved-2"},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveSavedQueryGroup latest: %v", err)
|
||||
}
|
||||
|
||||
groups, err := app.GetSavedQueryGroups()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSavedQueryGroups returned error: %v", err)
|
||||
}
|
||||
if group := findSavedQueryGroup(groups, "group-root"); group == nil || !sameStringSlice(group.QueryIDs, []string{"saved-1"}) {
|
||||
t.Fatalf("expected latest group assignment to remove saved-2 from root, got %#v", group)
|
||||
}
|
||||
if group := findSavedQueryGroup(groups, "group-latest"); group == nil || !sameStringSlice(group.QueryIDs, []string{"saved-2"}) {
|
||||
t.Fatalf("expected latest group to own saved-2, got %#v", group)
|
||||
}
|
||||
if _, err := app.SaveSavedQueryGroup(connection.SavedQueryGroup{
|
||||
ID: "group-latest",
|
||||
Name: "Latest renamed",
|
||||
QueryIDs: []string{},
|
||||
ChildOrder: []string{},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveSavedQueryGroup full replacement: %v", err)
|
||||
}
|
||||
groups, err = app.GetSavedQueryGroups()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSavedQueryGroups after replacement returned error: %v", err)
|
||||
}
|
||||
if group := findSavedQueryGroup(groups, "group-latest"); group == nil || group.Name != "Latest renamed" || len(group.QueryIDs) != 0 || len(group.ChildOrder) != 0 {
|
||||
t.Fatalf("expected empty queryIds and childOrder to fully replace the group, got %#v", group)
|
||||
}
|
||||
|
||||
if _, err := app.SaveSavedQueryGroup(connection.SavedQueryGroup{
|
||||
ID: "group-invalid-parent",
|
||||
Name: "Invalid parent",
|
||||
ParentGroupID: "missing-group",
|
||||
}); err == nil {
|
||||
t.Fatal("expected missing parent to be rejected")
|
||||
}
|
||||
if _, err := app.SaveSavedQueryGroup(connection.SavedQueryGroup{
|
||||
ID: "group-invalid-query",
|
||||
Name: "Invalid query",
|
||||
QueryIDs: []string{"missing-query"},
|
||||
}); err == nil {
|
||||
t.Fatal("expected unknown query id to be rejected")
|
||||
}
|
||||
if err := app.MoveSavedQueryGroup("group-root", "group-child"); err == nil {
|
||||
t.Fatal("expected move below descendant to be rejected")
|
||||
}
|
||||
if err := app.MoveSavedQueryGroup("group-child", "group-child"); err == nil {
|
||||
t.Fatal("expected self-parenting move to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSavedQueryGroupsLatestDuplicateMembershipWins(t *testing.T) {
|
||||
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
|
||||
app.configDir = t.TempDir()
|
||||
|
||||
if _, err := app.ImportSavedQueries(connection.SavedQueryImportPayload{
|
||||
Queries: []connection.SavedQuery{
|
||||
{
|
||||
ID: "saved-1",
|
||||
Name: "One",
|
||||
SQL: "select 1",
|
||||
ConnectionID: "conn-1",
|
||||
DBName: "app",
|
||||
CreatedAt: 100,
|
||||
},
|
||||
},
|
||||
Groups: []connection.SavedQueryGroup{
|
||||
{ID: "group-first", Name: "First", QueryIDs: []string{"saved-1"}},
|
||||
{ID: "group-last", Name: "Last", QueryIDs: []string{"saved-1"}},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("ImportSavedQueries returned error: %v", err)
|
||||
}
|
||||
|
||||
groups, err := app.GetSavedQueryGroups()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSavedQueryGroups returned error: %v", err)
|
||||
}
|
||||
if group := findSavedQueryGroup(groups, "group-first"); group == nil || len(group.QueryIDs) != 0 {
|
||||
t.Fatalf("expected first imported group to release duplicate query, got %#v", group)
|
||||
}
|
||||
if group := findSavedQueryGroup(groups, "group-last"); group == nil || !sameStringSlice(group.QueryIDs, []string{"saved-1"}) {
|
||||
t.Fatalf("expected last imported group to own duplicate query, got %#v", group)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSavedQueryGroupPromotesDirectQueriesAndChildGroups(t *testing.T) {
|
||||
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
|
||||
app.configDir = t.TempDir()
|
||||
seedSavedQueryGroupQueries(t, app, "saved-1", "saved-2", "saved-3")
|
||||
|
||||
if _, err := app.SaveSavedQueryGroup(connection.SavedQueryGroup{
|
||||
ID: "group-parent",
|
||||
Name: "Parent",
|
||||
QueryIDs: []string{"saved-1"},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveSavedQueryGroup parent: %v", err)
|
||||
}
|
||||
if _, err := app.SaveSavedQueryGroup(connection.SavedQueryGroup{
|
||||
ID: "group-child",
|
||||
Name: "Child",
|
||||
ParentGroupID: "group-parent",
|
||||
QueryIDs: []string{"saved-2"},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveSavedQueryGroup child: %v", err)
|
||||
}
|
||||
if _, err := app.SaveSavedQueryGroup(connection.SavedQueryGroup{
|
||||
ID: "group-grandchild",
|
||||
Name: "Grandchild",
|
||||
ParentGroupID: "group-child",
|
||||
QueryIDs: []string{"saved-3"},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveSavedQueryGroup grandchild: %v", err)
|
||||
}
|
||||
|
||||
if err := app.DeleteSavedQueryGroup("group-child"); err != nil {
|
||||
t.Fatalf("DeleteSavedQueryGroup returned error: %v", err)
|
||||
}
|
||||
groups, err := app.GetSavedQueryGroups()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSavedQueryGroups returned error: %v", err)
|
||||
}
|
||||
if findSavedQueryGroup(groups, "group-child") != nil {
|
||||
t.Fatalf("expected deleted group to be absent, got %#v", groups)
|
||||
}
|
||||
parent := findSavedQueryGroup(groups, "group-parent")
|
||||
if parent == nil || !sameStringSlice(parent.QueryIDs, []string{"saved-1", "saved-2"}) {
|
||||
t.Fatalf("expected parent to receive deleted group's direct queries, got %#v", parent)
|
||||
}
|
||||
if got, want := parent.ChildOrder, []string{"query:saved-1", "query:saved-2", "group:group-grandchild"}; !sameStringSlice(got, want) {
|
||||
t.Fatalf("expected promoted children to retain placement, got %#v want %#v", got, want)
|
||||
}
|
||||
grandchild := findSavedQueryGroup(groups, "group-grandchild")
|
||||
if grandchild == nil || grandchild.ParentGroupID != "group-parent" {
|
||||
t.Fatalf("expected grandchild to be promoted to parent, got %#v", grandchild)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRootSavedQueryGroupPromotesChildrenAndUngroupsDirectQueries(t *testing.T) {
|
||||
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
|
||||
app.configDir = t.TempDir()
|
||||
seedSavedQueryGroupQueries(t, app, "saved-1", "saved-2")
|
||||
if _, err := app.SaveSavedQueryGroup(connection.SavedQueryGroup{
|
||||
ID: "group-root",
|
||||
Name: "Root",
|
||||
QueryIDs: []string{"saved-1"},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveSavedQueryGroup root: %v", err)
|
||||
}
|
||||
if _, err := app.SaveSavedQueryGroup(connection.SavedQueryGroup{
|
||||
ID: "group-child",
|
||||
Name: "Child",
|
||||
ParentGroupID: "group-root",
|
||||
QueryIDs: []string{"saved-2"},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveSavedQueryGroup child: %v", err)
|
||||
}
|
||||
|
||||
if err := app.DeleteSavedQueryGroup("group-root"); err != nil {
|
||||
t.Fatalf("DeleteSavedQueryGroup root: %v", err)
|
||||
}
|
||||
groups, err := app.GetSavedQueryGroups()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSavedQueryGroups returned error: %v", err)
|
||||
}
|
||||
if findSavedQueryGroup(groups, "group-root") != nil {
|
||||
t.Fatalf("expected root group to be deleted, got %#v", groups)
|
||||
}
|
||||
child := findSavedQueryGroup(groups, "group-child")
|
||||
if child == nil || child.ParentGroupID != "" || !sameStringSlice(child.QueryIDs, []string{"saved-2"}) {
|
||||
t.Fatalf("expected direct child to be promoted to root intact, got %#v", child)
|
||||
}
|
||||
for _, group := range groups {
|
||||
if containsString(group.QueryIDs, "saved-1") {
|
||||
t.Fatalf("expected deleted root's direct query to become ungrouped, got %#v", groups)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveSavedQueryAndGroupUpdatesMembershipAndParent(t *testing.T) {
|
||||
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
|
||||
app.configDir = t.TempDir()
|
||||
seedSavedQueryGroupQueries(t, app, "saved-1")
|
||||
if _, err := app.SaveSavedQueryGroup(connection.SavedQueryGroup{
|
||||
ID: "group-root",
|
||||
Name: "Root",
|
||||
QueryIDs: []string{"saved-1"},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveSavedQueryGroup root: %v", err)
|
||||
}
|
||||
if _, err := app.SaveSavedQueryGroup(connection.SavedQueryGroup{
|
||||
ID: "group-child",
|
||||
Name: "Child",
|
||||
ParentGroupID: "group-root",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveSavedQueryGroup child: %v", err)
|
||||
}
|
||||
|
||||
if err := app.MoveSavedQueryToGroup("saved-1", "group-child"); err != nil {
|
||||
t.Fatalf("MoveSavedQueryToGroup returned error: %v", err)
|
||||
}
|
||||
groups, err := app.GetSavedQueryGroups()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSavedQueryGroups returned error: %v", err)
|
||||
}
|
||||
if group := findSavedQueryGroup(groups, "group-root"); group == nil || len(group.QueryIDs) != 0 {
|
||||
t.Fatalf("expected source group to release moved query, got %#v", group)
|
||||
}
|
||||
if group := findSavedQueryGroup(groups, "group-child"); group == nil || !sameStringSlice(group.QueryIDs, []string{"saved-1"}) {
|
||||
t.Fatalf("expected destination group to own moved query, got %#v", group)
|
||||
}
|
||||
|
||||
if err := app.MoveSavedQueryToGroup("saved-1", ""); err != nil {
|
||||
t.Fatalf("MoveSavedQueryToGroup ungroup returned error: %v", err)
|
||||
}
|
||||
if err := app.MoveSavedQueryGroup("group-child", ""); err != nil {
|
||||
t.Fatalf("MoveSavedQueryGroup root returned error: %v", err)
|
||||
}
|
||||
groups, err = app.GetSavedQueryGroups()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSavedQueryGroups after moves returned error: %v", err)
|
||||
}
|
||||
if group := findSavedQueryGroup(groups, "group-child"); group == nil || group.ParentGroupID != "" || len(group.QueryIDs) != 0 {
|
||||
t.Fatalf("expected child group and query to be moved to roots, got %#v", group)
|
||||
}
|
||||
if group := findSavedQueryGroup(groups, "group-root"); group == nil || containsString(group.ChildOrder, "group:group-child") {
|
||||
t.Fatalf("expected old parent order to remove moved group token, got %#v", group)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedQueryGroupsSurviveSaveImportAndRebindThenPruneOnDelete(t *testing.T) {
|
||||
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
|
||||
app.configDir = t.TempDir()
|
||||
seedSavedQueryGroupQueries(t, app, "saved-1")
|
||||
if _, err := app.SaveSavedQueryGroup(connection.SavedQueryGroup{
|
||||
ID: "group-keep",
|
||||
Name: "Keep",
|
||||
QueryIDs: []string{"saved-1"},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveSavedQueryGroup returned error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := app.SaveQuery(connection.SavedQuery{
|
||||
ID: "saved-1",
|
||||
Name: "Updated",
|
||||
SQL: "select 11",
|
||||
ConnectionID: "conn-1",
|
||||
DBName: "app",
|
||||
CreatedAt: 101,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveQuery update returned error: %v", err)
|
||||
}
|
||||
if _, err := app.ImportSavedQueries(connection.SavedQueryImportPayload{
|
||||
Queries: []connection.SavedQuery{{
|
||||
ID: "saved-2",
|
||||
Name: "Imported",
|
||||
SQL: "select 2",
|
||||
ConnectionID: "conn-1",
|
||||
DBName: "app",
|
||||
CreatedAt: 102,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("ImportSavedQueries returned error: %v", err)
|
||||
}
|
||||
|
||||
target, err := app.SaveConnection(connection.SavedConnectionInput{
|
||||
ID: "conn-target",
|
||||
Name: "Target",
|
||||
Config: connection.ConnectionConfig{
|
||||
ID: "conn-target",
|
||||
Type: "postgres",
|
||||
Host: "db.local",
|
||||
Port: 5432,
|
||||
User: "app",
|
||||
Database: "app",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConnection returned error: %v", err)
|
||||
}
|
||||
if _, err := app.RebindSavedQuery("saved-1", target.ID); err != nil {
|
||||
t.Fatalf("RebindSavedQuery returned error: %v", err)
|
||||
}
|
||||
|
||||
groups, err := app.GetSavedQueryGroups()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSavedQueryGroups returned error: %v", err)
|
||||
}
|
||||
if group := findSavedQueryGroup(groups, "group-keep"); group == nil || !sameStringSlice(group.QueryIDs, []string{"saved-1"}) {
|
||||
t.Fatalf("expected group metadata to survive query save/import/rebind, got %#v", group)
|
||||
}
|
||||
|
||||
if err := app.DeleteQuery("saved-1"); err != nil {
|
||||
t.Fatalf("DeleteQuery returned error: %v", err)
|
||||
}
|
||||
groups, err = app.GetSavedQueryGroups()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSavedQueryGroups after delete returned error: %v", err)
|
||||
}
|
||||
if group := findSavedQueryGroup(groups, "group-keep"); group == nil || len(group.QueryIDs) != 0 || len(group.ChildOrder) != 0 {
|
||||
t.Fatalf("expected deleted query to be pruned from group, got %#v", group)
|
||||
}
|
||||
}
|
||||
|
||||
func seedSavedQueryGroupQueries(t *testing.T, app *App, ids ...string) {
|
||||
t.Helper()
|
||||
for index, id := range ids {
|
||||
if _, err := app.SaveQuery(connection.SavedQuery{
|
||||
ID: id,
|
||||
Name: id,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func findSavedQueryGroup(groups []connection.SavedQueryGroup, id string) *connection.SavedQueryGroup {
|
||||
for index := range groups {
|
||||
if groups[index].ID == id {
|
||||
return &groups[index]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameStringSlice(got []string, want []string) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
}
|
||||
for index := range got {
|
||||
if got[index] != want[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func containsString(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -13,7 +13,19 @@ type SavedQuery struct {
|
||||
OriginalConnectionID string `json:"originalConnectionId,omitempty"`
|
||||
}
|
||||
|
||||
// SavedQueryGroup describes a user-managed saved SQL folder. QueryIDs contains
|
||||
// only the group's direct queries; ChildOrder can mix query:<id> and group:<id>
|
||||
// tokens to retain the visible order of direct queries and child groups.
|
||||
type SavedQueryGroup struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ParentGroupID string `json:"parentGroupId"`
|
||||
QueryIDs []string `json:"queryIds"`
|
||||
ChildOrder []string `json:"childOrder"`
|
||||
}
|
||||
|
||||
type SavedQueryImportPayload struct {
|
||||
Queries []SavedQuery `json:"queries"`
|
||||
Groups []SavedQueryGroup `json:"groups,omitempty"`
|
||||
LegacyConnections []SavedConnectionInput `json:"legacyConnections,omitempty"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user