mirror of https://github.com/grafana/grafana.git
				
				
				
			Folders: Introduce folder service function for fetching folders by org and UIDs that contain optionally the folder full path (#80716)
* Folders: Expose function for getting all org folders with specific UIDs * Return all org folders if UIDs is empty * Filter out not accessible folders by the user * Modify query to optionally returning a string that contains the UIDs of all parent folders separated by slash.
This commit is contained in:
		
							parent
							
								
									f154b2b855
								
							
						
					
					
						commit
						5e88d29814
					
				|  | @ -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) | ||||
| 	} | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -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 { | ||||
| 		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) | ||||
| 				} | ||||
| 		return sess.SQL(b.String(), args...).Find(&folders) | ||||
| 			} | ||||
| 
 | ||||
| 			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 | ||||
| } | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -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) | ||||
| } | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
|  | @ -46,6 +46,8 @@ type Folder struct { | |||
| 	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 { | ||||
|  |  | |||
|  | @ -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) | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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, ", ")) | ||||
| } | ||||
|  |  | |||
|  | @ -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, " || ") | ||||
| } | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue