Skip to content

Commit e6886ef

Browse files
committed
implement search functionality for projects with subprojects and assets
Signed-off-by: rafi <refaei.shikho@hotmail.com>
1 parent 401ceaf commit e6886ef

6 files changed

Lines changed: 182 additions & 0 deletions

File tree

controllers/project_controller.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,16 @@ func (ProjectController *ProjectController) List(c shared.Context) error {
368368
return c.JSON(200, projects)
369369
}
370370

371+
func (ProjectController *ProjectController) SearchProjectsWithSubProjectsAndAssets(c shared.Context) error {
372+
373+
results, err := ProjectController.projectService.SearchProjectsWithSubProjectsAndAssetsPaged(c)
374+
if err != nil {
375+
return err
376+
}
377+
378+
return c.JSON(200, results)
379+
}
380+
371381
// @Summary Update project
372382
// @Tags Projects
373383
// @Security CookieAuth

database/repositories/project_repository.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package repositories
33
import (
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+
115251
func (g *projectRepository) ListSubProjectsAndAssets(
116252
ctx context.Context,
117253
tx *gorm.DB,

dtos/project_dto.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ type ProjectDTO struct {
6969

7070
ExternalEntityProviderID *string `json:"externalEntityProviderId,omitempty"`
7171
ExternalEntityID *string `json:"externalEntityId,omitempty"` // only set if this is an external entity
72+
73+
SubGroupsAndAssets []ProjectAssetDTO `json:"subGroupsAndAsset"`
7274
}
7375

7476
type ProjectDetailsDTO struct {
@@ -89,4 +91,6 @@ type ProjectAssetDTO struct {
8991
State string `json:"state"`
9092
CreatedAt time.Time `json:"createdAt"`
9193
UpdatedAt time.Time `json:"updatedAt"`
94+
95+
SubGroupsAndAssets []ProjectAssetDTO `json:"subGroupsAndAsset" gorm:"-"`
9296
}

router/org_router.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ func NewOrgRouter(
7878
organizationRouter.GET("/members/", orgController.Members)
7979
organizationRouter.GET("/integrations/finish-installation/", integrationController.FinishInstallation)
8080
organizationRouter.GET("/projects/", projectController.List)
81+
organizationRouter.GET("/projects/search/", projectController.SearchProjectsWithSubProjectsAndAssets)
8182
organizationRouter.GET("/integrations/repositories/", integrationController.ListRepositories)
8283

8384
organizationUpdateAccessControlRequired := organizationRouter.Group("", middlewares.NeededScope([]string{"manage"}), middlewares.OrganizationAccessControlMiddleware(shared.ObjectOrganization, shared.ActionUpdate))

services/project_service.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,35 @@ func (s *projectService) projectsForUser(c shared.Context, projectsIdsStr []stri
177177
return projectIDsSlice, parentID, nil
178178
}
179179

180+
func (s *projectService) SearchProjectsWithSubProjectsAndAssetsPaged(c shared.Context) (shared.Paged[dtos.ProjectDTO], error) {
181+
rbac := shared.GetRBAC(c)
182+
parentIDStr := c.QueryParam("parentId")
183+
var parentID *uuid.UUID
184+
if parentIDStr != "" {
185+
tmp, err := uuid.Parse(parentIDStr)
186+
if err != nil {
187+
return shared.Paged[dtos.ProjectDTO]{}, echo.NewHTTPError(400, "invalid parentId").WithInternal(err)
188+
}
189+
parentID = &tmp
190+
}
191+
192+
allowedAssetIDs, err := rbac.GetAllAssetsForUser(shared.GetSession(c).GetUserID())
193+
if err != nil {
194+
return shared.Paged[dtos.ProjectDTO]{}, echo.NewHTTPError(500, "could not get allowed assets for user").WithInternal(err)
195+
}
196+
allowedProjectIDs, err := rbac.GetAllProjectsForUser(shared.GetSession(c).GetUserID())
197+
if err != nil {
198+
return shared.Paged[dtos.ProjectDTO]{}, echo.NewHTTPError(500, "could not get allowed projects for user").WithInternal(err)
199+
}
200+
201+
projects, err := s.projectRepository.SearchProjectsWithSubProjectsAndAssetsPaged(c.Request().Context(), nil, allowedAssetIDs, allowedProjectIDs, parentID, shared.GetOrg(c).GetID(), shared.GetPageInfo(c), c.QueryParam("search"), shared.GetFilterQuery(c), shared.GetSortQuery(c))
202+
if err != nil {
203+
return shared.Paged[dtos.ProjectDTO]{}, err
204+
}
205+
206+
return projects, nil
207+
}
208+
180209
func (s *projectService) ListAllowedSubProjectsAndAssetsPaged(c shared.Context) (shared.Paged[dtos.ProjectAssetDTO], error) {
181210

182211
rbac := shared.GetRBAC(c)

shared/common_interfaces.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ type ProjectRepository interface {
103103
EnableCommunityManagedPolicies(ctx context.Context, tx DB, projectID uuid.UUID) error
104104
UpsertSplit(ctx context.Context, tx DB, externalProviderID string, projects []*models.Project) ([]*models.Project, []*models.Project, error)
105105
ListSubProjectsAndAssets(ctx context.Context, tx DB, allowedAssetIDs []string, allowedProjectIDs []uuid.UUID, parentID *uuid.UUID, orgID uuid.UUID, pageInfo PageInfo, search string, filter []FilterQuery, sort []SortQuery) (Paged[dtos.ProjectAssetDTO], error)
106+
SearchProjectsWithSubProjectsAndAssetsPaged(ctx context.Context, tx DB, allowedAssetIDs []string, allowedProjectIDs []string, parentID *uuid.UUID, orgID uuid.UUID, pageInfo PageInfo, search string, filter []FilterQuery, sort []SortQuery) (Paged[dtos.ProjectDTO], error)
106107
}
107108

108109
type Verifier interface {
@@ -360,6 +361,7 @@ type ProjectService interface {
360361
GetDirectChildProjects(ctx context.Context, projectID uuid.UUID) ([]models.Project, error)
361362
CreateProject(ctx Context, project *models.Project) error
362363
BootstrapProject(ctx context.Context, rbac AccessControl, project *models.Project) error
364+
SearchProjectsWithSubProjectsAndAssetsPaged(c Context) (Paged[dtos.ProjectDTO], error)
363365
}
364366

365367
type InTotoVerifierService interface {

0 commit comments

Comments
 (0)