@@ -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,141 @@ 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+ }
123+
124+ projectParams := []interface {}{
125+ "%" + search + "%" ,
126+ pq .Array (allowedProjectIDs ),
127+ orgID ,
128+ }
129+
130+ // When parentID is set, stop the upward recursion once we reach that project.
131+ var assetChainStopCond , projectChainStopCond string
132+ var parentStopParam []interface {}
133+ if parentID != nil {
134+ assetChainStopCond = " WHERE apc.id != ?"
135+ projectChainStopCond = " WHERE pc.id != ?"
136+ parentStopParam = []interface {}{* parentID }
137+ }
138+
139+ // matching_assets CTE avoids repeating the asset filter (and its params) twice.
140+ // asset_project_chain walks up to root projects that contain matching assets.
141+ // project_chain walks up to root projects that match the project filter.
142+ // Both recursive steps stop at parentID when it is set.
143+ cteBlock := `
144+ WITH RECURSIVE
145+ matching_assets AS (
146+ SELECT * FROM assets a WHERE a.name ILIKE ? AND (a.id = ANY(?) OR a.is_public = true)
147+ ),
148+ asset_project_chain AS (
149+ SELECT pr.*
150+ FROM projects pr
151+ WHERE pr.id IN (SELECT project_id FROM matching_assets)
152+ UNION
153+ SELECT pr.*
154+ FROM projects pr
155+ INNER JOIN asset_project_chain apc ON pr.id = apc.parent_id` + assetChainStopCond + `
156+ ),
157+ project_chain AS (
158+ SELECT p.*
159+ FROM projects p
160+ WHERE p.name ILIKE ? AND (p.id = ANY(?) OR (p.organization_id = ? AND p.is_public = true))
161+ UNION
162+ SELECT pr.*
163+ FROM projects pr
164+ INNER JOIN project_chain pc ON pr.id = pc.parent_id` + projectChainStopCond + `
165+ ),
166+ combined AS (
167+ SELECT 'asset'::text AS resource_type, a.id, a.name, a.slug, a.description,
168+ a.project_id, NULL::uuid AS parent_id, NULL::uuid AS organization_id,
169+ a.is_public, a.state, a.created_at, a.updated_at
170+ FROM matching_assets a
171+ UNION ALL
172+ SELECT 'project'::text AS resource_type, c.id, c.name, c.slug, c.description,
173+ NULL::uuid AS project_id, c.parent_id, c.organization_id,
174+ c.is_public, c.state, c.created_at, c.updated_at
175+ FROM (SELECT * FROM asset_project_chain UNION SELECT * FROM project_chain) c
176+ )`
177+
178+ allParams := make ([]interface {}, 0 , len (assetParams )+ len (parentStopParam )+ len (projectParams )+ len (parentStopParam ))
179+ allParams = append (allParams , assetParams ... )
180+ allParams = append (allParams , parentStopParam ... )
181+ allParams = append (allParams , projectParams ... )
182+ allParams = append (allParams , parentStopParam ... )
183+
184+ var count int64
185+ if err := g .GetDB (ctx , tx ).Raw (cteBlock + " SELECT COUNT(*) FROM combined" , allParams ... ).Scan (& count ).Error ; err != nil {
186+ return shared.Paged [dtos.ProjectDTO ]{}, err
187+ }
188+
189+ orderClause := "combined.name ASC"
190+ if len (sort ) > 0 {
191+ parts := make ([]string , len (sort ))
192+ for i , s := range sort {
193+ parts [i ] = s .SQL ()
194+ }
195+ orderClause = strings .Join (parts , ", " )
196+ }
197+
198+ dataSQL := cteBlock + " SELECT * FROM combined ORDER BY " + orderClause + " LIMIT ? OFFSET ?"
199+ dataParams := append (allParams , pageInfo .PageSize , (pageInfo .Page - 1 )* pageInfo .PageSize )
200+ if err := g .GetDB (ctx , tx ).Raw (dataSQL , dataParams ... ).Scan (& results ).Error ; err != nil {
201+ return shared.Paged [dtos.ProjectDTO ]{}, err
202+ }
203+
204+ return shared .NewPaged (pageInfo , count , transformToProjectDTOs (results )), nil
205+ }
206+
207+ func transformToProjectDTOs (results []dtos.ProjectAssetDTO ) []dtos.ProjectDTO {
208+ assetsByProjectID := map [uuid.UUID ][]dtos.ProjectAssetDTO {}
209+ subprojectsByParentID := map [uuid.UUID ][]dtos.ProjectAssetDTO {}
210+ var rootProjects []dtos.ProjectAssetDTO
211+
212+ for _ , r := range results {
213+ if r .ResourceType == "asset" {
214+ assetsByProjectID [r .ProjectID ] = append (assetsByProjectID [r .ProjectID ], r )
215+ } else {
216+ if r .ParentID != nil {
217+ subprojectsByParentID [* r .ParentID ] = append (subprojectsByParentID [* r .ParentID ], r )
218+ } else {
219+ rootProjects = append (rootProjects , r )
220+ }
221+ }
222+ }
223+
224+ // recursively build SubGroupsAndAssets for a given project ID
225+ var buildChildren func (projectID uuid.UUID ) []dtos.ProjectAssetDTO
226+ buildChildren = func (projectID uuid.UUID ) []dtos.ProjectAssetDTO {
227+ var children []dtos.ProjectAssetDTO
228+ for _ , sub := range subprojectsByParentID [projectID ] {
229+ sub .SubGroupsAndAssets = buildChildren (sub .ID )
230+ children = append (children , sub )
231+ }
232+ children = append (children , assetsByProjectID [projectID ]... )
233+ return children
234+ }
235+
236+ rootDTOs := make ([]dtos.ProjectDTO , 0 , len (rootProjects ))
237+ for _ , r := range rootProjects {
238+ rootDTOs = append (rootDTOs , dtos.ProjectDTO {
239+ ID : r .ID ,
240+ Name : r .Name ,
241+ Slug : r .Slug ,
242+ Description : r .Description ,
243+ IsPublic : r .IsPublic ,
244+ SubGroupsAndAssets : buildChildren (r .ID ),
245+ })
246+ }
247+
248+ return rootDTOs
249+ }
250+
115251func (g * projectRepository ) ListSubProjectsAndAssets (
116252 ctx context.Context ,
117253 tx * gorm.DB ,
0 commit comments