Skip to content

Commit 06b0233

Browse files
committed
Update ListEntities: optimize database queries, support multiple types, and report additional data
1 parent f57f7f9 commit 06b0233

17 files changed

Lines changed: 739 additions & 62 deletions

File tree

cmd/server/app/webhook_update.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import (
3333
)
3434

3535
func cmdWebhookUpdate() *cobra.Command {
36-
var updateCmd = &cobra.Command{
36+
updateCmd := &cobra.Command{
3737
Use: "update",
3838
Short: "update the webhook configuration",
3939
Long: `Command to upgrade webhook configuration`,

database/mock/store.go

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

database/query/entities.sql

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,27 @@ WHERE entity_instances.entity_type = $1
7070
AND entity_instances.provider_id = sqlc.arg(provider_id)
7171
AND entity_instances.project_id = ANY(sqlc.arg(projects)::uuid[]);
7272

73+
-- GetEntitiesWithProps retrieves all entities of a given type with their properties for a project or hierarchy of projects.
74+
75+
-- name: GetEntitiesWithProps :many
76+
SELECT entity_instances.*,
77+
providers.name AS provider_name,
78+
providers.class AS provider_class,
79+
JSON_OBJECT_AGG(
80+
p.key,
81+
p.value
82+
) AS properties
83+
FROM entity_instances
84+
LEFT JOIN providers ON entity_instances.provider_id = providers.id
85+
LEFT JOIN properties p ON entity_instances.id = p.entity_id
86+
WHERE (entity_instances.entity_type = sqlc.narg(entity_type) OR sqlc.narg(entity_type) IS NULL)
87+
AND entity_instances.id >= sqlc.arg('cursor')::uuid
88+
AND entity_instances.provider_id = sqlc.arg(provider_id)
89+
AND entity_instances.project_id = ANY(sqlc.arg(projects)::uuid[])
90+
GROUP BY entity_instances.id, providers.id
91+
ORDER BY entity_instances.id
92+
LIMIT sqlc.arg('limit')::bigint;
93+
7394
-- ListEntitiesAfterID retrieves entities of a given type after a cursor ID, for pagination.
7495
-- This is used for cursor-based iteration over all entities (e.g., in the reminder service).
7596

docs/docs/ref/proto.mdx

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

internal/controlplane/handlers_entity_instances.go

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -42,43 +42,39 @@ func (s *Server) ListEntities(
4242
return nil, fmt.Errorf("error getting provider: %w", err)
4343
}
4444

45-
// Get entity type from request
46-
entityType := in.GetEntityType()
47-
if entityType == pb.Entity_ENTITY_UNSPECIFIED {
48-
return nil, util.UserVisibleError(codes.InvalidArgument, "entity type must be specified")
49-
}
50-
5145
// Get limit from request
5246
limit := in.GetCursor().GetSize()
5347
if limit == 0 {
54-
limit = 20 // Default limit
48+
limit = 100 // Default limit
5549
}
5650

57-
// Get cursor from request
58-
cursor := in.GetCursor().GetCursor()
59-
6051
// Call service to get entities
6152
outentities, nextCursor, err := s.entityService.ListEntities(
6253
ctx,
6354
projectID,
6455
provider.ID,
65-
entityType,
66-
cursor,
56+
in.GetEntityType(),
57+
in.GetCursor().GetCursor(),
6758
int64(limit),
6859
)
6960
if err != nil {
70-
return nil, err
61+
return nil, fmt.Errorf("error listing entities: %w", err)
7162
}
7263

73-
// Create response
74-
resp := &pb.ListEntitiesResponse{
75-
Results: outentities,
76-
Page: &pb.CursorPage{
64+
var pageInfo *pb.CursorPage
65+
if nextCursor != "" {
66+
pageInfo = &pb.CursorPage{
7767
Next: &pb.Cursor{
7868
Cursor: nextCursor,
7969
Size: limit,
8070
},
81-
},
71+
}
72+
}
73+
74+
// Create response
75+
resp := &pb.ListEntitiesResponse{
76+
Results: outentities,
77+
Page: pageInfo,
8278
}
8379

8480
return resp, nil

internal/controlplane/handlers_entity_instances_test.go

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,3 +410,184 @@ func TestParseIdentifyingProperties(t *testing.T) {
410410
})
411411
}
412412
}
413+
414+
func TestServer_ListEntities(t *testing.T) {
415+
t.Parallel()
416+
417+
projectID := uuid.New()
418+
providerID := uuid.New()
419+
providerName := "github"
420+
421+
tests := []struct {
422+
name string
423+
request *pb.ListEntitiesRequest
424+
setupContext func(context.Context) context.Context
425+
setupMocks func(*mockproviders.MockProviderStore, *mockentitysvc.MockEntityService)
426+
wantCode codes.Code
427+
errContains string
428+
validateResp func(*testing.T, *pb.ListEntitiesResponse)
429+
}{
430+
{
431+
name: "successfully lists entities",
432+
request: &pb.ListEntitiesRequest{
433+
EntityType: pb.Entity_ENTITY_REPOSITORIES,
434+
},
435+
setupMocks: func(provStore *mockproviders.MockProviderStore, svc *mockentitysvc.MockEntityService) {
436+
provStore.EXPECT().
437+
GetByName(gomock.Any(), projectID, providerName).
438+
Return(&db.Provider{
439+
ID: providerID,
440+
Name: providerName,
441+
ProjectID: projectID,
442+
}, nil)
443+
444+
svc.EXPECT().
445+
ListEntities(gomock.Any(), projectID, providerID, pb.Entity_ENTITY_REPOSITORIES, "", int64(100)).
446+
Return([]*pb.EntityInstance{
447+
{Id: uuid.New().String(), Name: "repo1", Type: pb.Entity_ENTITY_REPOSITORIES},
448+
{Id: uuid.New().String(), Name: "repo2", Type: pb.Entity_ENTITY_REPOSITORIES},
449+
}, "", nil)
450+
},
451+
validateResp: func(t *testing.T, resp *pb.ListEntitiesResponse) {
452+
t.Helper()
453+
assert.Len(t, resp.Results, 2)
454+
assert.Nil(t, resp.Page)
455+
},
456+
},
457+
{
458+
name: "lists multiple entity types",
459+
request: &pb.ListEntitiesRequest{},
460+
setupMocks: func(provStore *mockproviders.MockProviderStore, svc *mockentitysvc.MockEntityService) {
461+
provStore.EXPECT().
462+
GetByName(gomock.Any(), projectID, providerName).
463+
Return(&db.Provider{
464+
ID: providerID,
465+
Name: providerName,
466+
ProjectID: projectID,
467+
}, nil)
468+
469+
svc.EXPECT().
470+
ListEntities(gomock.Any(), projectID, providerID, pb.Entity_ENTITY_UNSPECIFIED, "", int64(100)).
471+
Return([]*pb.EntityInstance{
472+
{Id: uuid.New().String(), Name: "repo1", Type: pb.Entity_ENTITY_REPOSITORIES},
473+
{Id: uuid.New().String(), Name: "repo1/pull/2", Type: pb.Entity_ENTITY_PULL_REQUESTS},
474+
}, "", nil)
475+
},
476+
validateResp: func(t *testing.T, resp *pb.ListEntitiesResponse) {
477+
t.Helper()
478+
assert.Len(t, resp.Results, 2)
479+
assert.Nil(t, resp.Page)
480+
},
481+
},
482+
{
483+
name: "successfully lists entities with pagination",
484+
request: &pb.ListEntitiesRequest{
485+
EntityType: pb.Entity_ENTITY_REPOSITORIES,
486+
Cursor: &pb.Cursor{
487+
Size: 1,
488+
},
489+
},
490+
setupMocks: func(provStore *mockproviders.MockProviderStore, svc *mockentitysvc.MockEntityService) {
491+
provStore.EXPECT().
492+
GetByName(gomock.Any(), projectID, providerName).
493+
Return(&db.Provider{
494+
ID: providerID,
495+
Name: providerName,
496+
ProjectID: projectID,
497+
}, nil)
498+
499+
svc.EXPECT().
500+
ListEntities(gomock.Any(), projectID, providerID, pb.Entity_ENTITY_REPOSITORIES, "", int64(1)).
501+
Return([]*pb.EntityInstance{
502+
{Id: uuid.New().String(), Name: "repo1", Type: pb.Entity_ENTITY_REPOSITORIES},
503+
}, "next-cursor", nil)
504+
},
505+
validateResp: func(t *testing.T, resp *pb.ListEntitiesResponse) {
506+
t.Helper()
507+
assert.Len(t, resp.Results, 1)
508+
assert.NotNil(t, resp.Page)
509+
assert.Equal(t, "next-cursor", resp.Page.Next.Cursor)
510+
assert.Equal(t, uint32(1), resp.Page.Next.Size)
511+
},
512+
},
513+
{
514+
name: "fails when provider not found",
515+
request: &pb.ListEntitiesRequest{
516+
EntityType: pb.Entity_ENTITY_REPOSITORIES,
517+
},
518+
setupMocks: func(provStore *mockproviders.MockProviderStore, _ *mockentitysvc.MockEntityService) {
519+
provStore.EXPECT().
520+
GetByName(gomock.Any(), projectID, providerName).
521+
Return(nil, sql.ErrNoRows)
522+
},
523+
wantCode: codes.NotFound,
524+
errContains: "provider not found",
525+
},
526+
{
527+
name: "handles service error",
528+
request: &pb.ListEntitiesRequest{
529+
EntityType: pb.Entity_ENTITY_REPOSITORIES,
530+
},
531+
setupMocks: func(provStore *mockproviders.MockProviderStore, svc *mockentitysvc.MockEntityService) {
532+
provStore.EXPECT().
533+
GetByName(gomock.Any(), projectID, providerName).
534+
Return(&db.Provider{
535+
ID: providerID,
536+
Name: providerName,
537+
ProjectID: projectID,
538+
}, nil)
539+
540+
svc.EXPECT().
541+
ListEntities(gomock.Any(), projectID, providerID, pb.Entity_ENTITY_REPOSITORIES, "", int64(100)).
542+
Return(nil, "", errors.New("service error"))
543+
},
544+
wantCode: codes.Internal,
545+
},
546+
}
547+
548+
for _, tt := range tests {
549+
t.Run(tt.name, func(t *testing.T) {
550+
t.Parallel()
551+
552+
ctrl := gomock.NewController(t)
553+
defer ctrl.Finish()
554+
555+
mockProvStore := mockproviders.NewMockProviderStore(ctrl)
556+
mockEntitySvc := mockentitysvc.NewMockEntityService(ctrl)
557+
558+
if tt.setupMocks != nil {
559+
tt.setupMocks(mockProvStore, mockEntitySvc)
560+
}
561+
562+
server := &Server{
563+
providerStore: mockProvStore,
564+
entityService: mockEntitySvc,
565+
}
566+
567+
ctx := engcontext.WithEntityContext(context.Background(), &engcontext.EntityContext{
568+
Project: engcontext.Project{ID: projectID},
569+
Provider: engcontext.Provider{Name: providerName},
570+
})
571+
572+
resp, err := server.ListEntities(ctx, tt.request)
573+
574+
if tt.wantCode != codes.OK {
575+
require.Error(t, err)
576+
577+
if tt.wantCode != codes.Internal {
578+
st, ok := status.FromError(err)
579+
require.True(t, ok, "error should be a gRPC status error")
580+
assert.Equal(t, tt.wantCode, st.Code())
581+
}
582+
assert.Contains(t, err.Error(), tt.errContains)
583+
return
584+
}
585+
586+
require.NoError(t, err)
587+
require.NotNil(t, resp)
588+
if tt.validateResp != nil {
589+
tt.validateResp(t, resp)
590+
}
591+
})
592+
}
593+
}

0 commit comments

Comments
 (0)