diff --git a/pkg/services/folder/folderimpl/folder.go b/pkg/services/folder/folderimpl/folder.go index 6d45f4d5627..06fc3b52ac6 100644 --- a/pkg/services/folder/folderimpl/folder.go +++ b/pkg/services/folder/folderimpl/folder.go @@ -121,6 +121,37 @@ func (s *Service) DBMigration(db db.DB) { s.log.Debug("syncing dashboard and folder tables finished") } +func (s *Service) GetFolders(ctx context.Context, q folder.GetFoldersQuery) ([]*folder.Folder, error) { + if q.SignedInUser == nil { + return nil, folder.ErrBadRequest.Errorf("missing signed in user") + } + + qry := NewGetFoldersQuery(q) + permissions := q.SignedInUser.GetPermissions() + folderPermissions := permissions[dashboards.ActionFoldersRead] + qry.ancestorUIDs = make([]string, 0, len(folderPermissions)) + for _, p := range folderPermissions { + if p == dashboards.ScopeFoldersAll { + // no need to query for folders with permissions + // the user has permission to access all folders + qry.ancestorUIDs = nil + break + } + if folderUid, found := strings.CutPrefix(p, dashboards.ScopeFoldersPrefix); found { + if !slices.Contains(qry.ancestorUIDs, folderUid) { + qry.ancestorUIDs = append(qry.ancestorUIDs, folderUid) + } + } + } + + dashFolders, err := s.store.GetFolders(ctx, qry) + if err != nil { + return nil, folder.ErrInternal.Errorf("failed to fetch subfolders: %w", err) + } + + return dashFolders, nil +} + func (s *Service) Get(ctx context.Context, q *folder.GetFolderQuery) (*folder.Folder, error) { if q.SignedInUser == nil { return nil, folder.ErrBadRequest.Errorf("missing signed in user") @@ -357,7 +388,7 @@ func (s *Service) getAvailableNonRootFolders(ctx context.Context, orgID int64, u return nonRootFolders, nil } - dashFolders, err := s.store.GetFolders(ctx, orgID, folderUids) + dashFolders, err := s.store.GetFolders(ctx, NewGetFoldersQuery(folder.GetFoldersQuery{OrgID: orgID, UIDs: folderUids})) if err != nil { return nil, folder.ErrInternal.Errorf("failed to fetch subfolders: %w", err) } diff --git a/pkg/services/folder/folderimpl/folder_test.go b/pkg/services/folder/folderimpl/folder_test.go index 86371a13fbf..1b9806292b7 100644 --- a/pkg/services/folder/folderimpl/folder_test.go +++ b/pkg/services/folder/folderimpl/folder_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "math/rand" + "strings" "testing" "time" @@ -420,11 +421,11 @@ func TestIntegrationNestedFolderService(t *testing.T) { lps, err := librarypanels.ProvideService(cfg, db, routeRegister, elementService, serviceWithFlagOn) require.NoError(t, err) - ancestorUIDs := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, depth, "getDescendantCountsOn", createCmd) + ancestors := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, depth, "getDescendantCountsOn", createCmd) - parent, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDs[0]) + parent, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestors[0].UID) require.NoError(t, err) - subfolder, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDs[1]) + subfolder, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestors[1].UID) require.NoError(t, err) // nolint:staticcheck _ = insertTestDashboard(t, serviceWithFlagOn.dashboardStore, "dashboard in parent", orgID, parent.ID, parent.UID, "prod") @@ -443,7 +444,7 @@ func TestIntegrationNestedFolderService(t *testing.T) { require.NoError(t, err) countCmd := folder.GetDescendantCountsQuery{ - UID: &ancestorUIDs[0], + UID: &ancestors[0].UID, OrgID: orgID, SignedInUser: &signedInUser, } @@ -456,8 +457,8 @@ func TestIntegrationNestedFolderService(t *testing.T) { t.Cleanup(func() { guardian.New = origNewGuardian - for _, uid := range ancestorUIDs { - err := serviceWithFlagOn.store.Delete(context.Background(), uid, orgID) + for _, ancestor := range ancestors { + err := serviceWithFlagOn.store.Delete(context.Background(), ancestor.UID, orgID) assert.NoError(t, err) } }) @@ -500,11 +501,11 @@ func TestIntegrationNestedFolderService(t *testing.T) { lps, err := librarypanels.ProvideService(cfg, db, routeRegister, elementService, serviceWithFlagOff) require.NoError(t, err) - ancestorUIDs := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, depth, "getDescendantCountsOff", createCmd) + ancestors := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, depth, "getDescendantCountsOff", createCmd) - parent, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDs[0]) + parent, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestors[0].UID) require.NoError(t, err) - subfolder, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDs[1]) + subfolder, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestors[1].UID) require.NoError(t, err) // nolint:staticcheck _ = insertTestDashboard(t, serviceWithFlagOn.dashboardStore, "dashboard in parent", orgID, parent.ID, parent.UID, "prod") @@ -523,7 +524,7 @@ func TestIntegrationNestedFolderService(t *testing.T) { require.NoError(t, err) countCmd := folder.GetDescendantCountsQuery{ - UID: &ancestorUIDs[0], + UID: &ancestors[0].UID, OrgID: orgID, SignedInUser: &signedInUser, } @@ -536,8 +537,8 @@ func TestIntegrationNestedFolderService(t *testing.T) { t.Cleanup(func() { guardian.New = origNewGuardian - for _, uid := range ancestorUIDs { - err := serviceWithFlagOn.store.Delete(context.Background(), uid, orgID) + for _, ancestor := range ancestors { + err := serviceWithFlagOn.store.Delete(context.Background(), ancestor.UID, orgID) assert.NoError(t, err) } }) @@ -638,9 +639,9 @@ func TestIntegrationNestedFolderService(t *testing.T) { alertStore, err := ngstore.ProvideDBStore(cfg, tc.featuresFlag, db, tc.service, dashSrv, ac) require.NoError(t, err) - ancestorUIDs := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, tc.depth, tc.prefix, createCmd) + ancestors := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, tc.depth, tc.prefix, createCmd) - parent, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDs[0]) + parent, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestors[0].UID) require.NoError(t, err) _ = createRule(t, alertStore, parent.UID, "parent alert") @@ -649,7 +650,7 @@ func TestIntegrationNestedFolderService(t *testing.T) { subPanel model.LibraryElementDTO ) if tc.depth > 1 { - subfolder, err = serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDs[1]) + subfolder, err = serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestors[1].UID) require.NoError(t, err) _ = createRule(t, alertStore, subfolder.UID, "sub alert") // nolint:staticcheck @@ -663,7 +664,7 @@ func TestIntegrationNestedFolderService(t *testing.T) { require.NoError(t, err) deleteCmd := folder.DeleteFolderCommand{ - UID: ancestorUIDs[0], + UID: ancestors[0].UID, OrgID: orgID, SignedInUser: &signedInUser, ForceDeleteRules: tc.forceDelete, @@ -672,12 +673,12 @@ func TestIntegrationNestedFolderService(t *testing.T) { err = tc.service.Delete(context.Background(), &deleteCmd) require.ErrorIs(t, err, tc.deletionErr) - for i, uid := range ancestorUIDs { + for i, ancestor := range ancestors { // dashboard table - _, err := tc.service.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, uid) + _, err := tc.service.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestor.UID) require.ErrorIs(t, err, tc.dashboardErr) // folder table - _, err = tc.service.store.Get(context.Background(), folder.GetFolderQuery{UID: &ancestorUIDs[i], OrgID: orgID}) + _, err = tc.service.store.Get(context.Background(), folder.GetFolderQuery{UID: &ancestors[i].UID, OrgID: orgID}) require.ErrorIs(t, err, tc.folderErr) } @@ -1325,12 +1326,12 @@ func TestIntegrationNestedFolderSharedWithMe(t *testing.T) { CanViewValue: true, }) - ancestorUIDsFolderWithPermissions := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, depth, "withPermissions", createCmd) - ancestorUIDsFolderWithoutPermissions := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, depth, "withoutPermissions", createCmd) + ancestorFoldersWithPermissions := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, depth, "withPermissions", createCmd) + ancestorFoldersWithoutPermissions := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, depth, "withoutPermissions", createCmd) - parent, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDsFolderWithoutPermissions[0]) + parent, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorFoldersWithoutPermissions[0].UID) require.NoError(t, err) - subfolder, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorUIDsFolderWithoutPermissions[1]) + subfolder, err := serviceWithFlagOn.dashboardFolderStore.GetFolderByUID(context.Background(), orgID, ancestorFoldersWithoutPermissions[1].UID) require.NoError(t, err) // nolint:staticcheck dash1 := insertTestDashboard(t, serviceWithFlagOn.dashboardStore, "dashboard in parent", orgID, parent.ID, parent.UID, "prod") @@ -1341,19 +1342,19 @@ func TestIntegrationNestedFolderSharedWithMe(t *testing.T) { CanSaveValue: true, CanViewValue: true, CanViewUIDs: []string{ - ancestorUIDsFolderWithPermissions[0], - ancestorUIDsFolderWithPermissions[1], - ancestorUIDsFolderWithoutPermissions[1], + ancestorFoldersWithPermissions[0].UID, + ancestorFoldersWithPermissions[1].UID, + ancestorFoldersWithoutPermissions[1].UID, dash1.UID, dash2.UID, }, }) signedInUser.Permissions[orgID][dashboards.ActionFoldersRead] = []string{ - dashboards.ScopeFoldersProvider.GetResourceScopeUID(ancestorUIDsFolderWithPermissions[0]), + dashboards.ScopeFoldersProvider.GetResourceScopeUID(ancestorFoldersWithPermissions[0].UID), // Add permission to the subfolder of folder with permission (to check deduplication) - dashboards.ScopeFoldersProvider.GetResourceScopeUID(ancestorUIDsFolderWithPermissions[1]), + dashboards.ScopeFoldersProvider.GetResourceScopeUID(ancestorFoldersWithPermissions[1].UID), // Add permission to the subfolder of folder without permission - dashboards.ScopeFoldersProvider.GetResourceScopeUID(ancestorUIDsFolderWithoutPermissions[1]), + dashboards.ScopeFoldersProvider.GetResourceScopeUID(ancestorFoldersWithoutPermissions[1].UID), } signedInUser.Permissions[orgID][dashboards.ActionDashboardsRead] = []string{ dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dash1.UID), @@ -1374,8 +1375,8 @@ func TestIntegrationNestedFolderSharedWithMe(t *testing.T) { require.NoError(t, err) require.Len(t, sharedFolders, 1) - require.Contains(t, sharedFoldersUIDs, ancestorUIDsFolderWithoutPermissions[1]) - require.NotContains(t, sharedFoldersUIDs, ancestorUIDsFolderWithPermissions[1]) + require.Contains(t, sharedFoldersUIDs, ancestorFoldersWithoutPermissions[1].UID) + require.NotContains(t, sharedFoldersUIDs, ancestorFoldersWithPermissions[1].UID) sharedDashboards, err := dashboardService.GetDashboardsSharedWithUser(context.Background(), &signedInUser) sharedDashboardsUIDs := make([]string, 0) @@ -1390,28 +1391,236 @@ func TestIntegrationNestedFolderSharedWithMe(t *testing.T) { t.Cleanup(func() { guardian.New = origNewGuardian - for _, uid := range ancestorUIDsFolderWithPermissions { - err := serviceWithFlagOn.store.Delete(context.Background(), uid, orgID) + for _, ancestor := range ancestorFoldersWithPermissions { + err := serviceWithFlagOn.store.Delete(context.Background(), ancestor.UID, orgID) assert.NoError(t, err) } }) t.Cleanup(func() { guardian.New = origNewGuardian - for _, uid := range ancestorUIDsFolderWithoutPermissions { - err := serviceWithFlagOn.store.Delete(context.Background(), uid, orgID) + for _, ancestor := range ancestorFoldersWithoutPermissions { + err := serviceWithFlagOn.store.Delete(context.Background(), ancestor.UID, orgID) assert.NoError(t, err) } }) }) + + t.Run("Should get org folders visible", func(t *testing.T) { + depth := 3 + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{ + CanSaveValue: true, + CanViewValue: true, + }) + + // create folder sctructure like this: + // tree1-folder-0 + // └──tree1-folder-1 + // └──tree1-folder-2 + // tree2-folder-0 + // └──tree2-folder-1 + // └──tree2-folder-2 + tree1 := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, depth, "tree1-", createCmd) + tree2 := CreateSubtreeInStore(t, nestedFolderStore, serviceWithFlagOn, depth, "tree2-", createCmd) + + signedInUser.Permissions[orgID][dashboards.ActionFoldersRead] = []string{ + // Add permission to tree1-folder-0 + dashboards.ScopeFoldersProvider.GetResourceScopeUID(tree1[0].UID), + // Add permission to the subfolder of folder with permission (tree1-folder-1) to check deduplication + dashboards.ScopeFoldersProvider.GetResourceScopeUID(tree1[1].UID), + // Add permission to the subfolder of folder without permission (tree2-folder-1) + dashboards.ScopeFoldersProvider.GetResourceScopeUID(tree2[1].UID), + } + + t.Cleanup(func() { + guardian.New = origNewGuardian + for _, f := range tree1 { + err := serviceWithFlagOn.store.Delete(context.Background(), f.UID, orgID) + assert.NoError(t, err) + } + for _, f := range tree2 { + err := serviceWithFlagOn.store.Delete(context.Background(), f.UID, orgID) + assert.NoError(t, err) + } + }) + + testCases := []struct { + name string + cmd folder.GetFoldersQuery + expected []*folder.Folder + }{ + { + name: "Should get all org folders visible to the user", + cmd: folder.GetFoldersQuery{ + OrgID: orgID, + SignedInUser: &signedInUser, + }, + expected: []*folder.Folder{ + { + UID: tree1[0].UID, + }, + { + UID: tree1[1].UID, + }, + { + UID: tree1[2].UID, + }, + { + UID: tree2[1].UID, + }, + { + UID: tree2[2].UID, + }, + }, + }, + { + name: "Should get all org folders visible to the user with fullpath", + cmd: folder.GetFoldersQuery{ + OrgID: orgID, + WithFullpath: true, + SignedInUser: &signedInUser, + }, + expected: []*folder.Folder{ + { + UID: tree1[0].UID, + Fullpath: "tree1-folder-0", + }, + { + UID: tree1[1].UID, + Fullpath: "tree1-folder-0/tree1-folder-1", + }, + { + UID: tree1[2].UID, + Fullpath: "tree1-folder-0/tree1-folder-1/tree1-folder-2", + }, + { + UID: tree2[1].UID, + Fullpath: "tree2-folder-0/tree2-folder-1", + }, + { + UID: tree2[2].UID, + Fullpath: "tree2-folder-0/tree2-folder-1/tree2-folder-2", + }, + }, + }, + { + name: "Should get all org folders visible to the user with fullpath UIDs", + cmd: folder.GetFoldersQuery{ + OrgID: orgID, + WithFullpathUIDs: true, + SignedInUser: &signedInUser, + }, + expected: []*folder.Folder{ + { + UID: tree1[0].UID, + FullpathUIDs: strings.Join([]string{tree1[0].UID}, "/"), + }, + { + UID: tree1[1].UID, + FullpathUIDs: strings.Join([]string{tree1[0].UID, tree1[1].UID}, "/"), + }, + { + UID: tree1[2].UID, + FullpathUIDs: strings.Join([]string{tree1[0].UID, tree1[1].UID, tree1[2].UID}, "/"), + }, + { + UID: tree2[1].UID, + FullpathUIDs: strings.Join([]string{tree2[0].UID, tree2[1].UID}, "/"), + }, + { + UID: tree2[2].UID, + FullpathUIDs: strings.Join([]string{tree2[0].UID, tree2[1].UID, tree2[2].UID}, "/"), + }, + }, + }, + { + name: "Should get specific org folders visible to the user", + cmd: folder.GetFoldersQuery{ + OrgID: orgID, + UIDs: []string{tree1[0].UID, tree2[0].UID, tree2[1].UID}, + SignedInUser: &signedInUser, + }, + expected: []*folder.Folder{ + { + UID: tree1[0].UID, + }, + { + UID: tree2[1].UID, + }, + }, + }, + { + name: "Should get all org folders visible to the user with admin permissions", + cmd: folder.GetFoldersQuery{ + OrgID: orgID, + SignedInUser: &signedInAdminUser, + }, + expected: []*folder.Folder{ + { + UID: tree1[0].UID, + Fullpath: "tree1-folder-0", + FullpathUIDs: strings.Join([]string{tree1[0].UID}, "/"), + }, + { + UID: tree1[1].UID, + Fullpath: "tree1-folder-0/tree1-folder-1", + FullpathUIDs: strings.Join([]string{tree1[0].UID, tree1[1].UID}, "/"), + }, + { + UID: tree1[2].UID, + Fullpath: "tree1-folder-0/tree1-folder-1/tree1-folder-2", + }, + { + UID: tree2[0].UID, + Fullpath: "tree2-folder-0", + FullpathUIDs: strings.Join([]string{tree2[0].UID}, "/"), + }, + { + UID: tree2[1].UID, + Fullpath: "tree2-folder-0/tree2-folder-1", + FullpathUIDs: strings.Join([]string{tree2[0].UID, tree2[1].UID}, "/"), + }, + { + UID: tree2[2].UID, + Fullpath: "tree2-folder-0/tree2-folder-1/tree2-folder-2", + FullpathUIDs: strings.Join([]string{tree2[0].UID, tree2[1].UID, tree2[2].UID}, "/"), + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + actualFolders, err := serviceWithFlagOn.GetFolders(context.Background(), tc.cmd) + require.NoError(t, err) + + require.NoError(t, err) + require.Len(t, actualFolders, len(tc.expected)) + + for i, expected := range tc.expected { + actualFolder := actualFolders[i] + require.Equal(t, expected.UID, actualFolder.UID) + if tc.cmd.WithFullpath { + require.Equal(t, expected.Fullpath, actualFolder.Fullpath) + } else { + require.Empty(t, actualFolder.Fullpath) + } + + if tc.cmd.WithFullpathUIDs { + require.Equal(t, expected.FullpathUIDs, actualFolder.FullpathUIDs) + } else { + require.Empty(t, actualFolder.FullpathUIDs) + } + } + }) + } + }) } -func CreateSubtreeInStore(t *testing.T, store *sqlStore, service *Service, depth int, prefix string, cmd folder.CreateFolderCommand) []string { +func CreateSubtreeInStore(t *testing.T, store *sqlStore, service *Service, depth int, prefix string, cmd folder.CreateFolderCommand) []*folder.Folder { t.Helper() - ancestorUIDs := []string{} - if cmd.ParentUID != "" { - ancestorUIDs = append(ancestorUIDs, cmd.ParentUID) - } + folders := make([]*folder.Folder, 0, depth) for i := 0; i < depth; i++ { title := fmt.Sprintf("%sfolder-%d", prefix, i) cmd.Title = title @@ -1422,23 +1631,12 @@ func CreateSubtreeInStore(t *testing.T, store *sqlStore, service *Service, depth require.Equal(t, title, f.Title) require.NotEmpty(t, f.UID) - parents, err := store.GetParents(context.Background(), folder.GetParentsQuery{ - UID: f.UID, - OrgID: cmd.OrgID, - }) - require.NoError(t, err) - parentUIDs := []string{} - for _, p := range parents { - parentUIDs = append(parentUIDs, p.UID) - } - require.Equal(t, ancestorUIDs, parentUIDs) - - ancestorUIDs = append(ancestorUIDs, f.UID) + folders = append(folders, f) cmd.ParentUID = f.UID } - return ancestorUIDs + return folders } func setup(t *testing.T, dashStore dashboards.Store, dashboardFolderStore folder.FolderStore, nestedFolderStore store, features featuremgmt.FeatureToggles, ac accesscontrol.AccessControl, db db.DB) folder.Service { diff --git a/pkg/services/folder/folderimpl/sqlstore.go b/pkg/services/folder/folderimpl/sqlstore.go index c7e8033bff7..b107222bc59 100644 --- a/pkg/services/folder/folderimpl/sqlstore.go +++ b/pkg/services/folder/folderimpl/sqlstore.go @@ -2,6 +2,7 @@ package folderimpl import ( "context" + "fmt" "runtime" "strings" "time" @@ -12,10 +13,13 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) +const DEFAULT_BATCH_SIZE = 999 + type sqlStore struct { db db.DB log log.Logger @@ -342,27 +346,176 @@ func (ss *sqlStore) GetHeight(ctx context.Context, foldrUID string, orgID int64, return height, nil } -func (ss *sqlStore) GetFolders(ctx context.Context, orgID int64, uids []string) ([]*folder.Folder, error) { - if len(uids) == 0 { - return []*folder.Folder{}, nil +// GetFolders returns org folders by their UIDs. +// If UIDs is empty, it returns all folders in the org. +// If WithFullpath is true it computes also the full path of a folder. +// The full path is a string that contains the titles of all parent folders separated by a slash. +// For example, if the folder structure is: +// +// A +// └── B +// └── C +// +// The full path of C is "A/B/C". +// The full path of B is "A/B". +// The full path of A is "A". +// If a folder contains a slash in its title, it is escaped with a backslash. +// For example, if the folder structure is: +// +// A +// └── B/C +// +// The full path of C is "A/B\/C". +// +// If FullpathUIDs is true it computes a string that contains the UIDs of all parent folders separated by slash. +// For example, if the folder structure is: +// +// A (uid: "uid1") +// └── B (uid: "uid2") +// └── C (uid: "uid3") +// +// The full path UIDs of C is "uid1/uid2/uid3". +// The full path UIDs of B is "uid1/uid2". +// The full path UIDs of A is "uid1". +func (ss *sqlStore) GetFolders(ctx context.Context, q getFoldersQuery) ([]*folder.Folder, error) { + if q.BatchSize == 0 { + q.BatchSize = DEFAULT_BATCH_SIZE } + var folders []*folder.Folder if err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { - b := strings.Builder{} - b.WriteString(`SELECT * FROM folder WHERE org_id=? AND uid IN (?` + strings.Repeat(", ?", len(uids)-1) + `)`) - args := []any{orgID} - for _, uid := range uids { - args = append(args, uid) - } - return sess.SQL(b.String(), args...).Find(&folders) + return batch(len(q.UIDs), int(q.BatchSize), func(start, end int) error { + partialFolders := make([]*folder.Folder, 0, q.BatchSize) + partialUIDs := q.UIDs[start:min(end, len(q.UIDs))] + s := strings.Builder{} + s.WriteString(`SELECT f0.id, f0.org_id, f0.uid, f0.parent_uid, f0.title, f0.description, f0.created, f0.updated`) + // compute full path column if requested + if q.WithFullpath { + s.WriteString(fmt.Sprintf(`, %s AS fullpath`, getFullpathSQL(ss.db.GetDialect()))) + } + // compute full path UIDs column if requested + if q.WithFullpathUIDs { + s.WriteString(fmt.Sprintf(`, %s AS fullpath_uids`, getFullapathUIDsSQL(ss.db.GetDialect()))) + } + s.WriteString(` FROM folder f0`) + // join the same table multiple times to compute the full path of a folder + if q.WithFullpath || q.WithFullpathUIDs || len(q.ancestorUIDs) > 0 { + s.WriteString(getFullpathJoinsSQL()) + } + s.WriteString(` WHERE f0.org_id=?`) + args := []any{q.OrgID} + if len(partialUIDs) > 0 { + s.WriteString(` AND f0.uid IN (?` + strings.Repeat(", ?", len(partialUIDs)-1) + `)`) + for _, uid := range partialUIDs { + args = append(args, uid) + } + } + + if len(q.ancestorUIDs) == 0 { + err := sess.SQL(s.String(), args...).Find(&partialFolders) + if err != nil { + return err + } + folders = append(folders, partialFolders...) + return nil + } + + // filter out folders if they are not in the subtree of the given ancestor folders + if err := batch(len(q.ancestorUIDs), int(q.BatchSize), func(start2, end2 int) error { + s2, args2 := getAncestorsSQL(ss.db.GetDialect(), q.ancestorUIDs, start2, end2, s.String(), args) + err := sess.SQL(s2, args2...).Find(&partialFolders) + if err != nil { + return err + } + folders = append(folders, partialFolders...) + return nil + }); err != nil { + return err + } + return nil + }) }); err != nil { return nil, err } // Add URLs for i, f := range folders { + f.Fullpath = strings.TrimLeft(f.Fullpath, "/") + f.FullpathUIDs = strings.TrimLeft(f.FullpathUIDs, "/") folders[i] = f.WithURL() } return folders, nil } + +func getFullpathSQL(dialect migrator.Dialect) string { + concatCols := make([]string, 0, folder.MaxNestedFolderDepth) + concatCols = append(concatCols, "COALESCE(REPLACE(f0.title, '/', '\\/'), '')") + for i := 1; i <= folder.MaxNestedFolderDepth; i++ { + concatCols = append([]string{fmt.Sprintf("COALESCE(REPLACE(f%d.title, '/', '\\/'), '')", i), "'/'"}, concatCols...) + } + return dialect.Concat(concatCols...) +} + +func getFullapathUIDsSQL(dialect migrator.Dialect) string { + concatCols := make([]string, 0, folder.MaxNestedFolderDepth) + concatCols = append(concatCols, "COALESCE(f0.uid, '')") + for i := 1; i <= folder.MaxNestedFolderDepth; i++ { + concatCols = append([]string{fmt.Sprintf("COALESCE(f%d.uid, '')", i), "'/'"}, concatCols...) + } + return dialect.Concat(concatCols...) +} + +// getFullpathJoinsSQL returns a SQL fragment that joins the same table multiple times to get the full path of a folder. +func getFullpathJoinsSQL() string { + joins := make([]string, 0, folder.MaxNestedFolderDepth) + for i := 1; i <= folder.MaxNestedFolderDepth; i++ { + joins = append(joins, fmt.Sprintf(` LEFT JOIN folder f%d ON f%d.org_id = f%d.org_id AND f%d.uid = f%d.parent_uid`, i, i, i-1, i, i-1)) + } + return strings.Join(joins, "\n") +} + +func getAncestorsSQL(dialect migrator.Dialect, ancestorUIDs []string, start int, end int, origSQL string, origArgs []any) (string, []any) { + s2 := strings.Builder{} + s2.WriteString(origSQL) + args2 := make([]any, 0, len(ancestorUIDs)*folder.MaxNestedFolderDepth) + args2 = append(args2, origArgs...) + + partialAncestorUIDs := ancestorUIDs[start:min(end, len(ancestorUIDs))] + partialArgs := make([]any, 0, len(partialAncestorUIDs)) + for _, uid := range partialAncestorUIDs { + partialArgs = append(partialArgs, uid) + } + s2.WriteString(` AND ( f0.uid IN (?` + strings.Repeat(", ?", len(partialAncestorUIDs)-1) + `)`) + args2 = append(args2, partialArgs...) + for i := 1; i <= folder.MaxNestedFolderDepth; i++ { + s2.WriteString(fmt.Sprintf(` OR f%d.uid IN (?`+strings.Repeat(", ?", len(partialAncestorUIDs)-1)+`)`, i)) + args2 = append(args2, partialArgs...) + } + s2.WriteString(` )`) + return s2.String(), args2 +} + +func batch(count, batchSize int, eachFn func(start, end int) error) error { + if count == 0 { + if err := eachFn(0, 0); err != nil { + return err + } + return nil + } + + for i := 0; i < count; { + end := i + batchSize + if end > count { + end = count + } + + if err := eachFn(i, end); err != nil { + return err + } + + i = end + } + + return nil +} diff --git a/pkg/services/folder/folderimpl/sqlstore_test.go b/pkg/services/folder/folderimpl/sqlstore_test.go index 6d2151aec80..7729c8fbab6 100644 --- a/pkg/services/folder/folderimpl/sqlstore_test.go +++ b/pkg/services/folder/folderimpl/sqlstore_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -759,24 +760,91 @@ func TestIntegrationGetFolders(t *testing.T) { }) t.Run("get folders by UIDs should succeed", func(t *testing.T) { - ff, err := folderStore.GetFolders(context.Background(), orgID, uids) + actualFolders, err := folderStore.GetFolders(context.Background(), NewGetFoldersQuery(folder.GetFoldersQuery{OrgID: orgID, UIDs: uids[1:]})) require.NoError(t, err) - assert.Equal(t, len(uids), len(ff)) - for _, f := range folders { - folderInResponseIdx := slices.IndexFunc(ff, func(rf *folder.Folder) bool { + assert.Equal(t, len(uids[1:]), len(actualFolders)) + for _, f := range folders[1:] { + folderInResponseIdx := slices.IndexFunc(actualFolders, func(rf *folder.Folder) bool { return rf.UID == f.UID }) assert.NotEqual(t, -1, folderInResponseIdx) - rf := ff[folderInResponseIdx] - assert.Equal(t, f.UID, rf.UID) - assert.Equal(t, f.OrgID, rf.OrgID) - assert.Equal(t, f.Title, rf.Title) - assert.Equal(t, f.Description, rf.Description) - assert.NotEmpty(t, rf.Created) - assert.NotEmpty(t, rf.Updated) - assert.NotEmpty(t, rf.URL) + actualFolder := actualFolders[folderInResponseIdx] + assert.Equal(t, f.UID, actualFolder.UID) + assert.Equal(t, f.OrgID, actualFolder.OrgID) + assert.Equal(t, f.Title, actualFolder.Title) + assert.Equal(t, f.Description, actualFolder.Description) + assert.NotEmpty(t, actualFolder.Created) + assert.NotEmpty(t, actualFolder.Updated) + assert.NotEmpty(t, actualFolder.URL) } }) + + t.Run("get folders by UIDs batching should work as expected", func(t *testing.T) { + q := NewGetFoldersQuery(folder.GetFoldersQuery{OrgID: orgID, UIDs: uids[1:], BatchSize: 3}) + actualFolders, err := folderStore.GetFolders(context.Background(), q) + require.NoError(t, err) + assert.Equal(t, len(uids[1:]), len(actualFolders)) + for _, f := range folders[1:] { + folderInResponseIdx := slices.IndexFunc(actualFolders, func(rf *folder.Folder) bool { + return rf.UID == f.UID + }) + assert.NotEqual(t, -1, folderInResponseIdx) + actualFolder := actualFolders[folderInResponseIdx] + assert.Equal(t, f.UID, actualFolder.UID) + assert.Equal(t, f.OrgID, actualFolder.OrgID) + assert.Equal(t, f.Title, actualFolder.Title) + assert.Equal(t, f.Description, actualFolder.Description) + assert.NotEmpty(t, actualFolder.Created) + assert.NotEmpty(t, actualFolder.Updated) + assert.NotEmpty(t, actualFolder.URL) + } + }) + + t.Run("get folders by UIDs with fullpath should succeed", func(t *testing.T) { + q := NewGetFoldersQuery(folder.GetFoldersQuery{OrgID: orgID, UIDs: uids[1:], WithFullpath: true}) + q.BatchSize = 3 + actualFolders, err := folderStore.GetFolders(context.Background(), q) + require.NoError(t, err) + assert.Equal(t, len(uids[1:]), len(actualFolders)) + for _, f := range folders[1:] { + folderInResponseIdx := slices.IndexFunc(actualFolders, func(rf *folder.Folder) bool { + return rf.UID == f.UID + }) + assert.NotEqual(t, -1, folderInResponseIdx) + actualFolder := actualFolders[folderInResponseIdx] + assert.Equal(t, f.UID, actualFolder.UID) + assert.Equal(t, f.OrgID, actualFolder.OrgID) + assert.Equal(t, f.Title, actualFolder.Title) + assert.Equal(t, f.Description, actualFolder.Description) + assert.NotEmpty(t, actualFolder.Created) + assert.NotEmpty(t, actualFolder.Updated) + assert.NotEmpty(t, actualFolder.URL) + assert.NotEmpty(t, actualFolder.Fullpath) + } + }) + + t.Run("get folders by UIDs and ancestor UIDs should work as expected", func(t *testing.T) { + q := NewGetFoldersQuery(folder.GetFoldersQuery{OrgID: orgID, UIDs: uids[1:], BatchSize: 3}) + q.ancestorUIDs = make([]string, 0, int(q.BatchSize)+1) + for i := 0; i < int(q.BatchSize); i++ { + q.ancestorUIDs = append(q.ancestorUIDs, uuid.New().String()) + } + q.ancestorUIDs = append(q.ancestorUIDs, folders[len(folders)-1].UID) + + actualFolders, err := folderStore.GetFolders(context.Background(), q) + require.NoError(t, err) + assert.Equal(t, 1, len(actualFolders)) + + f := folders[len(folders)-1] + actualFolder := actualFolders[0] + assert.Equal(t, f.UID, actualFolder.UID) + assert.Equal(t, f.OrgID, actualFolder.OrgID) + assert.Equal(t, f.Title, actualFolder.Title) + assert.Equal(t, f.Description, actualFolder.Description) + assert.NotEmpty(t, actualFolder.Created) + assert.NotEmpty(t, actualFolder.Updated) + assert.NotEmpty(t, actualFolder.URL) + }) } func CreateOrg(t *testing.T, db *sqlstore.SQLStore) int64 { diff --git a/pkg/services/folder/folderimpl/store.go b/pkg/services/folder/folderimpl/store.go index 4472593c268..4cee3fcf162 100644 --- a/pkg/services/folder/folderimpl/store.go +++ b/pkg/services/folder/folderimpl/store.go @@ -6,6 +6,18 @@ import ( "github.com/grafana/grafana/pkg/services/folder" ) +type getFoldersQuery struct { + folder.GetFoldersQuery + ancestorUIDs []string +} + +func NewGetFoldersQuery(q folder.GetFoldersQuery) getFoldersQuery { + return getFoldersQuery{ + GetFoldersQuery: q, + ancestorUIDs: []string{}, + } +} + // store is the interface which a folder store must implement. type store interface { // Create creates a folder and returns the newly-created folder. @@ -35,5 +47,5 @@ type store interface { GetHeight(ctx context.Context, foldrUID string, orgID int64, parentUID *string) (int, error) // GetFolders returns folders with given uids - GetFolders(ctx context.Context, orgID int64, uids []string) ([]*folder.Folder, error) + GetFolders(ctx context.Context, q getFoldersQuery) ([]*folder.Folder, error) } diff --git a/pkg/services/folder/folderimpl/store_fake.go b/pkg/services/folder/folderimpl/store_fake.go index c373d44674c..f355bd17262 100644 --- a/pkg/services/folder/folderimpl/store_fake.go +++ b/pkg/services/folder/folderimpl/store_fake.go @@ -57,6 +57,6 @@ func (f *fakeStore) GetHeight(ctx context.Context, folderUID string, orgID int64 return f.ExpectedFolderHeight, f.ExpectedError } -func (f *fakeStore) GetFolders(ctx context.Context, orgID int64, uids []string) ([]*folder.Folder, error) { +func (f *fakeStore) GetFolders(ctx context.Context, q getFoldersQuery) ([]*folder.Folder, error) { return f.ExpectedFolders, f.ExpectedError } diff --git a/pkg/services/folder/foldertest/foldertest.go b/pkg/services/folder/foldertest/foldertest.go index 8a0864985b4..7cfccb3b5c2 100644 --- a/pkg/services/folder/foldertest/foldertest.go +++ b/pkg/services/folder/foldertest/foldertest.go @@ -51,3 +51,7 @@ func (s *FakeService) RegisterService(service folder.RegistryService) error { func (s *FakeService) GetDescendantCounts(ctx context.Context, q *folder.GetDescendantCountsQuery) (folder.DescendantCounts, error) { return s.ExpectedDescendantCounts, s.ExpectedError } + +func (s *FakeService) GetFolders(ctx context.Context, q folder.GetFoldersQuery) ([]*folder.Folder, error) { + return s.ExpectedFolders, s.ExpectedError +} diff --git a/pkg/services/folder/model.go b/pkg/services/folder/model.go index a28f24fe8f4..da7fa6c837a 100644 --- a/pkg/services/folder/model.go +++ b/pkg/services/folder/model.go @@ -41,11 +41,13 @@ type Folder struct { // TODO: validate if this field is required/relevant to folders. // currently there is no such column - Version int - URL string - UpdatedBy int64 - CreatedBy int64 - HasACL bool + Version int + URL string + UpdatedBy int64 + CreatedBy int64 + HasACL bool + Fullpath string `xorm:"fullpath"` + FullpathUIDs string `xorm:"fullpath_uids"` } var GeneralFolder = Folder{ID: 0, Title: "General"} @@ -149,6 +151,16 @@ type GetFolderQuery struct { SignedInUser identity.Requester `json:"-"` } +type GetFoldersQuery struct { + OrgID int64 + UIDs []string + WithFullpath bool + WithFullpathUIDs bool + BatchSize uint64 + + SignedInUser identity.Requester `json:"-"` +} + // GetParentsQuery captures the information required by the folder service to // return a list of all parent folders of a given folder. type GetParentsQuery struct { diff --git a/pkg/services/folder/service.go b/pkg/services/folder/service.go index 7bca2417a9d..242a00e2f0b 100644 --- a/pkg/services/folder/service.go +++ b/pkg/services/folder/service.go @@ -25,6 +25,12 @@ type Service interface { // Move changes a folder's parent folder to the requested new parent. Move(ctx context.Context, cmd *MoveFolderCommand) (*Folder, error) RegisterService(service RegistryService) error + // GetFolders returns org folders that are accessible by the signed in user by their UIDs. + // If WithFullpath is true it computes also the full path of a folder. + // The full path is a string that contains the titles of all parent folders separated by a slash. + // If a folder contains a slash in its title, it is escaped with a backslash. + // If FullpathUIDs is true it computes a string that contains the UIDs of all parent folders separated by slash. + GetFolders(ctx context.Context, q GetFoldersQuery) ([]*Folder, error) GetDescendantCounts(ctx context.Context, q *GetDescendantCountsQuery) (DescendantCounts, error) } diff --git a/pkg/services/sqlstore/migrator/dialect.go b/pkg/services/sqlstore/migrator/dialect.go index 53b75365a20..5aae742b6ca 100644 --- a/pkg/services/sqlstore/migrator/dialect.go +++ b/pkg/services/sqlstore/migrator/dialect.go @@ -91,6 +91,10 @@ type Dialect interface { // column names to values to use in the where clause. // The update is executed as part of the provided session. Update(ctx context.Context, tx *session.SessionTx, tableName string, row map[string]any, where map[string]any) error + // Concat returns the sql statement for concating multiple strings + // Implementations are not expected to quote the arguments + // therefore any callers should take care to quote arguments as necessary + Concat(...string) string } type LockCfg struct { @@ -450,3 +454,7 @@ func (b *BaseDialect) Update(ctx context.Context, tx *session.SessionTx, tableNa _, err = tx.Exec(ctx, query, args...) return err } + +func (b *BaseDialect) Concat(strs ...string) string { + return fmt.Sprintf("CONCAT(%s)", strings.Join(strs, ", ")) +} diff --git a/pkg/services/sqlstore/migrator/sqlite_dialect.go b/pkg/services/sqlstore/migrator/sqlite_dialect.go index 17bb097e199..c2979ea1925 100644 --- a/pkg/services/sqlstore/migrator/sqlite_dialect.go +++ b/pkg/services/sqlstore/migrator/sqlite_dialect.go @@ -209,3 +209,7 @@ func (db *SQLite3) UpsertMultipleSQL(tableName string, keyCols, updateCols []str ) return s, nil } + +func (db *SQLite3) Concat(strs ...string) string { + return strings.Join(strs, " || ") +}