Skip to content

Commit 7e662f2

Browse files
authored
Merge pull request #1920 from l3montree-dev/add-deep-search-feature
2 parents ca6227c + 93a3358 commit 7e662f2

9 files changed

Lines changed: 737 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: 148 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,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+
115263
func (g *projectRepository) ListSubProjectsAndAssets(
116264
ctx context.Context,
117265
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
}

mocks/mock_ProjectRepository.go

Lines changed: 114 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

mocks/mock_ProjectService.go

Lines changed: 60 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)