@@ -3,6 +3,7 @@ package repositories
33import (
44 "context"
55 "fmt"
6+ "strings"
67
78 "github.com/google/uuid"
89 "github.com/l3montree-dev/devguard/database/models"
@@ -112,6 +113,153 @@ func (g *projectRepository) Update(ctx context.Context, tx *gorm.DB, project *mo
112113 return g .GetDB (ctx , tx ).Save (project ).Error
113114}
114115
116+ func (g * projectRepository ) SearchProjectsWithSubProjectsAndAssetsPaged (ctx context.Context , tx * gorm.DB , allowedAssetIDs []string , allowedProjectIDs []string , parentID * uuid.UUID , orgID uuid.UUID , pageInfo shared.PageInfo , search string , filter []shared.FilterQuery , sort []shared.SortQuery ) (shared.Paged [dtos.ProjectDTO ], error ) {
117+ var results []dtos.ProjectAssetDTO
118+
119+ assetParams := []interface {}{
120+ "%" + search + "%" ,
121+ pq .Array (allowedAssetIDs ),
122+ orgID ,
123+ }
124+
125+ projectParams := []interface {}{
126+ "%" + search + "%" ,
127+ pq .Array (allowedProjectIDs ),
128+ orgID ,
129+ }
130+
131+ // When parentID is set, stop the upward recursion once we reach that project.
132+ var assetChainStopCond , projectChainStopCond string
133+ var parentStopParam []interface {}
134+ if parentID != nil {
135+ assetChainStopCond = " WHERE apc.id != ?"
136+ projectChainStopCond = " WHERE pc.id != ?"
137+ parentStopParam = []interface {}{* parentID }
138+ }
139+
140+ searchResultsCTE := `
141+ WITH RECURSIVE
142+ searchResults AS (
143+ SELECT 'asset'::text AS resource_type, a.id, a.name, a.slug, a.description,
144+ a.project_id, NULL::uuid AS parent_id, NULL::uuid AS organization_id,
145+ a.is_public, a.state, a.created_at, a.updated_at
146+ FROM assets a
147+ INNER JOIN projects p ON p.id = a.project_id
148+ WHERE a.name ILIKE ? AND (a.id = ANY(?) OR (a.is_public = true AND p.organization_id = ?))
149+ UNION
150+ SELECT 'project'::text AS resource_type, p.id, p.name, p.slug, p.description,
151+ NULL::uuid AS project_id, p.parent_id, p.organization_id,
152+ p.is_public, p.state, p.created_at, p.updated_at
153+ FROM projects p
154+ WHERE p.name ILIKE ? AND (p.id = ANY(?) OR (p.organization_id = ? AND p.is_public = true))
155+ )`
156+
157+ // Count only the matched assets and projects, not the parent chain.
158+ countParams := make ([]interface {}, 0 , len (assetParams )+ len (projectParams ))
159+ countParams = append (countParams , assetParams ... )
160+ countParams = append (countParams , projectParams ... )
161+
162+ var count int64
163+ if err := g .GetDB (ctx , tx ).Raw (searchResultsCTE + " SELECT COUNT(*) FROM searchResults" , countParams ... ).Scan (& count ).Error ; err != nil {
164+ return shared.Paged [dtos.ProjectDTO ]{}, err
165+ }
166+
167+ orderClause := "name ASC"
168+ if len (sort ) > 0 {
169+ parts := make ([]string , len (sort ))
170+ for i , s := range sort {
171+ parts [i ] = s .SQL ()
172+ }
173+ orderClause = strings .Join (parts , ", " )
174+ }
175+
176+ // Paginate the matched results first, then fetch parent chains for each item.
177+ dataSQL := searchResultsCTE + `,
178+ paginatedResults AS (
179+ SELECT * FROM searchResults ORDER BY ` + orderClause + ` LIMIT ? OFFSET ?
180+ ),
181+ asset_project_chain AS (
182+ SELECT pr.*
183+ FROM projects pr
184+ WHERE pr.id IN (SELECT project_id FROM paginatedResults WHERE resource_type = 'asset')
185+ UNION
186+ SELECT pr.*
187+ FROM projects pr
188+ INNER JOIN asset_project_chain apc ON pr.id = apc.parent_id` + assetChainStopCond + `
189+ ),
190+ project_chain AS (
191+ SELECT pr.*
192+ FROM projects pr
193+ WHERE pr.id IN (SELECT parent_id FROM paginatedResults WHERE resource_type = 'project' AND parent_id IS NOT NULL)
194+ UNION
195+ SELECT pr.*
196+ FROM projects pr
197+ INNER JOIN project_chain pc ON pr.id = pc.parent_id` + projectChainStopCond + `
198+ )
199+ SELECT resource_type, id, name, slug, description, project_id, parent_id, organization_id, is_public, state, created_at, updated_at FROM paginatedResults
200+ UNION
201+ SELECT 'project'::text AS resource_type, id, name, slug, description, NULL::uuid AS project_id, parent_id, organization_id, is_public, state, created_at, updated_at FROM asset_project_chain
202+ UNION
203+ SELECT 'project'::text AS resource_type, id, name, slug, description, NULL::uuid AS project_id, parent_id, organization_id, is_public, state, created_at, updated_at FROM project_chain`
204+
205+ dataParams := make ([]interface {}, 0 , len (assetParams )+ len (projectParams )+ 2 + len (parentStopParam )* 2 )
206+ dataParams = append (dataParams , assetParams ... )
207+ dataParams = append (dataParams , projectParams ... )
208+ dataParams = append (dataParams , pageInfo .PageSize , (pageInfo .Page - 1 )* pageInfo .PageSize )
209+ dataParams = append (dataParams , parentStopParam ... )
210+ dataParams = append (dataParams , parentStopParam ... )
211+
212+ if err := g .GetDB (ctx , tx ).Raw (dataSQL , dataParams ... ).Scan (& results ).Error ; err != nil {
213+ return shared.Paged [dtos.ProjectDTO ]{}, err
214+ }
215+
216+ return shared .NewPaged (pageInfo , count , transformToProjectDTOs (results )), nil
217+ }
218+
219+ func transformToProjectDTOs (results []dtos.ProjectAssetDTO ) []dtos.ProjectDTO {
220+ assetsByProjectID := map [uuid.UUID ][]dtos.ProjectAssetDTO {}
221+ subprojectsByParentID := map [uuid.UUID ][]dtos.ProjectAssetDTO {}
222+ var rootProjects []dtos.ProjectAssetDTO
223+
224+ for _ , r := range results {
225+ if r .ResourceType == "asset" {
226+ assetsByProjectID [r .ProjectID ] = append (assetsByProjectID [r .ProjectID ], r )
227+ } else {
228+ if r .ParentID != nil {
229+ subprojectsByParentID [* r .ParentID ] = append (subprojectsByParentID [* r .ParentID ], r )
230+ } else {
231+ rootProjects = append (rootProjects , r )
232+ }
233+ }
234+ }
235+
236+ // recursively build SubGroupsAndAssets for a given project ID
237+ var buildChildren func (projectID uuid.UUID ) []dtos.ProjectAssetDTO
238+ buildChildren = func (projectID uuid.UUID ) []dtos.ProjectAssetDTO {
239+ var children []dtos.ProjectAssetDTO
240+ for _ , sub := range subprojectsByParentID [projectID ] {
241+ sub .SubGroupsAndAssets = buildChildren (sub .ID )
242+ children = append (children , sub )
243+ }
244+ children = append (children , assetsByProjectID [projectID ]... )
245+ return children
246+ }
247+
248+ rootDTOs := make ([]dtos.ProjectDTO , 0 , len (rootProjects ))
249+ for _ , r := range rootProjects {
250+ rootDTOs = append (rootDTOs , dtos.ProjectDTO {
251+ ID : r .ID ,
252+ Name : r .Name ,
253+ Slug : r .Slug ,
254+ Description : r .Description ,
255+ IsPublic : r .IsPublic ,
256+ SubGroupsAndAssets : buildChildren (r .ID ),
257+ })
258+ }
259+
260+ return rootDTOs
261+ }
262+
115263func (g * projectRepository ) ListSubProjectsAndAssets (
116264 ctx context.Context ,
117265 tx * gorm.DB ,
0 commit comments